├── .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 |

13 |
14 |

15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |

23 |

24 |

25 |

26 |

27 |
28 |
29 |

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 | 
290 |
291 | ### Acknowledgements
292 |
293 |
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 |
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 | 
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 |
--------------------------------------------------------------------------------