radio_browser.py 6.8 KB

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