From 8852fd1623cbbf912cea94de0c172ea27dfeed9e Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sun, 7 Jan 2024 11:51:07 +0530 Subject: [PATCH] 2.9.1 (#107) * feat: play random station from favorite list Signed-off-by: Dipankar Pal * fix: :bug: duplicate favorite entry issue from runtime command fixes #100 * updates * feat: :sparkles: multiple media player support * fix: :bug: fails to play a single result #102 * help docs added * update * feat: :sparkles: Defult config file support #103 * fix: :bug: Default recording filepath was ambiguas before * feat: :sparkles: toggle playbacks from runtime command #84 * feat: :sparkles: filter support added #86 * fix: :bug: ability to stop different players * v2.9.1 --------- Signed-off-by: Dipankar Pal --- .vscode/settings.json | 8 +- CHANGELOG.md | 10 ++ README.md | 108 ++++++++++++++----- radioactive/__main__.py | 70 +++++++++++-- radioactive/alias.py | 1 + radioactive/app.py | 5 +- radioactive/args.py | 47 +++++++-- radioactive/config.py | 77 ++++++++++++++ radioactive/{player.py => ffplay.py} | 136 +++++++++++------------- radioactive/filter.py | 151 +++++++++++++++++++++++++++ radioactive/handler.py | 74 ++++++++----- radioactive/help.py | 11 ++ radioactive/last_station.py | 4 +- radioactive/mpv.py | 54 ++++++++++ radioactive/parser.py | 3 + radioactive/utilities.py | 33 ++++-- radioactive/vlc.py | 54 ++++++++++ requirements.txt | 1 + 18 files changed, 688 insertions(+), 159 deletions(-) create mode 100644 radioactive/config.py rename radioactive/{player.py => ffplay.py} (51%) create mode 100644 radioactive/filter.py create mode 100644 radioactive/mpv.py create mode 100644 radioactive/vlc.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 356a09c..b5a61b5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,11 +17,15 @@ ], "files.autoSave": "off", "editor.wordWrap": "wordWrapColumn", - "workbench.colorTheme": "Quiet Light", + "workbench.colorTheme": "GitHub Dark", "editor.minimap.autohide": true, "editor.minimap.renderCharacters": false, "editor.experimentalWhitespaceRendering": "font", "editor.fontFamily": "'Fira Code', Consolas, 'Courier New', monospace", "editor.codeLensFontFamily": "'Fira Code'", - "editor.fontLigatures": true + "editor.fontLigatures": true, + "editor.defaultFormatter": "ms-python.black-formatter", + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 59b0e36..07fa100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 2.9.1 + +1. Play a random station from favorite list `--random` +2. Multiple media player support ( MPV, VLC, FFplay) `--player` +3. Filter search results with `--filter` +4. Play/Pause player from runtime command `p` +5. Default config file support added +6. Fixed minor bugs while giving runtime commands + + ## 2.9.0 1. Fetch current playing track info from runtime commands 🎶 ⚡ diff --git a/README.md b/README.md index fe8ecf2..14f83b7 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,9 @@ - [x] Finds nearby stations - [x] Discovers stations by genre - [x] Discovers stations by language +- [x] VLC, MPV player support +- [x] Default config file - [ ] I'm feeling lucky! Play Random stations -- [ ] VLC, MPV player support > See my progress ➡️ [here](https://github.com/users/deep5050/projects/5) @@ -80,7 +81,7 @@ I recommend installing it using `pipx install radio-active` ### External Dependency It needs [FFmpeg](https://ffmpeg.org/download.html) to be installed on your -system in order to play the audio +system in order to record the audio on Ubuntu-based system >= 20.04 Run @@ -120,30 +121,34 @@ Search a station with `radio --search [STATION_NAME]` or simply `radio` :zap: to ### Options -| Argument | Note | Description | Default | -| ------------------ | -------- | ---------------------------------------------- | ------------- | -| `--search`, `-S` | Optional | Station name | None | -| `--play`, `-P` | Optional | A station from fav list or url for direct play | None | -| `--country`, `-C` | Optional | Discover stations by country code | False | -| `--state` | Optional | Discover stations by country state | False | -| `--language` | optional | Discover stations by | False | -| `--tag` | Optional | Discover stations by tags/genre | False | -| `--uuid`, `-U` | Optional | ID of the station | None | -| `--record` , `-R` | Optional | Record a station and save to file | False | -| `--filename`, `-N` | Optional | Filename to used to save the recorded audio | None | -| `--filepath` | Optional | Path to save the recordings | | -| `--filetype`, `-T` | Optional | Format of the recording (mp3/auto) | mp3 | -| `--last` | Optional | Play last played station | False | -| `--sort` | Optional | Sort the result page | name | -| `--limit` | Optional | Limit the # of results in the Discover table | 100 | -| `--volume` , `-V` | Optional | Change the volume passed into ffplay | 80 | -| `--favorite`, `-F` | Optional | Add current station to fav list | False | -| `--add` , `-A` | Optional | Add an entry to fav list | False | -| `--list`, `-W` | Optional | Show fav list | False | -| `--remove` | Optional | Remove entries from favorite list | False | -| `--flush` | Optional | Remove all the entries from fav list | False | -| `--kill` , `-K` | Optional | Kill background radios. | False | -| `--loglevel` | Optional | Log level of the program | Info | +| Options | Note | Description | Default | Values | +| ------------------ | -------- | ---------------------------------------------- | ------------- | ---------------------- | +| (No Option) | Optional | Select a station from menu to play | False | | +| `--search`, `-S` | Optional | Station name | None | | +| `--play`, `-P` | Optional | A station from fav list or url for direct play | None | | +| `--country`, `-C` | Optional | Discover stations by country code | False | | +| `--state` | Optional | Discover stations by country state | False | | +| `--language` | optional | Discover stations by | False | | +| `--tag` | Optional | Discover stations by tags/genre | False | | +| `--uuid`, `-U` | Optional | ID of the station | None | | +| `--record` , `-R` | Optional | Record a station and save to file | False | | +| `--filename`, `-N` | Optional | Filename to used to save the recorded audio | None | | +| `--filepath` | Optional | Path to save the recordings | | | +| `--filetype`, `-T` | Optional | Format of the recording | mp3 | `mp3`,`auto` | +| `--last` | Optional | Play last played station | False | | +| `--random` | Optional | Play a random station from favorite list | False | | +| `--sort` | Optional | Sort the result page | votes | | +| `--filter` | Optional | Filter search results | None | | +| `--limit` | Optional | Limit the # of results in the Discover table | 100 | | +| `--volume` , `-V` | Optional | Change the volume passed into ffplay | 80 | [0-100] | +| `--favorite`, `-F` | Optional | Add current station to fav list | False | | +| `--add` , `-A` | Optional | Add an entry to fav list | False | | +| `--list`, `-W` | Optional | Show fav list | False | | +| `--remove` | Optional | Remove entries from favorite list | False | | +| `--flush` | Optional | Remove all the entries from fav list | False | | +| `--kill` , `-K` | Optional | Kill background radios. | False | | +| `--loglevel` | Optional | Log level of the program | Info | `info`, `warning`, `error`, `debug` | +| `--player` | Optional | Media player to use | ffplay | `vlc`, `mpv`, `ffplay` |
@@ -185,7 +190,7 @@ h/H/help/?: Show this help message q/Q/quit: Quit radioactive ``` -### sort parameters +### Sort Parameters you can sort the result page with these parameters: - `name` (default) @@ -198,6 +203,55 @@ you can sort the result page with these parameters: - `clicktrend` (currently trending stations) - `random` +### Filter Parameters + +Filter search results with `--filter`. Some possible expressions are +- `--filter "name=shows"` +- `--filter "name=shows,talks,tv"` +- `--filter "name!=news,shows"` +- `--filter "country=in"` +- `--filter "language=bengali,nepali"` +- `--filter "bitrate>64"` +- `--filter "votes<500"` +- `--filter "codec=mp3"` +- `--filter "tags!=rock,pop"` + +Allowed operators are: + +- `=` +- `,` +- `!=` +- `>` +- `<` +- `&` + +Allowed keys are: `name`, `country` (countrycode as value), `language`, `bitrate`, `votes`, `codec`, `tags` + +Provide multiple filters at one go, use `&` + +A complex filter example: `--filter "country!=CA&tags!=islamic,classical&votes>500"` + +> NOTE: set `--limit` to a higher value while filtering results + + +### Default Configs + +Default configuration file is added into your home directory as `.radio-active-configs.ini` + +```bash +[AppConfig] +loglevel = info +limit = 100 +sort = votes +filter = none +volume = 80 +filepath = /home/{user}/recordings/radioactive/ +filetype = mp3 +player = ffplay +``` + +Do NOT modify the keys, only change the values. you can give any absolute or relative path as filepath. + ### Bonus Tips 1. when using `rf`: you can force the recording to be in mp3 format by adding an extension to the file name. Example "talk-show.mp3". If you don't specify any extension it should auto-detect. Example "new_show" diff --git a/radioactive/__main__.py b/radioactive/__main__.py index a5cc308..e0989b4 100755 --- a/radioactive/__main__.py +++ b/radioactive/__main__.py @@ -8,11 +8,11 @@ from radioactive.alias import Alias from radioactive.app import App +from radioactive.ffplay import Ffplay, kill_background_ffplays from radioactive.handler import Handler from radioactive.help import show_help from radioactive.last_station import Last_station from radioactive.parser import parse_options -from radioactive.player import Player, kill_background_ffplays from radioactive.utilities import ( check_sort_by_parameter, handle_add_station, @@ -22,6 +22,7 @@ handle_favorite_table, handle_listen_keypress, handle_play_last_station, + handle_play_random_station, handle_record, handle_save_last_station, handle_search_stations, @@ -34,21 +35,44 @@ # globally needed as signal handler needs it # to terminate main() properly +ffplay = None player = None def final_step(options, last_station, alias, handler): + global ffplay # always needed global player + # check target URL for the last time if options["target_url"].strip() == "": log.error("something is wrong with the url") sys.exit(1) + if options["audio_player"] == "vlc": + from radioactive.vlc import VLC + + vlc = VLC() + vlc.start(options["target_url"]) + player = vlc + + elif options["audio_player"] == "mpv": + from radioactive.mpv import MPV + + mpv = MPV() + mpv.start(options["target_url"]) + player = mpv + + elif options["audio_player"] == "ffplay": + ffplay = Ffplay(options["target_url"], options["volume"], options["loglevel"]) + player = ffplay + + else: + log.error("Unsupported media player selected") + sys.exit(1) + if options["curr_station_name"].strip() == "": options["curr_station_name"] = "N/A" - player = Player(options["target_url"], options["volume"], options["loglevel"]) - handle_save_last_station( last_station, options["curr_station_name"], options["target_url"] ) @@ -72,6 +96,7 @@ def final_step(options, last_station, alias, handler): handle_listen_keypress( alias, + player, target_url=options["target_url"], station_name=options["curr_station_name"], station_url=options["target_url"], @@ -89,8 +114,6 @@ def main(): options = parse_options() - handle_welcome_screen() - VERSION = app.get_version() handler = Handler() @@ -104,6 +127,8 @@ def main(): log.info("RADIO-ACTIVE : version {}".format(VERSION)) sys.exit(0) + handle_welcome_screen() + if options["show_help_table"]: show_help() sys.exit(0) @@ -134,7 +159,10 @@ def main(): # ----------- country ----------- # if options["discover_country_code"]: response = handler.discover_by_country( - options["discover_country_code"], options["limit"], options["sort_by"] + options["discover_country_code"], + options["limit"], + options["sort_by"], + options["filter_with"], ) if response is not None: ( @@ -148,7 +176,10 @@ def main(): # -------------- state ------------- # if options["discover_state"]: response = handler.discover_by_state( - options["discover_state"], options["limit"], options["sort_by"] + options["discover_state"], + options["limit"], + options["sort_by"], + options["filter_with"], ) if response is not None: ( @@ -162,7 +193,10 @@ def main(): # ----------- language ------------ # if options["discover_language"]: response = handler.discover_by_language( - options["discover_language"], options["limit"], options["sort_by"] + options["discover_language"], + options["limit"], + options["sort_by"], + options["filter_with"], ) if response is not None: ( @@ -176,7 +210,10 @@ def main(): # -------------- tag ------------- # if options["discover_tag"]: response = handler.discover_by_tag( - options["discover_tag"], options["limit"], options["sort_by"] + options["discover_tag"], + options["limit"], + options["sort_by"], + options["filter_with"], ) if response is not None: ( @@ -193,6 +230,7 @@ def main(): and options["search_station_uuid"] is None and options["direct_play"] is None and not options["play_last_station"] + and not options["play_random"] ): ( options["curr_station_name"], @@ -221,6 +259,7 @@ def main(): options["search_station_name"], options["limit"], options["sort_by"], + options["filter_with"], ) if response is not None: ( @@ -239,6 +278,13 @@ def main(): ) final_step(options, last_station, alias, handler) + if options["play_random"]: + ( + options["curr_station_name"], + options["target_url"], + ) = handle_play_random_station(alias) + final_step(options, last_station, alias, handler) + if options["play_last_station"]: options["curr_station_name"], options["target_url"] = handle_play_last_station( last_station @@ -259,11 +305,15 @@ def main(): def signal_handler(sig, frame): + global ffplay global player log.debug("You pressed Ctrl+C!") log.debug("Stopping the radio") - if player and player.is_playing: + if ffplay and ffplay.is_playing: + ffplay.stop() + # kill the player player.stop() + log.info("Exiting now") sys.exit(0) diff --git a/radioactive/alias.py b/radioactive/alias.py index a6a63fd..8dd4b84 100644 --- a/radioactive/alias.py +++ b/radioactive/alias.py @@ -76,6 +76,7 @@ def search(self, entry): def add_entry(self, left, right): """Adds a new entry to the fav list""" + self.generate_map() if self.search(left) is not None: log.warning("An entry with same name already exists, try another name") return False diff --git a/radioactive/app.py b/radioactive/app.py index 736a0de..b4502a1 100644 --- a/radioactive/app.py +++ b/radioactive/app.py @@ -1,5 +1,6 @@ """ - Version of the current program, (in development mode it needs to be updated in every release) + Version of the current program, (in development mode + it needs to be updated in every release) and to check if an updated version available for the app or not """ import json @@ -9,7 +10,7 @@ class App: def __init__(self): - self.__VERSION__ = "2.9.0" # change this on every update # + self.__VERSION__ = "2.9.1" # change this on every update # self.pypi_api = "https://pypi.org/pypi/radio-active/json" self.remote_version = "" diff --git a/radioactive/args.py b/radioactive/args.py index f9af9a9..72b1717 100644 --- a/radioactive/args.py +++ b/radioactive/args.py @@ -3,6 +3,16 @@ from zenlog import log +from radioactive.config import Configs + + +# load default configs +def load_default_configs(): + # load config file and apply configs + configs = Configs() + default_configs = configs.load() + return default_configs + class Parser: @@ -11,6 +21,7 @@ class Parser: def __init__(self): self.parser = None self.result = None + self.defaults = load_default_configs() self.parser = argparse.ArgumentParser( description="Play any radio around the globe right from the CLI ", @@ -54,6 +65,14 @@ def __init__(self): help="Play last played station.", ) + self.parser.add_argument( + "--random", + action="store_true", + default=False, + dest="play_random_station", + help="Play random station from fav list.", + ) + self.parser.add_argument( "--uuid", "-U", @@ -65,7 +84,7 @@ def __init__(self): self.parser.add_argument( "--loglevel", action="store", - default="info", + default=self.defaults["loglevel"], dest="log_level", help="Specify log level", ) @@ -103,7 +122,7 @@ def __init__(self): "-L", action="store", dest="limit", - default=100, + default=self.defaults["limit"], help="Limit of entries in discover table", ) @@ -111,10 +130,18 @@ def __init__(self): "--sort", action="store", dest="stations_sort_by", - default="name", + default=self.defaults["sort"], help="Sort stations", ) + self.parser.add_argument( + "--filter", + action="store", + dest="stations_filter_with", + default=self.defaults["filter"], + help="Filter Results", + ) + self.parser.add_argument( "--add", "-A", @@ -161,7 +188,7 @@ def __init__(self): "-V", action="store", dest="volume", - default=80, + default=self.defaults["volume"], type=int, choices=range(0, 101, 10), help="Volume to pass down to ffplay", @@ -189,7 +216,7 @@ def __init__(self): "--filepath", action="store", dest="record_file_path", - default="", + default=self.defaults["filepath"], help="specify the audio format for recording", ) @@ -207,10 +234,18 @@ def __init__(self): "-T", action="store", dest="record_file_format", - default="mp3", + default=self.defaults["filetype"], help="specify the audio format for recording. auto/mp3", ) + self.parser.add_argument( + "--player", + action="store", + dest="audio_player", + default=self.defaults["player"], + help="specify the audio player to use. ffplay/vlc/mpv", + ) + def parse(self): self.result = self.parser.parse_args() if self.result is None: diff --git a/radioactive/config.py b/radioactive/config.py new file mode 100644 index 0000000..504b1a4 --- /dev/null +++ b/radioactive/config.py @@ -0,0 +1,77 @@ +# load configs from a file and apply. +# If any options are given on command line it will override the configs +import configparser +import getpass +import os +import sys + +from zenlog import log + + +def write_a_sample_config_file(): + # Create a ConfigParser object + config = configparser.ConfigParser() + + # Add sections and key-value pairs + config["AppConfig"] = { + "loglevel": "info", + "limit": "100", + "sort": "votes", + "filter": "none", + "volume": "80", + "filepath": "/home/{user}/recordings/radioactive/", + "filetype": "mp3", + "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") + + try: + # Write the configuration to the file + with open(file_path, "w") as config_file: + config.write(config_file) + + log.info(f"A sample default configuration file added at: {file_path}") + + except Exception as e: + print(f"Error writing the configuration file: {e}") + + +class Configs: + def __init__(self): + self.config_path = os.path.join( + os.path.expanduser("~"), ".radio-active-configs.ini" + ) + + def load(self): + self.config = configparser.ConfigParser() + + try: + self.config.read(self.config_path) + options = {} + options["volume"] = self.config.get("AppConfig", "volume") + options["loglevel"] = self.config.get("AppConfig", "loglevel") + options["sort"] = self.config.get("AppConfig", "sort") + options["filter"] = self.config.get("AppConfig", "filter") + options["limit"] = self.config.get("AppConfig", "limit") + options["filepath"] = self.config.get("AppConfig", "filepath") + # if filepath has any placeholder, replace + # {user} to actual user map + options["filepath"] = options["filepath"].replace( + "{user}", getpass.getuser() + ) + options["filetype"] = self.config.get("AppConfig", "filetype") + options["player"] = self.config.get("AppConfig", "player") + + return options + + except Exception as e: + log.error(f"Something went wrong while parsing the config file: {e}") + # write the example config file + write_a_sample_config_file() + log.info("Re-run radioative") + sys.exit(1) diff --git a/radioactive/player.py b/radioactive/ffplay.py similarity index 51% rename from radioactive/player.py rename to radioactive/ffplay.py index 82205f5..aeca804 100644 --- a/radioactive/player.py +++ b/radioactive/ffplay.py @@ -1,5 +1,3 @@ -""" FFplay process handler """ - import os import signal import subprocess @@ -34,138 +32,114 @@ def kill_background_ffplays(): log.info("No background radios are running!") -class Player: - - """FFPlayer handler, it holds all the attributes to properly execute ffplay - FFmepg required to be installed separately - """ - +class Ffplay: def __init__(self, URL, volume, loglevel): + self.program_name = "ffplay" self.url = URL self.volume = volume + self.loglevel = loglevel self.is_playing = False self.process = None - self.exe_path = None - self.program_name = "ffplay" # constant value - self.loglevel = loglevel - log.debug("player: url => {}".format(self.url)) - # check if FFplay is installed - self.exe_path = which(self.program_name) - log.debug("FFplay: {}".format(self.exe_path)) + self._check_ffplay_installation() + self.start_process() + def _check_ffplay_installation(self): + self.exe_path = which(self.program_name) if self.exe_path is None: log.critical("FFplay not found, install it first please") sys.exit(1) - self.start_process() - - def start_process(self): - ffplay_commands = [ - self.exe_path, - "-volume", - f"{self.volume}", - "-vn", # no video playback - self.url, - ] + def _construct_ffplay_commands(self): + ffplay_commands = [self.exe_path, "-volume", f"{self.volume}", "-vn", self.url] if self.loglevel == "debug": - # don't add no disp and - ffplay_commands.append("-loglevel") - ffplay_commands.append("error") - + ffplay_commands.extend(["-loglevel", "error"]) else: - ffplay_commands.append("-loglevel") - ffplay_commands.append("error") - ffplay_commands.append("-nodisp") + ffplay_commands.extend(["-loglevel", "error", "-nodisp"]) + + return ffplay_commands + + def start_process(self): try: + ffplay_commands = self._construct_ffplay_commands() self.process = subprocess.Popen( ffplay_commands, shell=False, - stdout=subprocess.PIPE, # Capture standard output - stderr=subprocess.PIPE, # Capture standard error - text=True, # Use text mode to capture strings + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, ) + self.is_running = True - log.debug("player: ffplay => PID {} initiated".format(self.process.pid)) - # Create a thread to continuously capture and check error output - error_thread = threading.Thread(target=self.check_error_output) - error_thread.daemon = True - error_thread.start() + self.is_playing = True + self._start_error_thread() except Exception as e: - # Handle exceptions that might occur during process setup log.error("Error while starting radio: {}".format(e)) - def check_error_output(self): + def _start_error_thread(self): + error_thread = threading.Thread(target=self._check_error_output) + error_thread.daemon = True + error_thread.start() + + def _check_error_output(self): while self.is_running: stderr_result = self.process.stderr.readline() if stderr_result: - print() # pass a blank line to command for better log messages - log.error("Could not connect to the station") - try: - # try to show the debug info - log.debug(stderr_result) - # only showing the server response - log.error(stderr_result.split(": ")[1]) - except Exception as e: - log.debug("Error: {}".format(e)) - pass - + self._handle_error(stderr_result) self.is_running = False self.stop() 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]) + except Exception as e: + log.debug("Error: {}".format(e)) + pass + def terminate_parent_process(self): parent_pid = os.getppid() - print(parent_pid) os.kill(parent_pid, signal.SIGINT) def is_active(self): - """Check if the ffplay process is still active.""" if not self.process: log.warning("Process is not initialized") return False + try: proc = psutil.Process(self.process.pid) if proc.status() == psutil.STATUS_ZOMBIE: log.debug("Process is a zombie") return False - if proc.status() == psutil.STATUS_RUNNING: + if proc.status() in [psutil.STATUS_RUNNING, psutil.STATUS_SLEEPING]: return True - if proc.status() == psutil.STATUS_SLEEPING: - log.debug("Process is sleeping") - return True # Sleeping is considered active for our purpose - - # Handle other process states if needed - log.warning("Process is not in an expected state") return False - except psutil.NoSuchProcess: - log.debug("Process not found") - return False - except Exception as e: - log.error("Error while checking process status: {}".format(e)) + + except (psutil.NoSuchProcess, Exception) as e: + log.debug("Process not found or error while checking status: {}".format(e)) return False def play(self): - """Play a station""" if not self.is_playing: - pass # call the init function again ? + self.start_process() def stop(self): - """stop the ffplayer""" - if self.is_playing: try: - self.process.terminate() # Terminate the process gracefully - self.process.wait(timeout=5) # Wait for process to finish - log.info("Radio playback stopped successfully") + self.process.kill() + self.process.wait(timeout=5) + log.debug("Radio playback stopped successfully") except subprocess.TimeoutExpired: log.warning("Radio process did not terminate, killing...") - self.process.kill() # Kill the process forcefully + self.process.kill() except Exception as e: log.error("Error while stopping radio: {}".format(e)) raise @@ -174,5 +148,13 @@ def stop(self): self.process = None else: log.debug("Radio is not currently playing") - current_pid = os.getpid() - os.kill(current_pid, signal.SIGINT) + self.terminate_parent_process() + + def toggle(self): + if self.is_playing: + log.debug("Stopping the ffplay process") + self.is_running = False + self.stop() + else: + log.debug("Starting the ffplay process") + self.start_process() diff --git a/radioactive/filter.py b/radioactive/filter.py new file mode 100644 index 0000000..c2876cf --- /dev/null +++ b/radioactive/filter.py @@ -0,0 +1,151 @@ +import sys + +from zenlog import log + + +# function to filter strings +def _filter_entries_by_key(data, filter_param, key): + log.debug(f"filter: {filter_param}") + + filtered_entries = [] + + for entry in data: + value = entry.get(key) + + if value is not None and value != "": + if "!=" in filter_param: + # Handle exclusion + exclusion_values = filter_param.split("!=")[1].split(",") + + if all( + exclusion_value.lower() not in value.lower() + for exclusion_value in exclusion_values + ): + filtered_entries.append(entry) + + elif "=" in filter_param: + # Handle inclusion + inclusion_values = filter_param.split("=")[1].split(",") + + if any( + inclusion_value.lower() in value.lower() + for inclusion_value in inclusion_values + ): + filtered_entries.append(entry) + + return filtered_entries + + +# function to filter numeric values +def _filter_entries_by_numeric_key(data, filter_param, key): + filtered_entries = [] + + filter_key = filter_param.split(key)[0] # most left hand of the expression + filter_param = filter_param.split(key)[1] # portion after the operator + filter_operator = filter_param[0] # operator part + filter_value = int(filter_param[1:]) # value part + # log.debug(f"filter: parameter:{filter_param}") + + for entry in data: + value = int(entry.get(key)) + + if value is not None: + try: + if filter_operator not in [">", "<", "="]: + log.warning("Unsupported filter operator, not filtering !!") + return data + if filter_operator == "<" and value < filter_value: + filtered_entries.append(entry) + elif filter_operator == ">" and value > filter_value: + filtered_entries.append(entry) + elif filter_operator == "=" and value == filter_value: + filtered_entries.append(entry) + + except ValueError: + log.error(f"Invalid filter value for {key}: {filter_param}") + sys.exit(1) + + return filtered_entries + + +# allowed string string filters +def _filter_entries_by_name(data, filter_param): + return _filter_entries_by_key(data, filter_param, key="name") + + +def _filter_entries_by_language(data, filter_param): + return _filter_entries_by_key(data, filter_param, key="language") + + +def _filter_entries_by_country(data, filter_param): + return _filter_entries_by_key(data, filter_param, key="countrycode") + + +def _filter_entries_by_tags(data, filter_param): + return _filter_entries_by_key(data, filter_param, key="tags") + + +def _filter_entries_by_codec(data, filter_param): + return _filter_entries_by_key(data, filter_param, key="codec") + + +# allowed numeric filters +def _filter_entries_by_votes(data, filter_param): + return _filter_entries_by_numeric_key(data, filter_param, key="votes") + + +def _filter_entries_by_bitrate(data, filter_param): + return _filter_entries_by_numeric_key(data, filter_param, key="bitrate") + + +def _filter_entries_by_clickcount(data, filter_param): + return _filter_entries_by_numeric_key(data, filter_param, key="clickcount") + + +# top level filter function +def _filter_results(data, expression): + log.debug(f"Filter exp: {expression}") + if not data: + log.error("Empty results") + sys.exit(0) + + if "name" in expression: + return _filter_entries_by_name(data, expression) + elif "language" in expression: + return _filter_entries_by_language(data, expression) + elif "country" in expression: + return _filter_entries_by_country(data, expression) + elif "tags" in expression: + return _filter_entries_by_tags(data, expression) + elif "codec" in expression: + return _filter_entries_by_codec(data, expression) + elif "bitrate" in expression: + return _filter_entries_by_bitrate(data, expression) + elif "clickcount" in expression: + return _filter_entries_by_clickcount(data, expression) + elif "votes" in expression: + return _filter_entries_by_votes(data, expression) + else: + log.warning("Unknown filter expression, not filtering!") + return data + + +# Top most function for multiple filtering expressions with '&' +# NOTE: it will filter maintaining the order you provided on the CLI + + +def filter_expressions(data, input_expression): + log.info( + "Setting a higher value for the --limit parameter is preferable when filtering stations." + ) + if "&" in input_expression: + log.debug("filter: multiple expressions found") + expression_parts = input_expression.split("&") + + for expression in expression_parts: + if data: + data = _filter_results(data, expression) + return data + + else: + return _filter_results(data, input_expression) diff --git a/radioactive/handler.py b/radioactive/handler.py index 85b6599..2a5e905 100644 --- a/radioactive/handler.py +++ b/radioactive/handler.py @@ -12,6 +12,8 @@ from rich.table import Table from zenlog import log +from radioactive.filter import filter_expressions + console = Console() @@ -32,7 +34,7 @@ def trim_string(text, max_length=40): return text -def print_table(response, columns, sort_by="name"): +def print_table(response, columns, sort_by, filter_expression): """ Print the table applying the sort logic. @@ -49,6 +51,16 @@ def print_table(response, columns, sort_by="name"): log.error("No stations found") sys.exit(1) + # need to filter? + if filter_expression.lower() != "none": + response = filter_expressions(response, filter_expression) + + if not response: + log.error("No stations found after filtering") + sys.exit(1) + else: + log.debug("Not filtering") + if len(response) >= 1: table = Table( show_header=True, @@ -75,29 +87,32 @@ def print_table(response, columns, sort_by="name"): if sort_by not in ["name", "random"]: table.add_column(sort_by, justify="left") - for i, station in enumerate(response): - row_data = [str(i + 1)] # for ID + for i, station in enumerate(response): + row_data = [str(i + 1)] # for ID - for col_spec in columns: - col_name, response_key, max_str = ( - col_spec.split(":")[0], - col_spec.split(":")[1].split("@")[0], - int(col_spec.split("@")[1]), - ) - row_data.append( - trim_string(station.get(response_key, ""), max_length=max_str) - ) + for col_spec in columns: + col_name, response_key, max_str = ( + col_spec.split(":")[0], + col_spec.split(":")[1].split("@")[0], + int(col_spec.split("@")[1]), + ) + row_data.append( + trim_string(station.get(response_key, ""), max_length=max_str) + ) - if sort_by not in ["name", "random"]: - row_data.append(str(station.get(sort_by, ""))) + if sort_by not in ["name", "random"]: + row_data.append(str(station.get(sort_by, ""))) - table.add_row(*row_data) + table.add_row(*row_data) - console.print(table) - # log.info( - # "If the table does not fit into your screen, \ntry to maximize the window, decrease the font by a bit, and retry" - # ) - return response + console.print(table) + # log.info( + # "If the table does not fit into your screen, \ntry to maximize the window, decrease the font by a bit, and retry" + # ) + return response + else: + log.info("No stations found") + sys.exit(0) class Handler: @@ -140,7 +155,7 @@ def validate_uuid_station(self): return self.response # ---------------------------- NAME -------------------------------- # - def search_by_station_name(self, _name=None, limit=100, sort_by: str = "name"): + def search_by_station_name(self, _name, limit, sort_by, filter_with): """search and play a station by its name""" reversed = sort_by != "name" @@ -156,6 +171,7 @@ def search_by_station_name(self, _name=None, limit=100, sort_by: str = "name"): response, ["Station:name@30", "Country:country@20", "Tags:tags@20"], sort_by=sort_by, + filter_expression=filter_with, ) except Exception as e: log.debug("Error: {}".format(e)) @@ -174,7 +190,7 @@ def play_by_station_uuid(self, _uuid): sys.exit(1) # -------------------------- COUNTRY ----------------------# - def discover_by_country(self, country_code_or_name, limit, sort_by: str = "name"): + def discover_by_country(self, country_code_or_name, limit, sort_by, filter_with): # set reverse to false if name is is the parameter for sorting reversed = sort_by != "name" @@ -223,13 +239,14 @@ def discover_by_country(self, country_code_or_name, limit, sort_by: str = "name" "Tags:tags@20", "Language:language@20", ], - sort_by=sort_by, + sort_by, + filter_with, ) return response # ------------------- by state --------------------- - def discover_by_state(self, state, limit, sort_by: str = "name"): + def discover_by_state(self, state, limit, sort_by, filter_with): reversed = sort_by != "name" try: @@ -249,12 +266,13 @@ def discover_by_state(self, state, limit, sort_by: str = "name"): "Tags:tags@20", "Language:language@20", ], - sort_by=sort_by, + sort_by, + filter_with, ) # -----------------by language -------------------- - def discover_by_language(self, language, limit, sort_by: str = "name"): + def discover_by_language(self, language, limit, sort_by, filter_with): reversed = sort_by != "name" try: @@ -275,10 +293,11 @@ def discover_by_language(self, language, limit, sort_by: str = "name"): "Tags:tags@20", ], sort_by, + filter_with, ) # -------------------- by tag ---------------------- # - def discover_by_tag(self, tag, limit, sort_by: str = "name"): + def discover_by_tag(self, tag, limit, sort_by, filter_with): reversed = sort_by != "name" try: @@ -299,6 +318,7 @@ def discover_by_tag(self, tag, limit, sort_by: str = "name"): "Tags:tags@50", ], sort_by, + filter_with, ) # ---- Increase click count ------------- # diff --git a/radioactive/help.py b/radioactive/help.py index ac48b8f..6a25412 100644 --- a/radioactive/help.py +++ b/radioactive/help.py @@ -61,6 +61,11 @@ def show_help(): "Play last played station", "False", ) + table.add_row( + "--random", + "Play a random station from favorite list", + "False", + ) table.add_row( "--add , -A", @@ -145,6 +150,12 @@ def show_help(): "info", ) + table.add_row( + "--player", + "Media player to use. vlc/mpv/ffplay", + "ffplay", + ) + console.print(table) print( "For more details : https://github.com/deep5050/radio-active/blob/main/README.md" diff --git a/radioactive/last_station.py b/radioactive/last_station.py index 18325a0..a24be3a 100644 --- a/radioactive/last_station.py +++ b/radioactive/last_station.py @@ -9,8 +9,8 @@ class Last_station: - """Saves the last played radio station information, when user don't provide any -S or -U - it looks for the information. + """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 diff --git a/radioactive/mpv.py b/radioactive/mpv.py new file mode 100644 index 0000000..872dbb1 --- /dev/null +++ b/radioactive/mpv.py @@ -0,0 +1,54 @@ +import subprocess +import sys +from shutil import which + +from zenlog import log + + +class MPV: + def __init__(self): + self.program_name = "mpv" + self.exe_path = which(self.program_name) + 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") + sys.exit(1) + + self.is_running = False + self.process = None + self.url = None + + def _construct_mpv_commands(self, url): + return [self.exe_path, url] + + def start(self, url): + 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" + ) + + 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 + + def toggle(self): + if self.is_running: + self.stop() + else: + self.start(self.url) diff --git a/radioactive/parser.py b/radioactive/parser.py index 00a19de..0eedd35 100644 --- a/radioactive/parser.py +++ b/radioactive/parser.py @@ -29,8 +29,10 @@ def parse_options(): options["play_last_station"] = args.play_last_station options["direct_play"] = args.direct_play + options["play_random"] = args.play_random_station options["sort_by"] = args.stations_sort_by + options["filter_with"] = args.stations_filter_with options["discover_country_code"] = args.discover_country_code options["discover_state"] = args.discover_state @@ -53,5 +55,6 @@ def parse_options(): options["target_url"] = "" options["volume"] = args.volume + options["audio_player"] = args.audio_player return options diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 8bd3e9b..4bbf41f 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -16,8 +16,8 @@ from rich.text import Text from zenlog import log +from radioactive.ffplay import kill_background_ffplays from radioactive.last_station import Last_station -from radioactive.player import kill_background_ffplays from radioactive.recorder import record_audio_auto_codec, record_audio_from_url RED_COLOR = "\033[91m" @@ -96,7 +96,9 @@ def handle_record( elif not record_file_path: log.debug("filepath: fallback to default path") - record_file_path = os.path.join(os.path.expanduser("~"), "Music/radioactive") + 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: @@ -279,10 +281,10 @@ def check_sort_by_parameter(sort_by): return sort_by -def handle_search_stations(handler, station_name, limit, sort_by): +def handle_search_stations(handler, station_name, limit, sort_by, filter_with): log.debug("Searching API for: {}".format(station_name)) - return handler.search_by_station_name(station_name, limit, sort_by) + return handler.search_by_station_name(station_name, limit, sort_by, filter_with) def handle_station_selection_menu(handler, last_station, alias): @@ -354,6 +356,7 @@ def handle_save_last_station(last_station, station_name, station_url): def handle_listen_keypress( alias, + player, target_url, station_name, station_url, @@ -423,15 +426,21 @@ def handle_listen_keypress( handle_add_to_favorite(alias, station_name, station_url) elif user_input in ["q", "Q", "quit"]: - kill_background_ffplays() + # kill_background_ffplays() + player.stop() sys.exit(0) elif user_input in ["w", "W", "list"]: alias.generate_map() handle_favorite_table(alias) elif user_input in ["t", "T", "track"]: handle_fetch_song_title(target_url) + elif user_input in ["p", "P"]: + # toggle the player (start/stop) + player.toggle() + # TODO: toggle the player elif user_input in ["h", "H", "?", "help"]: + log.info("p: Play/Pause current station") log.info("t/track: Current track info") log.info("i/info: Station information") log.info("r/record: Record a station") @@ -465,7 +474,7 @@ def handle_user_choice_from_search_result(handler, response): print() sys.exit(0) - if user_input == ("y" or "Y"): + if user_input in ["y", "Y"]: log.debug("Playing UUID from single response") global_current_station_info = response[0] @@ -491,6 +500,9 @@ def handle_user_choice_from_search_result(handler, response): # pick a random integer withing range user_input = randint(1, len(response) - 1) log.debug(f"Radom station id: {user_input}") + # elif user_input in ["f", "F", "fuzzy"]: + # fuzzy find all the stations, and return the selected station id + # user_input = fuzzy_find(response) user_input = int(user_input) - 1 # because ID starts from 1 if user_input in range(0, len(response)): @@ -599,3 +611,12 @@ def handle_station_name_from_headers(url): ) ) return station_name + + +def handle_play_random_station(alias): + """Select a random station from favorite menu""" + log.debug("playing a random station") + alias_map = alias.alias_map + index = randint(0, len(alias_map) - 1) + station = alias_map[index] + return station["name"], station["uuid_or_url"] diff --git a/radioactive/vlc.py b/radioactive/vlc.py new file mode 100644 index 0000000..872ee29 --- /dev/null +++ b/radioactive/vlc.py @@ -0,0 +1,54 @@ +import subprocess +import sys +from shutil import which + +from zenlog import log + + +class VLC: + def __init__(self): + self.program_name = "vlc" + self.exe_path = which(self.program_name) + 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") + sys.exit(1) + + self.is_running = False + self.process = None + self.url = None + + def _construct_vlc_commands(self, url): + return [self.exe_path, url] + + def start(self, url): + self.url = url + vlc_commands = self._construct_vlc_commands(url) + + try: + self.process = subprocess.Popen( + vlc_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" + ) + + 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 + + def toggle(self): + if self.is_running: + self.stop() + else: + self.start(self.url) diff --git a/requirements.txt b/requirements.txt index bdad370..1ccde19 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +setuptools requests urllib3 psutil