some updates

This commit is contained in:
2026-04-15 19:56:40 +02:00
parent 2f4202a096
commit 32c61996f9
16 changed files with 126 additions and 221 deletions
+12 -27
View File
@@ -34,18 +34,14 @@ class ABC_ProcessManager(abc.ABC):
class BaseIMCModule: class BaseIMCModule:
"""This is not a module to be used but rather a placeholder IMC api to be used in other modules""" """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: def imc(self, imc: 'InterModuleCommunication') -> None:
""" """Receive an IMC object"""
Receive an IMC object
"""
self._imc = imc self._imc = imc
def imc_data(self, source: 'BaseIMCModule', source_name: str | None, data: object, broadcast: bool) -> object: def imc_data(self, source: 'BaseIMCModule', source_name: str | None, data: object, broadcast: bool) -> object: """React to IMC data"""
"""
React to IMC data
"""
return None
class ProcmanCommunicator(BaseIMCModule): 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: def imc(self, imc: 'InterModuleCommunication') -> None:
super().imc(imc) super().imc(imc)
self._imc.register(self, "procman") self._imc.register(self, "procman")
@@ -57,7 +53,7 @@ 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": 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 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))
@@ -71,28 +67,21 @@ class ProcmanCommunicator(BaseIMCModule):
class PlayerModule(BaseIMCModule): class PlayerModule(BaseIMCModule):
"""Simple passive observer, this allows you to send the current track the your RDS encoder, or to your website""" """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""" """This is called every new playlist"""
pass
def on_new_track(self, index: int, track: Track, next_track: Track | None) -> None: 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""" """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: 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) 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 Please don't put any blocking or code that takes time
""" """
pass def shutdown(self): """Ran while shutting down"""
def shutdown(self):
"""Ran while shutting down"""
pass
class PlaylistModifierModule: class PlaylistModifierModule:
"""Playlist modifier, this type of module allows you to shuffle, or put jingles into your playlist""" """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: 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 return playlist
# No IMC, as we only run on new playlists # No IMC, as we only run on new playlists
class PlaylistAdvisor(BaseIMCModule): class PlaylistAdvisor(BaseIMCModule):
@@ -105,9 +94,7 @@ class PlaylistAdvisor(BaseIMCModule):
return False return False
class ActiveModifier(BaseIMCModule): 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""" """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: def arguments(self, arguments: str | None) -> None: """Called at start up with the program arguments"""
"""Called at start up with the program arguments"""
pass
def play(self, index: int, track: Track | None, next_track: Track | None) -> tuple[tuple[Track | None, Track | None], bool | None]: 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 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 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 return (track, None), False
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: """Same behaviour as the basic module function"""
"""Same behaviour as the basic module function"""
pass
class InterModuleCommunication: class InterModuleCommunication:
def __init__(self, modules: Sequence[BaseIMCModule | None]) -> None: def __init__(self, modules: Sequence[BaseIMCModule | None]) -> None:
self.modules = modules self.modules = modules
+3 -4
View File
@@ -1,7 +1,7 @@
MORNING_START = 5 MORNING_START = 5
MORNING_END = 10 MORNING_END = 10
DAY_START = 10 DAY_START = 10
DAY_END = 18 DAY_END = 19
LATE_NIGHT_START = 0 LATE_NIGHT_START = 0
LATE_NIGHT_END = 5 LATE_NIGHT_END = 5
@@ -51,7 +51,6 @@ class Module(PlaylistAdvisor):
def __init__(self) -> None: def __init__(self) -> None:
self.last_mod_time = 0 self.last_mod_time = 0
self.last_playlist = None self.last_playlist = None
self.class_imc = None
self.custom_playlist = None self.custom_playlist = None
self.custom_playlist_path = Path("/tmp/radioPlayer_list") self.custom_playlist_path = Path("/tmp/radioPlayer_list")
@@ -104,7 +103,7 @@ class Module(PlaylistAdvisor):
logger.info(f"Playing {current_day} night playlist...") logger.info(f"Playing {current_day} night playlist...")
self.last_mod_time = Time.get_playlist_modification_time(night_playlist) self.last_mod_time = Time.get_playlist_modification_time(night_playlist)
self.last_playlist = 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 return self.last_playlist
def new_playlist(self) -> bool: def new_playlist(self) -> bool:
if self.custom_playlist and self.custom_playlist_path.exists(): if self.custom_playlist and self.custom_playlist_path.exists():
@@ -128,7 +127,7 @@ class Module(PlaylistAdvisor):
return True return True
return False return False
def imc(self, imc: InterModuleCommunication) -> None: def imc(self, imc: InterModuleCommunication) -> None:
self.class_imc = imc self._imc = imc
imc.register(self, "advisor") imc.register(self, "advisor")
def imc_data(self, source: BaseIMCModule, source_name: str | None, data: object, broadcast: bool): def imc_data(self, source: BaseIMCModule, source_name: str | None, data: object, broadcast: bool):
return (self.custom_playlist, MORNING_START, DAY_END) return (self.custom_playlist, MORNING_START, DAY_END)
+2 -2
View File
@@ -1,5 +1,5 @@
from . import PlaylistModifierModule, Track from . import PlaylistModifierModule, Track
DEFAULT_CROSSFADE = 5.0 DEFAULT_CROSSFADE = 6.0
class Module(PlaylistModifierModule): class Module(PlaylistModifierModule):
def modify(self, global_args: dict, playlist: list[Track]) -> list[Track] | None: def modify(self, global_args: dict, playlist: list[Track]) -> list[Track] | None:
out = [] 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)) out.append(Track(track.path, track_crossfade, track_crossfade, do_cross_fade, track.args, focus_time_offset=-track_crossfade))
else: out.append(track) else: out.append(track)
return out return out
playlistmod = (Module(),1) playlistmod = Module(), 0
# This is free and unencumbered software released into the public domain. # This is free and unencumbered software released into the public domain.
+4 -5
View File
@@ -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 from threading import Lock
import subprocess, time import subprocess, time
@@ -6,13 +6,12 @@ class ProcessManager(ABC_ProcessManager):
def __init__(self) -> None: def __init__(self) -> None:
self.lock = Lock() self.lock = Lock()
self.processes: list[Process] = [] self.processes: list[Process] = []
def _get_audio_duration(self, file_path: Path): self.tinytag = tinytag.TinyTag()
return tinytag.TinyTag().get(file_path, tags=False).duration
def play(self, track: Track) -> Process: def play(self, track: Track) -> Process:
assert track.path.exists() assert track.path.exists()
cmd = ['ffplay', '-nodisp', '-hide_banner', '-autoexit', '-loglevel', 'quiet'] 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 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 >= duration: track.offset = max(duration - 0.1, 0)
if track.offset > 0: cmd.extend(['-ss', str(track.offset)]) if track.offset > 0: cmd.extend(['-ss', str(track.offset)])
@@ -35,7 +34,7 @@ class ProcessManager(ABC_ProcessManager):
try: p.process.wait(timeout=0) try: p.process.wait(timeout=0)
except subprocess.TimeoutExpired: pass except subprocess.TimeoutExpired: pass
self.processes = alive self.processes = alive
return bool(self.processes) return bool(alive)
def stop_all(self, timeout: float | None = None) -> None: def stop_all(self, timeout: float | None = None) -> None:
with self.lock: with self.lock:
for process in self.processes: for process in self.processes:
+1 -2
View File
@@ -11,8 +11,7 @@ def _parse_args(text: str) -> dict[str, str]:
if "=" in line: if "=" in line:
key, val = line.split("=", 1) key, val = line.split("=", 1)
args[key.strip()] = val.strip() args[key.strip()] = val.strip()
else: else: args[line] = True
args[line] = True
return args return args
class FSDBParser(PlaylistParser): class FSDBParser(PlaylistParser):
+8 -6
View File
@@ -25,6 +25,8 @@ def get_jingles():
if not master: master = jingles.pop(0) if not master: master = jingles.pop(0)
return master, jingles return master, jingles
def chance(one_in_n): return random.randint(1, one_in_n) == 1
class Module(PlaylistModifierModule): class Module(PlaylistModifierModule):
def modify(self, global_args: dict, playlist: list[Track]) -> list[Track] | None: def modify(self, global_args: dict, playlist: list[Track]) -> list[Track] | None:
if int(global_args.get("no_jingle", 0)) != 0: return None if int(global_args.get("no_jingle", 0)) != 0: return None
@@ -34,26 +36,26 @@ class Module(PlaylistModifierModule):
out: list[Track] = [] out: list[Track] = []
last_jingiel = True last_jingiel = True
for track in playlist: 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)) out.append(Track(track.path, 0, track.fade_in, True, track.args))
jingle = primary 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, {})) out.append(Track(jingle, 0, 0, False, {}))
last_jingiel = True last_jingiel = True
continue continue
out.append(Track(track.path, track.fade_out, track.fade_in, True, track.args,focus_time_offset=-track.fade_out)) out.append(Track(track.path, track.fade_out, track.fade_in, True, track.args,focus_time_offset=-track.fade_out))
last_jingiel = False last_jingiel = False
return out return out
class Module2(PlayerModule): class Module2(PlayerModule):
def imc(self, imc: InterModuleCommunication) -> None: def imc(self, imc: InterModuleCommunication) -> None:
super().imc(imc) 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: def imc_data(self, source: BaseIMCModule, source_name: str | None, data: bool, broadcast: bool) -> object:
if broadcast: return if broadcast: return
jingle, secondary = get_jingles() 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)}) return self._imc.send(self, "activemod", {"action": "add_to_toplay", "songs": [f"!{jingle}"], "top": bool(data)})
module = Module2() module = Module2()
playlistmod = Module() playlistmod = Module(), 2
+8
View File
@@ -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
+1 -2
View File
@@ -34,7 +34,6 @@ class Module(PlayerModule):
def on_new_track(self, index: int, track: Track, next_track: Track | None) -> None: 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.counts[track.path.as_posix()] = self.counts.get(track.path.as_posix(), 0) + 1
self._save_counts() self._save_counts()
def shutdown(self): def shutdown(self): self._save_counts()
self._save_counts()
module = Module() module = Module()
+6 -17
View File
@@ -4,32 +4,22 @@ _log_out: log95.TextIO
assert _log_out # pyright: ignore[reportUnboundVariable] assert _log_out # pyright: ignore[reportUnboundVariable]
def load_play_counts(file_path: Path) -> dict[str, int]: def load_play_counts(file_path: Path) -> dict[str, int]:
"""
Loads the play counts from the file generated by the play_counter module.
"""
counts = {} counts = {}
try: try:
with open(file_path, 'r') as file: with open(file_path, 'r') as file:
for line in file: for line in file:
if line.strip() == "" or line.startswith(";"): if line.strip() == "" or line.startswith(";"): continue
continue
try: try:
key, value = line.split(':', 1) key, value = line.split(':', 1)
counts[key.strip()] = int(value.strip()) counts[key.strip()] = int(value.strip())
except ValueError: except ValueError: continue
continue
return counts return counts
except FileNotFoundError: except FileNotFoundError: return {}
return {}
class PopularitySorterModule(PlaylistModifierModule): 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: def __init__(self) -> None:
self.logger = log95.log95("PopSort", output=_log_out) 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]: def modify(self, global_args: dict, playlist: list[Track]) -> list[Track]:
self.logger.info("Applying popularity-based sorting to the playlist...") 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) count1 = play_counts.get(track1.path.as_posix(), 0)
count2 = play_counts.get(track2.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 playlist[i], playlist[i+1] = track2, track1
i += 2 i += 2
self.logger.info("Popularity sorting complete.") self.logger.info("Popularity sorting complete.")
return playlist return playlist
playlistmod = (PopularitySorterModule(), 2) playlistmod = PopularitySorterModule(), 2
-55
View File
@@ -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()
+29 -1
View File
@@ -1,3 +1,5 @@
from modules import InterModuleCommunication
from . import PlayerModule, log95, Track from . import PlayerModule, log95, Track
import socket 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 logger_level = log95.log95Levels.DEBUG if DEBUG else log95.log95Levels.CRITICAL_ERROR
_log_out: log95.TextIO _log_out: log95.TextIO
assert _log_out # pyright: ignore[reportUnboundVariable] 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]: def load_dict_from_custom_format(file_path: str) -> dict[str, str]:
try: try:
@@ -84,5 +86,31 @@ class Module(PlayerModule):
self._imc.send(self, "web", {"rt": rds_rt, "rtp": rds_rtp}, False) self._imc.send(self, "web", {"rt": rds_rt, "rtp": rds_rtp}, False)
logger.info(f"RT set to '{rds_rt}'") logger.info(f"RT set to '{rds_rt}'")
logger.debug(f"{rds_rtp=}") logger.debug(f"{rds_rtp=}")
def imc(self, imc: InterModuleCommunication) -> None: imc.register(self, "rds")
module = Module() 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 <https://unlicense.org>
+1 -1
View File
@@ -7,7 +7,7 @@ class Module(PlaylistModifierModule):
if int(global_args.get("no_shuffle", 0)) == 0: random.shuffle(playlist) if int(global_args.get("no_shuffle", 0)) == 0: random.shuffle(playlist)
return playlist return playlist
playlistmod = (Module(), 0) playlistmod = Module(), 1
# This is free and unencumbered software released into the public domain. # This is free and unencumbered software released into the public domain.
-43
View File
@@ -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 <https://unlicense.org>
+2 -3
View File
@@ -281,9 +281,8 @@
function handleMessage(msg){ function handleMessage(msg){
if(msg.event === "state"){ if(msg.event === "state"){
const d = msg.data || {}; const d = msg.data || {};
applyProgressState(d.track); if(d.track) applyProgressState(d.track);
updateDirs(d.dirs); if(d.dirs) updateDirs(d.dirs);
if(d.playlist) { playlist = d.playlist; renderPlaylist(); }
} else if(msg.event === "playlist"){ } else if(msg.event === "playlist"){
playlist = msg.data || []; playlist = msg.data || [];
renderPlaylist(); renderPlaylist();
+31 -42
View File
@@ -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): async def ws_handler(websocket: ServerConnection, shared_data: dict, imc_q: multiprocessing.Queue, ws_q: multiprocessing.Queue):
try: try:
initial = { initial = {
"playlist": json.loads(shared_data.get("playlist", "[]")),
"track": json.loads(shared_data.get("track", "{}")), "track": json.loads(shared_data.get("track", "{}")),
"progress": json.loads(shared_data.get("progress", "{}")), "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": "state", "data": initial}))
await websocket.send(json.dumps({"event": "playlist", "data": json.loads(shared_data.get("playlist", "[]"))}))
async for raw in websocket: async for raw in websocket:
try: msg: dict = json.loads(raw) 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()) key = str(uuid.uuid4())
imc_q.put({"name": name, "data": data, "key": key}) imc_q.put({"name": name, "data": data, "key": key})
start = time.monotonic() start = time.monotonic()
result = None
while time.monotonic() - start < 1: while time.monotonic() - start < 1:
if key in shared_data: if key in shared_data: return shared_data.pop(key)
result = shared_data.pop(key)
break
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
return result return None
action = msg.get("action") action = msg.get("action")
if action == "skip": 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"}) result = await get_imc("activemod", {"action": "get_toplay"})
if result is not None: if result is not None:
await asyncio.get_event_loop().run_in_executor(None, ws_q.put, {"data": result, "event": "toplay"}) 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": elif action == "request_dir":
what: str = msg.get("what", "") what: str = msg.get("what", "")
try: 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)}")]), Headers([("Content-Type", "text/html"), ("Content-Length", f"{len(data)}")]),
data data
) )
if request.path == "/favicon.ico": if request.path == "/ws":
data = b"Not Found" 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( return Response(
404, 404,
"Not Found", "Not Found",
Headers([("Content-Length", f"{len(data)}")]), Headers([("Content-Length", f"{len(data)}")]),
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) 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)) broadcaster = asyncio.create_task(broadcast_worker(ws_q, clients))
@@ -179,6 +162,7 @@ class Module(PlayerModule):
self.data["playlist"] = "[]" self.data["playlist"] = "[]"
self.data["track"] = "{}" self.data["track"] = "{}"
self.data["progress"] = "{}" self.data["progress"] = "{}"
self.data["rds"] = "{}"
self.ipc_thread_running = True self.ipc_thread_running = True
self.ipc_thread = threading.Thread(target=self._ipc_worker, daemon=True) self.ipc_thread = threading.Thread(target=self._ipc_worker, daemon=True)
@@ -192,10 +176,6 @@ class Module(PlayerModule):
except Exception: pass except Exception: pass
def _ipc_worker(self): 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: while self.ipc_thread_running:
try: try:
message: dict | None = self.imc_q.get(timeout=0.5) message: dict | None = self.imc_q.get(timeout=0.5)
@@ -215,8 +195,7 @@ class Module(PlayerModule):
"official": track.official, "official": track.official,
"args": track.args, "args": track.args,
"offset": track.offset, "offset": track.offset,
"focus_time_offset": track.focus_time_offset, "focus_time_offset": track.focus_time_offset
"global_args": global_args
}) })
self.data["playlist"] = json.dumps(api_data) self.data["playlist"] = json.dumps(api_data)
try: self.ws_q.put({"event": "playlist", "data": api_data}) try: self.ws_q.put({"event": "playlist", "data": api_data})
@@ -239,9 +218,13 @@ class Module(PlayerModule):
except Exception: pass except Exception: pass
def imc_data(self, source: BaseIMCModule, source_name: str | None, data: object, broadcast: bool) -> object: 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 except Exception: pass
def imc(self, imc: InterModuleCommunication) -> None: def imc(self, imc: InterModuleCommunication) -> None:
self._imc = imc self._imc = imc
imc.register(self, "web") imc.register(self, "web")
@@ -258,6 +241,9 @@ class Module(PlayerModule):
self.ipc_thread.join(timeout=1) self.ipc_thread.join(timeout=1)
self.ws_process.join(timeout=1) self.ws_process.join(timeout=1)
self.imc_q.close()
self.ws_q.close()
if self.ws_process.is_alive(): if self.ws_process.is_alive():
self.ws_process.terminate() self.ws_process.terminate()
self.ws_process.join(timeout=1) self.ws_process.join(timeout=1)
@@ -266,6 +252,9 @@ class Module(PlayerModule):
self.ws_process.kill() self.ws_process.kill()
self.ws_process.join(timeout=1) self.ws_process.join(timeout=1)
self.imc_q.join_thread()
self.ws_q.join_thread()
module = Module() module = Module()
# This is free and unencumbered software released into the public domain. # This is free and unencumbered software released into the public domain.
+18 -11
View File
@@ -54,6 +54,7 @@ class ModuleManager:
def start_modules(self, arg): def start_modules(self, arg):
procman = None procman = None
parser = None parser = None
neg_modifiers = []
"""Executes the module by the python interpreter""" """Executes the module by the python interpreter"""
def timed_loader(spec: importlib.machinery.ModuleSpec, module: types.ModuleType): def timed_loader(spec: importlib.machinery.ModuleSpec, module: types.ModuleType):
assert spec.loader assert spec.loader
@@ -82,8 +83,12 @@ class ModuleManager:
if md := getattr(module, "playlistmod", None): if md := getattr(module, "playlistmod", None):
if isinstance(md, tuple): if isinstance(md, tuple):
md, index = md md, index = md
if isinstance(md, list): self.playlist_modifier_modules[index:index] = md if index > -1:
else: self.playlist_modifier_modules.insert(index, md) 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) elif isinstance(md, list): self.playlist_modifier_modules.extend(md)
else: self.playlist_modifier_modules.append(md) else: self.playlist_modifier_modules.append(md)
if md := getattr(module, "advisor", None): if md := getattr(module, "advisor", None):
@@ -116,6 +121,7 @@ class ModuleManager:
self.logger.critical_error("Missing process mananger.") self.logger.critical_error("Missing process mananger.")
raise SystemExit("Missing process mananger.") raise SystemExit("Missing process mananger.")
if not parser: self.logger.warning("Missing parser, advisor-less will be forced.") 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]) InterModuleCommunication(self.simple_modules + [self.playlist_advisor, ProcmanCommunicator(procman), self.active_modifier])
return procman, parser return procman, parser
def advisor_advise(self, arguments: str | None): def advisor_advise(self, arguments: str | None):
@@ -183,6 +189,7 @@ class RadioPlayer:
def _play(self, playlist: list[Track] | None, max_iterator: int): def _play(self, playlist: list[Track] | None, max_iterator: int):
assert self.procman assert self.procman
running = True
return_pending = track = False return_pending = track = False
song_i = i = 0 song_i = i = 0
def get_track(): def get_track():
@@ -204,7 +211,7 @@ class RadioPlayer:
return track, next_track, extend return track, next_track, extend
def check_conditions(): def check_conditions():
nonlocal return_pending nonlocal return_pending, running
assert self.procman assert self.procman
if self.exit_pending: if self.exit_pending:
self.logger.info("Quit received, waiting for song end.") self.logger.info("Quit received, waiting for song end.")
@@ -213,18 +220,17 @@ class RadioPlayer:
elif return_pending: elif return_pending:
self.logger.info("Return reached, next song will reload the playlist.") self.logger.info("Return reached, next song will reload the playlist.")
self.procman.wait_all() self.procman.wait_all()
return True running = False
if self.modman.playlist_advisor and self.modman.playlist_advisor.new_playlist(): if self.modman.playlist_advisor and self.modman.playlist_advisor.new_playlist():
self.logger.info("Reloading now...") self.logger.info("Reloading now...")
return True running = False
return False
track, next_track, extend = get_track() track, next_track, extend = get_track()
while i < max_iterator: while i < max_iterator and running:
if check_conditions(): return check_conditions()
self.procman.anything_playing()
if not track.path.exists(): if not track.path.exists():
self.procman.anything_playing()
track, next_track, extend = get_track() track, next_track, extend = get_track()
prefetch(track.path) prefetch(track.path)
i += 1 i += 1
@@ -250,7 +256,8 @@ class RadioPlayer:
i += 1 i += 1
if not extend: song_i += 1 if not extend: song_i += 1
if check_conditions(): return check_conditions()
self.procman.anything_playing()
track, next_track, extend = get_track() track, next_track, extend = get_track()
prefetch(track.path) prefetch(track.path)
@@ -266,7 +273,7 @@ class RadioPlayer:
class RotatingLog(io.TextIOWrapper): class RotatingLog(io.TextIOWrapper):
def write(self, *args, **kwargs) -> int: def write(self, *args, **kwargs) -> int:
if self.tell() > 2_500_000: if self.tell() > 2_000_000:
self.truncate(0) self.truncate(0)
self.seek(0) self.seek(0)
return super().write(*args, **kwargs) return super().write(*args, **kwargs)