You've already forked RadioPlayer
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:
12
.gitignore
vendored
12
.gitignore
vendored
@@ -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
37
LICENSE
@@ -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>
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user