├── .all-contributorsrc ├── .coveragerc ├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── meme.yml ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTION.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── app.drawio ├── images ├── banner-animation.svg ├── banner.png ├── demo.svg ├── example.png ├── example_1.png ├── example_2.png ├── footer.png ├── logo.png └── maxresdefault.jpg ├── radioactive ├── __init__.py ├── __main__.py ├── alias.py ├── app.py ├── args.py ├── config.py ├── ffplay.py ├── filter.py ├── handler.py ├── help.py ├── last_station.py ├── mpv.py ├── parser.py ├── recorder.py ├── utilities.py └── vlc.py ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg └── setup.py /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "Yasumoto", 10 | "name": "Joe Smith", 11 | "avatar_url": "https://avatars.githubusercontent.com/u/48383?v=4", 12 | "profile": "http://www.bjoli.com", 13 | "contributions": [ 14 | "test", 15 | "code", 16 | "ideas" 17 | ] 18 | }, 19 | { 20 | "login": "salehjafarli", 21 | "name": "salehjafarli", 22 | "avatar_url": "https://avatars.githubusercontent.com/u/81613563?v=4", 23 | "profile": "https://github.com/salehjafarli", 24 | "contributions": [ 25 | "code" 26 | ] 27 | }, 28 | { 29 | "login": "marvoh", 30 | "name": "marvoh", 31 | "avatar_url": "https://avatars.githubusercontent.com/u/5451142?v=4", 32 | "profile": "https://github.com/marvoh", 33 | "contributions": [ 34 | "code", 35 | "bug" 36 | ] 37 | } 38 | ], 39 | "contributorsPerLine": 7, 40 | "projectName": "radio-active", 41 | "projectOwner": "deep5050", 42 | "repoType": "github", 43 | "repoHost": "https://github.com", 44 | "skipCi": true, 45 | "commitType": "docs", 46 | "commitConvention": "angular" 47 | } 48 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = . 3 | omit = ./.venv/*,*tests*,*apps.py,*manage.py,*__init__.py,*migrations*,*asgi*,*wsgi*,*admin.py,*urls.py 4 | 5 | [report] 6 | omit = ./.venv/*,*tests*,*apps.py,*manage.py,*__init__.py,*migrations*,*asgi*,*wsgi*,*admin.py,*urls.py 7 | 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | max_line_length = 80 11 | 12 | [*.c] 13 | indent_size = 4 14 | c_basic_style = linux 15 | c_brace_style = stroustrup 16 | 17 | [*.py] 18 | indent_size = 4 19 | indent_style = space 20 | max_line_length = 79 21 | 22 | [*.sh] 23 | indent_size = 2 24 | shell = bash 25 | 26 | [Makefile] 27 | indent_style = tab 28 | indent_size = 4 29 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: deep5050 4 | custom: ["https://deep5050.github.io/payme","https://www.paypal.com/paypalme/deep5050"] 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/meme.yml: -------------------------------------------------------------------------------- 1 | name: "Meme" 2 | on: 3 | issues: 4 | types: [opened, reopened] 5 | pull_request_target: 6 | types: [opened, reopened] 7 | 8 | 9 | jobs: 10 | test: 11 | name: setup environment 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: memes on isssues 15 | uses: deep5050/memes-on-issues-action@main 16 | with: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | last_station.json 131 | build.sh 132 | deploy.sh 133 | 134 | pylint.txt 135 | .gitpod.yml 136 | tests/ 137 | tests/ 138 | 139 | *.mp3 140 | cache.sqlite 141 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.provider": "black", 3 | "cSpell.words": [ 4 | "Dipankar", 5 | "Fmepg", 6 | "Popen", 7 | "ffplay", 8 | "psutil", 9 | "pyradios", 10 | "stationuuid" 11 | ], 12 | "grammarly.selectors": [ 13 | { 14 | "language": "markdown", 15 | "scheme": "file" 16 | } 17 | ], 18 | "files.autoSave": "off", 19 | "editor.wordWrap": "wordWrapColumn", 20 | "workbench.colorTheme": "GitHub Dark", 21 | "editor.minimap.autohide": true, 22 | "editor.minimap.renderCharacters": false, 23 | "editor.experimentalWhitespaceRendering": "font", 24 | "editor.fontFamily": "'Fira Code', Consolas, 'Courier New', monospace", 25 | "editor.codeLensFontFamily": "'Fira Code'", 26 | "editor.fontLigatures": true, 27 | "editor.defaultFormatter": "ms-python.black-formatter", 28 | "[python]": { 29 | "editor.defaultFormatter": "ms-python.black-formatter" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.9.1 2 | 3 | 1. Play a random station from favorite list `--random` 4 | 2. Multiple media player support ( MPV, VLC, FFplay) `--player` 5 | 3. Filter search results with `--filter` 6 | 4. Play/Pause player from runtime command `p` 7 | 5. Default config file support added 8 | 6. Fixed minor bugs while giving runtime commands 9 | 10 | 11 | ## 2.9.0 12 | 13 | 1. Fetch current playing track info from runtime commands 🎶 ⚡ 14 | 2. Sort results with several parameters like: click-count, popularity, bitrate, random etc. `--sort` 15 | 3. Select stations from favorite menu to remove them `--remove` 16 | 4. Result page will adjust width according to the actual terminal window size 17 | 5. Squashed CTRL+D unhandled bugs 18 | 6. Detect station name while playing with direct url 19 | 7. Play a random station from result page 20 | 8. See station information from runtime command 21 | 22 | ## 2.8.0 23 | 24 | 1. Selection menu added for `--country` and `--tag` results. Play directly from result page. 25 | 2. `ffplay` and `ffmpeg` will show debug info while started with `--loglevel debug` 26 | 3. Autodetect the codec information and set the file extension of the recorded file. 27 | 4. Force a recording to be in mp3 format only. 28 | 5. Simpler command help message 29 | 30 | 31 | ## 2.7.0 32 | 33 | 1. Recording support added 🎉 . save recording as mp3 or wav 🎶 `--record` 34 | 2. Play a station from your favorite list or stream a URL directly without any user selection menu. Useful when running from other scripts. `--play` 35 | 3. Play the last played station directly. `--last` 36 | 4. Runtime command feature added. Perform actions on demand ⚡ 37 | 5. A caching mechanism was added for fewer API calls. Faster radio playbacks! 38 | 6. Code refactored. It is easier for contributors to implement new features. 39 | 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` 40 | 41 | 42 | ## 2.6.0 43 | 44 | 1. Detect errors while trying to play a dead station or encountering other connection errors. 45 | 2. Playing a station will increase the click (vote) counter for that station on the server. 46 | 3. Fixed bugs that occurred when there was a blank entry in the favorite station file. 47 | 4. Fixed bugs that caused empty last stations. 48 | 5. Handled errors related to connection issues when attempting to search for a station. 49 | 6. Improved `ffplay` process handling by introducing a thread to monitor runtime errors from the process. 50 | 7. `pyradios` module updated to latest version. 51 | 52 | 53 | ## 2.5.2 54 | 55 | 1. Added `--kill` option to stop background radios if any. 56 | 2. Project restructured. 57 | 3. Fixed saving empty last station information. 58 | 59 | 60 | 61 | ## 2.5.1 62 | 63 | 1. Fixed RuntimeError with empty selection menu on no options provided to radio. 64 | 2. Display the current station name as a panel while starting the radio with `--uuid` 65 | 3. Minor typos were fixed in the help message. 66 | 4. Station names do not contain any unnecessary spaces now 67 | 5. Do not play any stations while `--flush` is given. Just delete the list and exit. 68 | 69 | ## 2.5.0 70 | 71 | 1. Added a selection menu while no station information is provided. This will include the last played station and the favorite list. 72 | 2. Added `--volume` option to the player. Now you can pass the volume level to the player. 73 | 3. `ffplay` initialization errors handled. Better logic to stop the PID of `ffplay` 74 | 4. Some unhandled errors are now handled 75 | 5. Minor typos fixed 76 | 6. `sentry-sdk` added to gater errors (will be removed on next major release) 77 | 7. About section updated to show donation link 78 | 8. The upgrade message will now point to this changelog file 79 | 9. Updated documentation 80 | 81 | ## 2.4.0 82 | 83 | 1. Crashes on Windows fixed 84 | 2. Fixed setup-related issues (development purpose) 85 | 86 | ## 2.3.0 87 | 88 | 1. Discover stations by country 89 | 2. Discover stations by state 90 | 3. Discover stations by genre/tags 91 | 4. Discover stations by language 92 | 5. More info on multiple results for a station name 93 | 6. Shows currently playing radio info as box 94 | 7. sentry-SDK removed 95 | 8. Help table improved 96 | 9. Other minor bugs fixed 97 | 98 | ## 2.2.0 99 | 100 | 1. Pretty Print welcome message using Rich 101 | 2. More user-friendly and gorgeous 102 | 3. Added several new options `-F`,`-W`,`-A`,`--flush` 103 | 4. Fixed unhandled Exception when trying to quit within 3 seconds 104 | 5. Supports User-Added stations 105 | 6. Alias file now supports both UUID and URL entry 106 | 7. Fixed bugs in playing last station (which is actually an alias under fav list) 107 | 8. New Table formatted help message 108 | 9. Notification for new app version 109 | 10. Several typos fixed 110 | 11. Asciinema demo added 111 | 12. README formatted 112 | 13. Pylint the codebase 113 | 14. Added support section 114 | 15. python imports Sorted 115 | 16. Alias pattern updated from `=` to `==` 116 | 17. Many more 117 | 118 | ## 2.1.3 119 | 120 | 1. Fixed bugs in the last station 121 | 2. Typos fixed 122 | 3. Formatted codebase 123 | 4. Logging issued fixed 124 | 5. Sentry Added to collect unhandled Exceptions logs only 125 | 126 | 127 | ## 2.1.2 128 | 129 | 1. Updated README and project details 130 | 2. Fixed minor bugs 131 | 132 | ## 2.1.1 133 | 134 | 1. Minor bugs fixed 135 | 2. Station aliasing support 136 | 137 | ## 2.1.0 138 | 139 | 1. Minor bugs quashed 140 | 141 | ## 2.0.4 142 | 143 | 1. Initial release 144 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | dipankarpal5050@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTION.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | 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. 4 | 5 | 6 | ## Getting Started 7 | 8 | To get started, make sure you have `git`, `ffmpeg` and `python3` installed on your local machine. You'll also need a GitHub account. 9 | 10 | ## How to Contribute 11 | 12 | ### Fork the Repository 13 | 14 | 1. Click the "Fork" button on the top right of this repository's page. 15 | 2. This will create a copy of the repository in your GitHub account. 16 | 17 | ### Clone Your Fork 18 | 19 | 1. Clone your fork to your local machine using the following command: 20 | ```bash 21 | git clone https://github.com/deep5050/radio-active.git 22 | git checkout -b your-branch-name 23 | ``` 24 | 25 | ### Install dependencies 26 | ```bash 27 | pip3 install -r requirements.txt 28 | pip3 install -r requirements-dev.txt 29 | ``` 30 | 31 | ### Make changes. 32 | 33 | Modify the code as required 34 | 35 | ### Test Your Changes 36 | 37 | Before submitting your changes, please ensure that your code doesn't break the existing functionality. 38 | 39 | Run `make` to install it locally and test before you push changes! 40 | 41 | ``` 42 | git add . 43 | git commit -m "Add your commit message here" --signoff 44 | git push 45 | ``` 46 | ### Create a Pull Request 47 | Visit the original repository on GitHub. 48 | You should see a "New Pull Request" button. Click on it. 49 | Follow the instructions to create your pull request. 50 | 51 | Fill the description section with meaningful message. 52 | 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Dipankar Pal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.py 2 | include README.md 3 | include LICENSE 4 | include requirements.txt 5 | include requirements-dev.txt -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | PYTHON = python3 3 | TEST_PATH = ./tests/ 4 | FLAKE8_EXCLUDE = venv,.venv,.eggs,.tox,.git,__pycache__,*.pyc,build 5 | SRC_DIR = "radioactive" 6 | TEST_DIR = "test" 7 | 8 | .PHONY: all clean isort check dist deploy test-deploy help build install install-dev test 9 | all: clean isort format check build install 10 | 11 | check: 12 | @echo "Chceking linting errors......." 13 | ${PYTHON} -m flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude ${FLAKE8_EXCLUDE} 14 | ${PYTHON} -m flake8 . --count --exit-zero --max-complexity=10 --max-line-length=79 --statistics --exclude ${FLAKE8_EXCLUDE} 15 | 16 | clean: 17 | @echo "Cleaning build artifacts......" 18 | @find . -name '*.pyc' -exec rm --force {} + 19 | @find . -name '*.pyo' -exec rm --force {} + 20 | @find . -name '*~' -exec rm --force {} + 21 | rm -rf build 22 | rm -rf dist 23 | rm -rf *.egg-info 24 | rm -f *.sqlite 25 | rm -rf .cache 26 | rm -rf *.mp3 27 | 28 | dist: clean 29 | ${PYTHON} setup.py sdist bdist_wheel 30 | 31 | deploy: dist 32 | twine upload --repository-url https://upload.pypi.org/legacy/ dist/* 33 | 34 | test-deploy: dist 35 | @echo "Sending to testpypi server......." 36 | @twine upload -r testpypi dist/* 37 | 38 | help: 39 | @echo "help............." 40 | @echo " clean" 41 | @echo " Remove python artifacts and build artifacts." 42 | @echo " isort" 43 | @echo " Sort import statements." 44 | @echo " build" 45 | @echo " Build the target app" 46 | @echo " install" 47 | @echo " Install the target app" 48 | @echo " check" 49 | @echo " Check style with flake8." 50 | @echo " test" 51 | @echo " Run pytest" 52 | @echo " todo" 53 | @echo " Finding lines with 'TODO'" 54 | 55 | isort: 56 | @echo "Sorting imports....." 57 | isort $(SRC_DIR) $(TEST_DIR) 58 | 59 | build: format 60 | @echo "Building........." 61 | ${PYTHON} setup.py build 62 | 63 | install: build 64 | @echo "Installing........." 65 | pip install -e . 66 | 67 | install-dev: install 68 | pip install --upgrade pip 69 | pip install -e .[dev] 70 | 71 | test: 72 | ${PYTHON} -m pytest ${TEST_PATH} 73 | 74 | todo: 75 | @echo "Finding lines with 'TODO:' in current directory..." 76 | @grep -rn 'TODO:' ./radioactive 77 | 78 | format: 79 | @echo "Formatting files using black..........." 80 | ${PYTHON} -m black setup.py 81 | ${PYTHON} -m black radioactive/* 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 |

RADIOACTIVE

4 |

SEARCH - PLAY - RECORD - REPEAT

5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | radio-active - Play more than 30K radio stations from your terminal | Product Hunt 13 | 14 |

15 |

16 | UPI 17 |

18 | 19 |

20 | 21 |


22 | GitHub 23 | PyPI 24 | PyPI - Downloads 25 | CodeFactor Grade 26 | Discord 27 |

28 | 29 |

YouTube Video Likes and Dislikes

30 |

Join Discord Server

31 | 32 |
33 | 34 | # Demo 35 |

36 | 37 | 38 | ### Features 39 | 40 | - [x] Supports more than 40K stations !! :radio: 41 | - [x] Record audio from live radio on demand :zap: 42 | - [x] Get song information on run-time 🎶 43 | - [x] Saves last station information 44 | - [x] Favorite stations :heart: 45 | - [x] Selection menu for favorite stations 46 | - [x] Supports user-added stations :wrench: 47 | - [x] Looks minimal and user-friendly 48 | - [x] Runs on Raspberry Pi 49 | - [x] Finds nearby stations 50 | - [x] Discovers stations by genre 51 | - [x] Discovers stations by language 52 | - [x] VLC, MPV player support 53 | - [x] Default config file 54 | - [ ] I'm feeling lucky! Play Random stations 55 | 56 | 57 | > See my progress ➡️ [here](https://github.com/users/deep5050/projects/5) 58 | 59 | ### Why radioactive? 60 | 61 | 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. 62 | 63 | ### In the Spotlight 64 | 65 | 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. 66 | 67 | 1. See DistroTube YouTube channel talks about my app: https://www.youtube.com/watch?v=H7sf1RDFXpU&pp=ygUYcmFkaW9hY3RpdmUgcGxheWVyIHJhZGlv 68 | 2. Ubunlog: https://ubunlog.com/en/radio-activate-an-application-to-listen-to-the-radio-from-the-terminal/ 69 | 3. LinuxLinks: https://www.linuxlinks.com/radio-active-listen-radio-terminal/3/ 70 | 4. Official entry in the RadioBrowser API: [https://www.radio-browser.info/users](https://www.radio-browser.info/users#:~:text=Sources-,radio%2Dactive,-Sources) 71 | 5. ThingsAndStuff: https://wiki.thingsandstuff.org/Streaming#:~:text=com/billniakas/bash_radio_gr-,radio%2Dactive,-https%3A//github.com 72 | 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 73 | 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 74 | 75 | 76 | ### Install 77 | 78 | Simply run: `pip3 install --upgrade radio-active` 79 | 80 | I recommend installing it using `pipx install radio-active` 81 | 82 | #### TODO: 83 | 84 | - [ ] Create deb, rpm and exe packages 85 | - [ ] Add it to various Linux distribution package repositories. 86 | - [ ] Add to scoop, chocolately 87 | 88 | ### External Dependency 89 | 90 | It needs [FFmpeg](https://ffmpeg.org/download.html) to be installed on your 91 | system in order to record the audio 92 | 93 | on Ubuntu-based system >= 20.04 Run 94 | 95 | ``` 96 | sudo apt update 97 | sudo apt install ffmpeg 98 | ``` 99 | 100 | For other systems including Windows see the above link 101 | 102 | #### Installing FFmpeg 103 | 104 | FFmpeg is required for this program to work correctly. Install FFmpeg by following these steps:- 105 | 106 | - On Linux - 107 | - On Windows - 108 | 109 | 110 | ### Run 111 | 112 | Search a station with `radio --search [STATION_NAME]` or simply `radio` :zap: to select from the favorite menu. 113 | 114 | ### Tips 115 | 116 | 1. Use a modern terminal emulator, otherwise the UI might break! (gets too ugly sometimes) 117 | 2. On Windows, instead of the default Command Prompt, use the new Windows Terminal or web-based emulators like Hyper, Cmdr, Terminus, etc. for better UI 118 | 3. Let the app run for at least 5 seconds (not a serious issue though, for better performance) 119 | 120 | 121 | ### Demo 122 | 123 | 125 | 126 | 127 | 128 | ### Options 129 | 130 | 131 | | Options | Note | Description | Default | Values | 132 | | ------------------ | -------- | ---------------------------------------------- | ------------- | ---------------------- | 133 | | (No Option) | Optional | Select a station from menu to play | False | | 134 | | `--search`, `-S` | Optional | Station name | None | | 135 | | `--play`, `-P` | Optional | A station from fav list or url for direct play | None | | 136 | | `--country`, `-C` | Optional | Discover stations by country code | False | | 137 | | `--state` | Optional | Discover stations by country state | False | | 138 | | `--language` | optional | Discover stations by | False | | 139 | | `--tag` | Optional | Discover stations by tags/genre | False | | 140 | | `--uuid`, `-U` | Optional | ID of the station | None | | 141 | | `--record` , `-R` | Optional | Record a station and save to file | False | | 142 | | `--filename`, `-N` | Optional | Filename to used to save the recorded audio | None | | 143 | | `--filepath` | Optional | Path to save the recordings | | | 144 | | `--filetype`, `-T` | Optional | Format of the recording | mp3 | `mp3`,`auto` | 145 | | `--last` | Optional | Play last played station | False | | 146 | | `--random` | Optional | Play a random station from favorite list | False | | 147 | | `--sort` | Optional | Sort the result page | votes | | 148 | | `--filter` | Optional | Filter search results | None | | 149 | | `--limit` | Optional | Limit the # of results in the Discover table | 100 | | 150 | | `--volume` , `-V` | Optional | Change the volume passed into ffplay | 80 | [0-100] | 151 | | `--favorite`, `-F` | Optional | Add current station to fav list | False | | 152 | | `--add` , `-A` | Optional | Add an entry to fav list | False | | 153 | | `--list`, `-W` | Optional | Show fav list | False | | 154 | | `--remove` | Optional | Remove entries from favorite list | False | | 155 | | `--flush` | Optional | Remove all the entries from fav list | False | | 156 | | `--kill` , `-K` | Optional | Kill background radios. | False | | 157 | | `--loglevel` | Optional | Log level of the program | Info | `info`, `warning`, `error`, `debug` | 158 | | `--player` | Optional | Media player to use | ffplay | `vlc`, `mpv`, `ffplay` | 159 | 160 |
161 | 162 | 163 | > [!NOTE] 164 | > Once you save/play at least one station, invoking `radio` without any options will show a selection menu 165 | 166 | > `--search`, `-S`: Search for a station online. 167 | 168 | > `--play`, `-P`: You can pass an exact name from your favorite stations or alternatively pass any direct stream URL. This would bypass any user selection menu (useful when running from another script) 169 | 170 | > `--uuid`,`-U`: When station names are too long or confusing (or multiple 171 | > results for the same name) use the station's uuid to play. --uuid gets the 172 | > greater priority than `--search`. Example: 96444e20-0601-11e8-ae97-52543be04c81. type `u` on the runtime command to get the UUID of a station. 173 | 174 | > `--loglevel`,: Don't need to specify unless you are developing it. `info`, `warning`, `error`, `debug` 175 | 176 | > `-F`: Add the current station to your favorite list. Example: `-F my_fav_1` 177 | 178 | > `-A`: Add any stations to your list. You can add stations that are not currently available on our API. When adding a new station enter a name and direct URL to the audio stream. 179 | 180 | > `--limit`: Specify how many search results should be displayed. 181 | 182 | > `--filetype`: Specify the extension of the final recording file. default is `mp3`. you can provide `-T auto` to autodetect the codec and set file extension accordingly (in original form). 183 | 184 | > DEFAULT_DIR: is `/home/user/Music/radioactive` 185 | 186 | ### Runtime Commands 187 | 188 | Input a command during the radio playback to perform an action. Available commands are: 189 | 190 | ``` 191 | Enter a command to perform an action: ? 192 | 193 | t/T/track: Current song name (track info) 194 | r/R/record: Record a station 195 | f/F/fav: Add station to favorite list 196 | rf/RF/recordfile: Specify a filename for the recording. 197 | h/H/help/?: Show this help message 198 | q/Q/quit: Quit radioactive 199 | ``` 200 | 201 | ### Sort Parameters 202 | 203 | you can sort the result page with these parameters: 204 | - `name` (default) 205 | - `votes` (based on user votes) 206 | - `codec` 207 | - `bitrate` 208 | - `lastcheckok` (active stations) 209 | - `lastchecktime` (recent active) 210 | - `clickcount` (total play count) 211 | - `clicktrend` (currently trending stations) 212 | - `random` 213 | 214 | ### Filter Parameters 215 | 216 | Filter search results with `--filter`. Some possible expressions are 217 | - `--filter "name=shows"` 218 | - `--filter "name=shows,talks,tv"` 219 | - `--filter "name!=news,shows"` 220 | - `--filter "country=in"` 221 | - `--filter "language=bengali,nepali"` 222 | - `--filter "bitrate>64"` 223 | - `--filter "votes<500"` 224 | - `--filter "codec=mp3"` 225 | - `--filter "tags!=rock,pop"` 226 | 227 | Allowed operators are: 228 | 229 | - `=` 230 | - `,` 231 | - `!=` 232 | - `>` 233 | - `<` 234 | - `&` 235 | 236 | Allowed keys are: `name`, `country` (countrycode as value), `language`, `bitrate`, `votes`, `codec`, `tags` 237 | 238 | Provide multiple filters at one go, use `&` 239 | 240 | A complex filter example: `--filter "country!=CA&tags!=islamic,classical&votes>500"` 241 | 242 | > [!NOTE] 243 | > set `--limit` to a higher value while filtering results 244 | 245 | 246 | ### Default Configs 247 | 248 | Default configuration file is added into your home directory as `.radio-active-configs.ini` 249 | 250 | ```bash 251 | [AppConfig] 252 | loglevel = info 253 | limit = 100 254 | sort = votes 255 | filter = none 256 | volume = 80 257 | filepath = /home/{user}/recordings/radioactive/ 258 | filetype = mp3 259 | player = ffplay 260 | ``` 261 | 262 | > [!WARNING] 263 | > Do NOT modify the keys, only change the values. you can give any absolute or relative path as filepath. 264 | 265 | ### Bonus Tips 266 | 267 | 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" 268 | 269 | 2. You don't have to pass the exact option name, a portion of it will also work. for example `--sea` for `--search`, `--coun` for `--country`, `--lim` for `--limit` 270 | 271 | 3. It's better to leave the `--filetype` as mp3 when you need to record something quickly. The autocodec takes a few milliseconds extra to determine the codec. 272 | 273 | ### Changes 274 | 275 | see [CHANGELOG](./CHANGELOG.md) 276 | 277 | ### Community 278 | 279 | Share you favorite list with our community 🌐 ➡️ [Here](https://github.com/deep5050/radio-active/discussions/10) 280 | 281 | > Your favorite list `.radio-active-alias` is under your home directory as a hidden file :) 282 | 283 | 284 | ### Support 285 | 286 |

287 | Visit my contribution page for more payment options. 288 |

289 |

Buy Me A Coffee

290 | 291 | ### Acknowledgements 292 | 293 |
Icons made by Freepik from www.flaticon.com
294 | 295 | 296 |
297 | 298 |

Happy Listening

299 | 300 |
301 | 302 | 303 | ## Contributors ✨ 304 | 305 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 |
Joe Smith
Joe Smith

⚠️ 💻 🤔
salehjafarli
salehjafarli

💻
marvoh
marvoh

💻 🐛
319 | 320 | 321 | 322 | 323 | 324 | 325 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 326 | 327 | ![Alt](https://repobeats.axiom.co/api/embed/753765f73315fcacbddcacbabc672771d939ebcb.svg "Repobeats analytics image") 328 | 329 |
330 |

331 | 332 |

333 |
334 | -------------------------------------------------------------------------------- /app.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | -------------------------------------------------------------------------------- /images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deep5050/radio-active/d5096a675ecaab79b29e2d6a3e099f541ac6560b/images/banner.png -------------------------------------------------------------------------------- /images/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deep5050/radio-active/d5096a675ecaab79b29e2d6a3e099f541ac6560b/images/example.png -------------------------------------------------------------------------------- /images/example_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deep5050/radio-active/d5096a675ecaab79b29e2d6a3e099f541ac6560b/images/example_1.png -------------------------------------------------------------------------------- /images/example_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deep5050/radio-active/d5096a675ecaab79b29e2d6a3e099f541ac6560b/images/example_2.png -------------------------------------------------------------------------------- /images/footer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deep5050/radio-active/d5096a675ecaab79b29e2d6a3e099f541ac6560b/images/footer.png -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deep5050/radio-active/d5096a675ecaab79b29e2d6a3e099f541ac6560b/images/logo.png -------------------------------------------------------------------------------- /images/maxresdefault.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deep5050/radio-active/d5096a675ecaab79b29e2d6a3e099f541ac6560b/images/maxresdefault.jpg -------------------------------------------------------------------------------- /radioactive/__init__.py: -------------------------------------------------------------------------------- 1 | __AUTHOR__ = "Dipankar Pal" 2 | __EMAIL__ = "dipankarpal5050@gmail.com" 3 | __WEBSITE__ = "deep5050.github.io" 4 | -------------------------------------------------------------------------------- /radioactive/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import signal 4 | import sys 5 | from time import sleep 6 | 7 | from zenlog import log 8 | 9 | from radioactive.alias import Alias 10 | from radioactive.app import App 11 | from radioactive.ffplay import Ffplay, kill_background_ffplays 12 | from radioactive.handler import Handler 13 | from radioactive.help import show_help 14 | from radioactive.last_station import Last_station 15 | from radioactive.parser import parse_options 16 | from radioactive.utilities import ( 17 | check_sort_by_parameter, 18 | handle_add_station, 19 | handle_add_to_favorite, 20 | handle_current_play_panel, 21 | handle_direct_play, 22 | handle_favorite_table, 23 | handle_listen_keypress, 24 | handle_play_last_station, 25 | handle_play_random_station, 26 | handle_record, 27 | handle_save_last_station, 28 | handle_search_stations, 29 | handle_station_selection_menu, 30 | handle_station_uuid_play, 31 | handle_update_screen, 32 | handle_user_choice_from_search_result, 33 | handle_welcome_screen, 34 | ) 35 | 36 | # globally needed as signal handler needs it 37 | # to terminate main() properly 38 | ffplay = None 39 | player = None 40 | 41 | 42 | def final_step(options, last_station, alias, handler): 43 | global ffplay # always needed 44 | global player 45 | 46 | # check target URL for the last time 47 | if options["target_url"].strip() == "": 48 | log.error("something is wrong with the url") 49 | sys.exit(1) 50 | 51 | if options["audio_player"] == "vlc": 52 | from radioactive.vlc import VLC 53 | 54 | vlc = VLC() 55 | vlc.start(options["target_url"]) 56 | player = vlc 57 | 58 | elif options["audio_player"] == "mpv": 59 | from radioactive.mpv import MPV 60 | 61 | mpv = MPV() 62 | mpv.start(options["target_url"]) 63 | player = mpv 64 | 65 | elif options["audio_player"] == "ffplay": 66 | ffplay = Ffplay(options["target_url"], options["volume"], options["loglevel"]) 67 | player = ffplay 68 | 69 | else: 70 | log.error("Unsupported media player selected") 71 | sys.exit(1) 72 | 73 | if options["curr_station_name"].strip() == "": 74 | options["curr_station_name"] = "N/A" 75 | 76 | handle_save_last_station( 77 | last_station, options["curr_station_name"], options["target_url"] 78 | ) 79 | 80 | if options["add_to_favorite"]: 81 | handle_add_to_favorite( 82 | alias, options["curr_station_name"], options["target_url"] 83 | ) 84 | 85 | handle_current_play_panel(options["curr_station_name"]) 86 | 87 | if options["record_stream"]: 88 | handle_record( 89 | options["target_url"], 90 | options["curr_station_name"], 91 | options["record_file_path"], 92 | options["record_file"], 93 | options["record_file_format"], 94 | options["loglevel"], 95 | ) 96 | 97 | handle_listen_keypress( 98 | alias, 99 | player, 100 | target_url=options["target_url"], 101 | station_name=options["curr_station_name"], 102 | station_url=options["target_url"], 103 | record_file_path=options["record_file_path"], 104 | record_file=options["record_file"], 105 | record_file_format=options["record_file_format"], 106 | loglevel=options["loglevel"], 107 | ) 108 | 109 | 110 | def main(): 111 | log.level("info") 112 | 113 | app = App() 114 | 115 | options = parse_options() 116 | 117 | VERSION = app.get_version() 118 | 119 | handler = Handler() 120 | alias = Alias() 121 | alias.generate_map() 122 | last_station = Last_station() 123 | 124 | # --------------- app logic starts here ------------------- # 125 | 126 | if options["version"]: 127 | log.info("RADIO-ACTIVE : version {}".format(VERSION)) 128 | sys.exit(0) 129 | 130 | handle_welcome_screen() 131 | 132 | if options["show_help_table"]: 133 | show_help() 134 | sys.exit(0) 135 | 136 | if options["flush_fav_list"]: 137 | sys.exit(alias.flush()) 138 | 139 | if options["kill_ffplays"]: 140 | kill_background_ffplays() 141 | sys.exit(0) 142 | 143 | if options["show_favorite_list"]: 144 | handle_favorite_table(alias) 145 | sys.exit(0) 146 | 147 | if options["add_station"]: 148 | handle_add_station(alias) 149 | 150 | if options["remove_fav_stations"]: 151 | # handle_remove_stations(alias) 152 | alias.remove_entries() 153 | sys.exit(0) 154 | 155 | options["sort_by"] = check_sort_by_parameter(options["sort_by"]) 156 | 157 | handle_update_screen(app) 158 | 159 | # ----------- country ----------- # 160 | if options["discover_country_code"]: 161 | response = handler.discover_by_country( 162 | options["discover_country_code"], 163 | options["limit"], 164 | options["sort_by"], 165 | options["filter_with"], 166 | ) 167 | if response is not None: 168 | ( 169 | options["curr_station_name"], 170 | options["target_url"], 171 | ) = handle_user_choice_from_search_result(handler, response) 172 | final_step(options, last_station, alias, handler) 173 | else: 174 | sys.exit(0) 175 | 176 | # -------------- state ------------- # 177 | if options["discover_state"]: 178 | response = handler.discover_by_state( 179 | options["discover_state"], 180 | options["limit"], 181 | options["sort_by"], 182 | options["filter_with"], 183 | ) 184 | if response is not None: 185 | ( 186 | options["curr_station_name"], 187 | options["target_url"], 188 | ) = handle_user_choice_from_search_result(handler, response) 189 | final_step(options, last_station, alias, handler) 190 | else: 191 | sys.exit(0) 192 | 193 | # ----------- language ------------ # 194 | if options["discover_language"]: 195 | response = handler.discover_by_language( 196 | options["discover_language"], 197 | options["limit"], 198 | options["sort_by"], 199 | options["filter_with"], 200 | ) 201 | if response is not None: 202 | ( 203 | options["curr_station_name"], 204 | options["target_url"], 205 | ) = handle_user_choice_from_search_result(handler, response) 206 | final_step(options, last_station, alias, handler) 207 | else: 208 | sys.exit(0) 209 | 210 | # -------------- tag ------------- # 211 | if options["discover_tag"]: 212 | response = handler.discover_by_tag( 213 | options["discover_tag"], 214 | options["limit"], 215 | options["sort_by"], 216 | options["filter_with"], 217 | ) 218 | if response is not None: 219 | ( 220 | options["curr_station_name"], 221 | options["target_url"], 222 | ) = handle_user_choice_from_search_result(handler, response) 223 | final_step(options, last_station, alias, handler) 224 | else: 225 | sys.exit(0) 226 | 227 | # -------------------- NOTHING PROVIDED --------------------- # 228 | if ( 229 | options["search_station_name"] is None 230 | and options["search_station_uuid"] is None 231 | and options["direct_play"] is None 232 | and not options["play_last_station"] 233 | and not options["play_random"] 234 | ): 235 | ( 236 | options["curr_station_name"], 237 | options["target_url"], 238 | ) = handle_station_selection_menu(handler, last_station, alias) 239 | final_step(options, last_station, alias, handler) 240 | 241 | # --------------------ONLY UUID PROVIDED --------------------- # 242 | 243 | if options["search_station_uuid"] is not None: 244 | options["curr_station_name"], options["target_url"] = handle_station_uuid_play( 245 | handler, options["search_station_uuid"] 246 | ) 247 | final_step(options, last_station, alias, handler) 248 | 249 | # ------------------- ONLY STATION PROVIDED ------------------ # 250 | 251 | elif ( 252 | options["search_station_name"] is not None 253 | and options["search_station_uuid"] is None 254 | and options["direct_play"] is None 255 | ): 256 | response = [{}] 257 | response = handle_search_stations( 258 | handler, 259 | options["search_station_name"], 260 | options["limit"], 261 | options["sort_by"], 262 | options["filter_with"], 263 | ) 264 | if response is not None: 265 | ( 266 | options["curr_station_name"], 267 | options["target_url"], 268 | ) = handle_user_choice_from_search_result(handler, response) 269 | # options["codec"] = response["codec"] 270 | # print(response) 271 | final_step(options, last_station, alias, handler) 272 | else: 273 | sys.exit(0) 274 | # ------------------------- direct play ------------------------# 275 | if options["direct_play"] is not None: 276 | options["curr_station_name"], options["target_url"] = handle_direct_play( 277 | alias, options["direct_play"] 278 | ) 279 | final_step(options, last_station, alias, handler) 280 | 281 | if options["play_random"]: 282 | ( 283 | options["curr_station_name"], 284 | options["target_url"], 285 | ) = handle_play_random_station(alias) 286 | final_step(options, last_station, alias, handler) 287 | 288 | if options["play_last_station"]: 289 | options["curr_station_name"], options["target_url"] = handle_play_last_station( 290 | last_station 291 | ) 292 | final_step(options, last_station, alias, handler) 293 | 294 | # final_step() 295 | 296 | if os.name == "nt": 297 | while True: 298 | sleep(5) 299 | else: 300 | try: 301 | signal.pause() 302 | except Exception as e: 303 | log.debug("Error: {}".format(e)) 304 | pass 305 | 306 | 307 | def signal_handler(sig, frame): 308 | global ffplay 309 | global player 310 | log.debug("You pressed Ctrl+C!") 311 | log.debug("Stopping the radio") 312 | if ffplay and ffplay.is_playing: 313 | ffplay.stop() 314 | # kill the player 315 | player.stop() 316 | 317 | log.info("Exiting now") 318 | sys.exit(0) 319 | 320 | 321 | signal.signal(signal.SIGINT, signal_handler) 322 | 323 | if __name__ == "__main__": 324 | main() 325 | -------------------------------------------------------------------------------- /radioactive/alias.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from pick import pick 4 | from zenlog import log 5 | 6 | 7 | class Alias: 8 | def __init__(self): 9 | self.alias_map = [] 10 | self.found = False 11 | 12 | self.alias_path = os.path.join(os.path.expanduser("~"), ".radio-active-alias") 13 | 14 | def write_stations(self, station_map): 15 | """Write stations file from generated map""" 16 | with open(self.alias_path, "w") as f: 17 | f.flush() 18 | for entry in station_map: 19 | f.write( 20 | "{}=={}\n".format( 21 | entry["name"].strip(), entry["uuid_or_url"].strip() 22 | ) 23 | ) 24 | return True 25 | 26 | def generate_map(self): 27 | """parses the fav list file and generates a list""" 28 | # create alias map 29 | self.alias_map = [] 30 | 31 | if os.path.exists(self.alias_path): 32 | log.debug(f"Alias file at: {self.alias_path}") 33 | try: 34 | with open(self.alias_path, "r+") as f: 35 | alias_data = f.read().strip() 36 | if alias_data == "": 37 | log.debug("Empty alias list") 38 | return 39 | alias_list = alias_data.splitlines() 40 | for alias in alias_list: 41 | if alias.strip() == "": 42 | # empty line pass 43 | continue 44 | temp = alias.split("==") 45 | left = temp[0] 46 | right = temp[1] 47 | # may contain both URL and UUID 48 | self.alias_map.append({"name": left, "uuid_or_url": right}) 49 | except Exception as e: 50 | log.debug(f"could not get / parse alias data: {e}") 51 | 52 | else: 53 | log.debug("Alias file does not exist") 54 | 55 | def search(self, entry): 56 | """searches for an entry in the fav list with the name 57 | the right side may contain both url or uuid , need to check properly 58 | """ 59 | log.debug("Alias search: {}".format(entry)) 60 | if len(self.alias_map) > 0: 61 | log.debug("looking under alias file") 62 | for alias in self.alias_map: 63 | if alias["name"].strip() == entry.strip(): 64 | log.debug( 65 | "Alias found: {} == {}".format( 66 | alias["name"], alias["uuid_or_url"] 67 | ) 68 | ) 69 | self.found = True 70 | return alias 71 | 72 | log.debug("Alias not found") 73 | else: 74 | log.debug("Empty Alias file") 75 | return None 76 | 77 | def add_entry(self, left, right): 78 | """Adds a new entry to the fav list""" 79 | self.generate_map() 80 | if self.search(left) is not None: 81 | log.warning("An entry with same name already exists, try another name") 82 | return False 83 | else: 84 | with open(self.alias_path, "a+") as f: 85 | f.write("{}=={}\n".format(left.strip(), right.strip())) 86 | log.info("Current station added to your favorite list") 87 | return True 88 | 89 | def flush(self): 90 | """deletes all the entries in the fav list""" 91 | try: 92 | with open(self.alias_path, "w") as f: 93 | f.flush() 94 | log.info("All entries deleted in your favorite list") 95 | return 0 96 | except Exception as e: 97 | log.debug("Error: {}".format(e)) 98 | log.error("could not delete your favorite list. something went wrong") 99 | return 1 100 | 101 | def remove_entries(self): 102 | # select entries from fav menu and remove them 103 | self.generate_map() 104 | 105 | if not self.alias_map: 106 | log.error("No stations to be removed!") 107 | return 108 | 109 | title = "Select stations to be removed. Hit 'SPACE' to select " 110 | options = [entry["name"] for entry in self.alias_map] 111 | selected = pick( 112 | options, title, indicator="->", multiselect=True, min_selection_count=1 113 | ) 114 | 115 | # Extract integer numbers and create a new list 116 | indices_to_remove = [item[1] for item in selected if isinstance(item[1], int)] 117 | 118 | # remove selected entries from the map, and regenerate 119 | filtered_list = [ 120 | self.alias_map[i] 121 | for i in range(len(self.alias_map)) 122 | if i not in indices_to_remove 123 | ] 124 | 125 | log.debug( 126 | f"Current # of entries reduced to : {len(filtered_list)} from {len(self.alias_map)}" 127 | ) 128 | 129 | self.write_stations(filtered_list) 130 | self.alias_map = filtered_list 131 | log.info("Stations removed successfully!") 132 | -------------------------------------------------------------------------------- /radioactive/app.py: -------------------------------------------------------------------------------- 1 | """ 2 | Version of the current program, (in development mode 3 | it needs to be updated in every release) 4 | and to check if an updated version available for the app or not 5 | """ 6 | import json 7 | 8 | import requests 9 | 10 | 11 | class App: 12 | def __init__(self): 13 | self.__VERSION__ = "2.9.1" # change this on every update # 14 | self.pypi_api = "https://pypi.org/pypi/radio-active/json" 15 | self.remote_version = "" 16 | 17 | def get_version(self): 18 | """get the version number as string""" 19 | return self.__VERSION__ 20 | 21 | def get_remote_version(self): 22 | return self.remote_version 23 | 24 | def is_update_available(self): 25 | """Checks if the user is using an outdated version of the app, 26 | if any updates available inform user 27 | """ 28 | 29 | try: 30 | remote_data = requests.get(self.pypi_api) 31 | remote_data = remote_data.content.decode("utf8") 32 | remote_data = json.loads(remote_data) 33 | self.remote_version = remote_data["info"]["version"] 34 | 35 | # compare two version number 36 | tup_local = tuple(map(int, self.__VERSION__.split("."))) 37 | tup_remote = tuple(map(int, self.remote_version.split("."))) 38 | 39 | if tup_remote > tup_local: 40 | return True 41 | return False 42 | 43 | except Exception: 44 | print("Could not fetch remote version number") 45 | -------------------------------------------------------------------------------- /radioactive/args.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | 4 | from zenlog import log 5 | 6 | from radioactive.config import Configs 7 | 8 | 9 | # load default configs 10 | def load_default_configs(): 11 | # load config file and apply configs 12 | configs = Configs() 13 | default_configs = configs.load() 14 | return default_configs 15 | 16 | 17 | class Parser: 18 | 19 | """Parse the command-line args and return result to the __main__""" 20 | 21 | def __init__(self): 22 | self.parser = None 23 | self.result = None 24 | self.defaults = load_default_configs() 25 | 26 | self.parser = argparse.ArgumentParser( 27 | description="Play any radio around the globe right from the CLI ", 28 | prog="radio-active", 29 | add_help=False, 30 | ) 31 | 32 | self.parser.add_argument( 33 | "--version", action="store_true", dest="version", default=False 34 | ) 35 | self.parser.add_argument( 36 | "--help", 37 | "-H", 38 | action="store_true", 39 | default=False, 40 | dest="help", 41 | help="Show help message", 42 | ) 43 | 44 | self.parser.add_argument( 45 | "--search", 46 | "-S", 47 | action="store", 48 | dest="search_station_name", 49 | help="Specify a station name", 50 | ) 51 | 52 | self.parser.add_argument( 53 | "--play", 54 | "-P", 55 | action="store", 56 | dest="direct_play", 57 | help="Specify a station from fav list or direct url", 58 | ) 59 | 60 | self.parser.add_argument( 61 | "--last", 62 | action="store_true", 63 | default=False, 64 | dest="play_last_station", 65 | help="Play last played station.", 66 | ) 67 | 68 | self.parser.add_argument( 69 | "--random", 70 | action="store_true", 71 | default=False, 72 | dest="play_random_station", 73 | help="Play random station from fav list.", 74 | ) 75 | 76 | self.parser.add_argument( 77 | "--uuid", 78 | "-U", 79 | action="store", 80 | dest="search_station_uuid", 81 | help="Specify a station UUID", 82 | ) 83 | 84 | self.parser.add_argument( 85 | "--loglevel", 86 | action="store", 87 | default=self.defaults["loglevel"], 88 | dest="log_level", 89 | help="Specify log level", 90 | ) 91 | 92 | self.parser.add_argument( 93 | "--country", 94 | "-C", 95 | action="store", 96 | dest="discover_country_code", 97 | help="Discover stations with country code", 98 | ) 99 | 100 | self.parser.add_argument( 101 | "--tag", 102 | action="store", 103 | dest="discover_tag", 104 | help="Discover stations with tag", 105 | ) 106 | 107 | self.parser.add_argument( 108 | "--state", 109 | action="store", 110 | dest="discover_state", 111 | help="Discover stations with state name", 112 | ) 113 | 114 | self.parser.add_argument( 115 | "--language", 116 | action="store", 117 | dest="discover_language", 118 | help="Discover stations with state name", 119 | ) 120 | self.parser.add_argument( 121 | "--limit", 122 | "-L", 123 | action="store", 124 | dest="limit", 125 | default=self.defaults["limit"], 126 | help="Limit of entries in discover table", 127 | ) 128 | 129 | self.parser.add_argument( 130 | "--sort", 131 | action="store", 132 | dest="stations_sort_by", 133 | default=self.defaults["sort"], 134 | help="Sort stations", 135 | ) 136 | 137 | self.parser.add_argument( 138 | "--filter", 139 | action="store", 140 | dest="stations_filter_with", 141 | default=self.defaults["filter"], 142 | help="Filter Results", 143 | ) 144 | 145 | self.parser.add_argument( 146 | "--add", 147 | "-A", 148 | action="store_true", 149 | default=False, 150 | dest="new_station", 151 | help="Add an entry to your favorite station", 152 | ) 153 | 154 | self.parser.add_argument( 155 | "--favorite", 156 | "-F", 157 | action="store", 158 | dest="add_to_favorite", 159 | help="Save current station to your favorite list", 160 | ) 161 | 162 | self.parser.add_argument( 163 | "--list", 164 | action="store_true", 165 | dest="show_favorite_list", 166 | default=False, 167 | help="Show your favorite list in table format", 168 | ) 169 | 170 | self.parser.add_argument( 171 | "--remove", 172 | action="store_true", 173 | default=False, 174 | dest="remove_fav_stations", 175 | help="Remove stations from favorite list", 176 | ) 177 | 178 | self.parser.add_argument( 179 | "--flush", 180 | action="store_true", 181 | dest="flush", 182 | default=False, 183 | help="Flush your favorite list", 184 | ) 185 | 186 | self.parser.add_argument( 187 | "--volume", 188 | "-V", 189 | action="store", 190 | dest="volume", 191 | default=self.defaults["volume"], 192 | type=int, 193 | choices=range(0, 101, 10), 194 | help="Volume to pass down to ffplay", 195 | ) 196 | 197 | self.parser.add_argument( 198 | "--kill", 199 | "-K", 200 | action="store_true", 201 | dest="kill_ffplays", 202 | default=False, 203 | help="kill all the ffplay process initiated by radioactive", 204 | ) 205 | 206 | self.parser.add_argument( 207 | "--record", 208 | "-R", 209 | action="store_true", 210 | dest="record_stream", 211 | default=False, 212 | help="record a station and save as audio file", 213 | ) 214 | 215 | self.parser.add_argument( 216 | "--filepath", 217 | action="store", 218 | dest="record_file_path", 219 | default=self.defaults["filepath"], 220 | help="specify the audio format for recording", 221 | ) 222 | 223 | self.parser.add_argument( 224 | "--filename", 225 | "-N", 226 | action="store", 227 | dest="record_file", 228 | default="", 229 | help="specify the output filename of the recorded audio", 230 | ) 231 | 232 | self.parser.add_argument( 233 | "--filetype", 234 | "-T", 235 | action="store", 236 | dest="record_file_format", 237 | default=self.defaults["filetype"], 238 | help="specify the audio format for recording. auto/mp3", 239 | ) 240 | 241 | self.parser.add_argument( 242 | "--player", 243 | action="store", 244 | dest="audio_player", 245 | default=self.defaults["player"], 246 | help="specify the audio player to use. ffplay/vlc/mpv", 247 | ) 248 | 249 | def parse(self): 250 | self.result = self.parser.parse_args() 251 | if self.result is None: 252 | log.error("Could not parse the arguments properly") 253 | sys.exit(1) 254 | return self.result 255 | -------------------------------------------------------------------------------- /radioactive/config.py: -------------------------------------------------------------------------------- 1 | # load configs from a file and apply. 2 | # If any options are given on command line it will override the configs 3 | import configparser 4 | import getpass 5 | import os 6 | import sys 7 | 8 | from zenlog import log 9 | 10 | 11 | def write_a_sample_config_file(): 12 | # Create a ConfigParser object 13 | config = configparser.ConfigParser() 14 | 15 | # Add sections and key-value pairs 16 | config["AppConfig"] = { 17 | "loglevel": "info", 18 | "limit": "100", 19 | "sort": "votes", 20 | "filter": "none", 21 | "volume": "80", 22 | "filepath": "/home/{user}/recordings/radioactive/", 23 | "filetype": "mp3", 24 | "player": "ffplay", 25 | } 26 | 27 | # Get the user's home directory 28 | home_directory = os.path.expanduser("~") 29 | 30 | # Specify the file path 31 | file_path = os.path.join(home_directory, ".radio-active-configs.ini") 32 | 33 | try: 34 | # Write the configuration to the file 35 | with open(file_path, "w") as config_file: 36 | config.write(config_file) 37 | 38 | log.info(f"A sample default configuration file added at: {file_path}") 39 | 40 | except Exception as e: 41 | print(f"Error writing the configuration file: {e}") 42 | 43 | 44 | class Configs: 45 | def __init__(self): 46 | self.config_path = os.path.join( 47 | os.path.expanduser("~"), ".radio-active-configs.ini" 48 | ) 49 | 50 | def load(self): 51 | self.config = configparser.ConfigParser() 52 | 53 | try: 54 | self.config.read(self.config_path) 55 | options = {} 56 | options["volume"] = self.config.get("AppConfig", "volume") 57 | options["loglevel"] = self.config.get("AppConfig", "loglevel") 58 | options["sort"] = self.config.get("AppConfig", "sort") 59 | options["filter"] = self.config.get("AppConfig", "filter") 60 | options["limit"] = self.config.get("AppConfig", "limit") 61 | options["filepath"] = self.config.get("AppConfig", "filepath") 62 | # if filepath has any placeholder, replace 63 | # {user} to actual user map 64 | options["filepath"] = options["filepath"].replace( 65 | "{user}", getpass.getuser() 66 | ) 67 | options["filetype"] = self.config.get("AppConfig", "filetype") 68 | options["player"] = self.config.get("AppConfig", "player") 69 | 70 | return options 71 | 72 | except Exception as e: 73 | log.error(f"Something went wrong while parsing the config file: {e}") 74 | # write the example config file 75 | write_a_sample_config_file() 76 | log.info("Re-run radioative") 77 | sys.exit(1) 78 | -------------------------------------------------------------------------------- /radioactive/ffplay.py: -------------------------------------------------------------------------------- 1 | import os 2 | import signal 3 | import subprocess 4 | import sys 5 | import threading 6 | from shutil import which 7 | from time import sleep 8 | 9 | import psutil 10 | from zenlog import log 11 | 12 | 13 | def kill_background_ffplays(): 14 | all_processes = psutil.process_iter(attrs=["pid", "name"]) 15 | count = 0 16 | # Iterate through the processes and terminate those named "ffplay" 17 | for process in all_processes: 18 | try: 19 | if process.info["name"] == "ffplay": 20 | pid = process.info["pid"] 21 | p = psutil.Process(pid) 22 | p.terminate() 23 | count += 1 24 | log.info(f"Terminated ffplay process with PID {pid}") 25 | if p.is_running(): 26 | p.kill() 27 | log.debug(f"Forcefully killing ffplay process with PID {pid}") 28 | except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): 29 | # Handle exceptions, such as processes that no longer exist or access denied 30 | log.debug("Could not terminate a ffplay processes!") 31 | if count == 0: 32 | log.info("No background radios are running!") 33 | 34 | 35 | class Ffplay: 36 | def __init__(self, URL, volume, loglevel): 37 | self.program_name = "ffplay" 38 | self.url = URL 39 | self.volume = volume 40 | self.loglevel = loglevel 41 | self.is_playing = False 42 | self.process = None 43 | 44 | self._check_ffplay_installation() 45 | self.start_process() 46 | 47 | def _check_ffplay_installation(self): 48 | self.exe_path = which(self.program_name) 49 | if self.exe_path is None: 50 | log.critical("FFplay not found, install it first please") 51 | sys.exit(1) 52 | 53 | def _construct_ffplay_commands(self): 54 | ffplay_commands = [self.exe_path, "-volume", f"{self.volume}", "-vn", self.url] 55 | 56 | if self.loglevel == "debug": 57 | ffplay_commands.extend(["-loglevel", "error"]) 58 | else: 59 | ffplay_commands.extend(["-loglevel", "error", "-nodisp"]) 60 | 61 | return ffplay_commands 62 | 63 | def start_process(self): 64 | try: 65 | ffplay_commands = self._construct_ffplay_commands() 66 | self.process = subprocess.Popen( 67 | ffplay_commands, 68 | shell=False, 69 | stdout=subprocess.PIPE, 70 | stderr=subprocess.PIPE, 71 | text=True, 72 | ) 73 | 74 | self.is_running = True 75 | self.is_playing = True 76 | self._start_error_thread() 77 | 78 | except Exception as e: 79 | log.error("Error while starting radio: {}".format(e)) 80 | 81 | def _start_error_thread(self): 82 | error_thread = threading.Thread(target=self._check_error_output) 83 | error_thread.daemon = True 84 | error_thread.start() 85 | 86 | def _check_error_output(self): 87 | while self.is_running: 88 | stderr_result = self.process.stderr.readline() 89 | if stderr_result: 90 | self._handle_error(stderr_result) 91 | self.is_running = False 92 | self.stop() 93 | sleep(2) 94 | 95 | def _handle_error(self, stderr_result): 96 | print() 97 | log.error("Could not connect to the station") 98 | try: 99 | log.debug(stderr_result) 100 | log.error(stderr_result.split(": ")[1]) 101 | except Exception as e: 102 | log.debug("Error: {}".format(e)) 103 | pass 104 | 105 | def terminate_parent_process(self): 106 | parent_pid = os.getppid() 107 | os.kill(parent_pid, signal.SIGINT) 108 | 109 | def is_active(self): 110 | if not self.process: 111 | log.warning("Process is not initialized") 112 | return False 113 | 114 | try: 115 | proc = psutil.Process(self.process.pid) 116 | if proc.status() == psutil.STATUS_ZOMBIE: 117 | log.debug("Process is a zombie") 118 | return False 119 | 120 | if proc.status() in [psutil.STATUS_RUNNING, psutil.STATUS_SLEEPING]: 121 | return True 122 | 123 | log.warning("Process is not in an expected state") 124 | return False 125 | 126 | except (psutil.NoSuchProcess, Exception) as e: 127 | log.debug("Process not found or error while checking status: {}".format(e)) 128 | return False 129 | 130 | def play(self): 131 | if not self.is_playing: 132 | self.start_process() 133 | 134 | def stop(self): 135 | if self.is_playing: 136 | try: 137 | self.process.kill() 138 | self.process.wait(timeout=5) 139 | log.debug("Radio playback stopped successfully") 140 | except subprocess.TimeoutExpired: 141 | log.warning("Radio process did not terminate, killing...") 142 | self.process.kill() 143 | except Exception as e: 144 | log.error("Error while stopping radio: {}".format(e)) 145 | raise 146 | finally: 147 | self.is_playing = False 148 | self.process = None 149 | else: 150 | log.debug("Radio is not currently playing") 151 | self.terminate_parent_process() 152 | 153 | def toggle(self): 154 | if self.is_playing: 155 | log.debug("Stopping the ffplay process") 156 | self.is_running = False 157 | self.stop() 158 | else: 159 | log.debug("Starting the ffplay process") 160 | self.start_process() 161 | -------------------------------------------------------------------------------- /radioactive/filter.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from zenlog import log 4 | 5 | 6 | # function to filter strings 7 | def _filter_entries_by_key(data, filter_param, key): 8 | log.debug(f"filter: {filter_param}") 9 | 10 | filtered_entries = [] 11 | 12 | for entry in data: 13 | value = entry.get(key) 14 | 15 | if value is not None and value != "": 16 | if "!=" in filter_param: 17 | # Handle exclusion 18 | exclusion_values = filter_param.split("!=")[1].split(",") 19 | 20 | if all( 21 | exclusion_value.lower() not in value.lower() 22 | for exclusion_value in exclusion_values 23 | ): 24 | filtered_entries.append(entry) 25 | 26 | elif "=" in filter_param: 27 | # Handle inclusion 28 | inclusion_values = filter_param.split("=")[1].split(",") 29 | 30 | if any( 31 | inclusion_value.lower() in value.lower() 32 | for inclusion_value in inclusion_values 33 | ): 34 | filtered_entries.append(entry) 35 | 36 | return filtered_entries 37 | 38 | 39 | # function to filter numeric values 40 | def _filter_entries_by_numeric_key(data, filter_param, key): 41 | filtered_entries = [] 42 | 43 | # filter_key = filter_param.split(key)[0] # most left hand of the expression 44 | filter_param = filter_param.split(key)[1] # portion after the operator 45 | filter_operator = filter_param[0] # operator part 46 | filter_value = int(filter_param[1:]) # value part 47 | # log.debug(f"filter: parameter:{filter_param}") 48 | 49 | for entry in data: 50 | value = int(entry.get(key)) 51 | 52 | if value is not None: 53 | try: 54 | if filter_operator not in [">", "<", "="]: 55 | log.warning("Unsupported filter operator, not filtering !!") 56 | return data 57 | if filter_operator == "<" and value < filter_value: 58 | filtered_entries.append(entry) 59 | elif filter_operator == ">" and value > filter_value: 60 | filtered_entries.append(entry) 61 | elif filter_operator == "=" and value == filter_value: 62 | filtered_entries.append(entry) 63 | 64 | except ValueError: 65 | log.error(f"Invalid filter value for {key}: {filter_param}") 66 | sys.exit(1) 67 | 68 | return filtered_entries 69 | 70 | 71 | # allowed string string filters 72 | def _filter_entries_by_name(data, filter_param): 73 | return _filter_entries_by_key(data, filter_param, key="name") 74 | 75 | 76 | def _filter_entries_by_language(data, filter_param): 77 | return _filter_entries_by_key(data, filter_param, key="language") 78 | 79 | 80 | def _filter_entries_by_country(data, filter_param): 81 | return _filter_entries_by_key(data, filter_param, key="countrycode") 82 | 83 | 84 | def _filter_entries_by_tags(data, filter_param): 85 | return _filter_entries_by_key(data, filter_param, key="tags") 86 | 87 | 88 | def _filter_entries_by_codec(data, filter_param): 89 | return _filter_entries_by_key(data, filter_param, key="codec") 90 | 91 | 92 | # allowed numeric filters 93 | def _filter_entries_by_votes(data, filter_param): 94 | return _filter_entries_by_numeric_key(data, filter_param, key="votes") 95 | 96 | 97 | def _filter_entries_by_bitrate(data, filter_param): 98 | return _filter_entries_by_numeric_key(data, filter_param, key="bitrate") 99 | 100 | 101 | def _filter_entries_by_clickcount(data, filter_param): 102 | return _filter_entries_by_numeric_key(data, filter_param, key="clickcount") 103 | 104 | 105 | # top level filter function 106 | def _filter_results(data, expression): 107 | log.debug(f"Filter exp: {expression}") 108 | if not data: 109 | log.error("Empty results") 110 | sys.exit(0) 111 | 112 | if "name" in expression: 113 | return _filter_entries_by_name(data, expression) 114 | elif "language" in expression: 115 | return _filter_entries_by_language(data, expression) 116 | elif "country" in expression: 117 | return _filter_entries_by_country(data, expression) 118 | elif "tags" in expression: 119 | return _filter_entries_by_tags(data, expression) 120 | elif "codec" in expression: 121 | return _filter_entries_by_codec(data, expression) 122 | elif "bitrate" in expression: 123 | return _filter_entries_by_bitrate(data, expression) 124 | elif "clickcount" in expression: 125 | return _filter_entries_by_clickcount(data, expression) 126 | elif "votes" in expression: 127 | return _filter_entries_by_votes(data, expression) 128 | else: 129 | log.warning("Unknown filter expression, not filtering!") 130 | return data 131 | 132 | 133 | # Top most function for multiple filtering expressions with '&' 134 | # NOTE: it will filter maintaining the order you provided on the CLI 135 | 136 | 137 | def filter_expressions(data, input_expression): 138 | log.info( 139 | "Setting a higher value for the --limit parameter is preferable when filtering stations." 140 | ) 141 | if "&" in input_expression: 142 | log.debug("filter: multiple expressions found") 143 | expression_parts = input_expression.split("&") 144 | 145 | for expression in expression_parts: 146 | if data: 147 | data = _filter_results(data, expression) 148 | return data 149 | 150 | else: 151 | return _filter_results(data, input_expression) 152 | -------------------------------------------------------------------------------- /radioactive/handler.py: -------------------------------------------------------------------------------- 1 | """ 2 | This handler solely depends on pyradios module to communicate with our remote API 3 | """ 4 | 5 | import datetime 6 | import json 7 | import sys 8 | 9 | import requests_cache 10 | from pyradios import RadioBrowser 11 | from rich.console import Console 12 | from rich.table import Table 13 | from zenlog import log 14 | 15 | from radioactive.filter import filter_expressions 16 | 17 | console = Console() 18 | 19 | 20 | def trim_string(text, max_length=40): 21 | """ 22 | Trim a string to a maximum length and add ellipsis if needed. 23 | 24 | Args: 25 | text (str): The input text to be trimmed. 26 | max_length (int, optional): The maximum length of the trimmed string. Defaults to 40. 27 | 28 | Returns: 29 | str: The trimmed string, possibly with an ellipsis (...) if it was shortened. 30 | """ 31 | if len(text) > max_length: 32 | return text[:max_length] + "..." 33 | else: 34 | return text 35 | 36 | 37 | def print_table(response, columns, sort_by, filter_expression): 38 | """ 39 | Print the table applying the sort logic. 40 | 41 | Args: 42 | response (list): A list of data to be displayed in the table. 43 | columns (list): List of column specifications in the format "col_name:response_key@max_str". 44 | sort_by (str): The column by which to sort the table. 45 | 46 | Returns: 47 | list: The original response data. 48 | """ 49 | 50 | if not response: 51 | log.error("No stations found") 52 | sys.exit(1) 53 | 54 | # need to filter? 55 | if filter_expression.lower() != "none": 56 | response = filter_expressions(response, filter_expression) 57 | 58 | if not response: 59 | log.error("No stations found after filtering") 60 | sys.exit(1) 61 | else: 62 | log.debug("Not filtering") 63 | 64 | if len(response) >= 1: 65 | table = Table( 66 | show_header=True, 67 | header_style="magenta", 68 | expand=True, 69 | min_width=85, 70 | safe_box=True, 71 | # show_footer=True, 72 | # show_lines=True, 73 | # padding=0.1, 74 | # collapse_padding=True, 75 | ) 76 | table.add_column("ID", justify="center") 77 | 78 | for col_spec in columns: 79 | col_name, response_key, max_str = ( 80 | col_spec.split(":")[0], 81 | col_spec.split(":")[1].split("@")[0], 82 | int(col_spec.split("@")[1]), 83 | ) 84 | table.add_column(col_name, justify="left") 85 | 86 | # do not need extra columns for these cases 87 | if sort_by not in ["name", "random"]: 88 | table.add_column(sort_by, justify="left") 89 | 90 | for i, station in enumerate(response): 91 | row_data = [str(i + 1)] # for ID 92 | 93 | for col_spec in columns: 94 | col_name, response_key, max_str = ( 95 | col_spec.split(":")[0], 96 | col_spec.split(":")[1].split("@")[0], 97 | int(col_spec.split("@")[1]), 98 | ) 99 | row_data.append( 100 | trim_string(station.get(response_key, ""), max_length=max_str) 101 | ) 102 | 103 | if sort_by not in ["name", "random"]: 104 | row_data.append(str(station.get(sort_by, ""))) 105 | 106 | table.add_row(*row_data) 107 | 108 | console.print(table) 109 | # log.info( 110 | # "If the table does not fit into your screen, \ntry to maximize the window, decrease the font by a bit, and retry" 111 | # ) 112 | return response 113 | else: 114 | log.info("No stations found") 115 | sys.exit(0) 116 | 117 | 118 | class Handler: 119 | """ 120 | radio-browser API handler. This module communicates with the underlying API via PyRadios 121 | """ 122 | 123 | def __init__(self): 124 | self.API = None 125 | self.response = None 126 | self.target_station = None 127 | 128 | # When RadioBrowser can not be initiated properly due to no internet (probably) 129 | try: 130 | expire_after = datetime.timedelta(days=3) 131 | session = requests_cache.CachedSession( 132 | cache_name="cache", backend="sqlite", expire_after=expire_after 133 | ) 134 | self.API = RadioBrowser(session=session) 135 | except Exception as e: 136 | log.debug("Error: {}".format(e)) 137 | log.critical("Something is wrong with your internet connection") 138 | sys.exit(1) 139 | 140 | def get_country_code(self, name): 141 | self.countries = self.API.countries() 142 | for country in self.countries: 143 | if country["name"].lower() == name.lower(): 144 | return country["iso_3166_1"] 145 | return None 146 | 147 | def validate_uuid_station(self): 148 | if len(self.response) == 1: 149 | log.debug(json.dumps(self.response[0], indent=3)) 150 | self.target_station = self.response[0] 151 | 152 | # register a valid click to increase its popularity 153 | self.API.click_counter(self.target_station["stationuuid"]) 154 | 155 | return self.response 156 | 157 | # ---------------------------- NAME -------------------------------- # 158 | def search_by_station_name(self, _name, limit, sort_by, filter_with): 159 | """search and play a station by its name""" 160 | reversed = sort_by != "name" 161 | 162 | try: 163 | response = self.API.search( 164 | name=_name, 165 | name_exact=False, 166 | limit=limit, 167 | order=str(sort_by), 168 | reverse=reversed, 169 | ) 170 | return print_table( 171 | response, 172 | ["Station:name@30", "Country:country@20", "Tags:tags@20"], 173 | sort_by=sort_by, 174 | filter_expression=filter_with, 175 | ) 176 | except Exception as e: 177 | log.debug("Error: {}".format(e)) 178 | log.error("Something went wrong. please try again.") 179 | sys.exit(1) 180 | 181 | # ------------------------- UUID ------------------------ # 182 | def play_by_station_uuid(self, _uuid): 183 | """search and play station by its stationuuid""" 184 | try: 185 | self.response = self.API.station_by_uuid(_uuid) 186 | return self.validate_uuid_station() 187 | except Exception as e: 188 | log.debug("Error: {}".format(e)) 189 | log.error("Something went wrong. please try again.") 190 | sys.exit(1) 191 | 192 | # -------------------------- COUNTRY ----------------------# 193 | def discover_by_country(self, country_code_or_name, limit, sort_by, filter_with): 194 | # set reverse to false if name is is the parameter for sorting 195 | reversed = sort_by != "name" 196 | 197 | # check if it is a code or name 198 | if len(country_code_or_name.strip()) == 2: 199 | # it's a code 200 | log.debug("Country code '{}' provided".format(country_code_or_name)) 201 | try: 202 | response = self.API.search( 203 | countrycode=country_code_or_name, 204 | limit=limit, 205 | order=str(sort_by), 206 | reverse=reversed, 207 | ) 208 | except Exception as e: 209 | log.debug("Error: {}".format(e)) 210 | log.error("Something went wrong. please try again.") 211 | sys.exit(1) 212 | else: 213 | # it's name 214 | log.debug("Country name '{}' provided".format(country_code_or_name)) 215 | code = self.get_country_code(country_code_or_name) 216 | if code: 217 | try: 218 | response = self.API.search( 219 | countrycode=code, 220 | limit=limit, 221 | country_exact=True, 222 | order=str(sort_by), 223 | reverse=reversed, 224 | ) 225 | except Exception as e: 226 | log.debug("Error: {}".format(e)) 227 | log.error("Something went wrong. please try again.") 228 | sys.exit(1) 229 | else: 230 | log.error("Not a valid country name") 231 | sys.exit(1) 232 | 233 | # display the result 234 | print_table( 235 | response, 236 | [ 237 | "Station:name@30", 238 | "State:state@20", 239 | "Tags:tags@20", 240 | "Language:language@20", 241 | ], 242 | sort_by, 243 | filter_with, 244 | ) 245 | return response 246 | 247 | # ------------------- by state --------------------- 248 | 249 | def discover_by_state(self, state, limit, sort_by, filter_with): 250 | reversed = sort_by != "name" 251 | 252 | try: 253 | response = self.API.search( 254 | state=state, limit=limit, order=str(sort_by), reverse=reversed 255 | ) 256 | except Exception: 257 | log.error("Something went wrong. please try again.") 258 | sys.exit(1) 259 | 260 | return print_table( 261 | response, 262 | [ 263 | "Station:name@30", 264 | "Country:country@20", 265 | "State:state@20", 266 | "Tags:tags@20", 267 | "Language:language@20", 268 | ], 269 | sort_by, 270 | filter_with, 271 | ) 272 | 273 | # -----------------by language -------------------- 274 | 275 | def discover_by_language(self, language, limit, sort_by, filter_with): 276 | reversed = sort_by != "name" 277 | 278 | try: 279 | response = self.API.search( 280 | language=language, limit=limit, order=str(sort_by), reverse=reversed 281 | ) 282 | except Exception as e: 283 | log.debug("Error: {}".format(e)) 284 | log.error("Something went wrong. please try again.") 285 | sys.exit(1) 286 | 287 | return print_table( 288 | response, 289 | [ 290 | "Station:name@30", 291 | "Country:country@20", 292 | "Language:language@20", 293 | "Tags:tags@20", 294 | ], 295 | sort_by, 296 | filter_with, 297 | ) 298 | 299 | # -------------------- by tag ---------------------- # 300 | def discover_by_tag(self, tag, limit, sort_by, filter_with): 301 | reversed = sort_by != "name" 302 | 303 | try: 304 | response = self.API.search( 305 | tag=tag, limit=limit, order=str(sort_by), reverse=reversed 306 | ) 307 | except Exception as e: 308 | log.debug("Error: {}".format(e)) 309 | log.error("Something went wrong. please try again.") 310 | sys.exit(1) 311 | 312 | return print_table( 313 | response, 314 | [ 315 | "Station:name@30", 316 | "Country:country@20", 317 | "Language:language@20", 318 | "Tags:tags@50", 319 | ], 320 | sort_by, 321 | filter_with, 322 | ) 323 | 324 | # ---- Increase click count ------------- # 325 | def vote_for_uuid(self, UUID): 326 | try: 327 | result = self.API.click_counter(UUID) 328 | return result 329 | except Exception as e: 330 | log.debug("Something went wrong during increasing click count:{}".format(e)) 331 | -------------------------------------------------------------------------------- /radioactive/help.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | from rich.console import Console 4 | from rich.table import Table 5 | 6 | user = path.expanduser("~") 7 | 8 | 9 | def show_help(): 10 | """Show help message as table""" 11 | console = Console() 12 | 13 | table = Table(show_header=True, header_style="bold magenta") 14 | table.add_column("Arguments", justify="left") 15 | table.add_column("Description", justify="left") 16 | table.add_column("Default", justify="center") 17 | 18 | table.add_row( 19 | "--search , -S", 20 | "A station name to search on the internet", 21 | "", 22 | ) 23 | 24 | table.add_row( 25 | "--uuid , -U", 26 | "A station UUID to play it directly", 27 | "", 28 | ) 29 | 30 | table.add_row( 31 | "--country, -C", 32 | "Discover stations by country code", 33 | "", 34 | ) 35 | table.add_row( 36 | "--state", 37 | "Discover stations by country state", 38 | "", 39 | ) 40 | 41 | table.add_row( 42 | "--tag", 43 | "Discover stations by tags/genre", 44 | "", 45 | ) 46 | 47 | table.add_row( 48 | "--language", 49 | "Discover stations by language", 50 | "", 51 | ) 52 | 53 | table.add_row( 54 | "--play , -P", 55 | "A station name from fav list or a stream url", 56 | "", 57 | ) 58 | 59 | table.add_row( 60 | "--last", 61 | "Play last played station", 62 | "False", 63 | ) 64 | table.add_row( 65 | "--random", 66 | "Play a random station from favorite list", 67 | "False", 68 | ) 69 | 70 | table.add_row( 71 | "--add , -A", 72 | "Add a station to your favorite list", 73 | "False", 74 | ) 75 | 76 | table.add_row( 77 | "--favorite, -F ", 78 | "Add current station to favorite list", 79 | "False", 80 | ) 81 | 82 | table.add_row( 83 | "--list", 84 | "Show your favorite list", 85 | "False", 86 | ) 87 | 88 | table.add_row( 89 | "--remove", 90 | "Remove stations from favorite list", 91 | "False", 92 | ) 93 | 94 | table.add_row( 95 | "--flush", 96 | "Clear your favorite list", 97 | "False", 98 | ) 99 | 100 | table.add_row( 101 | "--limit, -L", 102 | "Limit the number of station results", 103 | "100", 104 | ) 105 | 106 | table.add_row( 107 | "--sort", 108 | "Sort the results page, see documentation", 109 | "clickcount", 110 | ) 111 | 112 | table.add_row( 113 | "--volume, -V", 114 | "Volume of the radio between 0 and 100", 115 | "80", 116 | ) 117 | 118 | table.add_row( 119 | "--record, -R", 120 | "Record current stations audio", 121 | "False", 122 | ) 123 | 124 | table.add_row( 125 | "--filepath", 126 | "Path to save the recorded audio", 127 | f"{user}/Music/radioactive", 128 | ) 129 | 130 | table.add_row( 131 | "--filename, -N", 132 | "Filename to save the recorded audio", 133 | "", 134 | ) 135 | table.add_row( 136 | "--filetype, -T", 137 | "Type/codec of target recording. (mp3/auto)", 138 | "mp3", 139 | ) 140 | 141 | table.add_row( 142 | "--kill, -K", 143 | "Stop background radios", 144 | "False", 145 | ) 146 | 147 | table.add_row( 148 | "--loglevel", 149 | "Log level of the program: info,warning,error,debug", 150 | "info", 151 | ) 152 | 153 | table.add_row( 154 | "--player", 155 | "Media player to use. vlc/mpv/ffplay", 156 | "ffplay", 157 | ) 158 | 159 | console.print(table) 160 | print( 161 | "For more details : https://github.com/deep5050/radio-active/blob/main/README.md" 162 | ) 163 | -------------------------------------------------------------------------------- /radioactive/last_station.py: -------------------------------------------------------------------------------- 1 | """ This module saves the current playing station information to a hidden file, 2 | and loads the data when no arguments are provide """ 3 | 4 | import json 5 | import os.path 6 | 7 | from zenlog import log 8 | 9 | 10 | class Last_station: 11 | 12 | """Saves the last played radio station information, 13 | when user don't provide any -S or -U it looks for the information. 14 | 15 | on every successful run, it saves the station information. 16 | The file it uses to store the data is a hidden file under users' home directory 17 | """ 18 | 19 | def __init__(self): 20 | self.last_station_path = None 21 | 22 | self.last_station_path = os.path.join( 23 | os.path.expanduser("~"), ".radio-active-last-station" 24 | ) 25 | 26 | def get_info(self): 27 | try: 28 | with open(self.last_station_path, "r") as f: 29 | last_station = json.load(f) 30 | return last_station 31 | except Exception: 32 | return "" 33 | 34 | def save_info(self, station): 35 | """dumps the current station information as a json file""" 36 | 37 | log.debug("Dumping station information") 38 | with open(self.last_station_path, "w") as f: 39 | json.dump(station, f) 40 | -------------------------------------------------------------------------------- /radioactive/mpv.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | from shutil import which 4 | 5 | from zenlog import log 6 | 7 | 8 | class MPV: 9 | def __init__(self): 10 | self.program_name = "mpv" 11 | self.exe_path = which(self.program_name) 12 | log.debug(f"{self.program_name}: {self.exe_path}") 13 | 14 | if self.exe_path is None: 15 | log.critical(f"{self.program_name} not found, install it first please") 16 | sys.exit(1) 17 | 18 | self.is_running = False 19 | self.process = None 20 | self.url = None 21 | 22 | def _construct_mpv_commands(self, url): 23 | return [self.exe_path, url] 24 | 25 | def start(self, url): 26 | self.url = url 27 | mpv_commands = self._construct_mpv_commands(url) 28 | 29 | try: 30 | self.process = subprocess.Popen( 31 | mpv_commands, 32 | shell=False, 33 | stdout=subprocess.PIPE, 34 | stderr=subprocess.PIPE, 35 | text=True, 36 | ) 37 | self.is_running = True 38 | log.debug( 39 | f"player: {self.program_name} => PID {self.process.pid} initiated" 40 | ) 41 | 42 | except Exception as e: 43 | log.error(f"Error while starting player: {e}") 44 | 45 | def stop(self): 46 | if self.is_running: 47 | self.process.kill() 48 | self.is_running = False 49 | 50 | def toggle(self): 51 | if self.is_running: 52 | self.stop() 53 | else: 54 | self.start(self.url) 55 | -------------------------------------------------------------------------------- /radioactive/parser.py: -------------------------------------------------------------------------------- 1 | from zenlog import log 2 | 3 | from radioactive.args import Parser 4 | 5 | 6 | def parse_options(): 7 | parser = Parser() 8 | args = parser.parse() 9 | options = {} 10 | # ----------------- all the args ------------- # 11 | options["version"] = args.version 12 | options["show_help_table"] = args.help 13 | options["loglevel"] = args.log_level 14 | 15 | # check log levels 16 | if options["loglevel"] in ["info", "error", "warning", "debug"]: 17 | log.level(options["loglevel"]) 18 | else: 19 | log.level("info") 20 | log.warning("Correct log levels are: error,warning,info(default),debug") 21 | 22 | # check is limit is a valid integer 23 | limit = args.limit 24 | options["limit"] = int(limit) if limit else 100 25 | log.debug("limit is set to: {}".format(limit)) 26 | 27 | options["search_station_name"] = args.search_station_name 28 | options["search_station_uuid"] = args.search_station_uuid 29 | 30 | options["play_last_station"] = args.play_last_station 31 | options["direct_play"] = args.direct_play 32 | options["play_random"] = args.play_random_station 33 | 34 | options["sort_by"] = args.stations_sort_by 35 | options["filter_with"] = args.stations_filter_with 36 | 37 | options["discover_country_code"] = args.discover_country_code 38 | options["discover_state"] = args.discover_state 39 | options["discover_language"] = args.discover_language 40 | options["discover_tag"] = args.discover_tag 41 | 42 | options["add_station"] = args.new_station 43 | 44 | options["show_favorite_list"] = args.show_favorite_list 45 | options["add_to_favorite"] = args.add_to_favorite 46 | options["flush_fav_list"] = args.flush 47 | options["remove_fav_stations"] = args.remove_fav_stations 48 | 49 | options["kill_ffplays"] = args.kill_ffplays 50 | 51 | options["record_stream"] = args.record_stream 52 | options["record_file"] = args.record_file 53 | options["record_file_format"] = args.record_file_format 54 | options["record_file_path"] = args.record_file_path 55 | 56 | options["target_url"] = "" 57 | options["volume"] = args.volume 58 | options["audio_player"] = args.audio_player 59 | 60 | return options 61 | -------------------------------------------------------------------------------- /radioactive/recorder.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | from zenlog import log 4 | 5 | 6 | def record_audio_auto_codec(input_stream_url): 7 | try: 8 | # Run FFprobe to get the audio codec information 9 | ffprobe_command = [ 10 | "ffprobe", 11 | "-v", 12 | "error", 13 | "-select_streams", 14 | "a:0", 15 | "-show_entries", 16 | "stream=codec_name", 17 | "-of", 18 | "default=noprint_wrappers=1:nokey=1", 19 | input_stream_url, 20 | ] 21 | 22 | codec_info = subprocess.check_output(ffprobe_command, text=True) 23 | 24 | # Determine the file extension based on the audio codec 25 | audio_codec = codec_info.strip() 26 | audio_codec = audio_codec.split("\n")[0] 27 | return audio_codec 28 | 29 | except subprocess.CalledProcessError as e: 30 | log.error(f"Error: could not fetch codec {e}") 31 | return None 32 | 33 | 34 | def record_audio_from_url(input_url, output_file, force_mp3, loglevel): 35 | try: 36 | # Construct the FFmpeg command 37 | ffmpeg_command = [ 38 | "ffmpeg", 39 | "-i", 40 | input_url, # input URL 41 | "-vn", # disable video recording 42 | "-stats", # show stats 43 | ] 44 | 45 | # codec for audio stream 46 | ffmpeg_command.append("-c:a") 47 | if force_mp3: 48 | ffmpeg_command.append("libmp3lame") 49 | log.debug("Record: force libmp3lame") 50 | else: 51 | # file will be saved as as provided. this is more error prone 52 | # file extension must match the actual stream codec 53 | ffmpeg_command.append("copy") 54 | 55 | ffmpeg_command.append("-loglevel") 56 | if loglevel == "debug": 57 | ffmpeg_command.append("info") 58 | else: 59 | ffmpeg_command.append("error"), 60 | ffmpeg_command.append("-hide_banner") 61 | 62 | # output file 63 | ffmpeg_command.append(output_file) 64 | 65 | # Run FFmpeg command on foreground to catch 'q' without 66 | # any complex thread for now 67 | subprocess.run(ffmpeg_command, check=True) 68 | 69 | log.debug("Record: {}".format(str(ffmpeg_command))) 70 | log.info("Audio recorded successfully.") 71 | 72 | except subprocess.CalledProcessError as e: 73 | log.debug("Error: {}".format(e)) 74 | log.error(f"Error while recording audio: {e}") 75 | except Exception as ex: 76 | log.debug("Error: {}".format(ex)) 77 | log.error(f"An error occurred: {ex}") 78 | -------------------------------------------------------------------------------- /radioactive/utilities.py: -------------------------------------------------------------------------------- 1 | """Handler functions for __main__.py""" 2 | 3 | import datetime 4 | import json 5 | import os 6 | import subprocess 7 | import sys 8 | from random import randint 9 | 10 | import requests 11 | from pick import pick 12 | from rich import print 13 | from rich.console import Console 14 | from rich.panel import Panel 15 | from rich.table import Table 16 | from rich.text import Text 17 | from zenlog import log 18 | 19 | from radioactive.ffplay import kill_background_ffplays 20 | from radioactive.last_station import Last_station 21 | from radioactive.recorder import record_audio_auto_codec, record_audio_from_url 22 | 23 | RED_COLOR = "\033[91m" 24 | END_COLOR = "\033[0m" 25 | 26 | global_current_station_info = {} 27 | 28 | 29 | def handle_fetch_song_title(url): 30 | """Fetch currently playing track information""" 31 | log.info("Fetching the current track info") 32 | log.debug("Attempting to retrieve track info from: {}".format(url)) 33 | # Run ffprobe command and capture the metadata 34 | cmd = [ 35 | "ffprobe", 36 | "-v", 37 | "quiet", 38 | "-print_format", 39 | "json", 40 | "-show_format", 41 | "-show_entries", 42 | "format=icy", 43 | url, 44 | ] 45 | track_name = "" 46 | 47 | try: 48 | output = subprocess.check_output(cmd).decode("utf-8") 49 | data = json.loads(output) 50 | log.debug(f"station info: {data}") 51 | 52 | # Extract the station name (icy-name) if available 53 | track_name = data.get("format", {}).get("tags", {}).get("StreamTitle", "") 54 | except: 55 | log.error("Error while fetching the track name") 56 | 57 | if track_name != "": 58 | log.info(f"🎶: {track_name}") 59 | else: 60 | log.error("No track information available") 61 | 62 | 63 | def handle_record( 64 | target_url, 65 | curr_station_name, 66 | record_file_path, 67 | record_file, 68 | record_file_format, # auto/mp3 69 | loglevel, 70 | ): 71 | log.info("Press 'q' to stop recording") 72 | force_mp3 = False 73 | 74 | if record_file_format != "mp3" and record_file_format != "auto": 75 | record_file_format = "mp3" # default to mp3 76 | log.debug("Error: wrong codec supplied!. falling back to mp3") 77 | force_mp3 = True 78 | elif record_file_format == "auto": 79 | log.debug("Codec: fetching stream codec") 80 | codec = record_audio_auto_codec(target_url) 81 | if codec is None: 82 | record_file_format = "mp3" # default to mp3 83 | force_mp3 = True 84 | log.debug("Error: could not detect codec. falling back to mp3") 85 | else: 86 | record_file_format = codec 87 | log.debug("Codec: found {}".format(codec)) 88 | elif record_file_format == "mp3": 89 | # always save to mp3 to eliminate any runtime issues 90 | # it is better to leave it on libmp3lame 91 | force_mp3 = True 92 | 93 | if record_file_path and not os.path.exists(record_file_path): 94 | log.debug("filepath: {}".format(record_file_path)) 95 | os.makedirs(record_file_path, exist_ok=True) 96 | 97 | elif not record_file_path: 98 | log.debug("filepath: fallback to default path") 99 | record_file_path = os.path.join( 100 | os.path.expanduser("~"), "Music/radioactive" 101 | ) # fallback path 102 | try: 103 | os.makedirs(record_file_path, exist_ok=True) 104 | except Exception as e: 105 | log.debug("{}".format(e)) 106 | log.error("Could not make default directory") 107 | sys.exit(1) 108 | 109 | now = datetime.datetime.now() 110 | month_name = now.strftime("%b").upper() 111 | # Format AM/PM as 'AM' or 'PM' 112 | am_pm = now.strftime("%p") 113 | 114 | # format is : day-monthname-year@hour-minute-second-(AM/PM) 115 | formatted_date_time = now.strftime(f"%d-{month_name}-%Y@%I-%M-%S-{am_pm}") 116 | 117 | if not record_file_format.strip(): 118 | record_file_format = "mp3" 119 | 120 | if not record_file: 121 | record_file = "{}-{}".format( 122 | curr_station_name.strip(), formatted_date_time 123 | ).replace(" ", "-") 124 | 125 | tmp_filename = f"{record_file}.{record_file_format}" 126 | outfile_path = os.path.join(record_file_path, tmp_filename) 127 | 128 | log.info(f"Recording will be saved as: \n{outfile_path}") 129 | 130 | record_audio_from_url(target_url, outfile_path, force_mp3, loglevel) 131 | 132 | 133 | def handle_welcome_screen(): 134 | welcome = Panel( 135 | """ 136 | :radio: Play any radios around the globe right from this Terminal [yellow]:zap:[/yellow]! 137 | :smile: Author: Dipankar Pal 138 | :question: Type '--help' for more details on available commands 139 | :bug: Visit: https://github.com/deep5050/radio-active to submit issues 140 | :star: Show some love by starring the project on GitHub [red]:heart:[/red] 141 | :dollar: You can donate me at https://deep5050.github.io/payme/ 142 | :x: Press Ctrl+C to quit 143 | """, 144 | title="[b]RADIOACTIVE[/b]", 145 | width=85, 146 | expand=True, 147 | safe_box=True, 148 | ) 149 | print(welcome) 150 | 151 | 152 | def handle_update_screen(app): 153 | if app.is_update_available(): 154 | update_msg = ( 155 | "\t[blink]An update available, run [green][italic]pip install radio-active==" 156 | + app.get_remote_version() 157 | + "[/italic][/green][/blink]\nSee the changes: https://github.com/deep5050/radio-active/blob/main/CHANGELOG.md" 158 | ) 159 | update_panel = Panel( 160 | update_msg, 161 | width=85, 162 | ) 163 | print(update_panel) 164 | else: 165 | log.debug("Update not available") 166 | 167 | 168 | def handle_favorite_table(alias): 169 | # log.info("Your favorite station list is below") 170 | table = Table( 171 | show_header=True, 172 | header_style="bold magenta", 173 | min_width=85, 174 | safe_box=False, 175 | expand=True, 176 | ) 177 | table.add_column("Station", justify="left") 178 | table.add_column("URL / UUID", justify="left") 179 | if len(alias.alias_map) > 0: 180 | for entry in alias.alias_map: 181 | table.add_row(entry["name"], entry["uuid_or_url"]) 182 | print(table) 183 | log.info(f"Your favorite stations are saved in {alias.alias_path}") 184 | else: 185 | log.info("You have no favorite station list") 186 | 187 | 188 | def handle_show_station_info(): 189 | """Show important information regarding the current station""" 190 | global global_current_station_info 191 | custom_info = {} 192 | try: 193 | custom_info["name"] = global_current_station_info["name"] 194 | custom_info["uuid"] = global_current_station_info["stationuuid"] 195 | custom_info["url"] = global_current_station_info["url"] 196 | custom_info["website"] = global_current_station_info["homepage"] 197 | custom_info["country"] = global_current_station_info["country"] 198 | custom_info["language"] = global_current_station_info["language"] 199 | custom_info["tags"] = global_current_station_info["tags"] 200 | custom_info["codec"] = global_current_station_info["codec"] 201 | custom_info["bitrate"] = global_current_station_info["bitrate"] 202 | print(custom_info) 203 | except: 204 | log.error("No station information available") 205 | 206 | 207 | def handle_add_station(alias): 208 | try: 209 | left = input("Enter station name:") 210 | right = input("Enter station stream-url or radio-browser uuid:") 211 | except EOFError: 212 | print() 213 | log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") 214 | sys.exit(0) 215 | 216 | if left.strip() == "" or right.strip() == "": 217 | log.error("Empty inputs not allowed") 218 | sys.exit(1) 219 | alias.add_entry(left, right) 220 | log.info("New entry: {}={} added\n".format(left, right)) 221 | sys.exit(0) 222 | 223 | 224 | def handle_add_to_favorite(alias, station_name, station_uuid_url): 225 | try: 226 | response = alias.add_entry(station_name, station_uuid_url) 227 | if not response: 228 | try: 229 | user_input = input("Enter a different name: ") 230 | except EOFError: 231 | print() 232 | log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") 233 | sys.exit(0) 234 | 235 | if user_input.strip() != "": 236 | response = alias.add_entry(user_input.strip(), station_uuid_url) 237 | except Exception as e: 238 | log.debug("Error: {}".format(e)) 239 | log.error("Could not add to favorite. Already in list?") 240 | 241 | 242 | def handle_station_uuid_play(handler, station_uuid): 243 | log.debug("Searching API for: {}".format(station_uuid)) 244 | 245 | handler.play_by_station_uuid(station_uuid) 246 | 247 | log.debug("increased click count for: {}".format(station_uuid)) 248 | 249 | handler.vote_for_uuid(station_uuid) 250 | try: 251 | station_name = handler.target_station["name"] 252 | station_url = handler.target_station["url"] 253 | except Exception as e: 254 | log.debug("{}".format(e)) 255 | log.error("Something went wrong") 256 | sys.exit(1) 257 | 258 | return station_name, station_url 259 | 260 | 261 | def check_sort_by_parameter(sort_by): 262 | accepted_parameters = [ 263 | "name", 264 | "votes", 265 | "codec", 266 | "bitrate", 267 | "lastcheckok", 268 | "lastchecktime", 269 | "clickcount", 270 | "clicktrend", 271 | "random", 272 | ] 273 | 274 | if sort_by not in accepted_parameters: 275 | log.warning("Sort parameter is unknown. Falling back to 'name'") 276 | 277 | log.warning( 278 | "choose from: name,votes,codec,bitrate,lastcheckok,lastchecktime,clickcount,clicktrend,random" 279 | ) 280 | return "name" 281 | return sort_by 282 | 283 | 284 | def handle_search_stations(handler, station_name, limit, sort_by, filter_with): 285 | log.debug("Searching API for: {}".format(station_name)) 286 | 287 | return handler.search_by_station_name(station_name, limit, sort_by, filter_with) 288 | 289 | 290 | def handle_station_selection_menu(handler, last_station, alias): 291 | # Add a selection list here. first entry must be the last played station 292 | # try to fetch the last played station's information 293 | last_station_info = {} 294 | try: 295 | last_station_info = last_station.get_info() 296 | except Exception as e: 297 | log.debug("Error: {}".format(e)) 298 | # no last station?? 299 | pass 300 | 301 | # log.info("You can search for a station on internet using the --search option") 302 | title = "Please select a station from your favorite list:" 303 | station_selection_names = [] 304 | station_selection_urls = [] 305 | 306 | # add last played station first 307 | if last_station_info: 308 | station_selection_names.append( 309 | f"{last_station_info['name'].strip()} (last played station)" 310 | ) 311 | try: 312 | station_selection_urls.append(last_station_info["stationuuid"]) 313 | except Exception as e: 314 | log.debug("Error: {}".format(e)) 315 | station_selection_urls.append(last_station_info["uuid_or_url"]) 316 | 317 | fav_stations = alias.alias_map 318 | for entry in fav_stations: 319 | station_selection_names.append(entry["name"].strip()) 320 | station_selection_urls.append(entry["uuid_or_url"]) 321 | 322 | options = station_selection_names 323 | if len(options) == 0: 324 | log.info( 325 | f"{RED_COLOR}No stations to play. please search for a station first!{END_COLOR}" 326 | ) 327 | sys.exit(0) 328 | 329 | _, index = pick(options, title, indicator="-->") 330 | 331 | # check if there is direct URL or just UUID 332 | station_option_url = station_selection_urls[index] 333 | station_name = station_selection_names[index].replace("(last played station)", "") 334 | 335 | if station_option_url.find("://") != -1: 336 | # direct URL 337 | station_url = station_option_url 338 | return station_name, station_url 339 | 340 | else: 341 | # UUID 342 | station_uuid = station_option_url 343 | return handle_station_uuid_play(handler, station_uuid) 344 | 345 | 346 | def handle_save_last_station(last_station, station_name, station_url): 347 | last_station = Last_station() 348 | 349 | last_played_station = {} 350 | last_played_station["name"] = station_name 351 | last_played_station["uuid_or_url"] = station_url 352 | 353 | log.debug(f"Saving the current station: {last_played_station}") 354 | last_station.save_info(last_played_station) 355 | 356 | 357 | def handle_listen_keypress( 358 | alias, 359 | player, 360 | target_url, 361 | station_name, 362 | station_url, 363 | record_file_path, 364 | record_file, 365 | record_file_format, 366 | loglevel, 367 | ): 368 | log.info("Press '?' to see available commands\n") 369 | while True: 370 | try: 371 | user_input = input("Enter a command to perform an action: ") 372 | except EOFError: 373 | print() 374 | log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") 375 | kill_background_ffplays() 376 | sys.exit(0) 377 | 378 | if user_input in ["r", "R", "record"]: 379 | handle_record( 380 | target_url, 381 | station_name, 382 | record_file_path, 383 | record_file, 384 | record_file_format, 385 | loglevel, 386 | ) 387 | elif user_input in ["rf", "RF", "recordfile"]: 388 | # if no filename is provided try to auto detect 389 | # else if ".mp3" is provided, use libmp3lame to force write to mp3 390 | try: 391 | user_input = input("Enter output filename: ") 392 | except EOFError: 393 | print() 394 | log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") 395 | kill_background_ffplays() 396 | sys.exit(0) 397 | 398 | # try to get extension from filename 399 | try: 400 | file_name, file_ext = user_input.split(".") 401 | if file_ext == "mp3": 402 | log.debug("codec: force mp3") 403 | # overwrite original codec with "mp3" 404 | record_file_format = "mp3" 405 | else: 406 | log.warning("You can only specify mp3 as file extension.\n") 407 | log.warning( 408 | "Do not provide any extension to autodetect the codec.\n" 409 | ) 410 | except: 411 | file_name = user_input 412 | 413 | if user_input.strip() != "": 414 | handle_record( 415 | target_url, 416 | station_name, 417 | record_file_path, 418 | file_name, 419 | record_file_format, 420 | loglevel, 421 | ) 422 | elif user_input in ["i", "I", "info"]: 423 | handle_show_station_info() 424 | 425 | elif user_input in ["f", "F", "fav"]: 426 | handle_add_to_favorite(alias, station_name, station_url) 427 | 428 | elif user_input in ["q", "Q", "quit"]: 429 | # kill_background_ffplays() 430 | player.stop() 431 | sys.exit(0) 432 | elif user_input in ["w", "W", "list"]: 433 | alias.generate_map() 434 | handle_favorite_table(alias) 435 | elif user_input in ["t", "T", "track"]: 436 | handle_fetch_song_title(target_url) 437 | elif user_input in ["p", "P"]: 438 | # toggle the player (start/stop) 439 | player.toggle() 440 | # TODO: toggle the player 441 | 442 | elif user_input in ["h", "H", "?", "help"]: 443 | log.info("p: Play/Pause current station") 444 | log.info("t/track: Current track info") 445 | log.info("i/info: Station information") 446 | log.info("r/record: Record a station") 447 | log.info("rf/recordfile: Specify a filename for the recording") 448 | log.info("f/fav: Add station to favorite list") 449 | log.info("h/help/?: Show this help message") 450 | log.info("q/quit: Quit radioactive") 451 | 452 | 453 | def handle_current_play_panel(curr_station_name=""): 454 | panel_station_name = Text(curr_station_name, justify="center") 455 | 456 | station_panel = Panel(panel_station_name, title="[blink]:radio:[/blink]", width=85) 457 | console = Console() 458 | console.print(station_panel) 459 | 460 | 461 | def handle_user_choice_from_search_result(handler, response): 462 | global global_current_station_info 463 | 464 | if not response: 465 | log.debug("No result found!") 466 | sys.exit(0) 467 | if len(response) == 1: 468 | # single station found 469 | log.debug("Exactly one result found") 470 | 471 | try: 472 | user_input = input("Want to play this station? Y/N: ") 473 | except EOFError: 474 | print() 475 | sys.exit(0) 476 | 477 | if user_input in ["y", "Y"]: 478 | log.debug("Playing UUID from single response") 479 | global_current_station_info = response[0] 480 | 481 | return handle_station_uuid_play(handler, response[0]["stationuuid"]) 482 | else: 483 | log.debug("Quitting") 484 | sys.exit(0) 485 | else: 486 | # multiple station 487 | log.debug("Asking for user input") 488 | 489 | try: 490 | log.info("Type 'r' to play a random station") 491 | user_input = input("Type the result ID to play: ") 492 | except EOFError: 493 | print() 494 | log.info("Exiting") 495 | log.debug("EOF reached, quitting") 496 | sys.exit(0) 497 | 498 | try: 499 | if user_input in ["r", "R", "random"]: 500 | # pick a random integer withing range 501 | user_input = randint(1, len(response) - 1) 502 | log.debug(f"Radom station id: {user_input}") 503 | # elif user_input in ["f", "F", "fuzzy"]: 504 | # fuzzy find all the stations, and return the selected station id 505 | # user_input = fuzzy_find(response) 506 | 507 | user_input = int(user_input) - 1 # because ID starts from 1 508 | if user_input in range(0, len(response)): 509 | target_response = response[user_input] 510 | log.debug("Selected: {}".format(target_response)) 511 | # log.info("UUID: {}".format(target_response["stationuuid"])) 512 | 513 | # saving global info 514 | global_current_station_info = target_response 515 | 516 | return handle_station_uuid_play(handler, target_response["stationuuid"]) 517 | else: 518 | log.error("Please enter an ID within the range") 519 | sys.exit(1) 520 | except: 521 | log.err("Please enter an valid ID number") 522 | sys.exit(1) 523 | 524 | 525 | def handle_direct_play(alias, station_name_or_url=""): 526 | """Play a station directly with UUID or direct stream URL""" 527 | if "://" in station_name_or_url.strip(): 528 | log.debug("Direct play: URL provided") 529 | # stream URL 530 | # call using URL with no station name N/A 531 | # let's attempt to get station name from url headers 532 | # station_name = handle_station_name_from_headers(station_name_or_url) 533 | station_name = handle_get_station_name_from_metadata(station_name_or_url) 534 | return station_name, station_name_or_url 535 | else: 536 | log.debug("Direct play: station name provided") 537 | # station name from fav list 538 | # search for the station in fav list and return name and url 539 | 540 | response = alias.search(station_name_or_url) 541 | if not response: 542 | log.error("No station found on your favorite list with the name") 543 | sys.exit(1) 544 | else: 545 | log.debug("Direct play: {}".format(response)) 546 | return response["name"], response["uuid_or_url"] 547 | 548 | 549 | def handle_play_last_station(last_station): 550 | station_obj = last_station.get_info() 551 | return station_obj["name"], station_obj["uuid_or_url"] 552 | 553 | 554 | # uses ffprobe to fetch station name 555 | def handle_get_station_name_from_metadata(url): 556 | """Get ICY metadata from ffprobe""" 557 | log.info("Fetching the station name") 558 | log.debug("Attempting to retrieve station name from: {}".format(url)) 559 | # Run ffprobe command and capture the metadata 560 | cmd = [ 561 | "ffprobe", 562 | "-v", 563 | "quiet", 564 | "-print_format", 565 | "json", 566 | "-show_format", 567 | "-show_entries", 568 | "format=icy", 569 | url, 570 | ] 571 | station_name = "Unknown Station" 572 | 573 | try: 574 | output = subprocess.check_output(cmd).decode("utf-8") 575 | data = json.loads(output) 576 | log.debug(f"station info: {data}") 577 | 578 | # Extract the station name (icy-name) if available 579 | station_name = ( 580 | data.get("format", {}).get("tags", {}).get("icy-name", "Unknown Station") 581 | ) 582 | except: 583 | log.error("Could not fetch the station name") 584 | 585 | return station_name 586 | 587 | 588 | # uses requests module to fetch station name [deprecated] 589 | def handle_station_name_from_headers(url): 590 | # Get headers from URL so that we can get radio station 591 | log.info("Fetching the station name") 592 | log.debug("Attempting to retrieve station name from: {}".format(url)) 593 | station_name = "Unknown Station" 594 | try: 595 | # sync call, with timeout 596 | response = requests.get(url, timeout=5) 597 | if response.status_code == requests.codes.ok: 598 | if response.headers.get("Icy-Name"): 599 | station_name = response.headers.get("Icy-Name") 600 | else: 601 | log.error("Station name not found") 602 | else: 603 | log.debug("Response code received is: {}".format(response.status_code())) 604 | except Exception as e: 605 | # except requests.HTTPError and requests.exceptions.ReadTimeout as e: 606 | log.error("Could not fetch the station name") 607 | log.debug( 608 | """An error occurred: {} 609 | The response code was {}""".format( 610 | e, e.errno 611 | ) 612 | ) 613 | return station_name 614 | 615 | 616 | def handle_play_random_station(alias): 617 | """Select a random station from favorite menu""" 618 | log.debug("playing a random station") 619 | alias_map = alias.alias_map 620 | index = randint(0, len(alias_map) - 1) 621 | station = alias_map[index] 622 | return station["name"], station["uuid_or_url"] 623 | -------------------------------------------------------------------------------- /radioactive/vlc.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | from shutil import which 4 | 5 | from zenlog import log 6 | 7 | 8 | class VLC: 9 | def __init__(self): 10 | self.program_name = "vlc" 11 | self.exe_path = which(self.program_name) 12 | log.debug(f"{self.program_name}: {self.exe_path}") 13 | 14 | if self.exe_path is None: 15 | log.critical(f"{self.program_name} not found, install it first please") 16 | sys.exit(1) 17 | 18 | self.is_running = False 19 | self.process = None 20 | self.url = None 21 | 22 | def _construct_vlc_commands(self, url): 23 | return [self.exe_path, url] 24 | 25 | def start(self, url): 26 | self.url = url 27 | vlc_commands = self._construct_vlc_commands(url) 28 | 29 | try: 30 | self.process = subprocess.Popen( 31 | vlc_commands, 32 | shell=False, 33 | stdout=subprocess.PIPE, 34 | stderr=subprocess.PIPE, 35 | text=True, 36 | ) 37 | self.is_running = True 38 | log.debug( 39 | f"player: {self.program_name} => PID {self.process.pid} initiated" 40 | ) 41 | 42 | except Exception as e: 43 | log.error(f"Error while starting player: {e}") 44 | 45 | def stop(self): 46 | if self.is_running: 47 | self.process.kill() 48 | self.is_running = False 49 | 50 | def toggle(self): 51 | if self.is_running: 52 | self.stop() 53 | else: 54 | self.start(self.url) 55 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | flake8>=4.0.1 2 | twine 3 | black 4 | 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | setuptools 2 | requests 3 | urllib3 4 | psutil 5 | pyradios==1.0.2 6 | requests_cache 7 | rich 8 | pick 9 | zenlog 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description_file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages 2 | from setuptools import setup 3 | 4 | from radioactive.app import App 5 | import io 6 | 7 | app = App() 8 | 9 | DESCRIPTION = ( 10 | "Play and record any radio stations around the globe right from the terminal" 11 | ) 12 | VERSION = app.get_version() 13 | 14 | 15 | def readme(): 16 | with io.open("README.md", "r", encoding="utf-8") as f: 17 | return f.read() 18 | 19 | 20 | def required(sfx=""): 21 | with io.open(f"requirements{sfx}.txt", encoding="utf-8") as f: 22 | return f.read().splitlines() 23 | 24 | 25 | setup( 26 | name="radio-active", 27 | version=VERSION, 28 | description=DESCRIPTION, 29 | long_description=readme(), 30 | long_description_content_type="text/markdown", 31 | keywords="pyradios wrapper radios api shortwave internet-radio cli app", 32 | author="Dipankar Pal", 33 | author_email="dipankarpal5050@gmail.com", 34 | url="https://github.com/deep5050/radio-active", 35 | license="MIT", 36 | entry_points={ 37 | "console_scripts": [ 38 | "radioactive = radioactive.__main__:main", 39 | "radio = radioactive.__main__:main", 40 | ] 41 | }, 42 | packages=find_packages(exclude=["test*"]), 43 | install_requires=required(), 44 | extras_require={"dev": required("-dev")}, 45 | classifiers=[ 46 | "License :: OSI Approved :: MIT License", 47 | "Development Status :: 5 - Production/Stable", 48 | "Environment :: Console", 49 | "Programming Language :: Python :: 3", 50 | "Operating System :: OS Independent", 51 | "Intended Audience :: End Users/Desktop", 52 | "Topic :: Multimedia :: Sound/Audio", 53 | ], 54 | python_requires=">=3.6", 55 | project_urls={ 56 | "Source": "https://github.com/deep5050/radio-active/", 57 | "Upstream": "https://api.radio-browser.info/", 58 | }, 59 | ) 60 | --------------------------------------------------------------------------------