tests in procman, and rejected tracks

This commit is contained in:
2026-05-08 18:57:53 +02:00
parent 6dee062532
commit 5d0c6b5e93
11 changed files with 144 additions and 43 deletions
+8 -2
View File
@@ -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
+1 -4
View File
@@ -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)
+11 -2
View File
@@ -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()
+25
View File
@@ -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 <https://unlicense.org>
-16
View File
@@ -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
+25
View File
@@ -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 <https://unlicense.org>
+26 -1
View File
@@ -37,4 +37,29 @@ class Module(PlayerModule):
self._save_counts()
def shutdown(self): self._save_counts()
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>
+26 -1
View File
@@ -66,4 +66,29 @@ class PopularitySorterModule(PlaylistModifierModule):
self.logger.info("Popularity sorting complete.")
return playlist
playlistmod = PopularitySorterModule(), 2
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 <https://unlicense.org>
+4 -5
View File
@@ -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:
+1 -1
View File
@@ -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":
+17 -11
View File
@@ -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():