0
1
mirror of https://github.com/radio95-rnt/RadioPlayer.git synced 2026-02-26 13:52:00 +01:00

some changes

This commit is contained in:
2025-12-27 21:45:18 +01:00
parent 9bd0d88d39
commit 82e8423178
5 changed files with 2248 additions and 122 deletions

View File

@@ -3,6 +3,7 @@ from collections.abc import Sequence
from subprocess import Popen from subprocess import Popen
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import tinytag
@dataclass @dataclass
class Track: class Track:
@@ -22,9 +23,6 @@ class Process:
duration: float duration: float
class ABC_ProcessManager(abc.ABC): class ABC_ProcessManager(abc.ABC):
processes: list[Process]
@abc.abstractmethod
def _get_audio_duration(self, file_path): ...
@abc.abstractmethod @abc.abstractmethod
def play(self, track: Track) -> Process: ... def play(self, track: Track) -> Process: ...
@abc.abstractmethod @abc.abstractmethod
@@ -61,13 +59,13 @@ class ProcmanCommunicator(BaseIMCModule):
if int(op) == 0: return {"op": 0, "arg": "pong"} if int(op) == 0: return {"op": 0, "arg": "pong"}
elif int(op) == 1: elif int(op) == 1:
if arg := data.get("arg"): return {"op": 1, "arg": self.procman._get_audio_duration(arg)} if arg := data.get("arg"): return {"op": 1, "arg": tinytag.TinyTag().get(arg, tags=False).duration}
else: return else: return
elif int(op) == 2: elif int(op) == 2:
self.procman.stop_all(data.get("timeout", None)) self.procman.stop_all(data.get("timeout", None))
return {"op": 2} return {"op": 2}
elif int(op) == 3: elif int(op) == 3:
return {"op": 3, "arg": self.procman.processes} raise NotImplementedError("This feature was removed.")
elif int(op) == 4: elif int(op) == 4:
return {"op": 4, "arg": self.procman.anything_playing()} return {"op": 4, "arg": self.procman.anything_playing()}
elif int(op) == 5: elif int(op) == 5:

72
modules/ffmpeg_procman.py Normal file
View File

@@ -0,0 +1,72 @@
from . import ABC_ProcessManager, Process, Track, Path, Popen, tinytag
from threading import Lock
import subprocess, time
class ProcessManager(ABC_ProcessManager):
def __init__(self) -> None:
self.lock = Lock()
self.processes: list[Process] = []
def _get_audio_duration(self, file_path: Path):
return tinytag.TinyTag().get(file_path, tags=False).duration
def play(self, track: Track) -> Process:
assert track.path.exists()
cmd = ['ffplay', '-nodisp', '-hide_banner', '-autoexit', '-loglevel', 'quiet']
duration = self._get_audio_duration(track.path.absolute())
if not duration: raise Exception("Failed to get file duration for", track.path)
if track.offset >= duration: track.offset = max(duration - 0.1, 0)
if track.offset > 0: cmd.extend(['-ss', str(track.offset)])
filters = []
if track.fade_in != 0: filters.append(f"afade=t=in:st=0:d={track.fade_in}")
if track.fade_out != 0: filters.append(f"afade=t=out:st={duration - track.fade_out}:d={track.fade_out}")
if filters: cmd.extend(['-af', ",".join(filters)])
cmd.append(str(track.path.absolute()))
pr = Process(Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True), track, time.monotonic(), duration - track.offset)
with self.lock: self.processes.append(pr)
return pr
def anything_playing(self) -> bool:
with self.lock:
self.processes = [p for p in self.processes if p.process.poll() is None]
return bool(self.processes)
def stop_all(self, timeout: float | None = None) -> None:
with self.lock:
for process in self.processes:
process.process.terminate()
try: process.process.wait(timeout)
except subprocess.TimeoutExpired: process.process.kill()
self.processes.clear()
def wait_all(self, timeout: float | None = None) -> None:
with self.lock:
for process in self.processes:
try: process.process.wait(timeout)
except subprocess.TimeoutExpired: process.process.terminate()
self.processes.clear()
procman = ProcessManager()
# This is free and unencumbered software released into the public domain.
# Anyone is free to copy, modify, publish, use, compile, sell, or
# distribute this software, either in source code form or as a compiled
# binary, for any purpose, commercial or non-commercial, and by any
# means.
# In jurisdictions that recognize copyright laws, the author or authors
# of this software dedicate any and all copyright interest in the
# software to the public domain. We make this dedication for the benefit
# of the public at large and to the detriment of our heirs and
# successors. We intend this dedication to be an overt act of
# relinquishment in perpetuity of all present and future rights to this
# software under copyright law.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
# For more information, please refer to <https://unlicense.org>

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os, subprocess, importlib.util, importlib.machinery, types import os, importlib.util, importlib.machinery, types
import sys, signal, glob, time, traceback import sys, signal, glob, time, traceback, io
import libcache import concurrent.futures
from modules import * from modules import *
from threading import Lock from threading import Lock
@@ -16,54 +16,6 @@ def prefetch(path):
MODULES_PACKAGE = "modules" MODULES_PACKAGE = "modules"
MODULES_DIR = Path(__file__, "..", MODULES_PACKAGE).resolve() MODULES_DIR = Path(__file__, "..", MODULES_PACKAGE).resolve()
class ProcessManager(ABC_ProcessManager):
def __init__(self) -> None:
self.lock = Lock()
self.processes: list[Process] = []
self.duration_cache = libcache.Cache([])
def _get_audio_duration(self, file_path: Path):
if result := self.duration_cache.getElement(file_path.as_posix(), False): return result
result = subprocess.run(['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', str(file_path)], capture_output=True, text=True)
if result.returncode == 0:
result = float(result.stdout.strip())
self.duration_cache.saveElement(file_path.as_posix(), result, (60*60*2), False, True)
return result
def play(self, track: Track) -> Process:
assert track.path.exists()
cmd = ['ffplay', '-nodisp', '-hide_banner', '-autoexit', '-loglevel', 'quiet']
duration = self._get_audio_duration(track.path.absolute())
if not duration: raise Exception("Failed to get file duration for", track.path)
if track.offset >= duration: track.offset = max(duration - 0.1, 0)
if track.offset > 0: cmd.extend(['-ss', str(track.offset)])
filters = []
if track.fade_in != 0: filters.append(f"afade=t=in:st=0:d={track.fade_in}")
if track.fade_out != 0: filters.append(f"afade=t=out:st={duration - track.fade_out}:d={track.fade_out}")
if filters: cmd.extend(['-af', ",".join(filters)])
cmd.append(str(track.path.absolute()))
pr = Process(Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True), track, time.monotonic(), duration - track.offset)
with self.lock: self.processes.append(pr)
return pr
def anything_playing(self) -> bool:
with self.lock:
self.processes = [p for p in self.processes if p.process.poll() is None]
return bool(self.processes)
def stop_all(self, timeout: float | None = None) -> None:
with self.lock:
for process in self.processes:
process.process.terminate()
try: process.process.wait(timeout)
except subprocess.TimeoutExpired: process.process.kill()
self.processes.clear()
def wait_all(self, timeout: float | None = None) -> None:
with self.lock:
for process in self.processes:
try: process.process.wait(timeout)
except subprocess.TimeoutExpired: process.process.terminate()
self.processes.clear()
class PlaylistParser: class PlaylistParser:
def __init__(self, output: log95.TextIO): self.logger = log95.log95("PARSER", output=output) def __init__(self, output: log95.TextIO): self.logger = log95.log95("PARSER", output=output)
@@ -99,19 +51,25 @@ class PlaylistParser:
if line.startswith("|"): # No file name, we're defining global arguments if line.startswith("|"): # No file name, we're defining global arguments
args = line.removeprefix("|").split(";") args = line.removeprefix("|").split(";")
for arg in args: for arg in args:
key, val = arg.split("=", 1) if "=" in arg:
global_arguments[key] = val key, val = arg.split("=", 1)
arguments[key] = val
else:
arguments[arg] = True
else: else:
line, args = line.split("|", 1) line, args = line.split("|", 1)
args = args.split(";") args = args.split(";")
for arg in args: for arg in args:
key, val = arg.split("=", 1) if "=" in arg:
arguments[key] = val key, val = arg.split("=", 1)
arguments[key] = val
else:
arguments[arg] = True
out.append(([f for f in glob.glob(line) if Path(f).is_file()], arguments)) out.append(([f for f in glob.glob(line) if Path(f).is_file()], arguments))
return global_arguments, out return global_arguments, out
class ModuleManager: class ModuleManager:
def __init__(self, output: log95.TextIO) -> types.NoneType: def __init__(self, output: log95.TextIO) -> None:
self.simple_modules: list[PlayerModule] = [] self.simple_modules: list[PlayerModule] = []
self.playlist_modifier_modules: list[PlaylistModifierModule] = [] self.playlist_modifier_modules: list[PlaylistModifierModule] = []
self.playlist_advisor: PlaylistAdvisor | None = None self.playlist_advisor: PlaylistAdvisor | None = None
@@ -146,51 +104,61 @@ class ModuleManager:
module.__dict__['_log_out'] = self.logger.output module.__dict__['_log_out'] = self.logger.output
self.modules.append((spec, module, module_name)) self.modules.append((spec, module, module_name))
def start_modules(self, arg): def start_modules(self, arg):
procman = None
"""Executes the module by the python interpreter""" """Executes the module by the python interpreter"""
procman = ProcessManager() def timed_loader(spec: importlib.machinery.ModuleSpec, module: types.ModuleType):
for (spec, module, module_name) in self.modules:
assert spec.loader assert spec.loader
try: start = time.perf_counter()
start = time.perf_counter() spec.loader.exec_module(module)
if os.name == "posix": duration = time.perf_counter() - start
def handler(signum, frame): raise TimeoutError("Module loading timed out") return duration
signal.signal(signal.SIGALRM, handler) with concurrent.futures.ThreadPoolExecutor() as executor:
signal.alarm(5) for (spec, module, module_name) in self.modules:
try: spec.loader.exec_module(module) try:
except TimeoutError: self.logger.error(f"Module {module_name} took too long to load and was skipped.") future = executor.submit(timed_loader, spec, module)
finally: try:
if os.name == "posix": signal.alarm(0) time_took = future.result(5)
if (time_took := time.perf_counter() - start) > 0.15: self.logger.warning(f"{module_name} took {time_took:.1f}s to start") if time_took > 0.15: self.logger.warning(f"{module_name} took {time_took:.1f}s to start")
except Exception as e: except concurrent.futures.TimeoutError:
traceback.print_exc(file=self.logger.output) self.logger.error(f"Module {module_name} timed out.")
self.logger.error(f"Failed loading {module_name} due to {e}, continuing") continue
continue except Exception as e:
traceback.print_exc(file=self.logger.output)
if md := getattr(module, "module", None): self.logger.error(f"Failed loading {module_name} due to {e}, continuing")
if isinstance(md, list): self.simple_modules.extend(md)
else: self.simple_modules.append(md)
if md := getattr(module, "playlistmod", None):
if isinstance(md, tuple):
md, index = md
if isinstance(md, list): self.playlist_modifier_modules[index:index] = md
else: self.playlist_modifier_modules.insert(index, md)
elif isinstance(md, list): self.playlist_modifier_modules.extend(md)
else: self.playlist_modifier_modules.append(md)
if md := getattr(module, "advisor", None):
if self.playlist_advisor: raise Exception("Multiple playlist advisors")
self.playlist_advisor = md
if md := getattr(module, "activemod", None):
if self.active_modifier: raise Exception("Multiple active modifiers")
self.active_modifier = md
if md := getattr(module, "procman", None):
if not isinstance(md, ABC_ProcessManager):
self.logger.error("Modular process manager does not inherit from ABC_ProcessManager.")
continue continue
if procman.anything_playing(): procman.stop_all()
procman = md if md := getattr(module, "module", None):
InterModuleCommunication(self.simple_modules + [self.playlist_advisor, ProcmanCommunicator(procman), self.active_modifier]) if isinstance(md, list): self.simple_modules.extend(md)
else: self.simple_modules.append(md)
if md := getattr(module, "playlistmod", None):
if isinstance(md, tuple):
md, index = md
if isinstance(md, list): self.playlist_modifier_modules[index:index] = md
else: self.playlist_modifier_modules.insert(index, md)
elif isinstance(md, list): self.playlist_modifier_modules.extend(md)
else: self.playlist_modifier_modules.append(md)
if md := getattr(module, "advisor", None):
if self.playlist_advisor: raise Exception("Multiple playlist advisors")
self.playlist_advisor = md
if md := getattr(module, "activemod", None):
if self.active_modifier: raise Exception("Multiple active modifiers")
self.active_modifier = md
if md := getattr(module, "procman", None):
if procman: raise Exception("Multiple procmans")
if not isinstance(md, ABC_ProcessManager):
self.logger.error("Modular process manager does not inherit from ABC_ProcessManager.")
continue
procman = md
if self.active_modifier: self.active_modifier.arguments(arg) if self.active_modifier: self.active_modifier.arguments(arg)
if not self.playlist_advisor: self.logger.warning("Playlist advisor was not found. Beta mode of advisor-less is running (playlist modifiers will not work)")
if not procman:
self.logger.critical_error("Missing process mananger.")
raise SystemExit("Missing process mananger.")
InterModuleCommunication(self.simple_modules + [self.playlist_advisor, ProcmanCommunicator(procman), self.active_modifier])
return procman return procman
def advisor_advise(self, arguments: str | None):
if not self.playlist_advisor: return None
return self.playlist_advisor.advise(arguments)
class RadioPlayer: class RadioPlayer:
def __init__(self, arg: str | None, output: log95.TextIO): def __init__(self, arg: str | None, output: log95.TextIO):
@@ -224,12 +192,13 @@ class RadioPlayer:
self.logger.info("Core starting, loading modules") self.logger.info("Core starting, loading modules")
self.modman.load_modules() self.modman.load_modules()
self.procman = self.modman.start_modules(self.arg) self.procman = self.modman.start_modules(self.arg)
if not self.modman.playlist_advisor: self.logger.warning("Playlist advisor was not found. Beta mode of advisor-less is running (playlist modifiers will not work)")
def play_once(self): def play_once(self):
"""Plays a single playlist""" """Plays a single playlist"""
if self.modman.playlist_advisor: if not (playlist_path := self.modman.advisor_advise(self.arg)):
if not (playlist_path := self.modman.playlist_advisor.advise(self.arg)): return max_iterator = 1
playlist = None
else:
try: global_args, parsed = self.parser.parse(playlist_path) try: global_args, parsed = self.parser.parse(playlist_path)
except Exception as e: except Exception as e:
self.logger.info(f"Exception ({e}) while parsing playlist, retrying in 15 seconds...");traceback.print_exc(file=self.logger.output) self.logger.info(f"Exception ({e}) while parsing playlist, retrying in 15 seconds...");traceback.print_exc(file=self.logger.output)
@@ -237,22 +206,23 @@ class RadioPlayer:
return return
playlist: list[Track] | None = [] playlist: list[Track] | None = []
[playlist.extend(Track(Path(line).absolute(), 0, 0, True, args) for line in lns) for (lns, args) in parsed] # i can read this, i think for lines, args in parsed:
for line in lines:
playlist.append(Track(Path(line).absolute(), 0, 0, True, args))
[(playlist := module.modify(global_args, playlist) or playlist) for module in self.modman.playlist_modifier_modules if module] # yep for module in filter(None, self.modman.playlist_modifier_modules): playlist = module.modify(global_args, playlist) or playlist
assert len(playlist) assert len(playlist)
prefetch(playlist[0].path) prefetch(playlist[0].path)
[mod.on_new_playlist(playlist, global_args) for mod in self.modman.simple_modules + [self.modman.active_modifier] if mod] # one liner'd everything for module in filter(None, self.modman.simple_modules + [self.modman.active_modifier]): module.on_new_playlist(playlist, global_args)
max_iterator = len(playlist) max_iterator = len(playlist)
else: return self._play(playlist, max_iterator)
max_iterator = 1
playlist = None def _play(self, playlist: list[Track] | None, max_iterator: int):
assert self.procman
return_pending = track = False return_pending = track = False
song_i = i = 0 song_i = i = 0
assert self.procman
def get_track(): def get_track():
nonlocal song_i, playlist, max_iterator nonlocal song_i, playlist, max_iterator
track = None track = None
@@ -303,6 +273,7 @@ class RadioPlayer:
[module.progress(song_i, track, time.monotonic() - pr.started_at, pr.duration, end_time - pr.started_at) for module in self.modman.simple_modules if module] [module.progress(song_i, track, time.monotonic() - pr.started_at, pr.duration, end_time - pr.started_at) for module in self.modman.simple_modules if module]
if (elapsed := time.monotonic() - start) < 1 and (remaining_until_end := end_time - time.monotonic()) > 0: time.sleep(min(1 - elapsed, remaining_until_end)) if (elapsed := time.monotonic() - start) < 1 and (remaining_until_end := end_time - time.monotonic()) > 0: time.sleep(min(1 - elapsed, remaining_until_end))
prefetch(next_track)
i += 1 i += 1
if not extend: song_i += 1 if not extend: song_i += 1
@@ -320,16 +291,49 @@ class RadioPlayer:
traceback.print_exc(file=self.logger.output) traceback.print_exc(file=self.logger.output)
raise raise
class RotatingLog(io.TextIOWrapper):
def write(self, s: str) -> int:
if self.tell() > 2_500_000:
self.truncate(0)
self.seek(0)
return super().write(s)
def main(): def main():
log_file_path = Path("/tmp/radioPlayer_log") log_file_path = Path("/tmp/radioPlayer_log")
log_file_path.touch() log_file_path.touch()
core = RadioPlayer((" ".join(sys.argv[1:]) if len(sys.argv) > 1 else None), open(log_file_path, "w")) with RotatingLog(open(log_file_path, "wb", buffering=0), "utf-8") as f:
try: core = RadioPlayer((" ".join(sys.argv[1:]) if len(sys.argv) > 1 else None), f)
core.start() try:
signal.signal(signal.SIGINT, core.handle_sigint) core.start()
core.loop() signal.signal(signal.SIGINT, core.handle_sigint)
except SystemExit: core.loop()
try: core.shutdown() except SystemExit:
except BaseException: traceback.print_exc() try: core.shutdown()
raise except BaseException: traceback.print_exc()
raise
# This is free and unencumbered software released into the public domain.
# Anyone is free to copy, modify, publish, use, compile, sell, or
# distribute this software, either in source code form or as a compiled
# binary, for any purpose, commercial or non-commercial, and by any
# means.
# In jurisdictions that recognize copyright laws, the author or authors
# of this software dedicate any and all copyright interest in the
# software to the public domain. We make this dedication for the benefit
# of the public at large and to the detriment of our heirs and
# successors. We intend this dedication to be an overt act of
# relinquishment in perpetuity of all present and future rights to this
# software under copyright law.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
# For more information, please refer to <https://unlicense.org>

View File

@@ -1,3 +1,3 @@
libcache libcache
log95==1.3 log95
unidecode unidecode

2052
tinytag.py Normal file

File diff suppressed because it is too large Load Diff