Skip to content

Instantly share code, notes, and snippets.

@lpenaud
Created May 8, 2024 14:07
Show Gist options
  • Select an option

  • Save lpenaud/d6f0006de3f375905c05b4de1abd5ef9 to your computer and use it in GitHub Desktop.

Select an option

Save lpenaud/d6f0006de3f375905c05b4de1abd5ef9 to your computer and use it in GitHub Desktop.
Generate m3u playlist from radio-browser.info
import { CsvParseStream } from "https://deno.land/[email protected]/csv/mod.ts";
export interface RadioSearchOptions {
hidebroken?: boolean;
order?: "clickcount";
reverse?: boolean;
language?: string;
tagList?: string;
codec?: string;
limit?: number;
}
export interface RadioStation {
changeuuid: string;
stationuuid: string;
name: string;
favicon: string;
countryCode: string;
url: string;
homepage: string;
}
export interface RadioBrowserOptions {
servers: URL[];
userAgent: string;
}
export async function radioBrowserFactory(userAgent?: string) {
return new RadioBrowser({
servers: await getServers(),
userAgent: userAgent ?? `Deno/${Deno.version.deno}`,
});
}
export async function getServers() {
const hostnames = await Deno.resolveDns("_api._tcp.radio-browser.info", "SRV");
return hostnames.filter(h => h.port === 443)
// Remove the trailling dot
.map(h => new URL(`https://${h.target.slice(0, -1)}`));
}
class RadioBrowser {
#servers: URL[];
#headers: Headers;
get server() {
return this.#servers[(Math.random() * this.#servers.length) | 0];
}
constructor({ servers, userAgent }: RadioBrowserOptions) {
this.#servers = servers;
this.#headers = new Headers({
"User-Agent": userAgent,
});
}
async searchCsv(options: RadioSearchOptions) {
const res = await this.#call("csv/stations/search", options as Record<string, unknown>);
return parseStation(res.body);
}
async #call(pathname: string, params?: Record<string, unknown>) {
const url = new URL(pathname, this.server);
if (params !== undefined) {
for (const [name, value] of Object.entries(params)) {
url.searchParams.set(name, `${value}`);
}
}
const res = await fetch(url, {
headers: this.#headers,
});
console.error("GET", url.href, res.status, res.statusText);
if (!res.ok) {
const text = await res.text();
if (text.length > 0) {
console.error(text);
}
throw new Error(`${res.status} ${res.statusText}`);
}
return res;
}
}
async function* parseStation(readable: ReadableStream<Uint8Array> | null): AsyncGenerator<RadioStation> {
if (readable === null) {
return;
}
const csvReadable = readable.pipeThrough(new TextDecoderStream())
.pipeThrough(new CsvParseStream({
skipFirstRow: true,
}));
for await (const entry of csvReadable) {
yield {
changeuuid: entry.changeuuid,
countryCode: entry.countrycode,
favicon: entry.favicon,
name: entry.name,
stationuuid: entry.stationuuid,
url: entry.url,
homepage: entry.homepage,
} as RadioStation;
}
}
#!/bin/bash
declare outdir
function usage () {
printf "Usage: %s OUTDIR\n" "${0}"
}
if [ $# -ne 1 ]; then
usage >&2
exit 1
fi
outdir="${1}"
if [ ! -d "${1}" ]; then
echo "Outdir must be a directory" >&2
usage >&2
exit 1
fi
# Get all radio which speack breton
./radio.ts --language brezhoneg > "${outdir}/Radio-Brezhoneg.m3u" &
# Get the first 200 spanish radio
./radio.ts --language spanish --limit 200 > "${outdir}/Radio-Spanish.m3u" &
# Get all radio from public service "Radio France"
./radio.ts --tag-list 'radio france' --codec AAC > "${outdir}/Radio-France.m3u" &
wait
#!/usr/bin/env -S deno run --allow-net --allow-write
import {
radioBrowserFactory,
RadioSearchOptions,
RadioStation,
} from "./radio-browser.ts";
interface TvgVariables {
name: string;
logo?: string;
url?: string;
}
function formatTvg(tvg: TvgVariables) {
return Object.entries(tvg)
.map(([n, v]) => `tvg-${n}="${v}"`)
.join(" ");
}
async function* m3u(stations: AsyncIterable<RadioStation>) {
yield "#EXTM3U";
for await (const station of stations) {
const tvg: TvgVariables = { name: station.name };
if (station.favicon.length > 0) {
tvg.logo = station.favicon;
}
if (station.homepage.length > 0) {
tvg.url = station.homepage;
}
yield `#RADIOBROWSERUUID: ${station.stationuuid}`;
yield `#EXTINF:1,${formatTvg(tvg)},${station.name}`;
yield station.url;
yield "";
yield "";
}
}
function parseArgs(args: string[]) {
const dkey = Symbol("default key");
const gross: Record<string | symbol, string | boolean> = {};
const re = /^--([a-z\-]+)/;
let key: string | symbol = dkey;
let arg: string | undefined;
while ((arg = args.shift()) !== undefined) {
const match = re.exec(arg);
if (match === null) {
gross[key] = arg;
continue;
}
key = match[1];
}
const params: RadioSearchOptions = {};
const limit = parseInt(gross.limit as string, 10);
if (!isNaN(limit) && limit > 0) {
params.limit = limit;
}
if (typeof gross["tag-list"] === "string") {
params.tagList = gross["tag-list"];
}
if (typeof gross.language === "string") {
params.language = gross.language;
}
if (typeof gross.codec === "string") {
params.codec = gross.codec;
}
return params;
}
async function main(args: string[]): Promise<number> {
const search = parseArgs(args);
const radioBrowser = await radioBrowserFactory();
const stations = await radioBrowser.searchCsv(search);
let count = 0;
for await (const line of m3u(stations)) {
console.log(line);
count++;
}
console.error("line:", count, "stations", (count - 1) / 5);
return 0;
}
if (import.meta.main) {
main(Deno.args.slice())
.then(Deno.exit)
.catch((error) => {
console.error(error);
Deno.exit(2);
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment