diff --git a/.gitignore b/.gitignore index b931eb4..6f0b5f2 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,5 @@ tests/ *.mp3 cache.sqlite +tags +.vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json index b5a61b5..9d71ac0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,7 +17,7 @@ ], "files.autoSave": "off", "editor.wordWrap": "wordWrapColumn", - "workbench.colorTheme": "GitHub Dark", + "workbench.colorTheme": "GitHub Light", "editor.minimap.autohide": true, "editor.minimap.renderCharacters": false, "editor.experimentalWhitespaceRendering": "font", diff --git a/radioactive/__main__.py b/radioactive/__main__.py index e0989b4..2973c89 100755 --- a/radioactive/__main__.py +++ b/radioactive/__main__.py @@ -307,14 +307,25 @@ def main(): def signal_handler(sig, frame): global ffplay global player - log.debug("You pressed Ctrl+C!") - log.debug("Stopping the radio") - if ffplay and ffplay.is_playing: - ffplay.stop() - # kill the player - player.stop() - - log.info("Exiting now") + log.debug("SIGINT received. Initiating shutdown.") + + # Stop ffplay if it exists and is currently playing + try: + if ffplay and getattr(ffplay, "is_playing", False): + log.debug("Stopping ffplay...") + ffplay.stop() + except Exception as e: + log.error(f"Error while stopping ffplay: {e}") + + # Stop the player if it exists + try: + if player: + log.debug("Stopping player...") + player.stop() + except Exception as e: + log.error(f"Error while stopping player: {e}") + + log.info("Shutdown complete. Exiting now.") sys.exit(0) diff --git a/radioactive/app.py b/radioactive/app.py index b4502a1..aae087a 100644 --- a/radioactive/app.py +++ b/radioactive/app.py @@ -10,7 +10,7 @@ class App: def __init__(self): - self.__VERSION__ = "2.9.1" # change this on every update # + self.__VERSION__ = "2.9.2" # change this on every update # self.pypi_api = "https://pypi.org/pypi/radio-active/json" self.remote_version = "" diff --git a/radioactive/config.py b/radioactive/config.py index 504b1a4..519196a 100644 --- a/radioactive/config.py +++ b/radioactive/config.py @@ -6,6 +6,7 @@ import sys from zenlog import log +from radioactive.default_path import default_appconfig_file_path, handle_default_path def write_a_sample_config_file(): @@ -24,11 +25,8 @@ def write_a_sample_config_file(): "player": "ffplay", } - # Get the user's home directory - home_directory = os.path.expanduser("~") - - # Specify the file path - file_path = os.path.join(home_directory, ".radio-active-configs.ini") + handle_default_path(default_appconfig_file_path) + file_path = os.path.join(default_appconfig_file_path, ".radio-active-configs.ini") try: # Write the configuration to the file diff --git a/radioactive/default_path.py b/radioactive/default_path.py new file mode 100644 index 0000000..c84c441 --- /dev/null +++ b/radioactive/default_path.py @@ -0,0 +1,45 @@ +import os +import sys +from zenlog import log + +def get_xdg_paths(): + """ + Retrieve the XDG base directories with proper defaults. + XDG_CONFIG_HOME defaults to ~/.config. + XDG_DATA_HOME defaults to ~/.local/share. + XDG_CACHE_HOME defaults to ~/.cache. + """ + home = os.path.expanduser("~") + config_home = os.getenv("XDG_CONFIG_HOME", os.path.join(home, ".config")) + data_home = os.getenv("XDG_DATA_HOME", os.path.join(home, ".local", "share")) + cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(home, ".cache")) + return config_home, data_home, cache_home + +xdg_config_home, xdg_data_home, xdg_cache_home = get_xdg_paths() + +# Data files (record and station files) remain under XDG_DATA_HOME. +default_record_file_path = os.path.join(xdg_data_home, "radioactive") +default_station_file_path = os.path.join(xdg_data_home, "radioactive") + +# Configuration files now use XDG_CONFIG_HOME. +default_appconfig_file_path = os.path.join(xdg_config_home, "radioactive") + +def ensure_directory(path): + """ + Ensure the directory exists, otherwise attempt to create it. + Exits if the directory cannot be created. + """ + try: + os.makedirs(path, exist_ok=True) + log.debug(f"Directory ensured: {path}") + except Exception as e: + log.error(f"Could not create directory {path}: {e}") + sys.exit(1) + +def handle_default_path(default_path): + """ + Handle default path by ensuring the directory exists. + """ + log.debug(f"Ensuring default directory: {default_path}") + ensure_directory(default_path) + diff --git a/radioactive/ffplay.py b/radioactive/ffplay.py index aeca804..d37f445 100644 --- a/radioactive/ffplay.py +++ b/radioactive/ffplay.py @@ -22,12 +22,13 @@ def kill_background_ffplays(): p.terminate() count += 1 log.info(f"Terminated ffplay process with PID {pid}") + # Ensure process has time to terminate + sleep(0.5) if p.is_running(): p.kill() log.debug(f"Forcefully killing ffplay process with PID {pid}") except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): - # Handle exceptions, such as processes that no longer exist or access denied - log.debug("Could not terminate a ffplay processes!") + log.debug("Could not terminate a ffplay process!") if count == 0: log.info("No background radios are running!") @@ -40,6 +41,7 @@ def __init__(self, URL, volume, loglevel): self.loglevel = loglevel self.is_playing = False self.process = None + self.is_running = False self._check_ffplay_installation() self.start_process() @@ -70,13 +72,13 @@ def start_process(self): stderr=subprocess.PIPE, text=True, ) - self.is_running = True self.is_playing = True self._start_error_thread() - except Exception as e: log.error("Error while starting radio: {}".format(e)) + self.is_running = False + self.is_playing = False def _start_error_thread(self): error_thread = threading.Thread(target=self._check_error_output) @@ -84,23 +86,26 @@ def _start_error_thread(self): error_thread.start() def _check_error_output(self): - while self.is_running: + while self.is_running and self.process and self.process.stderr: stderr_result = self.process.stderr.readline() if stderr_result: self._handle_error(stderr_result) self.is_running = False self.stop() + break sleep(2) def _handle_error(self, stderr_result): - print() log.error("Could not connect to the station") try: log.debug(stderr_result) - log.error(stderr_result.split(": ")[1]) + parts = stderr_result.split(": ") + if len(parts) > 1: + log.error(parts[1].strip()) + else: + log.error(stderr_result.strip()) except Exception as e: log.debug("Error: {}".format(e)) - pass def terminate_parent_process(self): parent_pid = os.getppid() @@ -132,7 +137,7 @@ def play(self): self.start_process() def stop(self): - if self.is_playing: + if self.is_playing and self.process: try: self.process.kill() self.process.wait(timeout=5) @@ -145,6 +150,7 @@ def stop(self): raise finally: self.is_playing = False + self.is_running = False self.process = None else: log.debug("Radio is not currently playing") diff --git a/radioactive/last_station.py b/radioactive/last_station.py index a24be3a..ced3ee2 100644 --- a/radioactive/last_station.py +++ b/radioactive/last_station.py @@ -1,39 +1,34 @@ """ This module saves the current playing station information to a hidden file, -and loads the data when no arguments are provide """ +and loads the data when no arguments are provided. """ import json -import os.path - +import os from zenlog import log - +from radioactive.default_path import default_station_file_path, handle_default_path class Last_station: - - """Saves the last played radio station information, - when user don't provide any -S or -U it looks for the information. - - on every successful run, it saves the station information. - The file it uses to store the data is a hidden file under users' home directory + """Saves the last played radio station information. + + When the user doesn't provide any -S or -U, it looks for the information. + On every successful run, it saves the station information. + The file it uses to store the data is a hidden file under the user's home directory. """ def __init__(self): - self.last_station_path = None - - self.last_station_path = os.path.join( - os.path.expanduser("~"), ".radio-active-last-station" - ) + handle_default_path(default_station_file_path) + self.last_station_path = os.path.join(default_station_file_path, ".radioactive-last-station") def get_info(self): + """Loads the last station information from the hidden file.""" try: with open(self.last_station_path, "r") as f: - last_station = json.load(f) - return last_station - except Exception: - return "" + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + log.warning("No last station information found or invalid JSON.") + return None def save_info(self, station): - """dumps the current station information as a json file""" - + """Saves the current station information as a JSON file.""" log.debug("Dumping station information") with open(self.last_station_path, "w") as f: json.dump(station, f) diff --git a/radioactive/mpv.py b/radioactive/mpv.py index 872dbb1..0c70a1f 100644 --- a/radioactive/mpv.py +++ b/radioactive/mpv.py @@ -1,7 +1,6 @@ import subprocess import sys from shutil import which - from zenlog import log @@ -12,7 +11,7 @@ def __init__(self): log.debug(f"{self.program_name}: {self.exe_path}") if self.exe_path is None: - log.critical(f"{self.program_name} not found, install it first please") + log.critical(f"{self.program_name} not found. Please install it first.") sys.exit(1) self.is_running = False @@ -20,35 +19,73 @@ def __init__(self): self.url = None def _construct_mpv_commands(self, url): + """Constructs the command to run mpv with the given URL.""" return [self.exe_path, url] def start(self, url): + """Starts the mpv player with the specified URL.""" + if not self._is_valid_url(url): + log.error(f"Invalid URL provided: {url}") + return + + # If player is already running, do not start another process. + if self.is_running and self.process: + log.debug(f"{self.program_name} is already running with PID {self.process.pid}.") + return + self.url = url mpv_commands = self._construct_mpv_commands(url) try: self.process = subprocess.Popen( mpv_commands, - shell=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) self.is_running = True - log.debug( - f"player: {self.program_name} => PID {self.process.pid} initiated" - ) + log.debug(f"Player: {self.program_name} => PID {self.process.pid} initiated") - except Exception as e: + except FileNotFoundError: + log.critical(f"{self.program_name} executable not found at {self.exe_path}.") + sys.exit(1) + except subprocess.SubprocessError as e: log.error(f"Error while starting player: {e}") + except Exception as e: + log.error(f"Unexpected error while starting player: {e}") def stop(self): - if self.is_running: - self.process.kill() - self.is_running = False + """Stops the mpv player if it is running.""" + if self.is_running and self.process: + try: + self.process.terminate() # Use terminate for a graceful shutdown + self.process.wait(timeout=10) + log.debug(f"Player: {self.program_name} stopped.") + except subprocess.TimeoutExpired: + log.warning(f"{self.program_name} did not terminate gracefully, killing process.") + self.process.kill() + self.process.wait() + except Exception as e: + log.error(f"Error stopping {self.program_name}: {e}") + finally: + self.is_running = False + self.process = None + else: + log.debug(f"Player: {self.program_name} is not running.") def toggle(self): + """Toggles the mpv player state between running and stopped.""" if self.is_running: self.stop() else: + if self.url is None: + log.error("No URL set; cannot start the player.") + return self.start(self.url) + + def _is_valid_url(self, url): + """Validates the provided URL.""" + if not isinstance(url, str) or not url: + return False + # Basic validation: check if the URL starts with http or https + return url.startswith("http://") or url.startswith("https://") diff --git a/radioactive/recorder.py b/radioactive/recorder.py index c461451..86b5210 100644 --- a/radioactive/recorder.py +++ b/radioactive/recorder.py @@ -5,25 +5,16 @@ def record_audio_auto_codec(input_stream_url): try: - # Run FFprobe to get the audio codec information ffprobe_command = [ "ffprobe", - "-v", - "error", - "-select_streams", - "a:0", - "-show_entries", - "stream=codec_name", - "-of", - "default=noprint_wrappers=1:nokey=1", + "-v", "error", + "-select_streams", "a:0", + "-show_entries", "stream=codec_name", + "-of", "default=noprint_wrappers=1:nokey=1", input_stream_url, ] - codec_info = subprocess.check_output(ffprobe_command, text=True) - - # Determine the file extension based on the audio codec - audio_codec = codec_info.strip() - audio_codec = audio_codec.split("\n")[0] + audio_codec = codec_info.strip().split("\n", 1)[0] return audio_codec except subprocess.CalledProcessError as e: @@ -33,41 +24,20 @@ def record_audio_auto_codec(input_stream_url): def record_audio_from_url(input_url, output_file, force_mp3, loglevel): try: - # Construct the FFmpeg command - ffmpeg_command = [ - "ffmpeg", - "-i", - input_url, # input URL - "-vn", # disable video recording - "-stats", # show stats - ] - - # codec for audio stream - ffmpeg_command.append("-c:a") - if force_mp3: - ffmpeg_command.append("libmp3lame") - log.debug("Record: force libmp3lame") - else: - # file will be saved as as provided. this is more error prone - # file extension must match the actual stream codec - ffmpeg_command.append("copy") - - ffmpeg_command.append("-loglevel") - if loglevel == "debug": - ffmpeg_command.append("info") - else: - ffmpeg_command.append("error"), - ffmpeg_command.append("-hide_banner") + # Construct the FFmpeg command parts + base_command = ["ffmpeg", "-i", input_url, "-vn", "-stats"] + codec_command = ["-c:a", "libmp3lame" if force_mp3 else "copy"] + loglevel_command = (["-loglevel", "info"] + if loglevel == "debug" + else ["-loglevel", "error", "-hide_banner"]) - # output file - ffmpeg_command.append(output_file) + # Concatenate commands in one go + ffmpeg_command = base_command + codec_command + loglevel_command + [output_file] - # Run FFmpeg command on foreground to catch 'q' without - # any complex thread for now subprocess.run(ffmpeg_command, check=True) - log.debug("Record: {}".format(str(ffmpeg_command))) - log.info(f"Audio recorded successfully.") + log.debug("Record: {}".format(ffmpeg_command)) + log.info("Audio recorded successfully.") except subprocess.CalledProcessError as e: log.debug("Error: {}".format(e)) diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 4bbf41f..5a51f4a 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -16,6 +16,7 @@ from rich.text import Text from zenlog import log +from radioactive.default_path import default_record_file_path, handle_default_path from radioactive.ffplay import kill_background_ffplays from radioactive.last_station import Last_station from radioactive.recorder import record_audio_auto_codec, record_audio_from_url @@ -25,7 +26,6 @@ global_current_station_info = {} - def handle_fetch_song_title(url): """Fetch currently playing track information""" log.info("Fetching the current track info") @@ -92,19 +92,18 @@ def handle_record( if record_file_path and not os.path.exists(record_file_path): log.debug("filepath: {}".format(record_file_path)) - os.makedirs(record_file_path, exist_ok=True) - - elif not record_file_path: - log.debug("filepath: fallback to default path") - record_file_path = os.path.join( - os.path.expanduser("~"), "Music/radioactive" - ) # fallback path try: os.makedirs(record_file_path, exist_ok=True) - except Exception as e: - log.debug("{}".format(e)) - log.error("Could not make default directory") - sys.exit(1) + except: + log.error("Could not make directory: {}".format(record_file_path)) + log.info("Falling back to default path") + handle_default_path(default_record_file_path) + record_file_path = default_record_file_path + + elif not record_file_path: + handle_default_path(default_record_file_path) + record_file_path = default_record_file_path + now = datetime.datetime.now() month_name = now.strftime("%b").upper() diff --git a/radioactive/vlc.py b/radioactive/vlc.py index 872ee29..21e0b8a 100644 --- a/radioactive/vlc.py +++ b/radioactive/vlc.py @@ -19,10 +19,20 @@ def __init__(self): self.process = None self.url = None + def _is_valid_url(self, url): + """Ensure the URL is a non-empty string starting with http:// or https://.""" + if not isinstance(url, str) or not url.strip(): + return False + return url.startswith("http://") or url.startswith("https://") + def _construct_vlc_commands(self, url): return [self.exe_path, url] def start(self, url): + if not self._is_valid_url(url): + log.error(f"Invalid URL provided: {url}") + return + self.url = url vlc_commands = self._construct_vlc_commands(url) @@ -38,17 +48,30 @@ def start(self, url): log.debug( f"player: {self.program_name} => PID {self.process.pid} initiated" ) - except Exception as e: log.error(f"Error while starting player: {e}") def stop(self): - if self.is_running: - self.process.kill() - self.is_running = False + if self.is_running and self.process: + try: + self.process.kill() + self.process.wait(timeout=5) + log.debug("Player stopped successfully") + except Exception as e: + log.error(f"Error stopping player: {e}") + finally: + self.is_running = False + self.process = None + else: + log.debug("Player is not running or process is not initialized") def toggle(self): if self.is_running: + log.debug("Stopping the player") self.stop() else: + if self.url is None or not self._is_valid_url(self.url): + log.error("Invalid or missing URL; cannot start the player") + return + log.debug("Starting the player") self.start(self.url)