├── .devcontainer └── devcontainer.json ├── .editorconfig ├── .gitattributes ├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── labels.yml ├── release-drafter.yml ├── renovate.json └── workflows │ ├── codeql.yaml │ ├── labels.yaml │ ├── linting.yaml │ ├── lock.yaml │ ├── pr-labels.yaml │ ├── release-drafter.yaml │ ├── release.yaml │ ├── requirements.txt │ ├── stale.yaml │ ├── tests.yaml │ └── typing.yaml ├── .gitignore ├── .nvmrc ├── .pre-commit-config.yaml ├── .prettierignore ├── .yamllint ├── LICENSE.md ├── README.md ├── examples ├── example.py └── ruff.toml ├── package-lock.json ├── package.json ├── poetry.lock ├── pyproject.toml ├── sonar-project.properties ├── src └── radios │ ├── __init__.py │ ├── const.py │ ├── exceptions.py │ ├── models.py │ ├── py.typed │ └── radio_browser.py └── tests ├── __init__.py ├── ruff.toml └── test_radios.py /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "containerEnv": { 3 | "POETRY_VIRTUALENVS_IN_PROJECT": "true" 4 | }, 5 | "customizations": { 6 | "codespaces": { 7 | "openFiles": ["README.md", "src/radios/radio_browser.py", "src/radios/models.py"] 8 | }, 9 | "vscode": { 10 | "extensions": [ 11 | "ms-python.python", 12 | "redhat.vscode-yaml", 13 | "esbenp.prettier-vscode", 14 | "GitHub.vscode-pull-request-github", 15 | "charliermarsh.ruff", 16 | "GitHub.vscode-github-actions", 17 | "ryanluker.vscode-coverage-gutters" 18 | ], 19 | "settings": { 20 | "[python]": { 21 | "editor.codeActionsOnSave": { 22 | "source.fixAll": true, 23 | "source.organizeImports": true 24 | } 25 | }, 26 | "coverage-gutters.customizable.context-menu": true, 27 | "coverage-gutters.customizable.status-bar-toggler-watchCoverageAndVisibleEditors-enabled": true, 28 | "coverage-gutters.showGutterCoverage": false, 29 | "coverage-gutters.showLineCoverage": true, 30 | "coverage-gutters.xmlname": "coverage.xml", 31 | "python.analysis.extraPaths": ["${workspaceFolder}/src"], 32 | "python.defaultInterpreterPath": ".venv/bin/python", 33 | "python.formatting.provider": "ruff", 34 | "python.linting.enabled": true, 35 | "python.linting.mypyEnabled": true, 36 | "python.linting.pylintEnabled": true, 37 | "python.testing.cwd": "${workspaceFolder}", 38 | "python.testing.pytestArgs": ["--cov-report=xml"], 39 | "python.testing.pytestEnabled": true, 40 | "ruff.importStrategy": "fromEnvironment", 41 | "ruff.interpreter": [".venv/bin/python"], 42 | "terminal.integrated.defaultProfile.linux": "zsh" 43 | } 44 | } 45 | }, 46 | "features": { 47 | "ghcr.io/devcontainers-contrib/features/poetry:2": {}, 48 | "ghcr.io/devcontainers/features/github-cli:1": {}, 49 | "ghcr.io/devcontainers/features/node:1": {}, 50 | "ghcr.io/devcontainers/features/python:1": { 51 | "installTools": false 52 | } 53 | }, 54 | "image": "mcr.microsoft.com/devcontainers/base:ubuntu", 55 | "name": "Asynchronous Python client for the Radio Browser API", 56 | "updateContentCommand": ". ${NVM_DIR}/nvm.sh && nvm install && nvm use && npm install && poetry install && poetry run pre-commit install" 57 | } 58 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | ident_size = 4 10 | 11 | [*.md] 12 | ident_size = 2 13 | trim_trailing_whitespace = false 14 | 15 | [*.json] 16 | ident_size = 2 17 | 18 | [{.gitignore,.gitkeep,.editorconfig}] 19 | ident_size = 2 20 | 21 | [Makefile] 22 | ident_style = tab 23 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.py whitespace=error 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | .github/* @frenck 2 | -------------------------------------------------------------------------------- /.github/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, socioeconomic 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 | frenck@frenck.dev. 64 | 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.0, available at 120 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][mozilla coc]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][faq]. Translations are available 127 | at [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 131 | [mozilla coc]: https://github.com/mozilla/diversity 132 | [faq]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish 4 | to make via issue, email, or any other method with the owners of this repository 5 | before making a change. 6 | 7 | Please note we have a code of conduct, please follow it in all your interactions 8 | with the project. 9 | 10 | ## Issues and feature requests 11 | 12 | You've found a bug in the source code, a mistake in the documentation or maybe 13 | you'd like a new feature? You can help us by submitting an issue to our 14 | [GitHub Repository][github]. Before you create an issue, make sure you search 15 | the archive, maybe your question was already answered. 16 | 17 | Even better: You could submit a pull request with a fix / new feature! 18 | 19 | ## Pull request process 20 | 21 | 1. Search our repository for open or closed [pull requests][prs] that relates 22 | to your submission. You don't want to duplicate effort. 23 | 24 | 1. You may merge the pull request in once you have the sign-off of two other 25 | developers, or if you do not have permission to do that, you may request 26 | the second reviewer to merge it for you. 27 | 28 | [github]: https://github.com/frenck/python-radios/issues 29 | [prs]: https://github.com/frenck/python-radios/pulls 30 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: frenck 3 | patreon: frenck 4 | custom: https://frenck.dev/donate/ 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Problem/Motivation 2 | 3 | > (Why the issue was filed) 4 | 5 | ## Expected behavior 6 | 7 | > (What you expected to happen) 8 | 9 | ## Actual behavior 10 | 11 | > (What actually happened) 12 | 13 | ## Steps to reproduce 14 | 15 | > (How can someone else make/see it happen) 16 | 17 | ## Proposed changes 18 | 19 | > (If you have a proposed change, workaround or fix, 20 | > describe the rationale behind it) 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Proposed Changes 2 | 3 | > (Describe the changes and rationale behind them) 4 | 5 | ## Related Issues 6 | 7 | > ([Github link][autolink-references] to related issues or pull requests) 8 | 9 | [autolink-references]: https://help.github.com/articles/autolinked-references-and-urls/ 10 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "breaking-change" 3 | color: ee0701 4 | description: "A breaking change for existing users." 5 | - name: "bugfix" 6 | color: ee0701 7 | description: "Inconsistencies or issues which will cause a problem for users or implementers." 8 | - name: "documentation" 9 | color: 0052cc 10 | description: "Solely about the documentation of the project." 11 | - name: "enhancement" 12 | color: 1d76db 13 | description: "Enhancement of the code, not introducing new features." 14 | - name: "refactor" 15 | color: 1d76db 16 | description: "Improvement of existing code, not introducing new features." 17 | - name: "performance" 18 | color: 1d76db 19 | description: "Improving performance, not introducing new features." 20 | - name: "new-feature" 21 | color: 0e8a16 22 | description: "New features or options." 23 | - name: "maintenance" 24 | color: 2af79e 25 | description: "Generic maintenance tasks." 26 | - name: "ci" 27 | color: 1d76db 28 | description: "Work that improves the continue integration." 29 | - name: "dependencies" 30 | color: 1d76db 31 | description: "Upgrade or downgrade of project dependencies." 32 | 33 | - name: "in-progress" 34 | color: fbca04 35 | description: "Issue is currently being resolved by a developer." 36 | - name: "stale" 37 | color: fef2c0 38 | description: "There has not been activity on this issue or PR for quite some time." 39 | - name: "no-stale" 40 | color: fef2c0 41 | description: "This issue or PR is exempted from the stable bot." 42 | 43 | - name: "security" 44 | color: ee0701 45 | description: "Marks a security issue that needs to be resolved asap." 46 | - name: "incomplete" 47 | color: fef2c0 48 | description: "Marks a PR or issue that is missing information." 49 | - name: "invalid" 50 | color: fef2c0 51 | description: "Marks a PR or issue that is missing information." 52 | 53 | - name: "beginner-friendly" 54 | color: 0e8a16 55 | description: "Good first issue for people wanting to contribute to the project." 56 | - name: "help-wanted" 57 | color: 0e8a16 58 | description: "We need some extra helping hands or expertise in order to resolve this." 59 | 60 | - name: "hacktoberfest" 61 | description: "Issues/PRs are participating in the Hacktoberfest." 62 | color: fbca04 63 | - name: "hacktoberfest-accepted" 64 | description: "Issues/PRs are participating in the Hacktoberfest." 65 | color: fbca04 66 | 67 | - name: "priority-critical" 68 | color: ee0701 69 | description: "This should be dealt with ASAP. Not fixing this issue would be a serious error." 70 | - name: "priority-high" 71 | color: b60205 72 | description: "After critical issues are fixed, these should be dealt with before any further issues." 73 | - name: "priority-medium" 74 | color: 0e8a16 75 | description: "This issue may be useful, and needs some attention." 76 | - name: "priority-low" 77 | color: e4ea8a 78 | description: "Nice addition, maybe... someday..." 79 | 80 | - name: "major" 81 | color: b60205 82 | description: "This PR causes a major version bump in the version number." 83 | - name: "minor" 84 | color: 0e8a16 85 | description: "This PR causes a minor version bump in the version number." 86 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name-template: "v$RESOLVED_VERSION" 3 | tag-template: "v$RESOLVED_VERSION" 4 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 5 | sort-direction: ascending 6 | 7 | categories: 8 | - title: "🚨 Breaking changes" 9 | labels: 10 | - "breaking-change" 11 | - title: "✨ New features" 12 | labels: 13 | - "new-feature" 14 | - title: "🐛 Bug fixes" 15 | labels: 16 | - "bugfix" 17 | - title: "🚀 Enhancements" 18 | labels: 19 | - "enhancement" 20 | - "refactor" 21 | - "performance" 22 | - title: "🧰 Maintenance" 23 | labels: 24 | - "maintenance" 25 | - "ci" 26 | - title: "📚 Documentation" 27 | labels: 28 | - "documentation" 29 | - title: "⬆️ Dependency updates" 30 | labels: 31 | - "dependencies" 32 | 33 | version-resolver: 34 | major: 35 | labels: 36 | - "major" 37 | - "breaking-change" 38 | minor: 39 | labels: 40 | - "minor" 41 | - "new-feature" 42 | patch: 43 | labels: 44 | - "bugfix" 45 | - "chore" 46 | - "ci" 47 | - "dependencies" 48 | - "documentation" 49 | - "enhancement" 50 | - "performance" 51 | - "refactor" 52 | default: patch 53 | 54 | template: | 55 | ## What’s changed 56 | 57 | $CHANGES 58 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "rebaseWhen": "behind-base-branch", 4 | "dependencyDashboard": true, 5 | "labels": ["dependencies", "no-stale"], 6 | "lockFileMaintenance": { 7 | "enabled": true, 8 | "automerge": true 9 | }, 10 | "commitMessagePrefix": "⬆️", 11 | "packageRules": [ 12 | { 13 | "matchManagers": ["poetry"], 14 | "addLabels": ["python"] 15 | }, 16 | { 17 | "matchManagers": ["poetry"], 18 | "matchDepTypes": ["dev"], 19 | "rangeStrategy": "pin" 20 | }, 21 | { 22 | "matchManagers": ["poetry"], 23 | "matchUpdateTypes": ["minor", "patch"], 24 | "automerge": true 25 | }, 26 | { 27 | "matchManagers": ["npm", "nvm"], 28 | "addLabels": ["javascript"], 29 | "rangeStrategy": "pin" 30 | }, 31 | { 32 | "matchManagers": ["npm", "nvm"], 33 | "matchUpdateTypes": ["minor", "patch"], 34 | "automerge": true 35 | }, 36 | { 37 | "matchManagers": ["github-actions"], 38 | "addLabels": ["github_actions"], 39 | "rangeStrategy": "pin" 40 | }, 41 | { 42 | "matchManagers": ["github-actions"], 43 | "matchUpdateTypes": ["minor", "patch"], 44 | "automerge": true 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "CodeQL" 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: [main] 10 | workflow_dispatch: 11 | schedule: 12 | - cron: "30 1 * * 0" 13 | 14 | jobs: 15 | codeql: 16 | name: Scanning 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: ⤵️ Check out code from GitHub 20 | uses: actions/checkout@v4.2.2 21 | - name: 🏗 Initialize CodeQL 22 | uses: github/codeql-action/init@v3.28.19 23 | - name: 🚀 Perform CodeQL Analysis 24 | uses: github/codeql-action/analyze@v3.28.19 25 | -------------------------------------------------------------------------------- /.github/workflows/labels.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Sync labels 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - .github/labels.yml 11 | workflow_dispatch: 12 | 13 | jobs: 14 | labels: 15 | name: ♻️ Sync labels 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: ⤵️ Check out code from GitHub 19 | uses: actions/checkout@v4.2.2 20 | - name: 🚀 Run Label Syncer 21 | uses: micnncim/action-label-syncer@v1.3.0 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/linting.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Linting 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | env: 11 | DEFAULT_PYTHON: "3.11" 12 | 13 | jobs: 14 | codespell: 15 | name: codespell 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: ⤵️ Check out code from GitHub 19 | uses: actions/checkout@v4.2.2 20 | - name: 🏗 Set up Poetry 21 | run: pipx install poetry 22 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 23 | id: python 24 | uses: actions/setup-python@v5.6.0 25 | with: 26 | python-version: ${{ env.DEFAULT_PYTHON }} 27 | cache: "poetry" 28 | - name: 🏗 Install workflow dependencies 29 | run: | 30 | poetry config virtualenvs.create true 31 | poetry config virtualenvs.in-project true 32 | - name: 🏗 Install Python dependencies 33 | run: poetry install --no-interaction 34 | - name: 🚀 Check code for common misspellings 35 | run: poetry run pre-commit run codespell --all-files 36 | 37 | ruff: 38 | name: Ruff 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: ⤵️ Check out code from GitHub 42 | uses: actions/checkout@v4.2.2 43 | - name: 🏗 Set up Poetry 44 | run: pipx install poetry 45 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 46 | id: python 47 | uses: actions/setup-python@v5.6.0 48 | with: 49 | python-version: ${{ env.DEFAULT_PYTHON }} 50 | cache: "poetry" 51 | - name: 🏗 Install workflow dependencies 52 | run: | 53 | poetry config virtualenvs.create true 54 | poetry config virtualenvs.in-project true 55 | - name: 🏗 Install Python dependencies 56 | run: poetry install --no-interaction 57 | - name: 🚀 Run ruff linter 58 | run: poetry run ruff check --output-format=github . 59 | - name: 🚀 Run ruff formatter 60 | run: poetry run ruff format --check . 61 | 62 | pre-commit-hooks: 63 | name: pre-commit-hooks 64 | runs-on: ubuntu-latest 65 | steps: 66 | - name: ⤵️ Check out code from GitHub 67 | uses: actions/checkout@v4.2.2 68 | - name: 🏗 Set up Poetry 69 | run: pipx install poetry 70 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 71 | id: python 72 | uses: actions/setup-python@v5.6.0 73 | with: 74 | python-version: ${{ env.DEFAULT_PYTHON }} 75 | cache: "poetry" 76 | - name: 🏗 Install workflow dependencies 77 | run: | 78 | poetry config virtualenvs.create true 79 | poetry config virtualenvs.in-project true 80 | - name: 🏗 Install Python dependencies 81 | run: poetry install --no-interaction 82 | - name: 🚀 Check Python AST 83 | run: poetry run pre-commit run check-ast --all-files 84 | - name: 🚀 Check for case conflicts 85 | run: poetry run pre-commit run check-case-conflict --all-files 86 | - name: 🚀 Check docstring is first 87 | run: poetry run pre-commit run check-docstring-first --all-files 88 | - name: 🚀 Check that executables have shebangs 89 | run: poetry run pre-commit run check-executables-have-shebangs --all-files 90 | - name: 🚀 Check JSON files 91 | run: poetry run pre-commit run check-json --all-files 92 | - name: 🚀 Check for merge conflicts 93 | run: poetry run pre-commit run check-merge-conflict --all-files 94 | - name: 🚀 Check for broken symlinks 95 | run: poetry run pre-commit run check-symlinks --all-files 96 | - name: 🚀 Check TOML files 97 | run: poetry run pre-commit run check-toml --all-files 98 | - name: 🚀 Check YAML files 99 | run: poetry run pre-commit run check-yaml --all-files 100 | - name: 🚀 Detect Private Keys 101 | run: poetry run pre-commit run detect-private-key --all-files 102 | - name: 🚀 Check End of Files 103 | run: poetry run pre-commit run end-of-file-fixer --all-files 104 | - name: 🚀 Trim Trailing Whitespace 105 | run: poetry run pre-commit run trailing-whitespace --all-files 106 | 107 | pylint: 108 | name: pylint 109 | runs-on: ubuntu-latest 110 | steps: 111 | - name: ⤵️ Check out code from GitHub 112 | uses: actions/checkout@v4.2.2 113 | - name: 🏗 Set up Poetry 114 | run: pipx install poetry 115 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 116 | id: python 117 | uses: actions/setup-python@v5.6.0 118 | with: 119 | python-version: ${{ env.DEFAULT_PYTHON }} 120 | cache: "poetry" 121 | - name: 🏗 Install workflow dependencies 122 | run: | 123 | poetry config virtualenvs.create true 124 | poetry config virtualenvs.in-project true 125 | - name: 🏗 Install Python dependencies 126 | run: poetry install --no-interaction 127 | - name: 🚀 Run pylint 128 | run: poetry run pre-commit run pylint --all-files 129 | 130 | yamllint: 131 | name: yamllint 132 | runs-on: ubuntu-latest 133 | steps: 134 | - name: ⤵️ Check out code from GitHub 135 | uses: actions/checkout@v4.2.2 136 | - name: 🏗 Set up Poetry 137 | run: pipx install poetry 138 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 139 | id: python 140 | uses: actions/setup-python@v5.6.0 141 | with: 142 | python-version: ${{ env.DEFAULT_PYTHON }} 143 | cache: "poetry" 144 | - name: 🏗 Install workflow dependencies 145 | run: | 146 | poetry config virtualenvs.create true 147 | poetry config virtualenvs.in-project true 148 | - name: 🏗 Install Python dependencies 149 | run: poetry install --no-interaction 150 | - name: 🚀 Run yamllint 151 | run: poetry run yamllint . 152 | 153 | prettier: 154 | name: Prettier 155 | runs-on: ubuntu-latest 156 | steps: 157 | - name: ⤵️ Check out code from GitHub 158 | uses: actions/checkout@v4.2.2 159 | - name: 🏗 Set up Poetry 160 | run: pipx install poetry 161 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 162 | id: python 163 | uses: actions/setup-python@v5.6.0 164 | with: 165 | python-version: ${{ env.DEFAULT_PYTHON }} 166 | cache: "poetry" 167 | - name: 🏗 Install workflow dependencies 168 | run: | 169 | poetry config virtualenvs.create true 170 | poetry config virtualenvs.in-project true 171 | - name: 🏗 Install Python dependencies 172 | run: poetry install --no-interaction 173 | - name: 🏗 Set up Node.js 174 | uses: actions/setup-node@v4.4.0 175 | with: 176 | node-version-file: ".nvmrc" 177 | cache: "npm" 178 | - name: 🏗 Install NPM dependencies 179 | run: npm install 180 | - name: 🚀 Run prettier 181 | run: poetry run pre-commit run prettier --all-files 182 | -------------------------------------------------------------------------------- /.github/workflows/lock.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lock 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | schedule: 7 | - cron: "0 9 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | lock: 12 | name: 🔒 Lock closed issues and PRs 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: dessant/lock-threads@v5.0.1 16 | with: 17 | github-token: ${{ github.token }} 18 | issue-inactive-days: "30" 19 | issue-lock-reason: "" 20 | pr-inactive-days: "1" 21 | pr-lock-reason: "" 22 | -------------------------------------------------------------------------------- /.github/workflows/pr-labels.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: PR Labels 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | pull_request_target: 7 | types: 8 | - opened 9 | - labeled 10 | - unlabeled 11 | - synchronize 12 | workflow_call: 13 | 14 | jobs: 15 | pr_labels: 16 | name: Verify 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: 🏷 Verify PR has a valid label 20 | uses: jesusvasquez333/verify-pr-label-action@v1.4.0 21 | with: 22 | pull-request-number: "${{ github.event.pull_request.number }}" 23 | github-token: "${{ secrets.GITHUB_TOKEN }}" 24 | valid-labels: >- 25 | breaking-change, bugfix, documentation, enhancement, 26 | refactor, performance, new-feature, maintenance, ci, dependencies 27 | disable-reviews: true 28 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release Drafter 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | 11 | jobs: 12 | update_release_draft: 13 | name: ✏️ Draft release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: 🚀 Run Release Drafter 17 | uses: release-drafter/release-drafter@v6.1.0 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | release: 7 | types: 8 | - published 9 | 10 | env: 11 | DEFAULT_PYTHON: "3.11" 12 | 13 | jobs: 14 | release: 15 | name: Releasing to PyPi 16 | runs-on: ubuntu-latest 17 | environment: 18 | name: release 19 | url: https://pypi.org/p/radios 20 | permissions: 21 | contents: write 22 | id-token: write 23 | steps: 24 | - name: ⤵️ Check out code from GitHub 25 | uses: actions/checkout@v4.2.2 26 | - name: 🏗 Set up Poetry 27 | run: pipx install poetry 28 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 29 | id: python 30 | uses: actions/setup-python@v5.6.0 31 | with: 32 | python-version: ${{ env.DEFAULT_PYTHON }} 33 | cache: "poetry" 34 | - name: 🏗 Install workflow dependencies 35 | run: | 36 | poetry config virtualenvs.create true 37 | poetry config virtualenvs.in-project true 38 | - name: 🏗 Install dependencies 39 | run: poetry install --no-interaction 40 | - name: 🏗 Set package version 41 | run: | 42 | version="${{ github.event.release.tag_name }}" 43 | version="${version,,}" 44 | version="${version#v}" 45 | poetry version --no-interaction "${version}" 46 | - name: 🏗 Build package 47 | run: poetry build --no-interaction 48 | - name: 🚀 Publish to PyPi 49 | uses: pypa/gh-action-pypi-publish@v1.12.4 50 | with: 51 | verbose: true 52 | print-hash: true 53 | - name: ✍️ Sign published artifacts 54 | uses: sigstore/gh-action-sigstore-python@v3.0.0 55 | with: 56 | inputs: ./dist/*.tar.gz ./dist/*.whl 57 | release-signing-artifacts: true 58 | -------------------------------------------------------------------------------- /.github/workflows/requirements.txt: -------------------------------------------------------------------------------- 1 | pip==25.1.1 2 | poetry==2.1.3 3 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Stale 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | schedule: 7 | - cron: "0 8 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | stale: 12 | name: 🧹 Clean up stale issues and PRs 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: 🚀 Run stale 16 | uses: actions/stale@v9.1.0 17 | with: 18 | repo-token: ${{ secrets.GITHUB_TOKEN }} 19 | days-before-stale: 30 20 | days-before-close: 7 21 | remove-stale-when-updated: true 22 | stale-issue-label: "stale" 23 | exempt-issue-labels: "no-stale,help-wanted" 24 | stale-issue-message: > 25 | There hasn't been any activity on this issue recently, so we 26 | clean up some of the older and inactive issues. 27 | 28 | Please make sure to update to the latest version and 29 | check if that solves the issue. Let us know if that works for you 30 | by leaving a comment 👍 31 | 32 | This issue has now been marked as stale and will be closed if no 33 | further activity occurs. Thanks! 34 | stale-pr-label: "stale" 35 | exempt-pr-labels: "no-stale" 36 | stale-pr-message: > 37 | There hasn't been any activity on this pull request recently. This 38 | pull request has been automatically marked as stale because of that 39 | and will be closed if no further activity occurs within 7 days. 40 | Thank you for your contributions. 41 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Testing 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | env: 11 | DEFAULT_PYTHON: "3.11" 12 | 13 | jobs: 14 | pytest: 15 | name: Python ${{ matrix.python }} 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | python: ["3.11"] 20 | steps: 21 | - name: ⤵️ Check out code from GitHub 22 | uses: actions/checkout@v4.2.2 23 | - name: 🏗 Set up Poetry 24 | run: pipx install poetry 25 | - name: 🏗 Set up Python ${{ matrix.python }} 26 | id: python 27 | uses: actions/setup-python@v5.6.0 28 | with: 29 | python-version: ${{ matrix.python }} 30 | cache: "poetry" 31 | - name: 🏗 Install workflow dependencies 32 | run: | 33 | poetry config virtualenvs.create true 34 | poetry config virtualenvs.in-project true 35 | - name: 🏗 Install dependencies 36 | run: poetry install --no-interaction 37 | - name: 🚀 Run pytest 38 | run: poetry run pytest --cov radios tests 39 | - name: ⬆️ Upload coverage artifact 40 | uses: actions/upload-artifact@v4.6.2 41 | with: 42 | name: coverage-${{ matrix.python }} 43 | include-hidden-files: true 44 | path: .coverage 45 | 46 | coverage: 47 | runs-on: ubuntu-latest 48 | needs: pytest 49 | steps: 50 | - name: ⤵️ Check out code from GitHub 51 | uses: actions/checkout@v4.2.2 52 | with: 53 | fetch-depth: 0 54 | - name: ⬇️ Download coverage data 55 | uses: actions/download-artifact@v4.3.0 56 | - name: 🏗 Set up Poetry 57 | run: pipx install poetry 58 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 59 | id: python 60 | uses: actions/setup-python@v5.6.0 61 | with: 62 | python-version: ${{ env.DEFAULT_PYTHON }} 63 | cache: "poetry" 64 | - name: 🏗 Install workflow dependencies 65 | run: | 66 | poetry config virtualenvs.create true 67 | poetry config virtualenvs.in-project true 68 | - name: 🏗 Install dependencies 69 | run: poetry install --no-interaction 70 | - name: 🚀 Process coverage results 71 | run: | 72 | poetry run coverage combine coverage*/.coverage* 73 | poetry run coverage xml -i 74 | - name: 🚀 Upload coverage report 75 | uses: codecov/codecov-action@v5.4.3 76 | - name: SonarCloud Scan 77 | if: github.event.pull_request.head.repo.fork == false 78 | uses: SonarSource/sonarqube-scan-action@v5.2.0 79 | env: 80 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 81 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 82 | -------------------------------------------------------------------------------- /.github/workflows/typing.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Typing 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | env: 11 | DEFAULT_PYTHON: "3.11" 12 | 13 | jobs: 14 | mypy: 15 | name: mypy 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: ⤵️ Check out code from GitHub 19 | uses: actions/checkout@v4.2.2 20 | - name: 🏗 Set up Poetry 21 | run: pipx install poetry 22 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 23 | id: python 24 | uses: actions/setup-python@v5.6.0 25 | with: 26 | python-version: ${{ env.DEFAULT_PYTHON }} 27 | cache: "poetry" 28 | - name: 🏗 Install workflow dependencies 29 | run: | 30 | poetry config virtualenvs.create true 31 | poetry config virtualenvs.in-project true 32 | - name: 🏗 Install dependencies 33 | run: poetry install --no-interaction 34 | - name: 🚀 Run mypy 35 | run: poetry run mypy examples src tests 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # OSX useful to ignore 7 | *.DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | 30 | # C extensions 31 | *.so 32 | 33 | # Distribution / packaging 34 | .Python 35 | env/ 36 | build/ 37 | develop-eggs/ 38 | dist/ 39 | downloads/ 40 | eggs/ 41 | .eggs/ 42 | lib/ 43 | lib64/ 44 | parts/ 45 | sdist/ 46 | var/ 47 | *.egg-info/ 48 | .installed.cfg 49 | *.egg 50 | 51 | # PyInstaller 52 | # Usually these files are written by a python script from a template 53 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 54 | *.manifest 55 | *.spec 56 | 57 | # Installer logs 58 | pip-log.txt 59 | pip-delete-this-directory.txt 60 | 61 | # Unit test / coverage reports 62 | htmlcov/ 63 | .tox/ 64 | .coverage 65 | .coverage.* 66 | .cache 67 | nosetests.xml 68 | coverage.xml 69 | *,cover 70 | .hypothesis/ 71 | .pytest_cache/ 72 | 73 | # Translations 74 | *.mo 75 | *.pot 76 | 77 | # Django stuff: 78 | *.log 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # mypy 92 | .mypy_cache/ 93 | 94 | # Visual Studio Code 95 | .vscode 96 | 97 | # IntelliJ Idea family of suites 98 | .idea 99 | *.iml 100 | 101 | ## File-based project format: 102 | *.ipr 103 | *.iws 104 | 105 | ## mpeltonen/sbt-idea plugin 106 | .idea_modules/ 107 | 108 | # PyBuilder 109 | target/ 110 | 111 | # Cookiecutter 112 | output/ 113 | python_boilerplate/ 114 | 115 | # Node 116 | node_modules/ 117 | 118 | # Deepcode AI 119 | .dccache 120 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.16.0 2 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: ruff-check 6 | name: 🐶 Ruff Linter 7 | language: system 8 | types: [python] 9 | entry: poetry run ruff check --fix 10 | require_serial: true 11 | stages: [pre-commit, pre-push, manual] 12 | - id: ruff-format 13 | name: 🐶 Ruff Formatter 14 | language: system 15 | types: [python] 16 | entry: poetry run ruff format 17 | require_serial: true 18 | stages: [pre-commit, pre-push, manual] 19 | - id: check-ast 20 | name: 🐍 Check Python AST 21 | language: system 22 | types: [python] 23 | entry: poetry run check-ast 24 | - id: check-case-conflict 25 | name: 🔠 Check for case conflicts 26 | language: system 27 | entry: poetry run check-case-conflict 28 | - id: check-docstring-first 29 | name: ℹ️ Check docstring is first 30 | language: system 31 | types: [python] 32 | entry: poetry run check-docstring-first 33 | - id: check-executables-have-shebangs 34 | name: 🧐 Check that executables have shebangs 35 | language: system 36 | types: [text, executable] 37 | entry: poetry run check-executables-have-shebangs 38 | stages: [pre-commit, pre-push, manual] 39 | - id: check-json 40 | name: { Check JSON files 41 | language: system 42 | types: [json] 43 | entry: poetry run check-json 44 | - id: check-merge-conflict 45 | name: 💥 Check for merge conflicts 46 | language: system 47 | types: [text] 48 | entry: poetry run check-merge-conflict 49 | - id: check-symlinks 50 | name: 🔗 Check for broken symlinks 51 | language: system 52 | types: [symlink] 53 | entry: poetry run check-symlinks 54 | - id: check-toml 55 | name: ✅ Check TOML files 56 | language: system 57 | types: [toml] 58 | entry: poetry run check-toml 59 | - id: check-xml 60 | name: ✅ Check XML files 61 | entry: check-xml 62 | language: system 63 | types: [xml] 64 | - id: check-yaml 65 | name: ✅ Check YAML files 66 | language: system 67 | types: [yaml] 68 | entry: poetry run check-yaml 69 | - id: codespell 70 | name: ✅ Check code for common misspellings 71 | language: system 72 | types: [text] 73 | exclude: ^poetry\.lock$ 74 | entry: poetry run codespell 75 | - id: detect-private-key 76 | name: 🕵️ Detect Private Keys 77 | language: system 78 | types: [text] 79 | entry: poetry run detect-private-key 80 | - id: end-of-file-fixer 81 | name: ⮐ Fix End of Files 82 | language: system 83 | types: [text] 84 | entry: poetry run end-of-file-fixer 85 | stages: [pre-commit, pre-push, manual] 86 | - id: mypy 87 | name: 🆎 Static type checking using mypy 88 | language: system 89 | types: [python] 90 | entry: poetry run mypy 91 | require_serial: true 92 | - id: no-commit-to-branch 93 | name: 🛑 Don't commit to main branch 94 | language: system 95 | entry: poetry run no-commit-to-branch 96 | pass_filenames: false 97 | always_run: true 98 | args: 99 | - --branch=main 100 | - id: poetry 101 | name: 📜 Check pyproject with Poetry 102 | language: system 103 | entry: poetry check 104 | pass_filenames: false 105 | always_run: true 106 | - id: prettier 107 | name: 💄 Ensuring files are prettier 108 | language: system 109 | types: [yaml, json, markdown] 110 | entry: npm run prettier 111 | pass_filenames: false 112 | - id: pylint 113 | name: 🌟 Starring code with pylint 114 | language: system 115 | types: [python] 116 | entry: poetry run pylint 117 | - id: pytest 118 | name: 🧪 Running tests and test coverage with pytest 119 | language: system 120 | types: [python] 121 | entry: poetry run pytest 122 | pass_filenames: false 123 | - id: trailing-whitespace 124 | name: ✄ Trim Trailing Whitespace 125 | language: system 126 | types: [text] 127 | entry: poetry run trailing-whitespace-fixer 128 | stages: [pre-commit, pre-push, manual] 129 | - id: yamllint 130 | name: 🎗 Check YAML files with yamllint 131 | language: system 132 | types: [yaml] 133 | entry: poetry run yamllint 134 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | rules: 3 | braces: 4 | level: error 5 | min-spaces-inside: 0 6 | max-spaces-inside: 1 7 | min-spaces-inside-empty: -1 8 | max-spaces-inside-empty: -1 9 | brackets: 10 | level: error 11 | min-spaces-inside: 0 12 | max-spaces-inside: 0 13 | min-spaces-inside-empty: -1 14 | max-spaces-inside-empty: -1 15 | colons: 16 | level: error 17 | max-spaces-before: 0 18 | max-spaces-after: 1 19 | commas: 20 | level: error 21 | max-spaces-before: 0 22 | min-spaces-after: 1 23 | max-spaces-after: 1 24 | comments: 25 | level: error 26 | require-starting-space: true 27 | min-spaces-from-content: 2 28 | comments-indentation: 29 | level: error 30 | document-end: 31 | level: error 32 | present: false 33 | document-start: 34 | level: error 35 | present: true 36 | empty-lines: 37 | level: error 38 | max: 1 39 | max-start: 0 40 | max-end: 1 41 | hyphens: 42 | level: error 43 | max-spaces-after: 1 44 | indentation: 45 | level: error 46 | spaces: 2 47 | indent-sequences: true 48 | check-multi-line-strings: false 49 | key-duplicates: 50 | level: error 51 | line-length: 52 | level: warning 53 | max: 120 54 | allow-non-breakable-words: true 55 | allow-non-breakable-inline-mappings: true 56 | new-line-at-end-of-file: 57 | level: error 58 | new-lines: 59 | level: error 60 | type: unix 61 | trailing-spaces: 62 | level: error 63 | truthy: 64 | level: error 65 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2022-2024 Franck Nijhof 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python: Radio Browser API Client 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![Python Versions][python-versions-shield]][pypi] 5 | ![Project Stage][project-stage-shield] 6 | ![Project Maintenance][maintenance-shield] 7 | [![License][license-shield]](LICENSE.md) 8 | 9 | [![Build Status][build-shield]][build] 10 | [![Code Coverage][codecov-shield]][codecov] 11 | [![Quality Gate Status][sonarcloud-shield]][sonarcloud] 12 | [![Open in Dev Containers][devcontainer-shield]][devcontainer] 13 | 14 | [![Sponsor Frenck via GitHub Sponsors][github-sponsors-shield]][github-sponsors] 15 | 16 | [![Support Frenck on Patreon][patreon-shield]][patreon] 17 | 18 | 19 | Asynchronous Python client for the Radio Browser API. 20 | 21 | ## About 22 | 23 | [Radio Browser](https://www.radio-browser.info) community driven effort 24 | (like WikiPedia) with the aim of collecting as many internet radio and 25 | TV stations as possible. 26 | 27 | This Python library is an async API client for that, originally developed 28 | for use with the [Home Assistant](https://www.home-assistant.io) project. 29 | 30 | ## Installation 31 | 32 | ```bash 33 | pip install radios 34 | ``` 35 | 36 | ## Usage 37 | 38 | ```python 39 | # pylint: disable=W0621 40 | """Asynchronous Python client for the Radio Browser API.""" 41 | 42 | import asyncio 43 | 44 | from radios import FilterBy, Order, RadioBrowser 45 | 46 | 47 | async def main() -> None: 48 | """Show example on how to query the Radio Browser API.""" 49 | async with RadioBrowser(user_agent="MyAwesomeApp/1.0.0") as radios: 50 | # Print top 10 stations 51 | stations = await radios.stations( 52 | limit=10, order=Order.CLICK_COUNT, reverse=True 53 | ) 54 | for station in stations: 55 | print(f"{station.name} ({station.click_count})") 56 | 57 | # Get a specific station 58 | print(await radios.station(uuid="9608b51d-0601-11e8-ae97-52543be04c81")) 59 | 60 | # Print top 10 stations in a country 61 | stations = await radios.stations( 62 | limit=10, 63 | order=Order.CLICK_COUNT, 64 | reverse=True, 65 | filter_by=FilterBy.COUNTRY_CODE_EXACT, 66 | filter_term="NL", 67 | ) 68 | for station in stations: 69 | print(f"{station.name} ({station.click_count})") 70 | 71 | # Register a station "click" 72 | await radios.station_click(uuid="9608b51d-0601-11e8-ae97-52543be04c81") 73 | 74 | # Tags, countries and codes. 75 | print(await radios.tags(limit=10, order=Order.STATION_COUNT, reverse=True)) 76 | print(await radios.countries(limit=10, order=Order.NAME)) 77 | print(await radios.languages(limit=10, order=Order.NAME)) 78 | 79 | 80 | if __name__ == "__main__": 81 | asyncio.run(main()) 82 | ``` 83 | 84 | ## Changelog & Releases 85 | 86 | This repository keeps a change log using [GitHub's releases][releases] 87 | functionality. 88 | 89 | Releases are based on [Semantic Versioning][semver], and use the format 90 | of `MAJOR.MINOR.PATCH`. In a nutshell, the version will be incremented 91 | based on the following: 92 | 93 | - `MAJOR`: Incompatible or major changes. 94 | - `MINOR`: Backwards-compatible new features and enhancements. 95 | - `PATCH`: Backwards-compatible bugfixes and package updates. 96 | 97 | ## Contributing 98 | 99 | This is an active open-source project. We are always open to people who want to 100 | use the code or contribute to it. 101 | 102 | We've set up a separate document for our 103 | [contribution guidelines](CONTRIBUTING.md). 104 | 105 | Thank you for being involved! :heart_eyes: 106 | 107 | ## Setting up development environment 108 | 109 | This Python project is fully managed using the [Poetry][poetry] dependency 110 | manager. But also relies on the use of NodeJS for certain checks during 111 | development. 112 | 113 | You need at least: 114 | 115 | - Python 3.9+ 116 | - [Poetry][poetry-install] 117 | - NodeJS 14+ (including NPM) 118 | 119 | To install all packages, including all development requirements: 120 | 121 | ```bash 122 | npm install 123 | poetry install 124 | ``` 125 | 126 | As this repository uses the [pre-commit][pre-commit] framework, all changes 127 | are linted and tested with each commit. You can run all checks and tests 128 | manually, using the following command: 129 | 130 | ```bash 131 | poetry run pre-commit run --all-files 132 | ``` 133 | 134 | To run just the Python tests: 135 | 136 | ```bash 137 | poetry run pytest 138 | ``` 139 | 140 | ## Authors & contributors 141 | 142 | The original setup of this repository is by [Franck Nijhof][frenck]. 143 | 144 | For a full list of all authors and contributors, 145 | check [the contributor's page][contributors]. 146 | 147 | ## License 148 | 149 | MIT License 150 | 151 | Copyright (c) 2022-2024 Franck Nijhof 152 | 153 | Permission is hereby granted, free of charge, to any person obtaining a copy 154 | of this software and associated documentation files (the "Software"), to deal 155 | in the Software without restriction, including without limitation the rights 156 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 157 | copies of the Software, and to permit persons to whom the Software is 158 | furnished to do so, subject to the following conditions: 159 | 160 | The above copyright notice and this permission notice shall be included in all 161 | copies or substantial portions of the Software. 162 | 163 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 164 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 165 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 166 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 167 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 168 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 169 | SOFTWARE. 170 | 171 | [build-shield]: https://github.com/frenck/python-radios/actions/workflows/tests.yaml/badge.svg 172 | [build]: https://github.com/frenck/python-radios/actions/workflows/tests.yaml 173 | [codecov-shield]: https://codecov.io/gh/frenck/python-radios/branch/main/graph/badge.svg 174 | [codecov]: https://codecov.io/gh/frenck/python-radios 175 | [contributors]: https://github.com/frenck/python-radios/graphs/contributors 176 | [devcontainer-shield]: https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode 177 | [devcontainer]: https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/frenck/python-radios 178 | [frenck]: https://github.com/frenck 179 | [github-sponsors-shield]: https://frenck.dev/wp-content/uploads/2019/12/github_sponsor.png 180 | [github-sponsors]: https://github.com/sponsors/frenck 181 | [keepchangelog]: http://keepachangelog.com/en/1.0.0/ 182 | [license-shield]: https://img.shields.io/github/license/frenck/python-radios.svg 183 | [maintenance-shield]: https://img.shields.io/maintenance/yes/2024.svg 184 | [patreon-shield]: https://frenck.dev/wp-content/uploads/2019/12/patreon.png 185 | [patreon]: https://www.patreon.com/frenck 186 | [poetry-install]: https://python-poetry.org/docs/#installation 187 | [poetry]: https://python-poetry.org 188 | [pre-commit]: https://pre-commit.com/ 189 | [project-stage-shield]: https://img.shields.io/badge/project%20stage-production%20ready-brightgreen.svg 190 | [pypi]: https://pypi.org/project/radios/ 191 | [python-versions-shield]: https://img.shields.io/pypi/pyversions/radios 192 | [releases-shield]: https://img.shields.io/github/release/frenck/python-radios.svg 193 | [releases]: https://github.com/frenck/python-radios/releases 194 | [semver]: http://semver.org/spec/v2.0.0.html 195 | [sonarcloud-shield]: https://sonarcloud.io/api/project_badges/measure?project=frenck_python-radios&metric=alert_status 196 | [sonarcloud]: https://sonarcloud.io/summary/new_code?id=frenck_python-radios 197 | -------------------------------------------------------------------------------- /examples/example.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0621 2 | """Asynchronous Python client for the Radio Browser API.""" 3 | 4 | import asyncio 5 | 6 | from radios import FilterBy, Order, RadioBrowser 7 | 8 | 9 | async def main() -> None: 10 | """Show example on how to query the Radio Browser API.""" 11 | async with RadioBrowser(user_agent="MyAwesomeApp/1.0.0") as radios: 12 | # Print top 10 stations 13 | stations = await radios.stations( 14 | limit=10, order=Order.CLICK_COUNT, reverse=True 15 | ) 16 | for station in stations: 17 | print(f"{station.name} ({station.click_count})") 18 | 19 | # Get a specific station 20 | print(await radios.station(uuid="9608b51d-0601-11e8-ae97-52543be04c81")) 21 | 22 | # Print top 10 stations in a country 23 | stations = await radios.stations( 24 | limit=10, 25 | order=Order.CLICK_COUNT, 26 | reverse=True, 27 | filter_by=FilterBy.COUNTRY_CODE_EXACT, 28 | filter_term="NL", 29 | ) 30 | for station in stations: 31 | print(f"{station.name} ({station.click_count})") 32 | 33 | # Register a station "click" 34 | await radios.station_click(uuid="9608b51d-0601-11e8-ae97-52543be04c81") 35 | 36 | # Tags, countries and codes. 37 | print(await radios.tags(limit=10, order=Order.STATION_COUNT, reverse=True)) 38 | print(await radios.countries(limit=10, order=Order.NAME)) 39 | print(await radios.languages(limit=10, order=Order.NAME)) 40 | print(await radios.search(name="538", limit=10, order=Order.NAME)) 41 | 42 | 43 | if __name__ == "__main__": 44 | asyncio.run(main()) 45 | -------------------------------------------------------------------------------- /examples/ruff.toml: -------------------------------------------------------------------------------- 1 | # This extend our general Ruff rules specifically for the examples 2 | extend = "../pyproject.toml" 3 | 4 | lint.extend-ignore = [ 5 | "T201", # Allow the use of print() in examples 6 | ] 7 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "radios", 3 | "version": "0.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "radios", 9 | "version": "0.0.0", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "prettier": "3.5.3" 13 | } 14 | }, 15 | "node_modules/prettier": { 16 | "version": "3.5.3", 17 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", 18 | "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", 19 | "dev": true, 20 | "license": "MIT", 21 | "bin": { 22 | "prettier": "bin/prettier.cjs" 23 | }, 24 | "engines": { 25 | "node": ">=14" 26 | }, 27 | "funding": { 28 | "url": "https://github.com/prettier/prettier?sponsor=1" 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "radios", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Asynchronous Python client for the Radio Browser API", 6 | "scripts": { 7 | "prettier": "prettier --write **/*.{json,js,md,yml,yaml} *.{json,js,md,yml,yaml}" 8 | }, 9 | "author": "Franck Nijhof ", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "prettier": "3.5.3" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | authors = ["Franck Nijhof "] 3 | classifiers = [ 4 | "Development Status :: 4 - Beta", 5 | "Framework :: AsyncIO", 6 | "Intended Audience :: Developers", 7 | "Natural Language :: English", 8 | "Programming Language :: Python :: 3.11", 9 | "Programming Language :: Python :: 3.12", 10 | "Programming Language :: Python :: 3", 11 | "Topic :: Software Development :: Libraries :: Python Modules", 12 | ] 13 | description = "Asynchronous Python client for the Radio Browser API" 14 | documentation = "https://github.com/frenck/python-radios" 15 | homepage = "https://github.com/frenck/python-radios" 16 | keywords = ["radio browser", "radio", "web radio", "radios", "api", "async", "client"] 17 | license = "MIT" 18 | maintainers = ["Franck Nijhof "] 19 | name = "radios" 20 | packages = [ 21 | {include = "radios", from = "src"}, 22 | ] 23 | readme = "README.md" 24 | repository = "https://github.com/frenck/python-radios" 25 | version = "0.0.0" 26 | 27 | [tool.poetry.dependencies] 28 | aiodns = ">=3.0" 29 | aiohttp = ">=3.0.0" 30 | awesomeversion = ">=21.10.1" 31 | backoff = ">=1.9.0" 32 | cachetools = ">=4.0.0" 33 | mashumaro = ">=3.10" 34 | orjson = ">=3.9.8" 35 | pycountry = "^24.0.0" 36 | python = "^3.11" 37 | yarl = ">=1.6.0" 38 | 39 | [tool.poetry.urls] 40 | "Bug Tracker" = "https://github.com/frenck/python-radios/issues" 41 | Changelog = "https://github.com/frenck/python-radios/releases" 42 | 43 | [tool.poetry.group.dev.dependencies] 44 | aresponses = "3.0.0" 45 | codespell = "2.4.1" 46 | covdefaults = "2.3.0" 47 | coverage = {version = "7.8.2", extras = ["toml"]} 48 | mypy = "1.16.0" 49 | pre-commit = "4.2.0" 50 | pre-commit-hooks = "5.0.0" 51 | pylint = "3.3.7" 52 | pytest = "8.4.0" 53 | pytest-asyncio = "1.0.0" 54 | pytest-cov = "6.1.1" 55 | ruff = "0.11.13" 56 | safety = "3.5.2" 57 | syrupy = "4.9.1" 58 | types-cachetools = "6.0.0.20250525" 59 | yamllint = "1.37.1" 60 | 61 | [tool.coverage.run] 62 | plugins = ["covdefaults"] 63 | source = ["radios"] 64 | 65 | [tool.coverage.report] 66 | show_missing = true 67 | fail_under = 50 68 | 69 | [tool.mypy] 70 | # Specify the target platform details in config, so your developers are 71 | # free to run mypy on Windows, Linux, or macOS and get consistent 72 | # results. 73 | platform = "linux" 74 | python_version = "3.11" 75 | 76 | # show error messages from unrelated files 77 | follow_imports = "normal" 78 | 79 | # suppress errors about unsatisfied imports 80 | ignore_missing_imports = true 81 | 82 | # be strict 83 | check_untyped_defs = true 84 | disallow_any_generics = true 85 | disallow_incomplete_defs = true 86 | disallow_subclassing_any = true 87 | disallow_untyped_calls = true 88 | disallow_untyped_defs = true 89 | disallow_untyped_decorators = true 90 | no_implicit_optional = true 91 | no_implicit_reexport = true 92 | strict_optional = true 93 | warn_incomplete_stub = true 94 | warn_no_return = true 95 | warn_redundant_casts = true 96 | warn_return_any = true 97 | warn_unused_configs = true 98 | warn_unused_ignores = true 99 | 100 | [tool.pylint.MASTER] 101 | ignore= [ 102 | "tests" 103 | ] 104 | 105 | [tool.pylint.BASIC] 106 | good-names = [ 107 | "_", 108 | "ex", 109 | "fp", 110 | "i", 111 | "id", 112 | "j", 113 | "k", 114 | "on", 115 | "Run", 116 | "T", 117 | ] 118 | 119 | [tool.pylint.DESIGN] 120 | max-attributes = 8 121 | 122 | [tool.pylint."MESSAGES CONTROL"] 123 | disable= [ 124 | "duplicate-code", 125 | "format", 126 | "unsubscriptable-object", 127 | ] 128 | 129 | [tool.pylint.SIMILARITIES] 130 | ignore-imports = true 131 | 132 | [tool.pylint.FORMAT] 133 | max-line-length=88 134 | 135 | [tool.pytest.ini_options] 136 | addopts = "--cov" 137 | asyncio_mode = "auto" 138 | 139 | [tool.ruff.lint] 140 | select = ["ALL"] 141 | ignore = [ 142 | "ANN401", # Opinioated warning on disallowing dynamically typed expressions 143 | "D203", # Conflicts with other rules 144 | "D213", # Conflicts with other rules 145 | "D417", # False positives in some occasions 146 | "PLR2004", # Just annoying, not really useful 147 | 148 | # Conflicts with the Ruff formatter 149 | "COM812", 150 | "ISC001", 151 | ] 152 | 153 | [tool.ruff.lint.flake8-pytest-style] 154 | mark-parentheses = false 155 | fixture-parentheses = false 156 | 157 | [tool.ruff.lint.isort] 158 | known-first-party = ["radios"] 159 | 160 | [tool.ruff.lint.flake8-type-checking] 161 | runtime-evaluated-base-classes = [ 162 | "mashumaro.mixins.orjson.DataClassORJSONMixin", 163 | ] 164 | 165 | [tool.ruff.lint.mccabe] 166 | max-complexity = 25 167 | 168 | [build-system] 169 | requires = ["poetry-core>=1.0.0"] 170 | build-backend = "poetry.core.masonry.api" 171 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.organization=frenck 2 | sonar.projectKey=frenck_python-radios 3 | sonar.projectName=Radio Browser API Client 4 | sonar.projectVersion=1.0 5 | 6 | sonar.links.homepage=https://github.com/frenck/python-radios 7 | sonar.links.ci=https://github.com/frenck/python-radios/actions 8 | sonar.links.issue=https://github.com/frenck/python-radios/issues 9 | sonar.links.scm=https://github.com/frenck/python-radios/tree/main 10 | 11 | sonar.language=py 12 | sonar.sourceEncoding=UTF-8 13 | sonar.sources=src 14 | sonar.tests=tests 15 | 16 | sonar.python.version=3.11, 3.12 17 | sonar.python.coverage.reportPaths=coverage.xml 18 | -------------------------------------------------------------------------------- /src/radios/__init__.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for the Radio Browser APIs.""" 2 | 3 | from .const import FilterBy, Order 4 | from .exceptions import ( 5 | RadioBrowserConnectionError, 6 | RadioBrowserConnectionTimeoutError, 7 | RadioBrowserError, 8 | ) 9 | from .models import Country, Language, Station, Stats, Tag 10 | from .radio_browser import RadioBrowser 11 | 12 | __all__ = [ 13 | "Country", 14 | "FilterBy", 15 | "Language", 16 | "Order", 17 | "RadioBrowser", 18 | "RadioBrowserConnectionError", 19 | "RadioBrowserConnectionTimeoutError", 20 | "RadioBrowserError", 21 | "Station", 22 | "Stats", 23 | "Tag", 24 | ] 25 | -------------------------------------------------------------------------------- /src/radios/const.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for the Radio Browser API.""" 2 | 3 | from enum import Enum 4 | 5 | 6 | class Order(str, Enum): 7 | """Enum holding the order types.""" 8 | 9 | BITRATE = "bitrate" 10 | CHANGE_TIMESTAMP = "changetimestamp" 11 | CLICK_COUNT = "clickcount" 12 | CLICK_TIMESTAMP = "clicktimestamp" 13 | CLICK_TREND = "clicktrend" 14 | CODE = "code" 15 | CODEC = "codec" 16 | COUNTRY = "country" 17 | FAVICON = "favicon" 18 | HOMEPAGE = "homepage" 19 | LANGUAGE = "language" 20 | LAST_CHECK_OK = "lastcheckok" 21 | LAST_CHECK_TIME = "lastchecktime" 22 | NAME = "name" 23 | RANDOM = "random" 24 | STATE = "state" 25 | STATION_COUNT = "stationcount" 26 | TAGS = "tags" 27 | URL = "url" 28 | VOTES = "votes" 29 | 30 | 31 | class FilterBy(str, Enum): 32 | """Enum holding possible filter by types for radio stations.""" 33 | 34 | UUID = "byuuid" 35 | NAME = "byname" 36 | NAME_EXACT = "bynameexact" 37 | CODEC = "bycodec" 38 | CODEC_EXACT = "bycodecexact" 39 | COUNTRY = "bycountry" 40 | COUNTRY_EXACT = "bycountryexact" 41 | COUNTRY_CODE_EXACT = "bycountrycodeexact" 42 | STATE = "bystate" 43 | STATE_EXACT = "bystateexact" 44 | LANGUAGE = "bylanguage" 45 | LANGUAGE_EXACT = "bylanguageexact" 46 | TAG = "bytag" 47 | TAG_EXACT = "bytagexact" 48 | -------------------------------------------------------------------------------- /src/radios/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions for the Radio Browser API.""" 2 | 3 | 4 | class RadioBrowserError(Exception): 5 | """Generic Radio Browser exception.""" 6 | 7 | 8 | class RadioBrowserConnectionError(RadioBrowserError): 9 | """Radio Browser connection exception.""" 10 | 11 | 12 | class RadioBrowserConnectionTimeoutError(RadioBrowserConnectionError): 13 | """Radio Browser connection Timeout exception.""" 14 | -------------------------------------------------------------------------------- /src/radios/models.py: -------------------------------------------------------------------------------- 1 | """Models for the Radio Browser API.""" 2 | 3 | # pylint: disable=too-few-public-methods 4 | from __future__ import annotations 5 | 6 | from dataclasses import dataclass, field 7 | from datetime import datetime 8 | from typing import cast 9 | 10 | import pycountry 11 | from awesomeversion import AwesomeVersion 12 | from mashumaro import field_options 13 | from mashumaro.mixins.orjson import DataClassORJSONMixin 14 | from mashumaro.types import SerializationStrategy 15 | 16 | 17 | class CommaSeparatedString(SerializationStrategy): 18 | """String serialization strategy to handle comma separated strings.""" 19 | 20 | def serialize(self, value: list[str]) -> str: 21 | """Serialize a list of strings to a comma separated value.""" 22 | return ",".join(value) 23 | 24 | def deserialize(self, value: str) -> list[str]: 25 | """Deserialize a comma separated value to a list of strings.""" 26 | return [item.strip() for item in value.split(",")] 27 | 28 | 29 | @dataclass 30 | # pylint: disable=too-many-instance-attributes 31 | class Stats(DataClassORJSONMixin): 32 | """Object holding the Radio Browser stats.""" 33 | 34 | supported_version: int 35 | software_version: AwesomeVersion 36 | status: str 37 | stations: int 38 | stations_broken: int 39 | tags: int 40 | clicks_last_hour: int 41 | clicks_last_day: int 42 | languages: int 43 | countries: int 44 | 45 | 46 | @dataclass 47 | # pylint: disable=too-many-instance-attributes 48 | class Station(DataClassORJSONMixin): 49 | """Object information for a station from the Radio Browser.""" 50 | 51 | bitrate: int 52 | change_uuid: str = field(metadata=field_options(alias="changeuuid")) 53 | click_count: int = field(metadata=field_options(alias="clickcount")) 54 | click_timestamp: datetime | None = field( 55 | metadata=field_options(alias="clicktimestamp_iso8601") 56 | ) 57 | click_trend: int = field(metadata=field_options(alias="clicktrend")) 58 | codec: str 59 | country_code: str = field(metadata=field_options(alias="countrycode")) 60 | favicon: str 61 | latitude: float | None = field(metadata=field_options(alias="geo_lat")) 62 | longitude: float | None = field(metadata=field_options(alias="geo_long")) 63 | has_extended_info: bool 64 | hls: bool 65 | homepage: str 66 | iso_3166_2: str | None 67 | language: list[str] = field( 68 | metadata=field_options(serialization_strategy=CommaSeparatedString()) 69 | ) 70 | language_codes: list[str] = field( 71 | metadata=field_options( 72 | alias="languagecodes", serialization_strategy=CommaSeparatedString() 73 | ) 74 | ) 75 | lastchange_time: datetime | None = field( 76 | metadata=field_options(alias="lastchangetime_iso8601") 77 | ) 78 | lastcheckok: bool 79 | last_check_ok_time: datetime | None = field( 80 | metadata=field_options(alias="lastcheckoktime_iso8601") 81 | ) 82 | last_check_time: datetime | None = field( 83 | metadata=field_options(alias="lastchecktime_iso8601") 84 | ) 85 | last_local_check_time: datetime | None = field( 86 | metadata=field_options(alias="lastlocalchecktime_iso8601") 87 | ) 88 | name: str 89 | ssl_error: int 90 | state: str 91 | uuid: str = field(metadata=field_options(alias="stationuuid")) 92 | tags: list[str] = field( 93 | metadata=field_options(serialization_strategy=CommaSeparatedString()) 94 | ) 95 | url_resolved: str 96 | url: str 97 | votes: int 98 | 99 | @property 100 | def country(self) -> str | None: 101 | """Return country name of this station. 102 | 103 | Returns 104 | ------- 105 | Country name or None if no country code is set. 106 | 107 | """ 108 | if resolved_country := pycountry.countries.get(alpha_2=self.country_code): 109 | return cast("str", resolved_country.name) 110 | return None 111 | 112 | 113 | @dataclass 114 | class Country(DataClassORJSONMixin): 115 | """Object information for a Counbtry from the Radio Browser.""" 116 | 117 | code: str 118 | name: str 119 | station_count: str = field(metadata=field_options(alias="stationcount")) 120 | 121 | @property 122 | def favicon(self) -> str: 123 | """Return the favicon URL for the country. 124 | 125 | Returns 126 | ------- 127 | URL to the favicon. 128 | 129 | """ 130 | return f"https://flagcdn.com/256x192/{self.code.lower()}.png" 131 | 132 | 133 | @dataclass 134 | class Language(DataClassORJSONMixin): 135 | """Object information for a Language from the Radio Browser.""" 136 | 137 | code: str | None = field(metadata=field_options(alias="iso_639")) 138 | name: str 139 | station_count: str = field(metadata=field_options(alias="stationcount")) 140 | 141 | @property 142 | def favicon(self) -> str | None: 143 | """Return the favicon URL for the language. 144 | 145 | Returns 146 | ------- 147 | URL to the favicon. 148 | 149 | """ 150 | if self.code: 151 | return f"https://flagcdn.com/256x192/{self.code.lower()}.png" 152 | return None 153 | 154 | 155 | @dataclass 156 | class Tag(DataClassORJSONMixin): 157 | """Object information for a Tag from the Radio Browser.""" 158 | 159 | name: str 160 | station_count: str = field(metadata=field_options(alias="stationcount")) 161 | -------------------------------------------------------------------------------- /src/radios/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frenck/python-radios/93e83a07ca7390f9fa5ce466d4da1ef941018538/src/radios/py.typed -------------------------------------------------------------------------------- /src/radios/radio_browser.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for the Radio Browser API.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import random 7 | import socket 8 | from dataclasses import dataclass 9 | from typing import Any, Self 10 | 11 | import aiohttp 12 | import backoff 13 | import orjson 14 | import pycountry 15 | from aiodns import DNSResolver 16 | from aiohttp import hdrs 17 | from yarl import URL 18 | 19 | from .const import FilterBy, Order 20 | from .exceptions import ( 21 | RadioBrowserConnectionError, 22 | RadioBrowserConnectionTimeoutError, 23 | RadioBrowserError, 24 | ) 25 | from .models import Country, Language, Station, Stats, Tag 26 | 27 | 28 | @dataclass 29 | class RadioBrowser: 30 | """Main class for handling connections with the Radio Browser API.""" 31 | 32 | user_agent: str 33 | 34 | request_timeout: float = 8.0 35 | session: aiohttp.client.ClientSession | None = None 36 | 37 | _close_session: bool = False 38 | _host: str | None = None 39 | 40 | @backoff.on_exception( 41 | backoff.expo, RadioBrowserConnectionError, max_tries=5, logger=None 42 | ) 43 | async def _request( 44 | self, 45 | uri: str = "", 46 | method: str = hdrs.METH_GET, 47 | params: dict[str, Any] | None = None, 48 | ) -> str: 49 | """Handle a request to the Radio Browser API. 50 | 51 | A generic method for sending/handling HTTP requests done against 52 | the Radio Browser API. 53 | 54 | Args: 55 | ---- 56 | uri: Request URI, for example `stats`. 57 | method: HTTP method to use for the request.E.g., "GET" or "POST". 58 | params: Dictionary of data to send to the Radio Browser API. 59 | 60 | Returns: 61 | ------- 62 | The response from the Radio Browser API. 63 | 64 | Raises: 65 | ------ 66 | RadioBrowserConnectionError: An error occurred while communication with 67 | the Radio Browser API. 68 | RadioBrowserConnectionTimeoutError: A timeout occurred while communicating 69 | with the Radio Browser API. 70 | RadioBrowserError: Received an unexpected response from the 71 | Radio Browser API. 72 | 73 | """ 74 | if self._host is None: 75 | resolver = DNSResolver() 76 | result = await resolver.query("_api._tcp.radio-browser.info", "SRV") 77 | random.shuffle(result) 78 | self._host = result[0].host 79 | 80 | url = URL.build(scheme="https", host=self._host, path="/json/").join(URL(uri)) 81 | 82 | if self.session is None: 83 | self.session = aiohttp.ClientSession() 84 | self._close_session = True 85 | 86 | if params: 87 | for key, value in params.items(): 88 | if isinstance(value, bool): 89 | params[key] = str(value).lower() 90 | try: 91 | async with asyncio.timeout(self.request_timeout): 92 | response = await self.session.request( 93 | method, 94 | url, 95 | headers={ 96 | "User-Agent": self.user_agent, 97 | "Accept": "application/json", 98 | }, 99 | params=params, 100 | raise_for_status=True, 101 | ) 102 | 103 | content_type = response.headers.get("Content-Type", "") 104 | text = await response.text() 105 | if "application/json" not in content_type: 106 | raise RadioBrowserError(response.status, {"message": text}) 107 | except asyncio.TimeoutError as exception: 108 | self._host = None 109 | msg = "Timeout occurred while connecting to the Radio Browser API" 110 | raise RadioBrowserConnectionTimeoutError(msg) from exception 111 | except (aiohttp.ClientError, socket.gaierror) as exception: 112 | self._host = None 113 | msg = "Error occurred while communicating with the Radio Browser API" 114 | raise RadioBrowserConnectionError(msg) from exception 115 | 116 | return text 117 | 118 | async def stats(self) -> Stats: 119 | """Get Radio Browser service stats. 120 | 121 | Returns 122 | ------- 123 | A Stats object, with information about the Radio Browser API. 124 | 125 | """ 126 | response = await self._request("stats") 127 | return Stats.from_json(response) 128 | 129 | async def station_click(self, *, uuid: str) -> None: 130 | """Register click on a station. 131 | 132 | Increase the click count of a station by one. This should be called 133 | every time when a user starts playing a stream to mark the stream more 134 | popular than others. Every call to this endpoint from the same IP 135 | address and for the same station only gets counted once per day. 136 | 137 | Args: 138 | ---- 139 | uuid: UUID of the station. 140 | 141 | """ 142 | await self._request(f"url/{uuid}") 143 | 144 | # pylint: disable-next=too-many-arguments 145 | async def countries( 146 | self, 147 | *, 148 | hide_broken: bool = False, 149 | limit: int = 100000, 150 | offset: int = 0, 151 | order: Order = Order.NAME, 152 | reverse: bool = False, 153 | ) -> list[Country]: 154 | """Get list of available countries. 155 | 156 | Args: 157 | ---- 158 | hide_broken: Do not count broken stations. 159 | limit: Limit the number of results. 160 | offset: Offset the results. 161 | order: Order the results. 162 | reverse: Reverse the order of the results. 163 | 164 | Returns: 165 | ------- 166 | A Stats object, with information about the Radio Browser API. 167 | 168 | """ 169 | countries_data = await self._request( 170 | "countrycodes", 171 | params={ 172 | "hidebroken": hide_broken, 173 | "limit": limit, 174 | "offset": offset, 175 | "order": order.value, 176 | "reverse": reverse, 177 | }, 178 | ) 179 | 180 | countries = orjson.loads(countries_data) # pylint: disable=no-member 181 | for country in countries: # pylint: disable=not-an-iterable 182 | country["code"] = country["name"] 183 | # https://github.com/frenck/python-radios/issues/19 184 | if country["name"] == "XK": 185 | country["name"] = "Kosovo" 186 | elif resolved_country := pycountry.countries.get(alpha_2=country["name"]): 187 | country["name"] = resolved_country.name 188 | 189 | # Because we enrichted the countries we need to re-order in this case 190 | if order == Order.NAME: 191 | countries.sort(key=lambda country: country["name"]) 192 | 193 | # pylint: disable-next=not-an-iterable 194 | return [Country.from_dict(country) for country in countries] 195 | 196 | # pylint: disable-next=too-many-arguments 197 | async def languages( 198 | self, 199 | *, 200 | hide_broken: bool = False, 201 | limit: int = 100000, 202 | offset: int = 0, 203 | order: Order = Order.NAME, 204 | reverse: bool = False, 205 | ) -> list[Language]: 206 | """Get list of available languages. 207 | 208 | Args: 209 | ---- 210 | hide_broken: Do not count broken stations. 211 | limit: Limit the number of results. 212 | offset: Offset the results. 213 | order: Order the results. 214 | reverse: Reverse the order of the results. 215 | 216 | Returns: 217 | ------- 218 | A list of Language objects. 219 | 220 | """ 221 | languages_data = await self._request( 222 | "languages", 223 | params={ 224 | "hidebroken": hide_broken, 225 | "offset": offset, 226 | "order": order.value, 227 | "reverse": reverse, 228 | "limit": limit, 229 | }, 230 | ) 231 | 232 | languages = orjson.loads(languages_data) # pylint: disable=no-member 233 | for language in languages: # pylint: disable=not-an-iterable 234 | language["name"] = language["name"].title() 235 | 236 | # pylint: disable-next=not-an-iterable 237 | return [Language.from_dict(language) for language in languages] 238 | 239 | # pylint: disable-next=too-many-arguments, too-many-locals 240 | async def search( # noqa: PLR0913 241 | self, 242 | *, 243 | filter_by: FilterBy | None = None, 244 | filter_term: str | None = None, 245 | hide_broken: bool = False, 246 | limit: int = 100000, 247 | offset: int = 0, 248 | order: Order = Order.NAME, 249 | reverse: bool = False, 250 | name: str | None = None, 251 | name_exact: bool = False, 252 | country: str | None = "", 253 | country_exact: bool = False, 254 | state_exact: bool = False, 255 | language_exact: bool = False, 256 | tag_exact: bool = False, 257 | bitrate_min: int = 0, 258 | bitrate_max: int = 1000000, 259 | ) -> list[Station]: 260 | """Get list of radio stations. 261 | 262 | Args: 263 | ---- 264 | filter_by: Filter the results by a specific field. 265 | filter_term: Search term to filter the results. 266 | hide_broken: Do not count broken stations. 267 | limit: Limit the number of results. 268 | offset: Offset the results. 269 | order: Order the results. 270 | reverse: Reverse the order of the results. 271 | name: Search by name. 272 | name_exact: Search by exact name. 273 | country: Search by country. 274 | country_exact: Search by exact country. 275 | state_exact: Search by exact state. 276 | language_exact: Search by exact language. 277 | tag_exact: Search by exact tag. 278 | bitrate_min: Search by minimum bitrate. 279 | bitrate_max: Search by maximum bitrate. 280 | 281 | Returns: 282 | ------- 283 | A list of Station objects. 284 | 285 | """ 286 | uri = "stations/search" 287 | if filter_by is not None: 288 | uri = f"{uri}/{filter_by.value}" 289 | if filter_term is not None: 290 | uri = f"{uri}/{filter_term}" 291 | 292 | stations_data = await self._request( 293 | uri, 294 | params={ 295 | "hidebroken": hide_broken, 296 | "offset": offset, 297 | "order": order.value, 298 | "reverse": reverse, 299 | "limit": limit, 300 | "name": name, 301 | "name_exact": name_exact, 302 | "country": country, 303 | "country_exact": country_exact, 304 | "state_exact": state_exact, 305 | "language_exact": language_exact, 306 | "tag_exact": tag_exact, 307 | "bitrate_min": bitrate_min, 308 | "bitrate_max": bitrate_max, 309 | }, 310 | ) 311 | stations = orjson.loads(stations_data) # pylint: disable=no-member 312 | # pylint: disable-next=not-an-iterable 313 | return [Station.from_dict(station) for station in stations] 314 | 315 | async def station(self, *, uuid: str) -> Station | None: 316 | """Get station by UUID. 317 | 318 | Args: 319 | ---- 320 | uuid: UUID of the station. 321 | 322 | Returns: 323 | ------- 324 | A Station object if found. 325 | 326 | """ 327 | stations = await self.stations( 328 | filter_by=FilterBy.UUID, 329 | filter_term=uuid, 330 | limit=1, 331 | ) 332 | if not stations: 333 | return None 334 | return stations[0] 335 | 336 | # pylint: disable-next=too-many-arguments 337 | async def stations( # noqa: PLR0913 338 | self, 339 | *, 340 | filter_by: FilterBy | None = None, 341 | filter_term: str | None = None, 342 | hide_broken: bool = False, 343 | limit: int = 100000, 344 | offset: int = 0, 345 | order: Order = Order.NAME, 346 | reverse: bool = False, 347 | ) -> list[Station]: 348 | """Get list of radio stations. 349 | 350 | Args: 351 | ---- 352 | filter_by: Filter the results by a specific field. 353 | filter_term: Search term to filter the results. 354 | hide_broken: Do not count broken stations. 355 | limit: Limit the number of results. 356 | offset: Offset the results. 357 | order: Order the results. 358 | reverse: Reverse the order of the results. 359 | 360 | Returns: 361 | ------- 362 | A list of Station objects. 363 | 364 | """ 365 | uri = "stations" 366 | if filter_by is not None: 367 | uri = f"{uri}/{filter_by.value}" 368 | if filter_term is not None: 369 | uri = f"{uri}/{filter_term}" 370 | 371 | stations_data = await self._request( 372 | uri, 373 | params={ 374 | "hidebroken": hide_broken, 375 | "offset": offset, 376 | "order": order.value, 377 | "reverse": reverse, 378 | "limit": limit, 379 | }, 380 | ) 381 | stations = orjson.loads(stations_data) # pylint: disable=no-member 382 | # pylint: disable-next=not-an-iterable 383 | return [Station.from_dict(station) for station in stations] 384 | 385 | # pylint: disable-next=too-many-arguments 386 | async def tags( 387 | self, 388 | *, 389 | hide_broken: bool = False, 390 | limit: int = 100000, 391 | offset: int = 0, 392 | order: Order = Order.NAME, 393 | reverse: bool = False, 394 | ) -> list[Tag]: 395 | """Get list of available tags. 396 | 397 | Args: 398 | ---- 399 | hide_broken: Do not count broken stations. 400 | limit: Limit the number of results. 401 | offset: Offset the results. 402 | order: Order the results. 403 | reverse: Reverse the order of the results. 404 | 405 | Returns: 406 | ------- 407 | A list of Tags objects. 408 | 409 | """ 410 | tags_data = await self._request( 411 | "tags", 412 | params={ 413 | "hidebroken": hide_broken, 414 | "offset": offset, 415 | "order": order.value, 416 | "reverse": reverse, 417 | "limit": limit, 418 | }, 419 | ) 420 | tags = orjson.loads(tags_data) # pylint: disable=no-member 421 | # pylint: disable-next=not-an-iterable 422 | return [Tag.from_dict(tag) for tag in tags] 423 | 424 | async def close(self) -> None: 425 | """Close open client session.""" 426 | if self.session and self._close_session: 427 | await self.session.close() 428 | 429 | async def __aenter__(self) -> Self: 430 | """Async enter. 431 | 432 | Returns 433 | ------- 434 | The RadioBrowser object. 435 | 436 | """ 437 | return self 438 | 439 | async def __aexit__(self, *_exc_info: object) -> None: 440 | """Async exit. 441 | 442 | Args: 443 | ---- 444 | _exc_info: Exec type. 445 | 446 | """ 447 | await self.close() 448 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for the Radio Browser API.""" 2 | 3 | from pathlib import Path 4 | 5 | 6 | def load_fixture(filename: str) -> str: 7 | """Load a fixture.""" 8 | path = Path(__package__) / "fixtures" / filename 9 | return path.read_text(encoding="utf-8") 10 | -------------------------------------------------------------------------------- /tests/ruff.toml: -------------------------------------------------------------------------------- 1 | # This extend our general Ruff rules specifically for tests 2 | extend = "../pyproject.toml" 3 | 4 | lint.extend-select = [ 5 | "PT", # Use @pytest.fixture without parentheses 6 | ] 7 | 8 | lint.extend-ignore = [ 9 | "S101", # Use of assert detected. As these are tests... 10 | "SLF001", # Tests will access private/protected members... 11 | "TC002", # pytest doesn't like this one... 12 | ] 13 | -------------------------------------------------------------------------------- /tests/test_radios.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for the Radio Browser API.""" 2 | 3 | # pylint: disable=protected-access 4 | import aiohttp 5 | from aresponses import ResponsesMockServer 6 | 7 | from radios.radio_browser import RadioBrowser 8 | 9 | 10 | async def test_json_request(aresponses: ResponsesMockServer) -> None: 11 | """Test JSON response is handled correctly.""" 12 | aresponses.add( 13 | "example.com", 14 | "/json/test", 15 | "GET", 16 | aresponses.Response( 17 | status=200, 18 | headers={"Content-Type": "application/json"}, 19 | text='{"status": "ok"}', 20 | ), 21 | ) 22 | async with aiohttp.ClientSession() as session: 23 | radio = RadioBrowser(session=session, user_agent="Test") 24 | radio._host = "example.com" 25 | response = await radio._request("test") 26 | assert response == '{"status": "ok"}' 27 | --------------------------------------------------------------------------------