Python: Radio Browser API Client
Asynchronous Python client for the Radio Browser API.
About
Radio Browser is a community driven effort (like Wikipedia) with the aim of collecting as many internet radio and TV stations as possible.
This Python library is an async API client for that, originally developed for use with the Home Assistant project.
Installation
pip install radios
Usage
The client is an async context manager; every API call is a coroutine. The Radio Browser project asks every app to identify itself, so a descriptive user agent is required.
"""Asynchronous Python client for the Radio Browser API."""
import asyncio
from radios import FilterBy, Order, RadioBrowser
async def main() -> None:
"""Show example on how to query the Radio Browser API."""
async with RadioBrowser(user_agent="MyAwesomeApp/1.0.0") as radios:
# The 10 most popular stations in the Netherlands
stations = await radios.stations(
filter_by=FilterBy.COUNTRY_CODE_EXACT,
filter_term="NL",
order=Order.CLICK_COUNT,
reverse=True,
limit=10,
)
for station in stations:
print(f"{station.name} ({station.click_count} clicks)")
# The best voted jazz stations that stream at 128 kbps or more
stations = await radios.search(
tag="jazz",
bitrate_min=128,
hide_broken=True,
order=Order.VOTES,
reverse=True,
limit=10,
)
for station in stations:
print(f"{station.name} ({station.codec}, {station.bitrate} kbps)")
# Start playing a station: count the click, and get its stream URL
if stations:
url = await radios.station_click(uuid=stations[0].uuid)
print(f"Now playing {stations[0].name}: {url}")
if __name__ == "__main__":
asyncio.run(main())
Browsing stations
stations() lists stations, optionally filtered by one field with
filter_by and filter_term. All lists of stations, countries, languages,
tags, codecs and states take order, reverse, limit, offset and
hide_broken:
from radios import FilterBy, Order
stations = await radios.stations(
filter_by=FilterBy.TAG_EXACT,
filter_term="classical",
order=Order.VOTES,
reverse=True,
limit=25,
hide_broken=True,
)
# A single station by its UUID, or None if it does not exist
station = await radios.station(uuid="d1a54d2e-623e-4970-ab11-35f7b56c5ec3")
# Several stations in one request, like refreshing a list of favorites
stations = await radios.stations_by_uuid(
uuids=[
"d1a54d2e-623e-4970-ab11-35f7b56c5ec3",
"6c95ccdb-ca0a-4c59-a660-96e56ef2dca9",
]
)
# The stations behind a stream URL
stations = await radios.stations_by_url(
url="https://icecast.walmradio.com:8443/classic"
)
Without a limit, the lists return everything that matches. For
stations() without a filter, that is the whole catalog of more than 60,000
stations, close to 80 MB of JSON, so pass a limit there.
Searching
search() combines any number of filters. Text filters match part of a
value, unless you ask for an exact match. An exact tag or language still
matches stations that have other tags or languages next to it:
stations = await radios.search(
country_code="US",
language="english",
tag_list=["jazz", "blues"], # all of these tags
codec="MP3",
is_https=True,
)
# Stations within 25 kilometers of Amsterdam, nearest first
stations = await radios.search(geo_lat=52.37, geo_long=4.89, geo_distance=25_000)
for station in sorted(stations, key=lambda station: station.distance or 0):
print(f"{station.name} ({station.distance:.0f} meters away)")
The results of a geo search carry their distance to the location, in
meters. On other results it is None.
Invalid arguments, like a latitude of 91 or a geo_distance without a
location, raise a RadioBrowserValidationError before a request is sent.
Playing a station
Call station_click() when a user starts playing a station. It counts the
click, which helps Radio Browser rank popular stations, and returns the URL
to stream from:
url = await radios.station_click(uuid=station.uuid)
If the user likes what they hear, await radios.vote(uuid=station.uuid)
votes for the station. The API counts one vote per station from the same IP
address every 10 minutes. When it does not accept a vote, vote() raises a
RadioBrowserError.
Countries, languages, tags, codecs and states
countries = await radios.countries() # names resolved from ISO country codes
languages = await radios.languages(hide_broken=True)
tags = await radios.tags(order=Order.STATION_COUNT, reverse=True, limit=50)
codecs = await radios.codecs()
states = await radios.states(country_code="NL")
for country in countries:
print(country.name, country.station_count, country.favicon)
They all take a name to only get the ones whose name contains it, like
await radios.tags(name="jazz"), which is handy for autocompletion.
Countries and languages have a favicon with a flag. A language that is not
tied to one country, like Arabic, has none.
States are entered by hand along with the stations, so expect anything from a province to a full street address.
Station history
The Radio Browser servers check every station regularly. The check history of a station helps to find out why a stream does not play:
checks = await radios.checks(uuid=station.uuid, seconds=86400) # last day
for check in checks:
print(check.timestamp, "online" if check.ok else "offline", check.codec)
clicks() works the same way, for when stations were played. Both continue
after an earlier result with after= and the UUID of the last check or click.
Station corrections
Radio Browser is maintained by its community, and anyone can add a station. Fixing one is harder: the API has no way to edit a station, and corrections upstream take a long time to land, if they land at all. So this library ships corrections of its own, and applies them to every station it returns.
A correction matches a station by its UUID, and either deletes it, like a
duplicate, or overwrites some of its fields, like a stream URL that moved.
station_click() still counts the click upstream, but returns the corrected
stream URL. A page of results can be shorter than limit when a station on it
is deleted.
To get the stations exactly as the API returns them, turn the corrections off:
RadioBrowser(user_agent="MyAwesomeApp/1.0.0", corrections=False)
Found a station with wrong data? See CONTRIBUTING.md for how to add a correction.
Duplicate stations
Many streams are listed more than once, added again by someone who could not
edit the existing station. stations() and search() return one station per
stream: the one with the most votes, then the most clicks, in the place where
that stream first shows up in the results. Streams count as the same when only
the scheme, the case of the host name, or a trailing / or /; differs.
Looking a station up by its UUID, with station() or stations_by_uuid(),
still finds every one of them, so a station someone saved keeps working.
stations_by_url() returns every station with that stream too, as that is what
it is for.
Only the duplicates within one response are found, so a page of results can be
shorter than limit, and two copies on different pages both show up. To get
every station, turn it off:
RadioBrowser(user_agent="MyAwesomeApp/1.0.0", deduplicate=False)
Connection options
RadioBrowser(
user_agent="MyAwesomeApp/1.0.0", # required, identifies your app
request_timeout=8.0, # per-request timeout in seconds
corrections=True, # apply the station corrections this library ships
deduplicate=True, # return one station per stream in lists
)
You may also pass your own aiohttp.ClientSession via session=... to
share a connection pool. The client then leaves closing it to you.
Without the async context manager, call await radios.close() when you are
done, to close the session the client created. A closed client can still be
used: the next request opens a new session.
Radio Browser runs on a pool of community servers. The client looks them up
through DNS and tries them in a random order: when a connection fails, it moves
on to the next server, with an exponential backoff in between, for up to five
attempts in total. When the DNS lookup of the servers fails, which some home
routers do with this kind of record, it uses all.api.radio-browser.info.
To see which server the client uses, and why it retries, turn on debug
logging for radios:
import logging
logging.getLogger("radios").setLevel(logging.DEBUG)
Error handling
Everything that can go wrong raises a RadioBrowserError, so a single
except covers it all. Calling a method with invalid arguments raises a
RadioBrowserValidationError, before any request is sent. It is a
RadioBrowserError and a ValueError at the same time. The arguments of every
method are validated with probatio, and the
error says which argument is wrong and why, like
value must be at most 90 at 'geo_lat'.
from radios import (
RadioBrowser,
RadioBrowserConnectionError,
RadioBrowserError,
RadioBrowserValidationError,
)
try:
async with RadioBrowser(user_agent="MyAwesomeApp/1.0.0") as radios:
stats = await radios.stats()
except RadioBrowserValidationError:
# A bug in the call, like a negative limit
...
except RadioBrowserConnectionError:
# Could not reach the API, even after retrying (includes timeouts)
...
except RadioBrowserError:
# The API answered, but not with what we asked for (like a 404)
...
Changelog & releases
This repository keeps a change log using GitHub's releases
functionality. Releases are based on Semantic Versioning, and use the
format of MAJOR.MINOR.PATCH.
Contributing
Contributions are welcome. See CONTRIBUTING.md for how to get started and what the review expects.
Setting up development environment
This Python project is fully managed using the Poetry dependency manager. But also relies on the use of NodeJS for certain checks during development.
You need at least:
- Python 3.12+
- Poetry
- NodeJS 24+ (including NPM)
To install all packages, including all development requirements:
npm install
poetry install
As this repository uses the prek framework, all changes are linted and tested with each commit. You can run all checks and tests manually, using the following command:
poetry run prek run --all-files
To run just the Python tests:
poetry run pytest
Authors & contributors
The original setup of this repository is by Franck Nijhof.
For a full list of all authors and contributors, check the contributor's page.
Disclaimer
This project is an independent, community-driven effort. It is not affiliated with, endorsed by, or supported by the Radio Browser project. All station names, logos, and trademarks are property of their respective owners.
Station data comes from the public Radio Browser API, which is maintained by its community. This library does not host or verify any streams.
License
MIT License
Copyright (c) 2022-2026 Franck Nijhof
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Metadata
Release files for radios 2.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| radios-2.0.1.tar.gz | 73.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| radios-2.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 214.0 kB
Release files / radios-2.0.1.tar.gz
| Download URL | radios-2.0.1.tar.gz |
|---|---|
| Size | 73.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
78a2ce349787e47d203fbd429f4f089e2db77314a7872eb6cb8e91c69ebd53ac
|
|
BLAKE2b-256 checksum How to use checksums |
a53279eacd1009b80a47b2f0f336f79d9efbc7fefb0108fe407d0df4652ebd96
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 4, 2026.
Transparency logRelease files / radios-2.0.1-py3-none-any.whl
| Download URL | radios-2.0.1-py3-none-any.whl |
|---|---|
| Size | 140.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
97229f6fdf3242626d2b93ef57f0a214c7b11f032ba56cd1201a0ed65e9bc723
|
|
BLAKE2b-256 checksum How to use checksums |
6334c882de19b70f80718b4a87f98f82c881a325c8f572188afe0914c002679b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 4, 2026.
Transparency log