.oO SearXNG Developer Documentation Oo.
Loading...
Searching...
No Matches
radio_browser.py
Go to the documentation of this file.
1# SPDX-License-Identifier: AGPL-3.0-or-later
2"""Search radio stations from RadioBrowser by `Advanced station search API`_.
3
4.. _Advanced station search API:
5 https://de1.api.radio-browser.info/#Advanced_station_search
6
7"""
8from __future__ import annotations
9
10import typing
11import random
12import socket
13from urllib.parse import urlencode
14import babel
15from flask_babel import gettext
16
17from searx.network import get
18from searx.enginelib import EngineCache
19from searx.enginelib.traits import EngineTraits
20from searx.locales import language_tag
21
22if typing.TYPE_CHECKING:
23 import logging
24
25 logger = logging.getLogger()
26
27traits: EngineTraits
28
29about = {
30 "website": 'https://www.radio-browser.info/',
31 "wikidata_id": 'Q111664849',
32 "official_api_documentation": 'https://de1.api.radio-browser.info/',
33 "use_official_api": True,
34 "require_api_key": False,
35 "results": 'JSON',
36}
37paging = True
38categories = ['music', 'radio']
39
40number_of_results = 10
41
42station_filters = [] # ['countrycode', 'language']
43"""A list of filters to be applied to the search of radio stations. By default
44none filters are applied. Valid filters are:
45
46``language``
47 Filter stations by selected language. For instance the ``de`` from ``:de-AU``
48 will be translated to `german` and used in the argument ``language=``.
49
50``countrycode``
51 Filter stations by selected country. The 2-digit countrycode of the station
52 comes from the region the user selected. For instance ``:de-AU`` will filter
53 out all stations not in ``AU``.
54
55.. note::
56
57 RadioBrowser has registered a lot of languages and countrycodes unknown to
58 :py:obj:`babel` and note that when searching for radio stations, users are
59 more likely to search by name than by region or language.
60
61"""
62
63CACHE: EngineCache
64"""Persistent (SQLite) key/value cache that deletes its values after ``expire``
65seconds."""
66
67
68def init(_):
69 global CACHE # pylint: disable=global-statement
70 CACHE = EngineCache("radio_browser")
72
73
74def server_list() -> list[str]:
75
76 servers = CACHE.get("servers", [])
77 if servers:
78 return servers
79
80 # hint: can take up to 40sec!
81 ips = socket.getaddrinfo("all.api.radio-browser.info", 80, 0, 0, socket.IPPROTO_TCP)
82 for ip_tuple in ips:
83 _ip: str = ip_tuple[4][0] # type: ignore
84 url = socket.gethostbyaddr(_ip)[0]
85 srv = "https://" + url
86 if srv not in servers:
87 servers.append(srv)
88
89 # update server list once in 24h
90 CACHE.set(key="servers", value=servers, expire=60 * 60 * 24)
91
92 return servers
93
94
95def request(query, params):
96
97 servers = server_list()
98 if not servers:
99 logger.error("Fetched server list is empty!")
100 params["url"] = None
101 return
102
103 server = random.choice(servers)
104
105 args = {
106 'name': query,
107 'order': 'votes',
108 'offset': (params['pageno'] - 1) * number_of_results,
109 'limit': number_of_results,
110 'hidebroken': 'true',
111 'reverse': 'true',
112 }
113
114 if 'language' in station_filters:
115 lang = traits.get_language(params['searxng_locale']) # type: ignore
116 if lang:
117 args['language'] = lang
118
119 if 'countrycode' in station_filters:
120 if len(params['searxng_locale'].split('-')) > 1:
121 countrycode = params['searxng_locale'].split('-')[-1].upper()
122 if countrycode in traits.custom['countrycodes']: # type: ignore
123 args['countrycode'] = countrycode
124
125 params['url'] = f"{server}/json/stations/search?{urlencode(args)}"
126
127
128def response(resp):
129 results = []
130
131 json_resp = resp.json()
132
133 for result in json_resp:
134 url = result['homepage']
135 if not url:
136 url = result['url_resolved']
137
138 content = []
139 tags = ', '.join(result.get('tags', '').split(','))
140 if tags:
141 content.append(tags)
142 for x in ['state', 'country']:
143 v = result.get(x)
144 if v:
145 v = str(v).strip()
146 content.append(v)
147
148 metadata = []
149 codec = result.get('codec')
150 if codec and codec.lower() != 'unknown':
151 metadata.append(f'{codec} ' + gettext('radio'))
152 for x, y in [
153 (gettext('bitrate'), 'bitrate'),
154 (gettext('votes'), 'votes'),
155 (gettext('clicks'), 'clickcount'),
156 ]:
157 v = result.get(y)
158 if v:
159 v = str(v).strip()
160 metadata.append(f"{x} {v}")
161 results.append(
162 {
163 'url': url,
164 'title': result['name'],
165 'thumbnail': result.get('favicon', '').replace("http://", "https://"),
166 'content': ' | '.join(content),
167 'metadata': ' | '.join(metadata),
168 'iframe_src': result['url_resolved'].replace("http://", "https://"),
169 }
170 )
171
172 return results
173
174
175def fetch_traits(engine_traits: EngineTraits):
176 """Fetch languages and countrycodes from RadioBrowser
177
178 - ``traits.languages``: `list of languages API`_
179 - ``traits.custom['countrycodes']``: `list of countries API`_
180
181 .. _list of countries API: https://de1.api.radio-browser.info/#List_of_countries
182 .. _list of languages API: https://de1.api.radio-browser.info/#List_of_languages
183 """
184 # pylint: disable=import-outside-toplevel
185
186 init(None)
187 from babel.core import get_global
188
189 babel_reg_list = get_global("territory_languages").keys()
190
191 server = server_list()[0]
192 language_list = get(f'{server}/json/languages').json() # type: ignore
193 country_list = get(f'{server}/json/countries').json() # type: ignore
194
195 for lang in language_list:
196
197 babel_lang = lang.get('iso_639')
198 if not babel_lang:
199 # the language doesn't have any iso code, and hence can't be parsed
200 # print(f"ERROR: lang - no iso code in {lang}")
201 continue
202 try:
203 sxng_tag = language_tag(babel.Locale.parse(babel_lang, sep="-"))
204 except babel.UnknownLocaleError:
205 # print(f"ERROR: language tag {babel_lang} is unknown by babel")
206 continue
207
208 eng_tag = lang['name']
209 conflict = engine_traits.languages.get(sxng_tag)
210 if conflict:
211 if conflict != eng_tag:
212 print("CONFLICT: babel %s --> %s, %s" % (sxng_tag, conflict, eng_tag))
213 continue
214 engine_traits.languages[sxng_tag] = eng_tag
215
216 countrycodes = set()
217 for region in country_list:
218 # country_list contains duplicates that differ only in upper/lower case
219 _reg = region['iso_3166_1'].upper()
220 if _reg not in babel_reg_list:
221 print(f"ERROR: region tag {region['iso_3166_1']} is unknown by babel")
222 continue
223 countrycodes.add(_reg)
224
225 countrycodes = list(countrycodes)
226 countrycodes.sort()
227 engine_traits.custom['countrycodes'] = countrycodes
fetch_traits(EngineTraits engine_traits)