diff --git a/modules/__init__.py b/modules/__init__.py index d01c372..4a1cd8a 100644 --- a/modules/__init__.py +++ b/modules/__init__.py @@ -34,18 +34,14 @@ class ABC_ProcessManager(abc.ABC): class BaseIMCModule: """This is not a module to be used but rather a placeholder IMC api to be used in other modules""" def imc(self, imc: 'InterModuleCommunication') -> None: - """ - Receive an IMC object - """ + """Receive an IMC object""" self._imc = imc - def imc_data(self, source: 'BaseIMCModule', source_name: str | None, data: object, broadcast: bool) -> object: - """ - React to IMC data - """ - return None + def imc_data(self, source: 'BaseIMCModule', source_name: str | None, data: object, broadcast: bool) -> object: """React to IMC data""" class ProcmanCommunicator(BaseIMCModule): - def __init__(self, procman: ABC_ProcessManager) -> None: self.procman = procman + def __init__(self, procman: ABC_ProcessManager) -> None: + self.procman = procman + self.tinytag = tinytag.TinyTag() def imc(self, imc: 'InterModuleCommunication') -> None: super().imc(imc) self._imc.register(self, "procman") @@ -57,7 +53,7 @@ class ProcmanCommunicator(BaseIMCModule): if int(op) == 0: return {"op": 0, "arg": "pong"} elif int(op) == 1: - if arg := data.get("arg"): return {"op": 1, "arg": tinytag.TinyTag().get(arg, tags=False).duration} + if arg := data.get("arg"): return {"op": 1, "arg": self.tinytag.get(arg, tags=False).duration} else: return elif int(op) == 2: self.procman.stop_all(data.get("timeout", None)) @@ -71,28 +67,21 @@ class ProcmanCommunicator(BaseIMCModule): class PlayerModule(BaseIMCModule): """Simple passive observer, this allows you to send the current track the your RDS encoder, or to your website""" - def on_new_playlist(self, playlist: list[Track], global_args: dict[str, str]) -> None: + def on_new_playlist(self, playlist: list[Track], global_args: dict[str, str]) -> None: """This is called every new playlist""" - pass def on_new_track(self, index: int, track: Track, next_track: Track | None) -> None: """Called on every track including the ones added by the active modifier, you can check for that comparing the playlists[index] and the track""" - pass def progress(self, index: int, track: Track, elapsed: float, total: float, real_total: float) -> None: """ Real total and total differ in that, total is how much the track lasts, but real_total will be for how long we will focus on it (crossfade) - Runs at a frequency around 1 Hz + Runs at a frequency around 1 Hz (depending on other plugins it can be rarer, but never any faster) Please don't put any blocking or code that takes time """ - pass - def shutdown(self): - """Ran while shutting down""" - pass + def shutdown(self): """Ran while shutting down""" class PlaylistModifierModule: """Playlist modifier, this type of module allows you to shuffle, or put jingles into your playlist""" def modify(self, global_args: dict, playlist: list[Track]) -> list[Track] | None: - """ - global_args are playlist global args (see radioPlayer_playlist_file.txt) - """ + """global_args are playlist global args (see radioPlayer_playlist_file.txt)""" return playlist # No IMC, as we only run on new playlists class PlaylistAdvisor(BaseIMCModule): @@ -105,9 +94,7 @@ class PlaylistAdvisor(BaseIMCModule): return False class ActiveModifier(BaseIMCModule): """This changes the next song to be played live, which means that this picks the next song, not the playlist, but this is affected by the playlist""" - def arguments(self, arguments: str | None) -> None: - """Called at start up with the program arguments""" - pass + def arguments(self, arguments: str | None) -> None: """Called at start up with the program arguments""" def play(self, index: int, track: Track | None, next_track: Track | None) -> tuple[tuple[Track | None, Track | None], bool | None]: """ Returns a tuple, in the first case where a is the track and b is a bool, b corresponds to whether to extend the playlist, set to true when adding content instead of replacing it @@ -115,9 +102,7 @@ class ActiveModifier(BaseIMCModule): The second track object is the next track, which is optional which is also only used for metadata and will not be taken in as data to play """ return (track, None), False - def on_new_playlist(self, playlist: list[Track], global_args: dict[str, str]) -> None: - """Same behaviour as the basic module function""" - pass + def on_new_playlist(self, playlist: list[Track], global_args: dict[str, str]) -> None: """Same behaviour as the basic module function""" class InterModuleCommunication: def __init__(self, modules: Sequence[BaseIMCModule | None]) -> None: self.modules = modules diff --git a/modules/advisor.py b/modules/advisor.py index 9ed65fc..906a1aa 100644 --- a/modules/advisor.py +++ b/modules/advisor.py @@ -1,7 +1,7 @@ MORNING_START = 5 MORNING_END = 10 DAY_START = 10 -DAY_END = 18 +DAY_END = 19 LATE_NIGHT_START = 0 LATE_NIGHT_END = 5 @@ -51,7 +51,6 @@ class Module(PlaylistAdvisor): def __init__(self) -> None: self.last_mod_time = 0 self.last_playlist = None - self.class_imc = None self.custom_playlist = None self.custom_playlist_path = Path("/tmp/radioPlayer_list") @@ -104,7 +103,7 @@ class Module(PlaylistAdvisor): logger.info(f"Playing {current_day} night playlist...") self.last_mod_time = Time.get_playlist_modification_time(night_playlist) self.last_playlist = night_playlist - if self.class_imc: self.class_imc.send(self, "web", {"playlist": str(self.last_playlist)}) + if self._imc: self._imc.send(self, "web", {"playlist": str(self.last_playlist)}) return self.last_playlist def new_playlist(self) -> bool: if self.custom_playlist and self.custom_playlist_path.exists(): @@ -128,7 +127,7 @@ class Module(PlaylistAdvisor): return True return False def imc(self, imc: InterModuleCommunication) -> None: - self.class_imc = imc + self._imc = imc imc.register(self, "advisor") def imc_data(self, source: BaseIMCModule, source_name: str | None, data: object, broadcast: bool): return (self.custom_playlist, MORNING_START, DAY_END) diff --git a/modules/crossfade_init.py b/modules/crossfade_init.py index 4246b77..3a6286a 100644 --- a/modules/crossfade_init.py +++ b/modules/crossfade_init.py @@ -1,5 +1,5 @@ from . import PlaylistModifierModule, Track -DEFAULT_CROSSFADE = 5.0 +DEFAULT_CROSSFADE = 6.0 class Module(PlaylistModifierModule): def modify(self, global_args: dict, playlist: list[Track]) -> list[Track] | None: out = [] @@ -10,7 +10,7 @@ class Module(PlaylistModifierModule): out.append(Track(track.path, track_crossfade, track_crossfade, do_cross_fade, track.args, focus_time_offset=-track_crossfade)) else: out.append(track) return out -playlistmod = (Module(),1) +playlistmod = Module(), 0 # This is free and unencumbered software released into the public domain. diff --git a/modules/ffmpeg_procman.py b/modules/ffmpeg_procman.py index 1f73cfd..a260f1e 100644 --- a/modules/ffmpeg_procman.py +++ b/modules/ffmpeg_procman.py @@ -1,4 +1,4 @@ -from . import ABC_ProcessManager, Process, Track, Path, Popen, tinytag +from . import ABC_ProcessManager, Process, Track, Popen, tinytag from threading import Lock import subprocess, time @@ -6,13 +6,12 @@ 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 + self.tinytag = tinytag.TinyTag() 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()) + duration = self.tinytag.get(track.path.absolute(), tags=False).duration 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)]) @@ -35,7 +34,7 @@ class ProcessManager(ABC_ProcessManager): try: p.process.wait(timeout=0) except subprocess.TimeoutExpired: pass self.processes = alive - return bool(self.processes) + return bool(alive) def stop_all(self, timeout: float | None = None) -> None: with self.lock: for process in self.processes: diff --git a/modules/fsdb.py b/modules/fsdb.py index 426281d..9b6d97d 100644 --- a/modules/fsdb.py +++ b/modules/fsdb.py @@ -11,8 +11,7 @@ def _parse_args(text: str) -> dict[str, str]: if "=" in line: key, val = line.split("=", 1) args[key.strip()] = val.strip() - else: - args[line] = True + else: args[line] = True return args class FSDBParser(PlaylistParser): diff --git a/modules/jingle.py b/modules/jingle.py index 6cb75e0..e11c39e 100644 --- a/modules/jingle.py +++ b/modules/jingle.py @@ -25,6 +25,8 @@ def get_jingles(): if not master: master = jingles.pop(0) return master, jingles +def chance(one_in_n): return random.randint(1, one_in_n) == 1 + class Module(PlaylistModifierModule): def modify(self, global_args: dict, playlist: list[Track]) -> list[Track] | None: if int(global_args.get("no_jingle", 0)) != 0: return None @@ -34,26 +36,26 @@ class Module(PlaylistModifierModule): out: list[Track] = [] last_jingiel = True for track in playlist: - if not last_jingiel and (random.randint(1,3) == 1) and (track.args is None or int(track.args.get("no_jingle", 0)) == 0): + if not last_jingiel and chance(3) and (track.args is None or int(track.args.get("no_jingle", 0)) == 0): out.append(Track(track.path, 0, track.fade_in, True, track.args)) jingle = primary - if secondary and (random.randint(1,3) == 1): jingle = random.choice(secondary) + if secondary and chance(2): jingle = random.choice(secondary) out.append(Track(jingle, 0, 0, False, {})) last_jingiel = True continue out.append(Track(track.path, track.fade_out, track.fade_in, True, track.args,focus_time_offset=-track.fade_out)) last_jingiel = False return out - + class Module2(PlayerModule): def imc(self, imc: InterModuleCommunication) -> None: super().imc(imc) - self._imc.register(self, "jingle") + imc.register(self, "jingle") def imc_data(self, source: BaseIMCModule, source_name: str | None, data: bool, broadcast: bool) -> object: if broadcast: return jingle, secondary = get_jingles() - if secondary and (random.randint(1,3) == 1): jingle = random.choice(secondary) + if secondary and chance(2): jingle = random.choice(secondary) return self._imc.send(self, "activemod", {"action": "add_to_toplay", "songs": [f"!{jingle}"], "top": bool(data)}) module = Module2() -playlistmod = Module() +playlistmod = Module(), 2 diff --git a/modules/no_intro_fade_in.py b/modules/no_intro_fade_in.py new file mode 100644 index 0000000..55d3c53 --- /dev/null +++ b/modules/no_intro_fade_in.py @@ -0,0 +1,8 @@ +from . import PlaylistModifierModule, Track + +class Module(PlaylistModifierModule): + def modify(self, global_args: dict, playlist: list[Track]) -> list[Track] | None: + playlist[0].fade_in = 0 + return playlist + +playlistmod = Module(), -1 diff --git a/modules/play_counter.py b/modules/play_counter.py index 8413713..c2eb90e 100644 --- a/modules/play_counter.py +++ b/modules/play_counter.py @@ -34,7 +34,6 @@ class Module(PlayerModule): def on_new_track(self, index: int, track: Track, next_track: Track | None) -> None: self.counts[track.path.as_posix()] = self.counts.get(track.path.as_posix(), 0) + 1 self._save_counts() - def shutdown(self): - self._save_counts() + def shutdown(self): self._save_counts() module = Module() \ No newline at end of file diff --git a/modules/play_sort.py b/modules/play_sort.py index a66cb33..5ea3566 100644 --- a/modules/play_sort.py +++ b/modules/play_sort.py @@ -4,32 +4,22 @@ _log_out: log95.TextIO assert _log_out # pyright: ignore[reportUnboundVariable] def load_play_counts(file_path: Path) -> dict[str, int]: - """ - Loads the play counts from the file generated by the play_counter module. - """ counts = {} try: with open(file_path, 'r') as file: for line in file: - if line.strip() == "" or line.startswith(";"): - continue + if line.strip() == "" or line.startswith(";"): continue try: key, value = line.split(':', 1) counts[key.strip()] = int(value.strip()) - except ValueError: - continue + except ValueError: continue return counts - except FileNotFoundError: - return {} + except FileNotFoundError: return {} class PopularitySorterModule(PlaylistModifierModule): - """ - A playlist modifier that reorders tracks based on their play history. - For every pair of tracks, it gives a 60% probability to schedule the less-played track first. - """ def __init__(self) -> None: self.logger = log95.log95("PopSort", output=_log_out) - self.play_counts_file = Path("/home/user/mixes/.playlist/count.txt").resolve() + self.play_counts_file = Path("/home/user/mixes/.playlist/count.txt") def modify(self, global_args: dict, playlist: list[Track]) -> list[Track]: self.logger.info("Applying popularity-based sorting to the playlist...") @@ -69,12 +59,11 @@ class PopularitySorterModule(PlaylistModifierModule): count1 = play_counts.get(track1.path.as_posix(), 0) count2 = play_counts.get(track2.path.as_posix(), 0) - if count1 > count2: + if count1 > count2: playlist[i], playlist[i+1] = track2, track1 - i += 2 self.logger.info("Popularity sorting complete.") return playlist -playlistmod = (PopularitySorterModule(), 2) \ No newline at end of file +playlistmod = PopularitySorterModule(), 2 \ No newline at end of file diff --git a/modules/playlist_parser.py b/modules/playlist_parser.py deleted file mode 100644 index d2bb188..0000000 --- a/modules/playlist_parser.py +++ /dev/null @@ -1,55 +0,0 @@ -import glob -from . import log95, Path, PlaylistParser - -_log_out: log95.TextIO -class PlaintextParser(PlaylistParser): - def __init__(self): self.logger = log95.log95("PARSER", output=_log_out) - - def _check_for_imports(self, path: Path, seen=None) -> list[str]: - if seen is None: seen = set() - if not path.exists(): - self.logger.error(f"Playlist not found: {path.name}") - raise Exception("Playlist doesn't exist") - lines = [line.strip() for line in path.read_text().splitlines() if line.strip()] - - out = [] - for line in lines: - if line.startswith("@"): - target = Path(line.removeprefix("@")) - if target not in seen: - if not target.exists(): - self.logger.error(f"Target {target.name} of {path.name} does not exist") - continue - seen.add(target) - out.extend(self._check_for_imports(target, seen)) - else: out.append(line) - return out - - def parse(self, playlist_path: Path) -> tuple[dict[str, str], list[tuple[list[str], dict[str, str]]]]: - lines = self._check_for_imports(playlist_path) - out = [] - global_arguments = {} - for line in lines: - arguments = {} - line = line.strip() - if not line or line.startswith(";") or line.startswith("#"): continue - if "|" in line: - if line.startswith("|"): # No file name, we're defining global arguments - args = line.removeprefix("|").split(";") - for arg in args: - if "=" in arg: - key, val = arg.split("=", 1) - arguments[key] = val - else: arguments[arg] = True - else: - line, args = line.split("|", 1) - args = args.split(";") - for arg in args: - if "=" in arg: - 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)) - return global_arguments, out - -# parser = PlaintextParser() \ No newline at end of file diff --git a/modules/rds.py b/modules/rds.py index d64253b..8b4fba2 100644 --- a/modules/rds.py +++ b/modules/rds.py @@ -1,3 +1,5 @@ +from modules import InterModuleCommunication + from . import PlayerModule, log95, Track import socket @@ -13,7 +15,7 @@ udp_host = ("127.0.0.1", 5000) logger_level = log95.log95Levels.DEBUG if DEBUG else log95.log95Levels.CRITICAL_ERROR _log_out: log95.TextIO assert _log_out # pyright: ignore[reportUnboundVariable] -logger = log95.log95("RDS-MODULE", logger_level, output=_log_out) +logger = log95.log95("RDS", logger_level, output=_log_out) def load_dict_from_custom_format(file_path: str) -> dict[str, str]: try: @@ -84,5 +86,31 @@ class Module(PlayerModule): self._imc.send(self, "web", {"rt": rds_rt, "rtp": rds_rtp}, False) logger.info(f"RT set to '{rds_rt}'") logger.debug(f"{rds_rtp=}") + def imc(self, imc: InterModuleCommunication) -> None: imc.register(self, "rds") module = Module() + +# 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 \ No newline at end of file diff --git a/modules/shuffler.py b/modules/shuffler.py index 68d8f1c..1c4eec4 100644 --- a/modules/shuffler.py +++ b/modules/shuffler.py @@ -7,7 +7,7 @@ class Module(PlaylistModifierModule): if int(global_args.get("no_shuffle", 0)) == 0: random.shuffle(playlist) return playlist -playlistmod = (Module(), 0) +playlistmod = Module(), 1 # This is free and unencumbered software released into the public domain. diff --git a/modules/skipper.py b/modules/skipper.py deleted file mode 100644 index 9f1630b..0000000 --- a/modules/skipper.py +++ /dev/null @@ -1,43 +0,0 @@ -from . import PlayerModule, log95, Track -import os - -_log_out: log95.TextIO -assert _log_out # pyright: ignore[reportUnboundVariable] -logger = log95.log95("Skipper", output=_log_out) - -class Module(PlayerModule): - def __init__(self) -> None: self.playlist = [] - def on_new_playlist(self, playlist: list[Track], global_args: dict[str, str]): self.playlist = [str(t.path.absolute()) for t in playlist] - def progress(self, index: int, track: Track, elapsed: float, total: float, real_total: float) -> None: - if os.path.exists("/tmp/radioPlayer_skip"): - self._imc.send(self, "procman", {"op": 2}) # Ask procman to kill every track playing (usually there is one, unless we are in the default 5 seconds of the crossfade) - os.remove("/tmp/radioPlayer_skip") - def on_new_track(self, index: int, track: Track, next_track: Track | None): - if next_track: logger.info("Next up:", next_track.path.name) - -module = Module() - -# 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 \ No newline at end of file diff --git a/modules/web.html b/modules/web.html index 4502343..f992569 100644 --- a/modules/web.html +++ b/modules/web.html @@ -281,9 +281,8 @@ function handleMessage(msg){ if(msg.event === "state"){ const d = msg.data || {}; - applyProgressState(d.track); - updateDirs(d.dirs); - if(d.playlist) { playlist = d.playlist; renderPlaylist(); } + if(d.track) applyProgressState(d.track); + if(d.dirs) updateDirs(d.dirs); } else if(msg.event === "playlist"){ playlist = msg.data || []; renderPlaylist(); diff --git a/modules/web.py b/modules/web.py index f8062f1..f5f3b56 100644 --- a/modules/web.py +++ b/modules/web.py @@ -15,13 +15,14 @@ MAIN_PATH_DIR = Path("/home/user/mixes") async def ws_handler(websocket: ServerConnection, shared_data: dict, imc_q: multiprocessing.Queue, ws_q: multiprocessing.Queue): try: initial = { - "playlist": json.loads(shared_data.get("playlist", "[]")), "track": json.loads(shared_data.get("track", "{}")), "progress": json.loads(shared_data.get("progress", "{}")), - "dirs": {"files": [i.name for i in list(MAIN_PATH_DIR.iterdir()) if i.is_file()], "dirs": [i.name for i in list(MAIN_PATH_DIR.iterdir()) if i.is_dir()], "base": str(MAIN_PATH_DIR)} + "dirs": {"files": [i.name for i in list(MAIN_PATH_DIR.iterdir()) if i.is_file()], "dirs": [i.name for i in list(MAIN_PATH_DIR.iterdir()) if i.is_dir()], "base": str(MAIN_PATH_DIR)}, + "rds": json.loads(shared_data.get("rds", "{}")) } - except Exception: initial = {"playlist": [], "track": {}, "progress": {}, "dirs": {"files": [], "dirs": [], "base": ""}} + except Exception: initial = {"track": {}, "progress": {}, "dirs": {}, "rds": {}} await websocket.send(json.dumps({"event": "state", "data": initial})) + await websocket.send(json.dumps({"event": "playlist", "data": json.loads(shared_data.get("playlist", "[]"))})) async for raw in websocket: try: msg: dict = json.loads(raw) @@ -33,13 +34,10 @@ async def ws_handler(websocket: ServerConnection, shared_data: dict, imc_q: mult key = str(uuid.uuid4()) imc_q.put({"name": name, "data": data, "key": key}) start = time.monotonic() - result = None while time.monotonic() - start < 1: - if key in shared_data: - result = shared_data.pop(key) - break + if key in shared_data: return shared_data.pop(key) await asyncio.sleep(0.1) - return result + return None action = msg.get("action") if action == "skip": @@ -80,23 +78,6 @@ async def ws_handler(websocket: ServerConnection, shared_data: dict, imc_q: mult result = await get_imc("activemod", {"action": "get_toplay"}) if result is not None: await asyncio.get_event_loop().run_in_executor(None, ws_q.put, {"data": result, "event": "toplay"}) - elif action == "request_state": - # supports requesting specific parts if provided - what = msg.get("what", "") - try: - if what == "playlist": payload = json.loads(shared_data.get("playlist", "[]")) - elif what == "track": payload = json.loads(shared_data.get("track", "{}")) - elif what == "progress": payload = json.loads(shared_data.get("progress", "{}")) - elif what == "dirs": payload = {"files": [i.name for i in list(MAIN_PATH_DIR.iterdir()) if i.is_file()], "dirs": [i.name for i in list(MAIN_PATH_DIR.iterdir()) if i.is_dir()], "base": str(MAIN_PATH_DIR)} - else: - payload = { - "playlist": json.loads(shared_data.get("playlist", "[]")), - "track": json.loads(shared_data.get("track", "{}")), - "progress": json.loads(shared_data.get("progress", "{}")), - "dirs": {"files": [i.name for i in list(MAIN_PATH_DIR.iterdir()) if i.is_file()], "dirs": [i.name for i in list(MAIN_PATH_DIR.iterdir()) if i.is_dir()], "base": str(MAIN_PATH_DIR)} - } - except Exception: payload = {} - await websocket.send(json.dumps({"event": "state", "data": payload})) elif action == "request_dir": what: str = msg.get("what", "") try: @@ -142,21 +123,23 @@ def websocket_server_process(shared_data: dict, imc_q: multiprocessing.Queue, ws Headers([("Content-Type", "text/html"), ("Content-Length", f"{len(data)}")]), data ) - if request.path == "/favicon.ico": - data = b"Not Found" + if request.path == "/ws": + if not "upgrade" in request.headers.get("Connection", "").lower(): + return Response( + 426, + "Upgrade Required", + Headers([("Connection", "Upgrade"), ("Upgrade", "websocket")]), + b"WebSocket upgrade required\n" + ) + return None + else: + data = b"Not Found\n" return Response( 404, "Not Found", Headers([("Content-Length", f"{len(data)}")]), data ) - if not "upgrade" in request.headers.get("Connection", "").lower(): - return Response( - 426, - "Upgrade Required", - Headers([("Connection", "Upgrade"), ("Upgrade", "websocket")]), - b"WebSocket upgrade required\n" - ) server = await websockets.serve(handler_wrapper, "0.0.0.0", 3001, server_header="RadioPlayer ws plugin", process_request=process_request) broadcaster = asyncio.create_task(broadcast_worker(ws_q, clients)) @@ -179,6 +162,7 @@ class Module(PlayerModule): self.data["playlist"] = "[]" self.data["track"] = "{}" self.data["progress"] = "{}" + self.data["rds"] = "{}" self.ipc_thread_running = True self.ipc_thread = threading.Thread(target=self._ipc_worker, daemon=True) @@ -192,10 +176,6 @@ class Module(PlayerModule): except Exception: pass def _ipc_worker(self): - """ - Listens for messages placed in imc_q by websocket process or other modules, - forwards them to the main IPC layer and stores keyed responses into shared dict. - """ while self.ipc_thread_running: try: message: dict | None = self.imc_q.get(timeout=0.5) @@ -215,8 +195,7 @@ class Module(PlayerModule): "official": track.official, "args": track.args, "offset": track.offset, - "focus_time_offset": track.focus_time_offset, - "global_args": global_args + "focus_time_offset": track.focus_time_offset }) self.data["playlist"] = json.dumps(api_data) try: self.ws_q.put({"event": "playlist", "data": api_data}) @@ -239,9 +218,13 @@ class Module(PlayerModule): except Exception: pass def imc_data(self, source: BaseIMCModule, source_name: str | None, data: object, broadcast: bool) -> object: - try: self.ws_q.put({"event": "imc", "data": {"name": source_name, "data": data, "broadcast": broadcast}}) + wsdata = {"event": "imc", "data": {"name": source_name, "data": data, "broadcast": broadcast}} + if source_name == "rds": + self.data[source_name] = data + wsdata = {"event": "rds", "data": data} + try: self.ws_q.put(wsdata) except Exception: pass - + def imc(self, imc: InterModuleCommunication) -> None: self._imc = imc imc.register(self, "web") @@ -258,6 +241,9 @@ class Module(PlayerModule): self.ipc_thread.join(timeout=1) self.ws_process.join(timeout=1) + self.imc_q.close() + self.ws_q.close() + if self.ws_process.is_alive(): self.ws_process.terminate() self.ws_process.join(timeout=1) @@ -266,6 +252,9 @@ class Module(PlayerModule): self.ws_process.kill() self.ws_process.join(timeout=1) + self.imc_q.join_thread() + self.ws_q.join_thread() + module = Module() # This is free and unencumbered software released into the public domain. diff --git a/radioPlayer.py b/radioPlayer.py index f07aaae..e7f2245 100644 --- a/radioPlayer.py +++ b/radioPlayer.py @@ -54,6 +54,7 @@ class ModuleManager: def start_modules(self, arg): procman = None parser = None + neg_modifiers = [] """Executes the module by the python interpreter""" def timed_loader(spec: importlib.machinery.ModuleSpec, module: types.ModuleType): assert spec.loader @@ -82,8 +83,12 @@ class ModuleManager: 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) + if index > -1: + if isinstance(md, list): self.playlist_modifier_modules[index:index] = md + else: self.playlist_modifier_modules.insert(index, md) + else: + if isinstance(md, list): neg_modifiers[index:index] = md + else: neg_modifiers.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): @@ -116,6 +121,7 @@ class ModuleManager: self.logger.critical_error("Missing process mananger.") raise SystemExit("Missing process mananger.") if not parser: self.logger.warning("Missing parser, advisor-less will be forced.") + self.playlist_modifier_modules += neg_modifiers InterModuleCommunication(self.simple_modules + [self.playlist_advisor, ProcmanCommunicator(procman), self.active_modifier]) return procman, parser def advisor_advise(self, arguments: str | None): @@ -183,6 +189,7 @@ class RadioPlayer: def _play(self, playlist: list[Track] | None, max_iterator: int): assert self.procman + running = True return_pending = track = False song_i = i = 0 def get_track(): @@ -204,7 +211,7 @@ class RadioPlayer: return track, next_track, extend def check_conditions(): - nonlocal return_pending + nonlocal return_pending, running assert self.procman if self.exit_pending: self.logger.info("Quit received, waiting for song end.") @@ -213,18 +220,17 @@ class RadioPlayer: elif return_pending: self.logger.info("Return reached, next song will reload the playlist.") self.procman.wait_all() - return True + running = False if self.modman.playlist_advisor and self.modman.playlist_advisor.new_playlist(): self.logger.info("Reloading now...") - return True - return False + running = False track, next_track, extend = get_track() - while i < max_iterator: - if check_conditions(): return + while i < max_iterator and running: + check_conditions() + self.procman.anything_playing() if not track.path.exists(): - self.procman.anything_playing() track, next_track, extend = get_track() prefetch(track.path) i += 1 @@ -250,7 +256,8 @@ class RadioPlayer: i += 1 if not extend: song_i += 1 - if check_conditions(): return + check_conditions() + self.procman.anything_playing() track, next_track, extend = get_track() prefetch(track.path) @@ -266,7 +273,7 @@ class RadioPlayer: class RotatingLog(io.TextIOWrapper): def write(self, *args, **kwargs) -> int: - if self.tell() > 2_500_000: + if self.tell() > 2_000_000: self.truncate(0) self.seek(0) return super().write(*args, **kwargs)