diff --git a/lib/servicios/servicio_radio.dart b/lib/servicios/servicio_radio.dart index 7b1869c..54419d8 100644 --- a/lib/servicios/servicio_radio.dart +++ b/lib/servicios/servicio_radio.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; import '../modelos/emisora.dart'; @@ -9,15 +10,30 @@ import '../modelos/emisora.dart'; /// 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); - // Servidores conocidos como fallback si el DNS falla. - static const _servidoresFallback = [ + /// 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', - 'nl1.api.radio-browser.info', - 'at1.api.radio-browser.info', ]; ServicioRadio({ @@ -29,8 +45,11 @@ class ServicioRadio { }) : _cliente = cliente ?? http.Client(), _servidores = (servidores == null || servidores.isEmpty) - ? List.from(_servidoresFallback) + ? 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; @@ -42,6 +61,31 @@ class ServicioRadio { 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) { @@ -60,7 +104,61 @@ class ServicioRadio { 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!; + } + Future> _get(String path, Map params) async { + await _descubrirServidores(); Exception? ultimoError; final indiceBase = _indiceServidorInicial(); final totalIntentos = _maxIntentos; @@ -73,9 +171,7 @@ class ServicioRadio { final resp = await _cliente .get( uri, - headers: { - 'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)', - }, + headers: {'User-Agent': await _resolverUserAgent()}, ) .timeout(_timeout); diff --git a/test/servicios/servicio_radio_test.dart b/test/servicios/servicio_radio_test.dart index c755a0b..9c00b57 100644 --- a/test/servicios/servicio_radio_test.dart +++ b/test/servicios/servicio_radio_test.dart @@ -105,4 +105,152 @@ void main() { expect(emisoras.map((e) => e.uuid), equals(['alta', 'baja'])); }); }); + + // --------------------------------------------------------------------------- + // Server discovery + // + // The API docs are explicit: "Never use a direct link to a single new server. + // It is much better to get a list of the servers", because mirror names come + // and go. Two of the three names this client used to hardcode (nl1, at1) no + // longer resolve, which turned both retries into guaranteed failures. + // --------------------------------------------------------------------------- + + group('ServicioRadio — User-Agent', () { + test('identifica al cliente sin inventarse una versión', () async { + String? agente; + final servicio = ServicioRadio( + cliente: MockClient((request) async { + agente = request.headers['User-Agent']; + return http.Response(jsonEncode(const []), 200); + }), + servidores: const ['host.api.radio-browser.info'], + retryDelay: Duration.zero, + ); + + await servicio.obtenerPopulares(limit: 1); + + expect(agente, isNotNull); + expect(agente, contains('PluriWave')); + // The version comes from the build at runtime; CI bumps it on every + // release, so a literal here would be stale within one build. It used to + // claim 0.1.0 while the app shipped 1.1.x. + expect(agente, isNot(contains('0.1.0'))); + }); + }); + + group('ServicioRadio — descubrimiento de servidores', () { + List respuestaEmisoras() => utf8.encode( + jsonEncode([ + { + 'stationuuid': 'uuid-ok', + 'name': 'Radio OK', + 'url_resolved': 'https://stream.ok/audio', + }, + ]), + ); + + test('no arranca con hosts muertos: usa el alias round-robin', () { + expect( + ServicioRadio.servidoresSemilla.first, + equals('all.api.radio-browser.info'), + ); + expect( + ServicioRadio.servidoresSemilla, + isNot(contains('nl1.api.radio-browser.info')), + ); + expect( + ServicioRadio.servidoresSemilla, + isNot(contains('at1.api.radio-browser.info')), + ); + }); + + test('consulta /json/servers y usa los nombres descubiertos', () async { + final hosts = []; + final servicio = ServicioRadio( + cliente: MockClient((request) async { + hosts.add(request.url.host); + if (request.url.path == '/json/servers') { + return http.Response( + jsonEncode([ + {'ip': '1.2.3.4', 'name': 'descubierto.api.radio-browser.info'}, + {'ip': '::1', 'name': 'descubierto.api.radio-browser.info'}, + ]), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response.bytes(respuestaEmisoras(), 200); + }), + retryDelay: Duration.zero, + ); + + final emisoras = await servicio.obtenerPopulares(limit: 1); + + expect(emisoras, hasLength(1)); + expect(hosts.first, equals('all.api.radio-browser.info')); + // Duplicate names (one per IP family) collapse to a single host. + expect(hosts.sublist(1), equals(['descubierto.api.radio-browser.info'])); + }); + + test('descubrimiento fallido cae a la semilla sin romper', () async { + final hosts = []; + final servicio = ServicioRadio( + cliente: MockClient((request) async { + hosts.add(request.url.host); + if (request.url.path == '/json/servers') { + throw http.ClientException('sin red', request.url); + } + return http.Response.bytes(respuestaEmisoras(), 200); + }), + retryDelay: Duration.zero, + ); + + final emisoras = await servicio.obtenerPopulares(limit: 1); + + expect(emisoras, hasLength(1)); + expect(hosts.last, isIn(ServicioRadio.servidoresSemilla)); + }); + + test('el descubrimiento ocurre una sola vez por instancia', () async { + var llamadasServers = 0; + final servicio = ServicioRadio( + cliente: MockClient((request) async { + if (request.url.path == '/json/servers') { + llamadasServers += 1; + return http.Response( + jsonEncode([ + {'ip': '1.2.3.4', 'name': 'uno.api.radio-browser.info'}, + ]), + 200, + ); + } + return http.Response.bytes(respuestaEmisoras(), 200); + }), + retryDelay: Duration.zero, + ); + + await servicio.obtenerPopulares(limit: 1); + await servicio.obtenerPopulares(limit: 1); + + expect(llamadasServers, equals(1)); + }); + + test('servidores inyectados desactivan el descubrimiento', () async { + var llamadasServers = 0; + final servicio = ServicioRadio( + cliente: MockClient((request) async { + if (request.url.path == '/json/servers') { + llamadasServers += 1; + } + return http.Response.bytes(respuestaEmisoras(), 200); + }), + servidores: const ['host.api.radio-browser.info'], + retryDelay: Duration.zero, + ); + + await servicio.obtenerPopulares(limit: 1); + + expect(llamadasServers, equals(0)); + }); + }); }