From 0ce627826f2afab0169564f703f7652462ae1b24 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Mon, 30 Oct 2023 13:23:25 +0530 Subject: [PATCH] 2.9.0 (#96) * feat: :lipstick: less log messages * feat: * feat: :sparkles: add: sort result page Sort result page with several parameters like popularity, clickcount, random etc. * feat: :sparkles: add: new column for sorted results * fix: :bug: EOF error fixed #80 fixed #80 EOF error while pressed CTRL + D * Update commit author * refactor: :art: move argument parser logic to a new file * refactor: :art: some unnecessary functions removed * feat: :sparkles: select and remove stations from favorite list fixes #88 * When you add your radio station from URL, it is assigned the name "N/A". This becomes problematic when you want to record the from the station because the default naming structure is STATION_NAME_FILE_NAME. (#91) In linux you will end up getting directory "N/A" does not exist. This update attempts to get the station name from header 'ICY-Name' from the URL provided Updated custom url name from 'N/A' to 'Custom Station' Changed requests library from 'urllib.request' to 'requests' to get rid of 'Audit url open for permitted schemes' warning Co-authored-by: Marvin Ochieng * docs: add marvoh as a contributor for code, and bug (#94) * Update README.md * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: Dipankar Pal Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * feat: :sparkles: detect station name while playing with direct url fixes #92 allow record a station played with direct url * update README * fix: :ambulance: timeout error introduced on fetching station name * feat: :sparkles: improve logic for station name detection using ffprobe instead of requests to fetch station name * feat: :sparkles: dynamic width result page result page will now adjust its width accroding to the actual window size. * feat: :sparkles: play random stations from result page * feat: :sparkles: see station info from runtime command * updated * Update README.md * docs: :memo: contribution guide added * feat: :sparkles: current track information from runtime command * 2.8.1 * README update Signed-off-by: Dipankar Pal * 2.9.0 Signed-off-by: Dipankar Pal --------- Signed-off-by: Dipankar Pal Co-authored-by: John Doe Co-authored-by: marvoh Co-authored-by: Marvin Ochieng Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 10 ++ CHANGELOG.md | 15 +- CONTRIBUTION.md | 52 ++++++ Makefile | 2 +- README.md | 79 +++++---- radioactive/__main__.py | 95 +++++------ radioactive/alias.py | 47 ++++++ radioactive/app.py | 2 +- radioactive/args.py | 17 ++ radioactive/handler.py | 341 ++++++++++++++++++++------------------- radioactive/help.py | 84 ++++++---- radioactive/parser.py | 57 +++++++ radioactive/utilities.py | 268 ++++++++++++++++++++++++++---- 13 files changed, 744 insertions(+), 325 deletions(-) create mode 100644 CONTRIBUTION.md create mode 100644 radioactive/parser.py diff --git a/.all-contributorsrc b/.all-contributorsrc index bb89f29..45e11ed 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -24,6 +24,16 @@ "contributions": [ "code" ] + }, + { + "login": "marvoh", + "name": "marvoh", + "avatar_url": "https://avatars.githubusercontent.com/u/5451142?v=4", + "profile": "https://github.com/marvoh", + "contributions": [ + "code", + "bug" + ] } ], "contributorsPerLine": 7, diff --git a/CHANGELOG.md b/CHANGELOG.md index f76b635..59b0e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 2.9.0 + +1. Fetch current playing track info from runtime commands 🎶 ⚡ +2. Sort results with several parameters like: click-count, popularity, bitrate, random etc. `--sort` +3. Select stations from favorite menu to remove them `--remove` +4. Result page will adjust width according to the actual terminal window size +5. Squashed CTRL+D unhandled bugs +6. Detect station name while playing with direct url +7. Play a random station from result page +8. See station information from runtime command + ## 2.8.0 1. Selection menu added for `--country` and `--tag` results. Play directly from result page. @@ -15,7 +26,7 @@ 4. Runtime command feature added. Perform actions on demand ⚡ 5. A caching mechanism was added for fewer API calls. Faster radio playbacks! 6. Code refactored. It is easier for contributors to implement new features. -7. BREAKING CHANGES: `--station` -> `--search`, `--discover-by-country` -> `--country`, `--discover-by-tag` -> `--tag`, `--discover-by-state` -> `--state`, `--discover-by-language` -> `--lamguage`, `--add-station` -> `--add`, `--add-to-favorite` -> `--favorite`, `--show-favorite-list` -> `--list` +7. BREAKING CHANGES: `--station` -> `--search`, `--discover-by-country` -> `--country`, `--discover-by-tag` -> `--tag`, `--discover-by-state` -> `--state`, `--discover-by-language` -> `--language`, `--add-station` -> `--add`, `--add-to-favorite` -> `--favorite`, `--show-favorite-list` -> `--list` ## 2.6.0 @@ -60,7 +71,7 @@ ## 2.4.0 1. Crashes on Windows fixed -Fixed setup-related issues (development purpose) +2. Fixed setup-related issues (development purpose) ## 2.3.0 diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md new file mode 100644 index 0000000..9dd18e0 --- /dev/null +++ b/CONTRIBUTION.md @@ -0,0 +1,52 @@ +# Contribution Guide + +Welcome to the radio-active project! We're thrilled that you want to contribute. Before you get started, please take a moment to read this guide to understand our contribution process. + + +## Getting Started + +To get started, make sure you have `git`, `ffmpeg` and `python3` installed on your local machine. You'll also need a GitHub account. + +## How to Contribute + +### Fork the Repository + +1. Click the "Fork" button on the top right of this repository's page. +2. This will create a copy of the repository in your GitHub account. + +### Clone Your Fork + +1. Clone your fork to your local machine using the following command: + ```bash + git clone https://github.com/deep5050/radio-active.git + git checkout -b your-branch-name + ``` + +### Install dependencies +```bash +pip3 install -r requirements.txt +pip3 install -r requirements-dev.txt +``` + +### Make changes. + +Modify the code as required + +### Test Your Changes + +Before submitting your changes, please ensure that your code doesn't break the existing functionality. + +Run `make` to install it locally and test before you push changes! + + ``` + git add . + git commit -m "Add your commit message here" --signoff + git push + ``` +### Create a Pull Request +Visit the original repository on GitHub. +You should see a "New Pull Request" button. Click on it. +Follow the instructions to create your pull request. + +Fill the description section with meaningful message. + diff --git a/Makefile b/Makefile index d86ef1c..9f11434 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ SRC_DIR = "radioactive" TEST_DIR = "test" .PHONY: all clean isort check dist deploy test-deploy help build install install-dev test -all: clean format check build install +all: clean isort format check build install check: @echo "Chceking linting errors......." diff --git a/README.md b/README.md index ea47ec6..fe8ecf2 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,14 @@ + ### Features - [x] Supports more than 40K stations !! :radio: +- [x] Record audio from live radio on demand :zap: +- [x] Get song information on run-time 🎶 - [x] Saves last station information -- [x] Favorite stations (Aliasing) :heart: +- [x] Favorite stations :heart: - [x] Selection menu for favorite stations - [x] Supports user-added stations :wrench: - [x] Looks minimal and user-friendly @@ -39,18 +42,17 @@ - [x] Finds nearby stations - [x] Discovers stations by genre - [x] Discovers stations by language -- [x] Record audio from live radio on demand :zap: - [ ] I'm feeling lucky! Play Random stations - [ ] VLC, MPV player support -> See my progress ➡️ [here]([https://github.com/deep5050/radio-active/projects/1](https://github.com/users/deep5050/projects/5)) +> See my progress ➡️ [here](https://github.com/users/deep5050/projects/5) ### Why radioactive? While there are various CLI-based radio players like [PyRadio](https://github.com/coderholic/pyradio) and [TERA](https://github.com/shinokada/tera), Radioactive stands out for its simplicity. It's designed to work seamlessly right from the start. You don't need to be a hardcore Linux or Vim expert to enjoy radio stations with Radioactive. The goal of Radioactive is to offer a straightforward user interface that's easy to grasp and comes preconfigured, without unnecessary complexities. -### Showcase +### In the Spotlight The praise from YouTube channels and blogs underscores Radioactive's emergence as a top choice for radio enthusiasts. Its simple yet powerful features, make it a must-try application for radio lovers of all expertise levels. Discover the world of radio with ease – experience Radioactive today. @@ -62,6 +64,7 @@ The praise from YouTube channels and blogs underscores Radioactive's emergence a 6. Awesome-stars: https://arbal.github.io/awesome-stars/#:~:text=deep5050/radio%2Dactive%20%2D%20Play%20any%20radios%20around%20the%20globe%20right%20from%20the%20terminal%20%3Azap%3A 7. OpenSourceAgenda: https://www.opensourceagenda.com/projects/my-awesome-stars#:~:text=deep5050/radio%2Dactive%20%2D%20Play%20any%20radios%20around%20the%20globe%20right%20from%20the%20terminal%20%3Azap%3A + ### Install Simply run: `pip3 install --upgrade radio-active` @@ -111,39 +114,41 @@ Search a station with `radio --search [STATION_NAME]` or simply `radio` :zap: to - - + ### Options -| Argument | Note | Description | Default | -| ------------------ | ----------------------------------- | ---------------------------------------------- | ------------- | -| `--search`, `-S` | Required (Optional from second run) | Station name | None | -| `--play`, `-P` | Optional | A station from fav list or url for direct play | None | -| `--last` | Optional | Play last played station | False | -| `--uuid`, `-U` | Optional | ID of the station | None | -| `--loglevel` | Optional | Log level of the program | Info | -| `--add` , `-A` | Optional | Add an entry to fav list | False | -| `--list`, `-W` | Optional | Show fav list | False | -| `--favorite`, `-F` | Optional | Add current station to fav list | False | -| `--flush` | Optional | Remove all the entries from fav list | False | -| `--country`, `-C` | Optional | Discover stations by country code | False | -| `--state` | Optional | Discover stations by country state | False | -| `--tag` | Optional | Discover stations by tags/genre | False | -| `--language` | optional | Discover stations by | False | -| `--limit` | Optional | Limit the # of results in the Discover table | 100 | -| `--volume` , `-V` | Optional | Change the volume passed into ffplay | 80 | -| `--kill` , `-K` | Optional | Kill background radios. | False | -| `--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 | +| 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 |
+> **NOTE:** Once you save/play at least one station, invoking `radio` without any options will show a selection menu > `--search`, `-S`: Search for a station online. @@ -172,13 +177,26 @@ Input a command during the radio playback to perform an action. Available comman ``` Enter a command to perform an action: ? -q/Q/quit: Quit radioactive -h/H/help/?: Show this help message +t/T/track: Current song name (track info) r/R/record: Record a station f/F/fav: Add station to favorite list rf/RF/recordfile: Specify a filename for the recording. +h/H/help/?: Show this help message +q/Q/quit: Quit radioactive ``` +### sort parameters + +you can sort the result page with these parameters: +- `name` (default) +- `votes` (based on user votes) +- `codec` +- `bitrate` +- `lastcheckok` (active stations) +- `lastchecktime` (recent active) +- `clickcount` (total play count) +- `clicktrend` (currently trending stations) +- `random` ### Bonus Tips @@ -230,6 +248,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Joe Smith
Joe Smith

⚠️ 💻 🤔 salehjafarli
salehjafarli

💻 + marvoh
marvoh

💻 🐛 diff --git a/radioactive/__main__.py b/radioactive/__main__.py index ced3a74..a5cc308 100755 --- a/radioactive/__main__.py +++ b/radioactive/__main__.py @@ -8,23 +8,29 @@ from radioactive.alias import Alias from radioactive.app import App -from radioactive.args import Parser 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 (handle_add_station, handle_add_to_favorite, - handle_current_play_panel, - handle_direct_play, handle_favorite_table, - handle_listen_keypress, handle_log_level, - handle_play_last_station, handle_record, - handle_save_last_station, - handle_search_stations, - handle_station_selection_menu, - handle_station_uuid_play, - handle_update_screen, - handle_user_choice_from_search_result, - handle_welcome_screen) +from radioactive.utilities import ( + check_sort_by_parameter, + handle_add_station, + handle_add_to_favorite, + handle_current_play_panel, + handle_direct_play, + handle_favorite_table, + handle_listen_keypress, + handle_play_last_station, + handle_record, + handle_save_last_station, + handle_search_stations, + handle_station_selection_menu, + handle_station_uuid_play, + handle_update_screen, + handle_user_choice_from_search_result, + handle_welcome_screen, +) # globally needed as signal handler needs it # to terminate main() properly @@ -78,42 +84,12 @@ def final_step(options, last_station, alias, handler): def main(): log.level("info") - parser = Parser() - app = App() - args = parser.parse() - - options = {} - # ----------------- all the args ------------- # - options["show_help_table"] = args.help - options["search_station_name"] = args.search_station_name - options["direct_play"] = args.direct_play - options["play_last_station"] = args.play_last_station - - options["search_station_uuid"] = args.search_station_uuid - - options["discover_country_code"] = args.discover_country_code - options["discover_state"] = args.discover_state - options["discover_language"] = args.discover_language - options["discover_tag"] = args.discover_tag - limit = args.limit - options["limit"] = int(limit) if limit else 100 - log.debug("limit is set to: {}".format(limit)) - - options["add_station"] = args.new_station - options["add_to_favorite"] = args.add_to_favorite - options["show_favorite_list"] = args.show_favorite_list - - options["flush_fav_list"] = args.flush - options["kill_ffplays"] = args.kill_ffplays + app = App() - options["record_stream"] = args.record_stream - options["record_file"] = args.record_file - options["record_file_format"] = args.record_file_format - options["record_file_path"] = args.record_file_path + options = parse_options() - options["target_url"] = "" - options["volume"] = args.volume + handle_welcome_screen() VERSION = app.get_version() @@ -123,9 +99,8 @@ def main(): last_station = Last_station() # --------------- app logic starts here ------------------- # - handle_welcome_screen() - if args.version: + if options["version"]: log.info("RADIO-ACTIVE : version {}".format(VERSION)) sys.exit(0) @@ -133,8 +108,6 @@ def main(): show_help() sys.exit(0) - options["loglevel"] = handle_log_level(args) - if options["flush_fav_list"]: sys.exit(alias.flush()) @@ -149,12 +122,19 @@ def main(): if options["add_station"]: handle_add_station(alias) + if options["remove_fav_stations"]: + # handle_remove_stations(alias) + alias.remove_entries() + sys.exit(0) + + options["sort_by"] = check_sort_by_parameter(options["sort_by"]) + handle_update_screen(app) # ----------- country ----------- # if options["discover_country_code"]: response = handler.discover_by_country( - options["discover_country_code"], options["limit"] + options["discover_country_code"], options["limit"], options["sort_by"] ) if response is not None: ( @@ -168,7 +148,7 @@ def main(): # -------------- state ------------- # if options["discover_state"]: response = handler.discover_by_state( - options["discover_state"], options["limit"] + options["discover_state"], options["limit"], options["sort_by"] ) if response is not None: ( @@ -182,7 +162,7 @@ def main(): # ----------- language ------------ # if options["discover_language"]: response = handler.discover_by_language( - options["discover_language"], options["limit"] + options["discover_language"], options["limit"], options["sort_by"] ) if response is not None: ( @@ -195,7 +175,9 @@ def main(): # -------------- tag ------------- # if options["discover_tag"]: - response = handler.discover_by_tag(options["discover_tag"], options["limit"]) + response = handler.discover_by_tag( + options["discover_tag"], options["limit"], options["sort_by"] + ) if response is not None: ( options["curr_station_name"], @@ -235,7 +217,10 @@ def main(): ): response = [{}] response = handle_search_stations( - handler, options["search_station_name"], options["limit"] + handler, + options["search_station_name"], + options["limit"], + options["sort_by"], ) if response is not None: ( diff --git a/radioactive/alias.py b/radioactive/alias.py index 1059efe..a6a63fd 100644 --- a/radioactive/alias.py +++ b/radioactive/alias.py @@ -1,5 +1,6 @@ import os.path +from pick import pick from zenlog import log @@ -10,9 +11,23 @@ def __init__(self): self.alias_path = os.path.join(os.path.expanduser("~"), ".radio-active-alias") + def write_stations(self, station_map): + """Write stations file from generated map""" + with open(self.alias_path, "w") as f: + f.flush() + for entry in station_map: + f.write( + "{}=={}\n".format( + entry["name"].strip(), entry["uuid_or_url"].strip() + ) + ) + return True + def generate_map(self): """parses the fav list file and generates a list""" # create alias map + self.alias_map = [] + if os.path.exists(self.alias_path): log.debug(f"Alias file at: {self.alias_path}") try: @@ -81,3 +96,35 @@ def flush(self): log.debug("Error: {}".format(e)) log.error("could not delete your favorite list. something went wrong") return 1 + + def remove_entries(self): + # select entries from fav menu and remove them + self.generate_map() + + if not self.alias_map: + log.error("No stations to be removed!") + return + + title = "Select stations to be removed. Hit 'SPACE' to select " + options = [entry["name"] for entry in self.alias_map] + selected = pick( + options, title, indicator="->", multiselect=True, min_selection_count=1 + ) + + # Extract integer numbers and create a new list + indices_to_remove = [item[1] for item in selected if isinstance(item[1], int)] + + # remove selected entries from the map, and regenerate + filtered_list = [ + self.alias_map[i] + for i in range(len(self.alias_map)) + if i not in indices_to_remove + ] + + log.debug( + f"Current # of entries reduced to : {len(filtered_list)} from {len(self.alias_map)}" + ) + + self.write_stations(filtered_list) + self.alias_map = filtered_list + log.info("Stations removed successfully!") diff --git a/radioactive/app.py b/radioactive/app.py index badad9c..736a0de 100644 --- a/radioactive/app.py +++ b/radioactive/app.py @@ -9,7 +9,7 @@ class App: def __init__(self): - self.__VERSION__ = "2.8.0" # change this on every update # + self.__VERSION__ = "2.9.0" # 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 65f390c..f9af9a9 100644 --- a/radioactive/args.py +++ b/radioactive/args.py @@ -37,6 +37,7 @@ def __init__(self): dest="search_station_name", help="Specify a station name", ) + self.parser.add_argument( "--play", "-P", @@ -106,6 +107,14 @@ def __init__(self): help="Limit of entries in discover table", ) + self.parser.add_argument( + "--sort", + action="store", + dest="stations_sort_by", + default="name", + help="Sort stations", + ) + self.parser.add_argument( "--add", "-A", @@ -131,6 +140,14 @@ def __init__(self): help="Show your favorite list in table format", ) + self.parser.add_argument( + "--remove", + action="store_true", + default=False, + dest="remove_fav_stations", + help="Remove stations from favorite list", + ) + self.parser.add_argument( "--flush", action="store_true", diff --git a/radioactive/handler.py b/radioactive/handler.py index e78ed17..85b6599 100644 --- a/radioactive/handler.py +++ b/radioactive/handler.py @@ -16,12 +16,90 @@ def trim_string(text, max_length=40): + """ + Trim a string to a maximum length and add ellipsis if needed. + + Args: + text (str): The input text to be trimmed. + max_length (int, optional): The maximum length of the trimmed string. Defaults to 40. + + Returns: + str: The trimmed string, possibly with an ellipsis (...) if it was shortened. + """ if len(text) > max_length: return text[:max_length] + "..." else: return text +def print_table(response, columns, sort_by="name"): + """ + Print the table applying the sort logic. + + Args: + response (list): A list of data to be displayed in the table. + columns (list): List of column specifications in the format "col_name:response_key@max_str". + sort_by (str): The column by which to sort the table. + + Returns: + list: The original response data. + """ + + if not response: + log.error("No stations found") + sys.exit(1) + + if len(response) >= 1: + table = Table( + show_header=True, + header_style="magenta", + expand=True, + min_width=85, + safe_box=True, + # show_footer=True, + # show_lines=True, + # padding=0.1, + # collapse_padding=True, + ) + table.add_column("ID", justify="center") + + 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]), + ) + table.add_column(col_name, justify="left") + + # do not need extra columns for these cases + 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 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, ""))) + + 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 + + class Handler: """ radio-browser API handler. This module communicates with the underlying API via PyRadios @@ -51,61 +129,34 @@ def get_country_code(self, name): return country["iso_3166_1"] return None - def station_validator(self): - """Validates a response from the API and takes appropriate decision""" - - # when no response from the API - if not self.response: - log.error("No stations found by the name") - return [] - - # when multiple results found - if len(self.response) > 1: - table = Table(show_header=True, header_style="bold magenta") - table.add_column("ID", justify="center") - table.add_column("Station", justify="left") - # table.add_column("UUID", justify="center") - table.add_column("Country", justify="center") - table.add_column("Tags", justify="center") - - log.warn("showing {} stations with the name!".format(len(self.response))) - - for i in range(0, len(self.response)): - station = self.response[i] - table.add_row( - str(i + 1), - trim_string(station["name"], max_length=50), - # station["stationuuid"], - station["countrycode"], - trim_string( - station["tags"] - ), # trimming tags to make the table shorter - ) - - 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 self.response - - # when exactly one response found + def validate_uuid_station(self): if len(self.response) == 1: - log.info("Station found: {}".format(self.response[0]["name"].strip())) log.debug(json.dumps(self.response[0], indent=3)) self.target_station = self.response[0] + # register a valid click to increase its popularity self.API.click_counter(self.target_station["stationuuid"]) return self.response - # return self.response[0]["name"].strip() # ---------------------------- NAME -------------------------------- # - def search_by_station_name(self, _name=None, limit=100): + def search_by_station_name(self, _name=None, limit=100, sort_by: str = "name"): """search and play a station by its name""" + reversed = sort_by != "name" + try: - self.response = self.API.search(name=_name, name_exact=False, limit=limit) - return self.station_validator() + response = self.API.search( + name=_name, + name_exact=False, + limit=limit, + order=str(sort_by), + reverse=reversed, + ) + return print_table( + response, + ["Station:name@30", "Country:country@20", "Tags:tags@20"], + sort_by=sort_by, + ) except Exception as e: log.debug("Error: {}".format(e)) log.error("Something went wrong. please try again.") @@ -116,21 +167,27 @@ def play_by_station_uuid(self, _uuid): """search and play station by its stationuuid""" try: self.response = self.API.station_by_uuid(_uuid) - return self.station_validator() # should return a station name also + return self.validate_uuid_station() except Exception as e: log.debug("Error: {}".format(e)) log.error("Something went wrong. please try again.") sys.exit(1) # -------------------------- COUNTRY ----------------------# - def discover_by_country(self, country_code_or_name, limit): + def discover_by_country(self, country_code_or_name, limit, sort_by: str = "name"): + # set reverse to false if name is is the parameter for sorting + reversed = sort_by != "name" + # check if it is a code or name if len(country_code_or_name.strip()) == 2: # it's a code - log.debug("Country code {} provided".format(country_code_or_name)) + log.debug("Country code '{}' provided".format(country_code_or_name)) try: response = self.API.search( - countrycode=country_code_or_name, limit=limit + countrycode=country_code_or_name, + limit=limit, + order=str(sort_by), + reverse=reversed, ) except Exception as e: log.debug("Error: {}".format(e)) @@ -138,12 +195,16 @@ def discover_by_country(self, country_code_or_name, limit): sys.exit(1) else: # it's name - log.debug("Country name {} provided".format(country_code_or_name)) + log.debug("Country name '{}' provided".format(country_code_or_name)) code = self.get_country_code(country_code_or_name) if code: try: response = self.API.search( - countrycode=code, limit=limit, country_exact=True + countrycode=code, + limit=limit, + country_exact=True, + order=str(sort_by), + reverse=reversed, ) except Exception as e: log.debug("Error: {}".format(e)) @@ -153,150 +214,94 @@ def discover_by_country(self, country_code_or_name, limit): log.error("Not a valid country name") sys.exit(1) - if len(response) > 1: - log.info("Result for country: {}".format(response[0]["country"])) - table = Table(show_header=True, header_style="bold magenta") - table.add_column("ID", justify="center") - table.add_column("Station", justify="left") - # table.add_column("UUID", justify="center") - table.add_column("State", justify="center") - table.add_column("Tags", justify="center") - table.add_column("Language", justify="center") - - for i in range(0, len(response)): - current_response = response[i] - table.add_row( - str(i + 1), - trim_string(current_response["name"], max_length=30), - # res["stationuuid"], - current_response["state"], - trim_string(current_response["tags"], max_length=20), - trim_string(current_response["language"], max_length=20), - ) - 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.error("No stations found for the country code/name, recheck it") - sys.exit(1) + # display the result + print_table( + response, + [ + "Station:name@30", + "State:state@20", + "Tags:tags@20", + "Language:language@20", + ], + sort_by=sort_by, + ) + return response # ------------------- by state --------------------- - def discover_by_state(self, state, limit): + def discover_by_state(self, state, limit, sort_by: str = "name"): + reversed = sort_by != "name" + try: - discover_result = self.API.search(state=state, limit=limit) + response = self.API.search( + state=state, limit=limit, order=str(sort_by), reverse=reversed + ) except Exception: log.error("Something went wrong. please try again.") sys.exit(1) - if len(discover_result) > 1: - table = Table(show_header=True, header_style="bold magenta") - table.add_column("ID", justify="center") - table.add_column("Station", justify="left") - # table.add_column("UUID", justify="center") - table.add_column("Country", justify="center") - table.add_column("Tags", justify="center") - table.add_column("Language", justify="center") - - for i in range(0, len(discover_result)): - res = discover_result[i] - table.add_row( - str(i + 1), - trim_string(res["name"], max_length=30), - # res["stationuuid"], - trim_string(res["country"], max_length=20), - trim_string(res["tags"], max_length=20), - trim_string(res["language"], max_length=20), - ) - 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 discover_result - else: - log.error("No stations found for the state, recheck it") - sys.exit(1) + return print_table( + response, + [ + "Station:name@30", + "Country:country@20", + "State:state@20", + "Tags:tags@20", + "Language:language@20", + ], + sort_by=sort_by, + ) # -----------------by language -------------------- - def discover_by_language(self, language, limit): + def discover_by_language(self, language, limit, sort_by: str = "name"): + reversed = sort_by != "name" + try: - discover_result = self.API.search(language=language, limit=limit) + response = self.API.search( + language=language, limit=limit, order=str(sort_by), reverse=reversed + ) except Exception as e: log.debug("Error: {}".format(e)) log.error("Something went wrong. please try again.") sys.exit(1) - if len(discover_result) > 1: - table = Table(show_header=True, header_style="bold magenta") - table.add_column("ID", justify="center") - table.add_column("Station", justify="left") - # table.add_column("UUID", justify="center") - table.add_column("Country", justify="center") - table.add_column("Tags", justify="center") - - for i in range(0, len(discover_result)): - res = discover_result[i] - table.add_row( - str(i + 1), - trim_string(res["name"], max_length=30), - # res["stationuuid"], - trim_string(res["country"], max_length=20), - trim_string(res["tags"], max_length=30), - ) - 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 discover_result - else: - log.error("No stations found for the language, recheck it") - sys.exit(1) + return print_table( + response, + [ + "Station:name@30", + "Country:country@20", + "Language:language@20", + "Tags:tags@20", + ], + sort_by, + ) # -------------------- by tag ---------------------- # + def discover_by_tag(self, tag, limit, sort_by: str = "name"): + reversed = sort_by != "name" - def discover_by_tag(self, tag, limit): try: - discover_result = self.API.search(tag=tag, limit=limit) + response = self.API.search( + tag=tag, limit=limit, order=str(sort_by), reverse=reversed + ) except Exception as e: log.debug("Error: {}".format(e)) log.error("Something went wrong. please try again.") sys.exit(1) - if len(discover_result) > 1: - table = Table(show_header=True, header_style="bold magenta") - table.add_column("ID", justify="center") - table.add_column("Station", justify="left") - # table.add_column("UUID", justify="center") - table.add_column("country", justify="center") - table.add_column("Language", justify="center") - - for i in range(0, len(discover_result)): - res = discover_result[i] - table.add_row( - str(i + 1), - trim_string(res["name"], max_length=30), - # res["stationuuid"], - trim_string(res["country"], max_length=20), - trim_string(res["language"], max_length=20), - ) - 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 discover_result - else: - log.error("No stations found for the tag, recheck it") - sys.exit(1) - - # ---- increase click count ------------- # + return print_table( + response, + [ + "Station:name@30", + "Country:country@20", + "Language:language@20", + "Tags:tags@50", + ], + sort_by, + ) + + # ---- Increase click count ------------- # def vote_for_uuid(self, UUID): try: result = self.API.click_counter(UUID) diff --git a/radioactive/help.py b/radioactive/help.py index a556658..ac48b8f 100644 --- a/radioactive/help.py +++ b/radioactive/help.py @@ -20,31 +20,54 @@ def show_help(): "A station name to search on the internet", "", ) + table.add_row( - "--play , -P", - "A station name from fav list or a stream url", + "--uuid , -U", + "A station UUID to play it directly", "", ) + table.add_row( - "--last", - "Play last played station", - "False", + "--country, -C", + "Discover stations by country code", + "", ) table.add_row( - "--uuid , -U", - "A station UUID to play it directly", + "--state", + "Discover stations by country state", "", ) + table.add_row( - "--loglevel", - "Log level of the program: info,warning,error,debug", - "info", + "--tag", + "Discover stations by tags/genre", + "", + ) + + table.add_row( + "--language", + "Discover stations by language", + "", + ) + + table.add_row( + "--play , -P", + "A station name from fav list or a stream url", + "", ) + + table.add_row( + "--last", + "Play last played station", + "False", + ) + table.add_row( "--add , -A", "Add a station to your favorite list", "False", ) + table.add_row( "--favorite, -F ", "Add current station to favorite list", @@ -58,27 +81,15 @@ def show_help(): ) table.add_row( - "--country, -C", - "Discover stations by country code", - "", - ) - - table.add_row( - "--state", - "Discover stations by country state", - "", - ) - - table.add_row( - "--tag", - "Discover stations by tags/genre", - "", + "--remove", + "Remove stations from favorite list", + "False", ) table.add_row( - "--language", - "Discover stations by language", - "", + "--flush", + "Clear your favorite list", + "False", ) table.add_row( @@ -87,17 +98,18 @@ def show_help(): "100", ) + table.add_row( + "--sort", + "Sort the results page, see documentation", + "clickcount", + ) + table.add_row( "--volume, -V", "Volume of the radio between 0 and 100", "80", ) - table.add_row( - "--flush", - "Clear your favorite list", - "False", - ) table.add_row( "--record, -R", "Record current stations audio", @@ -127,6 +139,12 @@ def show_help(): "False", ) + table.add_row( + "--loglevel", + "Log level of the program: info,warning,error,debug", + "info", + ) + console.print(table) print( "For more details : https://github.com/deep5050/radio-active/blob/main/README.md" diff --git a/radioactive/parser.py b/radioactive/parser.py new file mode 100644 index 0000000..00a19de --- /dev/null +++ b/radioactive/parser.py @@ -0,0 +1,57 @@ +from zenlog import log + +from radioactive.args import Parser + + +def parse_options(): + parser = Parser() + args = parser.parse() + options = {} + # ----------------- all the args ------------- # + options["version"] = args.version + options["show_help_table"] = args.help + options["loglevel"] = args.log_level + + # check log levels + if options["loglevel"] in ["info", "error", "warning", "debug"]: + log.level(options["loglevel"]) + else: + log.level("info") + log.warning("Correct log levels are: error,warning,info(default),debug") + + # check is limit is a valid integer + limit = args.limit + options["limit"] = int(limit) if limit else 100 + log.debug("limit is set to: {}".format(limit)) + + options["search_station_name"] = args.search_station_name + options["search_station_uuid"] = args.search_station_uuid + + options["play_last_station"] = args.play_last_station + options["direct_play"] = args.direct_play + + options["sort_by"] = args.stations_sort_by + + options["discover_country_code"] = args.discover_country_code + options["discover_state"] = args.discover_state + options["discover_language"] = args.discover_language + options["discover_tag"] = args.discover_tag + + options["add_station"] = args.new_station + + options["show_favorite_list"] = args.show_favorite_list + options["add_to_favorite"] = args.add_to_favorite + options["flush_fav_list"] = args.flush + options["remove_fav_stations"] = args.remove_fav_stations + + options["kill_ffplays"] = args.kill_ffplays + + options["record_stream"] = args.record_stream + options["record_file"] = args.record_file + options["record_file_format"] = args.record_file_format + options["record_file_path"] = args.record_file_path + + options["target_url"] = "" + options["volume"] = args.volume + + return options diff --git a/radioactive/utilities.py b/radioactive/utilities.py index dca70af..8bd3e9b 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -1,9 +1,13 @@ """Handler functions for __main__.py""" import datetime +import json import os +import subprocess import sys +from random import randint +import requests from pick import pick from rich import print from rich.console import Console @@ -19,14 +23,41 @@ RED_COLOR = "\033[91m" END_COLOR = "\033[0m" +global_current_station_info = {} + + +def handle_fetch_song_title(url): + """Fetch currently playing track information""" + log.info("Fetching the current track info") + log.debug("Attempting to retrieve track info from: {}".format(url)) + # Run ffprobe command and capture the metadata + cmd = [ + "ffprobe", + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + "-show_entries", + "format=icy", + url, + ] + track_name = "" -def handle_log_level(args): - log_level = args.log_level - if log_level in ["info", "error", "warning", "debug"]: - log.level(log_level) - return args.log_level + try: + output = subprocess.check_output(cmd).decode("utf-8") + data = json.loads(output) + log.debug(f"station info: {data}") + + # Extract the station name (icy-name) if available + track_name = data.get("format", {}).get("tags", {}).get("StreamTitle", "") + except: + log.error("Error while fetching the track name") + + if track_name != "": + log.info(f"🎶: {track_name}") else: - log.warning("Correct log levels are: error,warning,info(default),debug") + log.error("No track information available") def handle_record( @@ -110,6 +141,8 @@ def handle_welcome_screen(): """, title="[b]RADIOACTIVE[/b]", width=85, + expand=True, + safe_box=True, ) print(welcome) @@ -131,8 +164,14 @@ def handle_update_screen(app): def handle_favorite_table(alias): - log.info("Your favorite station list is below") - table = Table(show_header=True, header_style="bold magenta") + # log.info("Your favorite station list is below") + table = Table( + show_header=True, + header_style="bold magenta", + min_width=85, + safe_box=False, + expand=True, + ) table.add_column("Station", justify="left") table.add_column("URL / UUID", justify="left") if len(alias.alias_map) > 0: @@ -144,9 +183,34 @@ def handle_favorite_table(alias): log.info("You have no favorite station list") +def handle_show_station_info(): + """Show important information regarding the current station""" + global global_current_station_info + custom_info = {} + try: + custom_info["name"] = global_current_station_info["name"] + custom_info["uuid"] = global_current_station_info["stationuuid"] + custom_info["url"] = global_current_station_info["url"] + custom_info["website"] = global_current_station_info["homepage"] + custom_info["country"] = global_current_station_info["country"] + custom_info["language"] = global_current_station_info["language"] + custom_info["tags"] = global_current_station_info["tags"] + custom_info["codec"] = global_current_station_info["codec"] + custom_info["bitrate"] = global_current_station_info["bitrate"] + print(custom_info) + except: + log.error("No station information available") + + def handle_add_station(alias): - left = input("Enter station name:") - right = input("Enter station stream-url or radio-browser uuid:") + try: + left = input("Enter station name:") + right = input("Enter station stream-url or radio-browser uuid:") + except EOFError: + print() + log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") + sys.exit(0) + if left.strip() == "" or right.strip() == "": log.error("Empty inputs not allowed") sys.exit(1) @@ -159,7 +223,13 @@ def handle_add_to_favorite(alias, station_name, station_uuid_url): try: response = alias.add_entry(station_name, station_uuid_url) if not response: - user_input = input("Enter a different name: ") + try: + user_input = input("Enter a different name: ") + except EOFError: + print() + log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") + sys.exit(0) + if user_input.strip() != "": response = alias.add_entry(user_input.strip(), station_uuid_url) except Exception as e: @@ -186,10 +256,33 @@ def handle_station_uuid_play(handler, station_uuid): return station_name, station_url -def handle_search_stations(handler, station_name, limit): +def check_sort_by_parameter(sort_by): + accepted_parameters = [ + "name", + "votes", + "codec", + "bitrate", + "lastcheckok", + "lastchecktime", + "clickcount", + "clicktrend", + "random", + ] + + if sort_by not in accepted_parameters: + log.warning("Sort parameter is unknown. Falling back to 'name'") + + log.warning( + "choose from: name,votes,codec,bitrate,lastcheckok,lastchecktime,clickcount,clicktrend,random" + ) + return "name" + return sort_by + + +def handle_search_stations(handler, station_name, limit, sort_by): log.debug("Searching API for: {}".format(station_name)) - return handler.search_by_station_name(station_name, limit) + return handler.search_by_station_name(station_name, limit, sort_by) def handle_station_selection_menu(handler, last_station, alias): @@ -203,7 +296,7 @@ def handle_station_selection_menu(handler, last_station, alias): # no last station?? pass - log.info("You can search for a station on internet using the --search option") + # log.info("You can search for a station on internet using the --search option") title = "Please select a station from your favorite list:" station_selection_names = [] station_selection_urls = [] @@ -271,8 +364,15 @@ def handle_listen_keypress( ): log.info("Press '?' to see available commands\n") while True: - user_input = input("Enter a command to perform an action: ") - if user_input == "r" or user_input == "R" or user_input == "record": + try: + user_input = input("Enter a command to perform an action: ") + except EOFError: + print() + log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") + kill_background_ffplays() + sys.exit(0) + + if user_input in ["r", "R", "record"]: handle_record( target_url, station_name, @@ -281,11 +381,17 @@ def handle_listen_keypress( record_file_format, loglevel, ) - elif user_input == "rf" or user_input == "RF" or user_input == "recordfile": + elif user_input in ["rf", "RF", "recordfile"]: # if no filename is provided try to auto detect # else if ".mp3" is provided, use libmp3lame to force write to mp3 + try: + user_input = input("Enter output filename: ") + except EOFError: + print() + log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") + kill_background_ffplays() + sys.exit(0) - user_input = input("Enter output filename: ") # try to get extension from filename try: file_name, file_ext = user_input.split(".") @@ -310,29 +416,29 @@ def handle_listen_keypress( record_file_format, loglevel, ) + elif user_input in ["i", "I", "info"]: + handle_show_station_info() - elif user_input == "f" or user_input == "F" or user_input == "fav": + elif user_input in ["f", "F", "fav"]: handle_add_to_favorite(alias, station_name, station_url) - elif user_input == "q" or user_input == "Q" or user_input == "quit": + elif user_input in ["q", "Q", "quit"]: kill_background_ffplays() sys.exit(0) - elif user_input == "w" or user_input == "W" or user_input == "list": + 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 == "h" - or user_input == "H" - or user_input == "?" - or user_input == "help" - ): - log.info("h/help/?: Show this help message") - log.info("q/quit: Quit radioactive") + elif user_input in ["h", "H", "?", "help"]: + log.info("t/track: Current track info") + log.info("i/info: Station information") log.info("r/record: Record a station") - log.info("f/fav: Add station to favorite list") log.info("rf/recordfile: Specify a filename for the recording") - # TODO: u for uuid, link for url, p for setting path + log.info("f/fav: Add station to favorite list") + log.info("h/help/?: Show this help message") + log.info("q/quit: Quit radioactive") def handle_current_play_panel(curr_station_name=""): @@ -344,6 +450,8 @@ def handle_current_play_panel(curr_station_name=""): def handle_user_choice_from_search_result(handler, response): + global global_current_station_info + if not response: log.debug("No result found!") sys.exit(0) @@ -351,9 +459,16 @@ def handle_user_choice_from_search_result(handler, response): # single station found log.debug("Exactly one result found") - user_input = input("Want to play this station? Y/N: ") + try: + user_input = input("Want to play this station? Y/N: ") + except EOFError: + print() + sys.exit(0) + if user_input == ("y" or "Y"): log.debug("Playing UUID from single response") + global_current_station_info = response[0] + return handle_station_uuid_play(handler, response[0]["stationuuid"]) else: log.debug("Quitting") @@ -362,12 +477,30 @@ def handle_user_choice_from_search_result(handler, response): # multiple station log.debug("Asking for user input") - user_input = input("Type the result ID to play: ") try: + log.info("Type 'r' to play a random station") + user_input = input("Type the result ID to play: ") + except EOFError: + print() + log.info("Exiting") + log.debug("EOF reached, quitting") + sys.exit(0) + + try: + if user_input in ["r", "R", "random"]: + # pick a random integer withing range + user_input = randint(1, len(response) - 1) + log.debug(f"Radom station id: {user_input}") + user_input = int(user_input) - 1 # because ID starts from 1 if user_input in range(0, len(response)): target_response = response[user_input] log.debug("Selected: {}".format(target_response)) + # log.info("UUID: {}".format(target_response["stationuuid"])) + + # saving global info + global_current_station_info = target_response + return handle_station_uuid_play(handler, target_response["stationuuid"]) else: log.error("Please enter an ID within the range") @@ -379,11 +512,14 @@ def handle_user_choice_from_search_result(handler, response): def handle_direct_play(alias, station_name_or_url=""): """Play a station directly with UUID or direct stream URL""" - if "http" in station_name_or_url.strip(): + if "://" in station_name_or_url.strip(): log.debug("Direct play: URL provided") # stream URL # call using URL with no station name N/A - return "N/A", station_name_or_url + # let's attempt to get station name from url headers + # station_name = handle_station_name_from_headers(station_name_or_url) + station_name = handle_get_station_name_from_metadata(station_name_or_url) + return station_name, station_name_or_url else: log.debug("Direct play: station name provided") # station name from fav list @@ -401,3 +537,65 @@ def handle_direct_play(alias, station_name_or_url=""): def handle_play_last_station(last_station): station_obj = last_station.get_info() return station_obj["name"], station_obj["uuid_or_url"] + + +# uses ffprobe to fetch station name +def handle_get_station_name_from_metadata(url): + """Get ICY metadata from ffprobe""" + log.info("Fetching the station name") + log.debug("Attempting to retrieve station name from: {}".format(url)) + # Run ffprobe command and capture the metadata + cmd = [ + "ffprobe", + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + "-show_entries", + "format=icy", + url, + ] + station_name = "Unknown Station" + + try: + output = subprocess.check_output(cmd).decode("utf-8") + data = json.loads(output) + log.debug(f"station info: {data}") + + # Extract the station name (icy-name) if available + station_name = ( + data.get("format", {}).get("tags", {}).get("icy-name", "Unknown Station") + ) + except: + log.error("Could not fetch the station name") + + return station_name + + +# uses requests module to fetch station name [deprecated] +def handle_station_name_from_headers(url): + # Get headers from URL so that we can get radio station + log.info("Fetching the station name") + log.debug("Attempting to retrieve station name from: {}".format(url)) + station_name = "Unknown Station" + try: + # sync call, with timeout + response = requests.get(url, timeout=5) + if response.status_code == requests.codes.ok: + if response.headers.get("Icy-Name"): + station_name = response.headers.get("Icy-Name") + else: + log.error("Station name not found") + else: + log.debug("Response code received is: {}".format(response.status_code())) + except Exception as e: + # except requests.HTTPError and requests.exceptions.ReadTimeout as e: + log.error("Could not fetch the station name") + log.debug( + """An error occurred: {} + The response code was {}""".format( + e, e.errno + ) + ) + return station_name