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

some changes, also change licence to the UNLICENCE!

This commit is contained in:
2025-12-02 17:42:11 +01:00
parent 75802ccef4
commit 88b44ddf32
9 changed files with 67 additions and 63 deletions

12
.gitignore vendored
View File

@@ -85,31 +85,31 @@ ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
@@ -165,7 +165,7 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.idea/
# Ruff stuff:
.ruff_cache/

37
LICENSE
View File

@@ -1,21 +1,24 @@
MIT License
This is free and unencumbered software released into the public domain.
Copyright (c) 2025 Kuba
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.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
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 above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
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.
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 OR COPYRIGHT HOLDERS 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

@@ -140,14 +140,14 @@ class InterModuleCommunication:
def __init__(self, modules: Sequence[BaseIMCModule | None]) -> None:
self.modules = modules
self.names_modules: dict[str, BaseIMCModule] = {}
for module in modules:
for module in modules:
if module: module.imc(self)
def broadcast(self, source: BaseIMCModule, data: object) -> None:
"""
Send data to all modules, other than ourself
"""
source_name = next((k for k, v in self.names_modules.items() if v is source), None)
for module in [f for f in self.modules if f is not source]:
for module in [f for f in self.modules if f is not source]:
if module: module.imc_data(source, source_name, data, True)
def register(self, module: BaseIMCModule, name: str) -> bool:
"""

View File

@@ -38,7 +38,6 @@ class Module(ActiveModifier):
if song.startswith("!"):
song = song[1:]
official = False
return Path(song).absolute(), official
if len(songs):
@@ -48,7 +47,7 @@ class Module(ActiveModifier):
else:
if (index - 1) >= 0: last_track_to_fade_out = self.playlist[index - 1].fade_out
else: last_track_to_fade_out = False
if len(songs) != 0: next_track_to_fade_in = True
else:
if index + 1 < len(self.playlist) and next_track: next_track_to_fade_in = next_track.fade_in
@@ -57,7 +56,7 @@ class Module(ActiveModifier):
if not self.originals or self.originals[-1] != track: self.originals.append(track)
with open("/tmp/radioPlayer_toplay", "w") as f:
with open("/tmp/radioPlayer_toplay", "w") as f:
f.write('\n'.join(songs))
f.write("\n")
@@ -73,14 +72,14 @@ class Module(ActiveModifier):
next_track = track
self.limit_tracks = False
return (self.last_track, next_track), True
elif len(self.originals):
elif len(self.originals):
self.last_track = self.originals.pop(0)
if len(self.originals): next_track = self.originals[0]
else: self.last_track = track
self.limit_tracks = self.can_limit_tracks
if self.limit_tracks:
last_track_duration = self._imc.send(self, "procman", {"op": 1, "arg": self.last_track.path})
last_track_duration = self._imc.send(self, "procman", {"op": 1, "arg": self.last_track.path}) # Ask procman for the duration of this file
assert isinstance(last_track_duration, dict)
last_track_duration = last_track_duration.get("arg")
if last_track_duration:

View File

@@ -65,7 +65,7 @@ class Module(PlaylistAdvisor):
self.custom_playlist_last_mod = Time.get_playlist_modification_time(self.custom_playlist)
return self.custom_playlist
elif self.custom_playlist: self.custom_playlist = None
current_day, current_hour = (time := datetime.datetime.now()).strftime('%A').lower(), time.hour
morning_playlist = Path(playlist_dir, current_day, "morning").absolute()
@@ -106,7 +106,7 @@ class Module(PlaylistAdvisor):
self.last_playlist = night_playlist
return self.last_playlist
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():
if Time.get_playlist_modification_time(self.custom_playlist) > self.custom_playlist_last_mod:
logger.info("Custom playlist changed on disc, reloading...")
self.custom_playlist = None

View File

@@ -8,7 +8,7 @@ def format_time(seconds) -> str:
class Module(PlayerModule):
def progress(self, index: int, track: Track, elapsed: float, total: float, real_total: float) -> None:
if track.official:
if track.official:
data = f"{track.path.name}: {format_time(elapsed)} / {format_time(total)}\n"
Path("/tmp/radioPlayer_progress").write_text(data)

View File

@@ -38,7 +38,7 @@ def update_rds(track_name: str):
except KeyError:
has_name = False
name = track_name.rsplit(".", 1)[0]
name = re.sub(r'^\s*\d+\s*[-.]?\s*', '', name)
if " - " in name:
@@ -52,9 +52,9 @@ def update_rds(track_name: str):
artist = rds_default_artist
title = name
if not has_name: logger.warning(f"File does not have a alias in the name table ({track_name})")
# title = re.sub(r'\s*[\(\[][^\(\)\[\]]*[\)\]]', '', title) # there might be junk
prt = rds_base.format(artist, title)
rtp = [4] # type 1
rtp.append(prt.find(artist)) # start 1
@@ -63,7 +63,7 @@ def update_rds(track_name: str):
rtp.append(prt.find(title)) # start 2
rtp.append(len(title) - 1) # len 2
try:
try:
f = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
f.settimeout(1.0)
data = f"TEXT={prt}\r\nRTP={rtp}\r\n".encode()

View File

@@ -12,7 +12,7 @@ class Module(PlayerModule):
def on_new_playlist(self, playlist: list[Track]): 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})
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)
@@ -22,7 +22,7 @@ class Module(PlayerModule):
lines = self.playlist[:index] + [f"> ({track.path})"] + [self.playlist[index]] + self.playlist[index+1:]
else: lines = self.playlist[:index] + [f"> {self.playlist[index]}"] + self.playlist[index+1:]
with open("/tmp/radioPlayer_playlist", "w") as f:
for line in lines:
for line in lines:
try: f.write(line + "\n")
except UnicodeEncodeError:
print(line.encode('utf-8', errors='ignore').decode('utf-8'))

View File

@@ -1,18 +1,18 @@
#!/usr/bin/env python3
import time, types
import os, subprocess, importlib.util
import os, subprocess, importlib.util, importlib.machinery
import sys, signal, glob
import libcache, traceback, atexit
from modules import *
from threading import Lock
def prefetch(path):
if os.name != "posix": return
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)
if os.name == "posix":
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)
MODULES_PACKAGE = "modules"
MODULES_DIR = Path(__file__, "..", MODULES_PACKAGE).resolve()
@@ -30,8 +30,8 @@ class ProcessManager(Skeleton_ProcessManager):
self.duration_cache.saveElement(file_path.as_posix(), result, (60*60*2), False, True)
return result
def play(self, track: Track, fade_time: int=5) -> Process:
cmd = ['ffplay', '-nodisp', '-hide_banner', '-autoexit', '-loglevel', 'quiet']
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)
@@ -70,7 +70,6 @@ class PlaylistParser:
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}")
return []
@@ -123,7 +122,7 @@ class RadioPlayer:
self.intr_time = 0
self.exit_lock = Lock()
self.procman = ProcessManager()
self.modules: list[tuple] = []
self.modules: list[tuple[importlib.machinery.ModuleSpec, types.ModuleType, str]] = []
self.parser = PlaylistParser(output)
self.arg = arg
@@ -132,8 +131,9 @@ class RadioPlayer:
def shutdown(self):
self.procman.stop_all()
[module.shutdown() for module in self.simple_modules if module]
self.logger.output.close()
def handle_sigint(self, signum, frame):
def handle_sigint(self, signum: int, frame: types.FrameType | None):
with self.exit_lock:
self.logger.info("Received CTRL+C (SIGINT)")
if (time.monotonic() - self.intr_time) > 5:
@@ -146,14 +146,15 @@ class RadioPlayer:
raise SystemExit(130)
def load_modules(self):
"""Loads the modules into memory"""
for file in MODULES_DIR.glob("*"):
if file.name.endswith(".py") and file.name != "__init__.py":
module_name = file.name[:-3]
full_module_name = f"{MODULES_PACKAGE}.{module_name}"
spec = importlib.util.spec_from_file_location(full_module_name, Path(MODULES_DIR, file))
assert spec
module = importlib.util.module_from_spec(spec)
module = importlib.util.module_from_spec(spec) if spec else None
assert spec and module
sys.modules[full_module_name] = module
if MODULES_PACKAGE not in sys.modules:
@@ -167,13 +168,14 @@ class RadioPlayer:
module.__dict__['_log_out'] = self.logger.output
self.modules.append((spec, module, module_name))
def start_modules(self):
"""Executes the module by the python interpreter"""
for (spec, module, module_name) in self.modules:
assert spec.loader
try:
start = time.perf_counter()
spec.loader.exec_module(module)
time_took = time.perf_counter() - start
if time_took > 0.2: 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:
traceback.print_exc(file=self.logger.output)
self.logger.error(f"Failed loading {module_name} due to {e}, continuing")
@@ -199,6 +201,7 @@ class RadioPlayer:
if self.active_modifier: self.active_modifier.arguments(self.arg)
def start(self):
"""Single functon for starting the core, returns but might exit raising an SystemExit"""
self.logger.info("Core starting, loading modules")
self.load_modules();self.start_modules()
if not self.playlist_advisor:
@@ -206,11 +209,11 @@ class RadioPlayer:
raise SystemExit(1)
def play_once(self):
"""Plays a single playlist"""
if not self.playlist_advisor or not (playlist_path := self.playlist_advisor.advise(self.arg)): return
try: global_args, parsed = self.parser.parse(playlist_path)
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)
time.sleep(15)
return
@@ -218,6 +221,7 @@ class RadioPlayer:
[playlist.extend(Track(Path(line).absolute(), True, True, True, args) for line in lns) for (lns, args) in parsed] # i can read this, i think
[(playlist := module.modify(global_args, playlist) or playlist) for module in self.playlist_modifier_modules if module] # yep
assert len(playlist)
prefetch(playlist[0].path)
[mod.on_new_playlist(playlist) for mod in self.simple_modules + [self.active_modifier] if mod] # one liner'd everything
@@ -260,12 +264,12 @@ class RadioPlayer:
return True
return False
track, next_track, extend = get_track()
while i < max_iterator:
if check_conditions(): return
if not track: track, next_track, extend = get_track()
prefetch(track.path)
self.logger.info(f"Now playing: {track.path.name}")
prefetch(track.path)
[module.on_new_track(song_i, track, next_track) for module in self.simple_modules if module]
@@ -288,23 +292,21 @@ class RadioPlayer:
prefetch(track.path)
def loop(self):
self.logger.info("Starting playback.")
"""Main loop of the player. This does not return and may or not raise an SystemExit"""
try:
while True:
self.play_once()
if self.exit_pending: raise SystemExit(self.exit_status_code)
except Exception as e:
except Exception:
traceback.print_exc(file=self.logger.output)
raise
def main():
log_file_path = Path("/tmp/radioPlayer_log")
log_file_path.touch()
log_file = open(log_file_path, "w")
core = RadioPlayer((" ".join(sys.argv[1:]) if len(sys.argv) > 1 else None), log_file)
core = RadioPlayer((" ".join(sys.argv[1:]) if len(sys.argv) > 1 else None), open(log_file_path, "w"))
atexit.register(core.shutdown)
core.start()
signal.signal(signal.SIGINT, core.handle_sigint)
try: core.loop()
finally: log_file.close()
core.loop()