import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; import '../modelos/emisora.dart'; import '../modelos/pais_radio.dart'; /// Cliente para la Radio Browser API (https://api.radio-browser.info/). /// /// Aplica reintentos acotados con rotación de host para tolerar fallos /// transitorios al iniciar. class ServicioRadio { /// Product name for the `User-Agent` the API asks every client to send /// ("Send a speaking http agent string"); requests without one may be /// throttled. static const _productoUserAgent = 'PluriWave'; static const _timeoutPorDefecto = Duration(seconds: 10); static const _maxIntentosPorDefecto = 3; static const _retryDelayPorDefecto = Duration(milliseconds: 250); /// Bootstrap hosts, used only until the live mirror list is discovered. /// /// `all.api.radio-browser.info` is the round-robin entry point the API docs /// point clients at; it tracks whichever mirrors exist without this app /// shipping their names. `de1` follows as a concrete fallback for the case /// where the round-robin record itself is unresolvable. /// /// Deliberately NOT a list of individual mirror names: the docs say "Never /// use a direct link to a single new server. It is much better to get a list /// of the servers", and the previous hardcoded `nl1`/`at1` entries proved the /// point by being decommissioned — they stopped resolving, so both retries /// after the first failure were guaranteed to fail too. static const servidoresSemilla = [ 'all.api.radio-browser.info', 'de1.api.radio-browser.info', ]; ServicioRadio({ http.Client? cliente, List? servidores, int maxIntentos = _maxIntentosPorDefecto, Duration retryDelay = _retryDelayPorDefecto, Duration timeout = _timeoutPorDefecto, }) : _cliente = cliente ?? http.Client(), _servidores = (servidores == null || servidores.isEmpty) ? List.from(servidoresSemilla) : List.from(servidores), // Explicit servers mean the caller is pinning the list (tests, or a // future user-configured mirror), so discovery must not override it. _descubrimientoHecho = servidores != null && servidores.isNotEmpty, _maxIntentos = maxIntentos < 1 ? 1 : maxIntentos, _retryDelay = retryDelay, _timeout = timeout; final http.Client _cliente; final List _servidores; final int _maxIntentos; final Duration _retryDelay; final Duration _timeout; String? _servidorActual; bool _descubrimientoHecho; Future? _descubrimientoEnCurso; String? _userAgent; /// Builds the `User-Agent` from the running build, once per instance. /// /// The version is read at runtime on purpose: CI bumps it on every release, /// so a literal here goes stale immediately — this header claimed `0.1.0` /// while the app shipped 1.1.x. When the package info is unavailable (unit /// tests, any platform without the plugin) the product name goes out on its /// own rather than a made-up version. Never throws: a header must not be able /// to fail a request. Future _resolverUserAgent() async { final cache = _userAgent; if (cache != null) return cache; try { final info = await PackageInfo.fromPlatform(); final version = info.version.isNotEmpty ? '/${info.version}' : ''; final paquete = info.packageName.isNotEmpty ? ' (${info.packageName})' : ''; return _userAgent = '$_productoUserAgent$version$paquete'; } catch (_) { return _userAgent = _productoUserAgent; } } int _indiceServidorInicial() { if (_servidorActual == null) { return 0; } final index = _servidores.indexOf(_servidorActual!); return index >= 0 ? index : 0; } String _servidorPorIntento(int indiceBase, int intento) { final index = (indiceBase + intento) % _servidores.length; return _servidores[index]; } Uri _uri(String servidor, String path, Map params) { return Uri.https(servidor, path, {'hidebroken': 'true', ...params}); } /// Replaces the seed list with the mirrors the API reports as live. /// /// Runs at most once per instance, and never blocks a request for long: any /// failure leaves the seed list in place, which still contains a working /// round-robin host. Concurrent callers share the same in-flight discovery /// instead of each firing their own `/json/servers` request — the home screen /// loads two lists at once through `Future.wait`. Future _descubrirServidores() { if (_descubrimientoHecho) return Future.value(); final enCurso = _descubrimientoEnCurso; if (enCurso != null) return enCurso; final descubrimiento = () async { for (final semilla in _servidores.toList()) { try { final resp = await _cliente .get( Uri.https(semilla, '/json/servers'), headers: {'User-Agent': await _resolverUserAgent()}, ) .timeout(_timeout); if (resp.statusCode != 200) continue; final lista = json.decode(resp.body) as List; // One entry per IP family, so the same name repeats; keep insertion // order and drop duplicates. final nombres = {}; for (final item in lista) { if (item is! Map) continue; final nombre = item['name']; if (nombre is String && nombre.isNotEmpty) nombres.add(nombre); } if (nombres.isEmpty) continue; _servidores ..clear() ..addAll(nombres); _servidorActual = null; return; } on Exception { // Try the next seed; the seed list stays usable either way. continue; } } }(); _descubrimientoEnCurso = descubrimiento.whenComplete(() { _descubrimientoHecho = true; _descubrimientoEnCurso = null; }); return _descubrimientoEnCurso!; } /// Transport ONLY: server discovery, host rotation, bounded retries, /// User-Agent, timeout, status check, `json.decode`, sticky-host /// bookkeeping. No filters, no models, no ordering — deliberately /// sort-agnostic and filter-agnostic so non-station endpoints (e.g. /// `/json/countries`, via [obtenerPaises]) can reuse this resilience /// behaviour without inheriting station-only semantics such as /// `lastcheckok` or bitrate ordering (design ADR-4). /// /// Extracted verbatim from `_get` — no logic edits — so the 8 existing /// station calls stay byte-identical /// (`test/servicios/servicio_radio_transporte_test.dart`). /// /// Named behaviour delta, accepted per ADR-4: `_servidorActual` is set here /// on success/failure, so a successful `/json/countries` call now warms /// the sticky host for subsequent station calls too. Future> _getJson( String path, Map params, ) async { await _descubrirServidores(); Exception? ultimoError; final indiceBase = _indiceServidorInicial(); final totalIntentos = _maxIntentos; for (int intento = 0; intento < totalIntentos; intento++) { final servidor = _servidorPorIntento(indiceBase, intento); final uri = _uri(servidor, path, params); try { final resp = await _cliente .get(uri, headers: {'User-Agent': await _resolverUserAgent()}) .timeout(_timeout); if (resp.statusCode != 200) { throw Exception('API error ${resp.statusCode}'); } final lista = json.decode(resp.body) as List; _servidorActual = servidor; return lista; } on Exception catch (e) { ultimoError = e; _servidorActual = null; final ultimoIntento = intento == (totalIntentos - 1); if (!ultimoIntento && _retryDelay > Duration.zero) { await Future.delayed(_retryDelay); } } } throw ultimoError ?? Exception('Error desconocido al consultar la API'); } /// Station layer over [_getJson]: adds the station-only `lastcheckok` /// filter, maps to [Emisora], drops entries with an empty `uuid`/`url`, /// and applies the quality sort. None of this belongs in transport — see /// [_getJson]'s doc comment. Future> _get(String path, Map params) async { final lista = await _getJson(path, {'lastcheckok': '1', ...params}); final emisoras = lista .cast>() .map(Emisora.fromApi) .where((e) => e.uuid.isNotEmpty && e.url.isNotEmpty) .toList(); emisoras.sort(_compararCalidad); return emisoras; } /// Emisoras más votadas globalmente. Future> obtenerPopulares({ int limit = 30, int offset = 0, }) async { return _get('/json/stations/search', { 'limit': limit.toString(), 'offset': offset.toString(), 'order': 'bitrate', 'reverse': 'true', }); } /// Emisoras más escuchadas (por clicks) globalmente. Future> obtenerTendencias({int limit = 20}) async { final emisoras = await _get('/json/stations/topclick/$limit', {}); emisoras.sort(_compararCalidad); return emisoras; } /// Buscar por nombre de emisora. Future> buscarPorNombre( String query, { int limit = 30, int offset = 0, }) async { return _get('/json/stations/search', { 'name': query, 'limit': limit.toString(), 'offset': offset.toString(), 'order': 'bitrate', 'reverse': 'true', }); } /// Buscar por código de país (ISO 3166-1 alpha-2, e.g. 'ES', 'US'). Future> buscarPorPais( String codigoPais, { int limit = 50, int offset = 0, }) async { return _get('/json/stations/bycountrycodeexact/$codigoPais', { 'limit': limit.toString(), 'offset': offset.toString(), 'order': 'bitrate', 'reverse': 'true', }); } /// Buscar por idioma (e.g. 'spanish', 'english'). Future> buscarPorIdioma( String idioma, { int limit = 30, int offset = 0, }) async { return _get('/json/stations/bylanguageexact/$idioma', { 'limit': limit.toString(), 'offset': offset.toString(), 'order': 'bitrate', 'reverse': 'true', }); } /// Buscar por tag/género (e.g. 'rock', 'jazz', 'pop'). Future> buscarPorTag( String tag, { int limit = 30, int offset = 0, }) async { return _get('/json/stations/bytagexact/$tag', { 'limit': limit.toString(), 'offset': offset.toString(), 'order': 'bitrate', 'reverse': 'true', }); } /// Búsqueda combinada: permite combinar nombre, país, idioma y tag. Future> buscar({ String? nombre, String? pais, String? idioma, String? tag, int limit = 30, int offset = 0, }) async { return _get('/json/stations/search', { if (nombre != null && nombre.isNotEmpty) 'name': nombre, if (pais != null && pais.isNotEmpty) 'countrycode': pais, if (idioma != null && idioma.isNotEmpty) 'language': idioma, if (tag != null && tag.isNotEmpty) 'tag': tag, 'limit': limit.toString(), 'offset': offset.toString(), 'order': 'bitrate', 'reverse': 'true', }); } /// Países disponibles vía `/json/countries` (station-discovery-browse /// spec — "Países Browser Over the Verified Countries Contract"). /// /// Deliberately reuses [_getJson], never [_get]: /// - **No `lastcheckok`.** That filter is station-only and meaningless on /// a countries listing — sending it would be the whole bug this /// extraction exists to avoid (Engram id 2500). /// - **No `order` parameter.** Not because the endpoint default is /// convenient, but because the screen sorts client-side by name anyway: /// the API orders by raw byte order, which is not proper collation for /// any locale this app ships (Engram id 2505's client-side-sort /// reasoning applies here too). /// - `hidebroken=true` is still applied (inherited from `_uri`, unchanged) /// — the endpoint's own default is `false`, so this keeps dead stations /// out of the per-country counts, which is desirable, not a station-only /// concern. /// /// The `.where(...)` guard mirrors `_get`'s own precedent of dropping /// entries with empty required fields — not a claim about API behaviour. Future> obtenerPaises() async { final lista = await _getJson('/json/countries', const {}); return lista .whereType>() .map(PaisRadio.fromApi) .where((p) => p.nombre.isNotEmpty && p.codigoIso.length == 2) .toList(); } int _compararCalidad(Emisora a, Emisora b) { final bitrateA = a.bitrate ?? 0; final bitrateB = b.bitrate ?? 0; final porBitrate = bitrateB.compareTo(bitrateA); if (porBitrate != 0) return porBitrate; final porClicks = b.clickcount.compareTo(a.clickcount); if (porClicks != 0) return porClicks; return b.votes.compareTo(a.votes); } /// Registrar un click en la API (best effort). Future registrarClick(String uuid) async { try { final servidor = _servidorActual ?? _servidorPorIntento(_indiceServidorInicial(), 0); await _cliente .get( Uri.https(servidor, '/json/url/$uuid'), headers: { 'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)', }, ) .timeout(_timeout); } catch (_) { // No crítico, ignorar. } } }