fix(radio): discover live API mirrors instead of hardcoding dead ones
Two of the three Radio Browser hosts this client shipped no longer resolve.
The retry loop rotates de1 -> nl1 -> at1, so once the first attempt failed
for any transient reason the remaining two were guaranteed to fail as well:
the retries meant to add resilience had become a dead end, and a single blip
surfaced as "No connection to the radio API" with a healthy API and a healthy
network. The live mirror list confirms only one server remains:
[{"ip":"91.98.4.78","name":"de1.api.radio-browser.info"},
{"ip":"2a01:4f8:1c1d:699::1","name":"de1.api.radio-browser.info"}]
The API docs say exactly what this code was doing wrong: "Never use a direct
link to a single new server. It is much better to get a list of the servers",
pointing clients at all.api.radio-browser.info to enumerate what exists.
Seed with that round-robin host plus de1, then resolve the real list from
/json/servers once per instance and rotate over that. Discovery shares one
in-flight request across concurrent callers, because the home screen loads
two lists at once through Future.wait, and any failure silently leaves the
seed list in place — it still contains a working host, so a failed discovery
must never be worse than not trying. Explicitly injected servers disable
discovery so callers can still pin a mirror.
Build the User-Agent from the running package too. The API asks clients to
identify themselves, and this header claimed PluriWave/0.1.0 while the app
shipped 1.1.x. A literal cannot stay correct here — CI bumps the version on
every single release — so read it via package_info_plus, already a dependency
used in three other places. If package info is unavailable the product name
goes out alone rather than a made-up version, and resolution never throws: a
header must not be able to fail a request.
This commit is contained in:
@@ -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<int> 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 = <String>[];
|
||||
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 = <String>[];
|
||||
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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user