radio-browser-api
Version:
Wrapper for free and open-source radio browser api: https://api.radio-browser.info/.
557 lines (501 loc) • 15.2 kB
text/typescript
import {
AdvancedStationQuery,
CountryResult,
CountryStateResult,
Query,
Station,
StationQuery,
StationResponse,
StationSearchType,
TagResult
} from './constants'
/**
* Query the radio browser api.
*/
export class RadioBrowserApi {
static version = __VERSION__
protected baseUrl: string | undefined
protected fetchConfig: RequestInit = {
method: 'GET',
redirect: 'follow'
}
/**
* Creates an instance of radio browser api.
* @param appName - App name to be used as user agent header to indentify the calls to the API
* @param hideBroken - Hide broken stations for all future API calls
*/
constructor(protected appName: string, protected hideBroken = true) {
if (!appName) {
throw new Error('appName is required')
}
this.fetchConfig.headers = { 'user-agent': this.appName }
}
/**
* Resolves API base url this will be the default for all class instances.
* @param config - Fetch configuration
* @returns Array of objects with the ip and name of the api server
*/
async resolveBaseUrl(
config: RequestInit = {}
): Promise<{ ip: string; name: string }[]> {
let result: { ip: string; name: string }[]
const response = await fetch(
'https://all.api.radio-browser.info/json/servers',
config
)
if (response.ok) {
result = await response.json()
return result
} else {
throw response
}
}
/**
* Sets base url for all api calls
* @param url - Url to the api server
*/
setBaseUrl(url: string): void {
this.baseUrl = url
}
/**
* Get current base url
* @returns Base url
*/
getBaseUrl(): string | undefined {
return this.baseUrl
}
/**
* Gets available countries
* @param search - Search for country
* @param query - Query params
* @param fetchConfig - Fetch configuration
* @returns Array of country results with the name of the station and station count
*/
async getCountries(
search?: string,
query?: Query,
fetchConfig?: RequestInit
): Promise<CountryResult[]> {
return this.runRequest(
this.buildRequest('countries', search, query),
fetchConfig
)
}
/**
* Gets countries by country code
* @param search - Country code
* @param query - Query
* @param fetchConfig - Fetch configuration
* @returns Array of country results with the name of the station and station count
*/
async getCountryCodes(
search?: string,
query?: Query,
fetchConfig?: RequestInit
): Promise<CountryResult[]> {
search = search ? `${search.toUpperCase()}` : ''
return this.runRequest(
this.buildRequest('countrycodes', search, query),
fetchConfig
)
}
/**
* Gets available codes
* @param query - Query
* @param fetchConfig - Fetch configuration
* @returns List of available codes
*/
async getCodecs(
query?: Query,
fetchConfig?: RequestInit
): Promise<CountryResult[]> {
return this.runRequest(this.buildRequest('codecs', '', query), fetchConfig)
}
/**
* Gets country states. States **should** be regions inside a country.
* @param country - Limit state to particular country
* @param query - Query
* @param fetchConfig - Fetch configuration
* @returns Array of country states
*/
async getCountryStates(
country?: string,
query?: Query,
fetchConfig?: RequestInit
): Promise<CountryStateResult[]> {
return this.runRequest(
this.buildRequest('states', country, query),
fetchConfig
)
}
/**
* Gets all available languages
* @param language - Limit results to particular language
* @param query - Query
* @param fetchConfig - Fetch configuration
* @returns Array of language results
*/
async getLanguages(
language?: string,
query?: Query,
fetchConfig?: RequestInit
): Promise<CountryResult[]> {
return this.runRequest(
this.buildRequest('languages', language, query),
fetchConfig
)
}
/**
* Gets all available tags
* @param tag - Limit results to particular tag
* @param query - Query
* @param fetchConfig - Fetch configuration
* @returns List of tag results
*/
async getTags(
tag?: string,
query?: Query,
fetchConfig?: RequestInit
): Promise<TagResult[]> {
tag = tag ? tag.toLowerCase() : '' // empty string returns all tags
return this.runRequest(this.buildRequest('tags', tag, query), fetchConfig)
}
/**
* Gets stations by various available parameters
* @param searchType - Parameter for the search
* @param search - Search value for the parameter
* @param query - Query
* @param fetchConfig - Fetch configuration
* @param removeDuplicates - remove duplicate stations
* @returns Array of station results
*/
async getStationsBy(
searchType: keyof typeof StationSearchType,
search?: string,
query?: StationQuery,
fetchConfig?: RequestInit,
removeDuplicates = false
): Promise<Station[]> {
if (!StationSearchType[searchType]) {
throw new Error(`search type does not exist: ${searchType}`)
}
search = search ? search.toLowerCase() : ''
// http://fr1.api.radio-browser.info/{format}/stations/byuuid/{searchterm}
const stations = await this.runRequest<StationResponse[]>(
this.buildRequest(`stations/${searchType.toLowerCase()}`, search, query),
fetchConfig
)
return this.normalizeStations(stations, removeDuplicates)
}
/**
* Normalizes stations from the API response
* @param stations - Array of station responses
* @param removeDuplicates - remove duplicate stations
* @returns Array of normalized stations
*/
protected normalizeStations(
stations: StationResponse[],
removeDuplicates = false
): Station[] {
const result = []
const duplicates: { [key: string]: boolean } = {}
for (const response of stations) {
if (removeDuplicates) {
const nameAndUrl = `${response.name.toLowerCase().trim()}${response.url
.toLowerCase()
.trim()}`
// guard against results having the same stations under different id's
if (duplicates[nameAndUrl]) continue
duplicates[nameAndUrl] = true
}
const station: Station = {
changeId: response.changeuuid,
id: response.stationuuid,
name: response.name,
url: response.url,
urlResolved: response.url_resolved,
homepage: response.homepage,
favicon: response.favicon,
country: response.country,
countryCode: response.countrycode,
state: response.state,
votes: response.votes,
codec: response.codec,
bitrate: response.bitrate,
clickCount: response.clickcount,
clickTrend: response.clicktrend,
hls: Boolean(response.hls),
lastCheckOk: Boolean(response.lastcheckok),
lastChangeTime: new Date(response.lastchangetime),
lastCheckOkTime: new Date(response.lastcheckoktime),
clickTimestamp: new Date(response.clicktimestamp),
lastLocalCheckTime: new Date(response.lastlocalchecktime),
language: response.language.split(','),
lastCheckTime: new Date(response.lastchecktime),
geoLat: response.geo_lat,
geoLong: response.geo_long,
tags: Array.from(new Set(response.tags.split(','))).filter(
(tag) => tag.length > 0 && tag.length < 10
) // drop duplicates and tags over 10 characters
}
result.push(station)
}
return result
}
/**
* Gets all available stations. Please note that if results
* are not limited somehow, they can be huge (size in MB)
* @param query - Query
* @param fetchConfig - Fetch configuration
* @param removeDuplicates - remove duplicate stations
* @returns Array of all available stations
*/
async getAllStations(
query?: Omit<StationQuery, 'hidebroken'>,
fetchConfig?: RequestInit,
removeDuplicates = false
): Promise<Station[]> {
const stations = await this.runRequest<StationResponse[]>(
this.buildRequest('stations', '', query),
fetchConfig
)
return this.normalizeStations(stations, removeDuplicates)
}
/**
* Searches stations by particular params
* @param query - Query
* @param fetchConfig - Fetch configuration
* @param removeDuplicates - remove duplicate stations
* @returns Array of station results
*/
async searchStations(
query: AdvancedStationQuery,
fetchConfig?: RequestInit,
removeDuplicates = false
): Promise<Station[]> {
const stations = await this.runRequest<StationResponse[]>(
this.buildRequest('stations/search', undefined, query),
fetchConfig
)
return this.normalizeStations(stations, removeDuplicates)
}
/**
* Gets stations by clicks. Stations with the highest number of clicks are most popular
* @param limit - Limit the number of returned stations
* @param fetchConfig - Fetch configuration
* @returns Array of stations
*/
async getStationsByClicks(
limit?: number,
fetchConfig?: RequestInit
): Promise<Station[]> {
return this.resolveGetStations('topclick', limit, fetchConfig)
}
/**
* Gets stations by votes. Returns most voted stations
* @param limit - Limit the number of returned stations
* @param fetchConfig - Fetch configuration
* @returns Array of stations
*/
async getStationsByVotes(
limit?: number,
fetchConfig?: RequestInit
): Promise<Station[]> {
return this.resolveGetStations('topvote', limit, fetchConfig)
}
/**
* Gets stations by recent clicks. They are basically most recently listened stations.
* @param limit - Limit the number of returned stations
* @param fetchConfig - Fetch configuration
* @returns Array of stations
*/
async getStationsByRecentClicks(
limit?: number,
fetchConfig?: RequestInit
): Promise<Station[]> {
return this.resolveGetStations('lastclick', limit, fetchConfig)
}
/**
* Sends click for the station. This method should be used when user starts to listen to the station.
* @param id - Station id
* @param fetchConfig - Fetch configuration
* @returns Station click object
*/
async sendStationClick(
id: string,
fetchConfig?: RequestInit
): Promise<{
ok: boolean
message: string
stationuuid: string
name: string
url: string
}> {
return this.runRequest(
this.buildRequest('url', id, undefined, false),
fetchConfig
)
}
/**
* Votes for station. This method should be used when user adds the station to favourites etc..
* @param id - Station id
* @param fetchConfig - Fetch configuration
* @returns Station vote object
*/
async voteForStation(
id: string,
fetchConfig?: RequestInit
): Promise<{
ok: boolean
message: string
stationuuid: string
name: string
url: string
}> {
return this.runRequest(this.buildRequest('vote', id), fetchConfig)
}
/**
* Gets stations by station id
* @param ids - Array of station id's
* @param fetchConfig - Fetch configuration
* @returns Array of stations
*/
async getStationsById(
ids: string[],
fetchConfig?: RequestInit
): Promise<Station[]> {
const stationsIds = ids.join(',')
const stations = await this.runRequest<StationResponse[]>(
this.buildRequest(
`stations/byuuid?uuids=${stationsIds}`,
undefined,
undefined,
false
),
fetchConfig
)
return this.normalizeStations(stations)
}
/**
* Gets station by station url
* @param url - Station url
* @param fetchConfig - Fetch configuration
* @returns Array of stations
*/
async getStationByUrl(
url: string,
fetchConfig?: RequestInit
): Promise<Station[]> {
const stations = await this.runRequest<StationResponse[]>(
this.buildRequest(
`stations/byurl?url=${url}`,
undefined,
undefined,
false
),
fetchConfig
)
return this.normalizeStations(stations)
}
protected async resolveGetStations(
endPoint: string,
limit?: number,
fetchConfig?: RequestInit
): Promise<Station[]> {
const limitStations = limit ? `/${limit}` : ''
const stations = await this.runRequest<StationResponse[]>(
this.buildRequest(
`stations/${endPoint}${limitStations}`,
undefined,
undefined,
false
),
fetchConfig
)
return this.normalizeStations(stations)
}
/**
* Builds request to the API
* @param endPoint - API endpoint
* @param search - Search term
* @param query - Query
* @param addHideBrokenParam - Hide broken stations from the results
* @returns Built request string
*/
protected buildRequest(
endPoint: string,
search?: string,
query?: Query | AdvancedStationQuery | StationQuery,
addHideBrokenParam = true
): string {
search = search ? `/${encodeURIComponent(search)}` : ''
let queryCopy
if (query) {
queryCopy = { ...query }
if ('tagList' in queryCopy && Array.isArray(queryCopy.tagList)) {
queryCopy.tagList = [...queryCopy.tagList]
}
if (addHideBrokenParam && queryCopy.hideBroken === undefined) {
queryCopy.hideBroken = this.hideBroken
}
}
const queryParams = queryCopy ? this.createQueryParams(queryCopy) : ''
return `${endPoint}${search}${queryParams}`
}
/**
* Fires of the request to the API
* @param url - Request url
* @param fetchConfig - Fetch configuration
* @returns Fetch response
*/
protected async runRequest<T>(
url: string,
fetchConfig: RequestInit = {}
): Promise<T> {
const finalConfig = {
...this.fetchConfig,
...fetchConfig,
headers: {
...this.fetchConfig.headers,
...fetchConfig.headers
}
}
if (!this.baseUrl) {
const results = await this.resolveBaseUrl()
const random = Math.floor(Math.random() * results.length)
this.baseUrl = `https://${results[random].name}`
}
const response = await fetch(`${this.baseUrl}/json/${url}`, finalConfig)
if (response.ok) {
return response.json()
} else {
throw response
}
}
/**
* Encodes query parameters
* @param params - Object that represents paramters as key value pairs
* @returns String of encoded query parameters
*/
protected createQueryParams(params?: object): string {
let result = ''
if (params) {
for (const [key, value] of Object.entries(params)) {
let finalKey = key.toLowerCase()
switch (finalKey) {
case 'hasgeoinfo':
finalKey = 'has_geo_info'
break
case 'hidebroken':
finalKey = 'hidebroken'
break
case 'taglist':
// github.com/segler-alex/radiobrowser-api-rust/issues/80
finalKey = 'tagList' // tagList is the only one that is not lowercased
}
result += `&${finalKey}=${encodeURIComponent(value)}`
}
}
return result.length ? `?${result.slice(1)}` : ''
}
}