diff --git a/modules/__init__.py b/modules/__init__.py index 4a1cd8a..fbc59c9 100644 --- a/modules/__init__.py +++ b/modules/__init__.py @@ -1,5 +1,6 @@ import log95, abc from collections.abc import Sequence +from typing import Literal from subprocess import Popen from dataclasses import dataclass from pathlib import Path @@ -22,15 +23,18 @@ class Process: started_at: float duration: float +class RejectedTrack(Exception): ... class ABC_ProcessManager(abc.ABC): @abc.abstractmethod - def play(self, track: Track) -> Process: ... + def play(self, track: Track) -> Process: """RejectedTrack may be raised by this function if the track cannot be played, but other can""" @abc.abstractmethod def anything_playing(self) -> bool: ... @abc.abstractmethod def stop_all(self, timeout: float | None = None) -> None: ... @abc.abstractmethod def wait_all(self, timeout: float | None = None) -> None: ... + @abc.abstractmethod + def test(self) -> bool: """Ran on startup. This should return false if this process manager can't play any track""" 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: @@ -136,7 +140,9 @@ class PlaylistParser: and a dictionary of str:str consistent of the arguments which affect the files given """ return {}, [] - + +# This file is a part of RadioPlayer + # This is free and unencumbered software released into the public domain. # Anyone is free to copy, modify, publish, use, compile, sell, or diff --git a/modules/active_modifier.py b/modules/active_modifier.py index ef0b7ba..baf4d0a 100644 --- a/modules/active_modifier.py +++ b/modules/active_modifier.py @@ -2,7 +2,7 @@ from modules import BaseIMCModule, InterModuleCommunication from . import ActiveModifier, log95, Track, Path import os, glob, datetime from threading import Lock -DEFAULT_CROSSFADE = 5 +DEFAULT_CROSSFADE = 6 from typing import TextIO _log_out: TextIO @@ -95,9 +95,6 @@ class Module(ActiveModifier): logger.info("Skipping...") self.skip_next -= 1 return self.play(index, track, next_track) - if index in self.skip_indexes: - logger.info("Skipping...") - return self.play(index, track, next_track) return (self.last_track, next_track), True elif len(self.originals): self.last_track = self.originals.pop(0) diff --git a/modules/ffmpeg_procman.py b/modules/ffmpeg_procman.py index a260f1e..89c914a 100644 --- a/modules/ffmpeg_procman.py +++ b/modules/ffmpeg_procman.py @@ -1,4 +1,4 @@ -from . import ABC_ProcessManager, Process, Track, Popen, tinytag +from . import ABC_ProcessManager, Process, Track, Popen, tinytag, RejectedTrack from threading import Lock import subprocess, time @@ -7,8 +7,9 @@ class ProcessManager(ABC_ProcessManager): self.lock = Lock() self.processes: list[Process] = [] self.tinytag = tinytag.TinyTag() + def play(self, track: Track) -> Process: - assert track.path.exists() + if track.path.suffix not in self.tinytag.SUPPORTED_FILE_EXTENSIONS or not track.path.exists(): raise RejectedTrack cmd = ['ffplay', '-nodisp', '-hide_banner', '-autoexit', '-loglevel', 'quiet'] duration = self.tinytag.get(track.path.absolute(), tags=False).duration @@ -48,6 +49,14 @@ class ProcessManager(ABC_ProcessManager): try: process.process.wait(timeout) except subprocess.TimeoutExpired: process.process.terminate() self.processes.clear() + def test(self) -> bool: + proc = Popen(["ffplay"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + start = time.monotonic() + while proc.poll() is None and (time.monotonic() - start) < 1: time.sleep(0.01) + + if proc.poll() is None: proc.kill() + return proc.poll() not in (None, 127) procman = ProcessManager() diff --git a/modules/jingle.py b/modules/jingle.py index e11c39e..9288c00 100644 --- a/modules/jingle.py +++ b/modules/jingle.py @@ -59,3 +59,28 @@ class Module2(PlayerModule): module = Module2() playlistmod = Module(), 2 + +# 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/modules.txt b/modules/modules.txt deleted file mode 100644 index fd7e894..0000000 --- a/modules/modules.txt +++ /dev/null @@ -1,16 +0,0 @@ -Modules in the radioPlayer are quite simple. - -First of all, ther are in total only 4 modules: -- Observer (PlayerModule), this module is a passive observer, you can use this as a status api or to send the song metadata to your RDS encoder -- Modifier (PlaylistModifierModule), module which intercepts the playlist before playing and replaced it, this module can shuffle the playlist, insert jingles or intros and outros -- (!) Advisor (PlaylistAdvisor), this module is very important and is required to run and there can be only one of these in a core session. It is responsible for picking the playlist file itself as in a file path. This can be a scheduler, or just a constant -- Active modifier (ActiveModifier), this module is optional, but there can still be only one. This module can replace the track while playing, allowing you to skip tracks or play tracks on demand, it can also extend the playlist - -Each module shall have a python script in the modules directory. Each of the modules need to define one or more global variables in order to be seen by the core: -- module (list['PlayerModule'] or 'PlayerModule'), this shall be just the list or one passive observer class -- playlistmod ('PlaylistModifierModule', list['PlaylistModifierModule'], tuple['PlaylistModifierModule' | list['PlaylistModifierModule'], int]), module itself, list of modules or the module itself and list of them with an index integer which sets the order of modifiers (0 is first) -- advisor ('PlaylistAdvisor') -- activemod ('ActiveModifier') - -NEW! The procman communicator allows you to get the track duration, but also STOP WHATEVER IS PLAYING! That means we can skip tracks WHILE THEY ARE PLAYING -Newer! You can run advisor-less, this however also remvoes the usage of any and all playlist modifiers, because there is no playlist in such case \ No newline at end of file diff --git a/modules/no_intro_fade_in.py b/modules/no_intro_fade_in.py index 55d3c53..e306f50 100644 --- a/modules/no_intro_fade_in.py +++ b/modules/no_intro_fade_in.py @@ -6,3 +6,28 @@ class Module(PlaylistModifierModule): return playlist playlistmod = Module(), -1 + +# 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/play_counter.py b/modules/play_counter.py index 210caa3..0d7d618 100644 --- a/modules/play_counter.py +++ b/modules/play_counter.py @@ -37,4 +37,29 @@ class Module(PlayerModule): self._save_counts() def shutdown(self): self._save_counts() -module = Module() \ No newline at end of file +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/play_sort.py b/modules/play_sort.py index 5ea3566..1b707f7 100644 --- a/modules/play_sort.py +++ b/modules/play_sort.py @@ -66,4 +66,29 @@ class PopularitySorterModule(PlaylistModifierModule): self.logger.info("Popularity sorting complete.") return playlist -playlistmod = PopularitySorterModule(), 2 \ No newline at end of file +playlistmod = PopularitySorterModule(), 2 + +# 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.py b/modules/web.py index 5a74a94..428bb7a 100644 --- a/modules/web.py +++ b/modules/web.py @@ -9,10 +9,8 @@ import mimetypes import shutil def get_content_type(filename: str) -> str: - mime_type, _ = mimetypes.guess_type(filename) + mime_type, _ = mimetypes.guess_type(filename, False) if mime_type: return mime_type - - # Final fallback return "application/octet-stream" from modules import InterModuleCommunication @@ -249,8 +247,9 @@ class Module(PlayerModule): "offset": track.offset, "focus_time_offset": track.focus_time_offset }) - self.data["playlist"] = json.dumps(api_data) - try: self.ws_q.put({"event": "playlist", "data": api_data}) + output_data = {"playlist": api_data, "global_args": global_args} + self.data["playlist"] = json.dumps(output_data) + try: self.ws_q.put({"event": "playlist", "data": output_data}) except Exception: pass def on_new_track(self, index: int, track: Track, next_track: Track | None) -> None: diff --git a/modules/web/web.js b/modules/web/web.js index 39dec5e..a865d40 100644 --- a/modules/web/web.js +++ b/modules/web/web.js @@ -104,7 +104,7 @@ function handleMessage(msg) { document.getElementById("rds-text").textContent = msg.data?.rt ?? ""; break; case "playlist": - playlist = msg.data || []; + playlist = msg.data?.playlist || []; renderAll(); break; case "new_track": diff --git a/radioPlayer.py b/radioPlayer.py index a1adb5c..5736a35 100644 --- a/radioPlayer.py +++ b/radioPlayer.py @@ -11,7 +11,6 @@ def prefetch(path): with open(path, "rb") as f: fd = f.fileno() os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_SEQUENTIAL) - os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_NOREUSE) os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_WILLNEED) except Exception: pass @@ -122,6 +121,9 @@ class ModuleManager: if not procman: self.logger.critical_error("Missing process mananger.") raise SystemExit("Missing process mananger.") + if not procman.test(): + self.logger.critical_error("Process manager has failed its test.") + raise SystemExit("Process manager has failed its test.") 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]) @@ -158,7 +160,7 @@ class RadioPlayer: raise SystemExit(130) def start(self): - """Single functon for starting the core, returns but might exit raising an SystemExit""" + """Single functon for starting the core, returns but might exit raising a SystemExit""" self.logger.info("Core starting, loading modules") self.modman.load_modules() self.procman, self.parser = self.modman.start_modules(self.arg) @@ -243,15 +245,18 @@ class RadioPlayer: self.logger.info("Now playing:", track.path.name) prefetch(track.path) - pr = self.procman.play(track) - [module.on_new_track(song_i, pr.track, next_track) for module in self.modman.simple_modules if module] - end_time = pr.started_at + pr.duration + pr.track.focus_time_offset - self.procman.anything_playing() + try: + pr = self.procman.play(track) + [module.on_new_track(song_i, pr.track, next_track) for module in self.modman.simple_modules if module] + end_time = pr.started_at + pr.duration + pr.track.focus_time_offset + self.procman.anything_playing() - while end_time >= time.monotonic() and pr.process.poll() is None: - start = time.monotonic() - [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)) + while end_time >= time.monotonic() and pr.process.poll() is None: + start = time.monotonic() + [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)) + except RejectedTrack: pass + except BaseException: raise self.procman.anything_playing() if next_track: prefetch(next_track.path) @@ -276,8 +281,9 @@ class RadioPlayer: class RotatingLog(io.TextIOWrapper): def write(self, *args, **kwargs) -> int: if self.tell() > 2_000_000: - self.truncate(0) + self.flush() self.seek(0) + self.truncate(0) return super().write(*args, **kwargs) def main():