Created
May 8, 2024 14:07
-
-
Save lpenaud/d6f0006de3f375905c05b4de1abd5ef9 to your computer and use it in GitHub Desktop.
Generate m3u playlist from radio-browser.info
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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