Radio Browser is a community-maintained, public-domain directory of internet radio stations. Anyone can add a station. Every week I turn it into plain M3U playlists, one per country and one per genre, that open in VLC, mpv, pyradio or an ESP32 radio: AlonDrilich/radio-playlists.
The 9 November build has 51,965 working stations after de-duplication, in 172 country playlists, 60 genre playlists and a top-500 list.
Writing an M3U file is trivial. Writing one from data that anyone can edit takes more care, because an M3U is a tiny program that a player executes: a list of #EXTINF lines and URLs to open. Here is what I had to defend against.
1. A line break in a name is a new entry
An entry is two lines:
#EXTINF:-1,Station name
https://stream.example/live
If a station's name contains a newline followed by a URL, the "name" becomes an extra playlist entry pointing wherever the author likes. So names lose every line-breaking character: C0 and C1 controls, plus U+2028 and U+2029, which many tools forget.
const LINE_BREAKING = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
const name = raw.replace(LINE_BREAKING, " ");
2. Commas and quotes in the #EXTINF line
Several players split #EXTINF on the first comma to find the title, and attributes such as tvg-logo="..." sit before that comma. A name like X tvg-logo="http://..." would smuggle in an attribute. Titles lose double quotes, and attribute values lose quotes and commas.
3. Text you can't see
Bidi overrides (U+202E and friends) can make a name display differently from what it says. Zero-width characters and the Unicode "tag" block can hide text altogether. Those are stripped. The exceptions are ZWJ, ZWNJ and the emoji variation selector, because Persian, Indic scripts and emoji need them, and even those are capped at runs of two.
4. URLs that point into your network
A public playlist shouldn't make someone's player, or their Home Assistant box, connect to 192.168.1.1. Stream URLs are kept only if they are plain public http(s) addresses:
- no username or password in the URL
- no
localhost,.local,.lan,.internal, or bare hostnames that only resolve on a LAN - no private, loopback, link-local or CGNAT IPv4 ranges (
10/8,127/8,169.254/16,172.16/12,192.168/16,100.64/10), and the IPv6 equivalents - no backslashes, which
new URL()and curl read differently, and nohttps:hostwithout slashes
One detail matters more than it looks. The build writes new URL(value).href, the parsed and canonical form, so the string that was checked is exactly the string that ends up in the file.
5. Check the output, not just the input
Before each file is written, the build parses the text it just generated and throws if the structure isn't exactly what it meant to produce: the #EXTM3U header, every #EXTINF followed by one URL, no URL without an #EXTINF, attributes in the expected form, no control characters anywhere, and the expected number of entries. If a hostile entry ever gets past the sanitising, the weekly update stops instead of publishing it.
There's also a guard against the source shrinking. If the directory suddenly returns far fewer stations than last week, for example during an outage, the build refuses to replace the old playlists with near-empty ones.
Using the playlists
Open any of them straight from GitHub, for example jazz:
mpv https://raw.githubusercontent.com/AlonDrilich/radio-playlists/main/genres/jazz.m3u
index.json lists every playlist with its URL and station count, if you'd rather pick one in code. The same weekly build also writes per-country station lists for Euro Truck Simulator 2 and American Truck Simulator, Farming Simulator, and Garry's Mod.
The station data is public domain (Radio Browser), and the playlists are CC0. The streams themselves belong to their stations. If you'd rather not deal with files, the same directory plays in the browser at 72fm.com.
Top comments (2)
Writing
new URL(value).hrefafter validation is a clean fix for parser-differential bugs between WHATWG URLs and C clients like curl or libvlc.Two edge cases still slip past static URL checks in media playlists. First,
new URL()normalizes octal and hex IPv4 literals onhttp:andhttps:, though IPv4-mapped IPv6 forms like::ffff:127.0.0.1or[::ffff:7f00:1]need explicit unpacking if your IPv6 blocklist only checksfc00::/7andfe80::/10. Second, a public radio stream URL can pass every build-time host check and still return a302redirect to127.0.0.1or169.254.169.254when a desktop player resolves it.Re-parsing the generated M3U and aborting on unexpected entry counts before replacing last-known-good files is a solid safeguard here.
Thanks, both good points.
The mapped-address case is already caught, because the check runs on the parsed host rather than the raw string:
new URL()turns[::ffff:127.0.0.1]into[::ffff:7f00:1], and anything starting with::ffff:is treated as local. That also drops mapped public addresses, which is fine for a radio list. Hex, octal and single-number IPv4 (0x7f.1,017700000001,2130706433) all come out of the parser as127.0.0.1, so the dotted-quad check sees them. Rarer IPv6 forms such as NAT64 (64:ff9b::/96) aren't covered yet.The redirect case is the real limit, and you're right about it. A static playlist can only vouch for the URL it contains, not where that server sends the player later; the same goes for a hostname that resolves to a private address. Resolving hosts at build time would catch some of it, but DNS can change after the build, so past that point it's on the player not to follow a redirect into a private range.