Files
plezy/lib/services/jellyfin_endpoint_discovery.dart
T
edde746 369c6279d6 fix(i18n): translate the player, downloads and server-setup text left in English
A Portuguese user reported "Skip Intro" rendering in English on Android TV.
The locale files were not the problem - all 22 were structurally complete.
skip_marker_button.dart simply never imported strings.g.dart and assigned
'Skip Intro' / 'Skip Credits' / 'Next Episode' as plain literals. An audit of
lib/ found ~120 more sites in the same state, in four shapes that need
different fixes:

A literal in a file that never imported the i18n layer is the easy one -
skip_marker_button, performance_stats, track_label_builder and codec_utils all
render text with no `t` in the file at all. TrackLabelBuilder._compose now takes
a fallbackLabel builder instead of an English fallbackPrefix, so the caller
supplies t.audioTracks.track / t.videoControls.subtitleTrack and every unnamed
audio and subtitle row in the track menus is localized.

English reaching the user through an exception message is the widest one, and
it needs care: MediaServerException.message feeds both toString() - logs and
Sentry grouping - and verbatim UI display. Localizing it in place would make
bug-report logs follow the user's locale and split one Sentry issue into 22.
The MediaServer and Seerr families instead gain a nullable `display` alongside
the English `message`, and the six screens that print these errors read
`display ?? message`. PlaybackException keeps the opposite rule, because it
already carries a PlaybackFailureReason for logic and classifyPlaybackFailure
already builds it from t.messages: its stragglers are localized at the throw
site. That also removes the literal "Exception: " prefix Live TV users saw on
a tune failure, since PlaybackException.toString() returns the bare message.

Localized parts hand-concatenated with bare English are the shape no search for
Text('...') can find: '${t.common.pause} auto-scroll' on the home carousel,
'${day} at ${time}' on the Live TV schedule row, and an actor-screen count that
hand-rolled its plural as `n == 1 ? 'title' : 'titles'` - wrong for ru and pl
regardless of translation, now a real Slang plural.

Finally a literal assigned to provider state that a widget renders later:
DownloadProgress.errorMessage, and the four background_downloader notification
bodies, which sit inside a plugin config call where no widget-shaped search
reaches them.

Two things surfaced while converting. track_chapter_controls compared a track
label against 'Audio Track N' to swap in a localized version; once the builder
localized its own fallback that branch became unreachable, so it and the
orphaned _joinTrackLabel are gone. And discovery_view's PeerError fallback arm
looks like a leak but is not - its producers already localize, and a test says
so - so it stays as it is.

All 21 non-base locales are translated, including the 21 keys left empty by
earlier commits that were falling back to English. No locale has an empty value.

scripts/check_hardcoded_strings.py guards the three shapes a structural check
can see, and runs in ci_checks.sh after translation hygiene. Its first draft
passed its own tests while missing this very bug, because 'Skip Intro' is bound
to a local rather than handed to Text(); the name-bound rule that closes that
gap is restricted to phrase-shaped literals, or it cannot tell copy from the
identifiers this codebase binds constantly ('cast_row', 'auto', 'liveTv'). It
cannot see English inside a throw or assigned to a provider field - neither is
distinguishable from a log message without dataflow analysis - and the docstring
says so. label: and actionLabel: are deliberately unscanned: here they name a
diagnostic operation, and a check that is chronically red is a check that gets
switched off.

One commit rather than one per area: the keys, the 22 locale files and the
generated output are a single unit, and any partial split fails the repo's own
unused-key scan on the way through.

close #1856
2026-08-10 15:32:43 +02:00

602 lines
23 KiB
Dart

import 'dart:async';
import 'package:http/http.dart' as http;
import '../i18n/strings.g.dart';
import '../exceptions/media_server_exceptions.dart';
import '../media/media_browser_dialect.dart';
import '../utils/endpoint_race.dart';
import '../utils/log_redaction_manager.dart';
import '../utils/media_server_http_client.dart';
import '../utils/media_server_timeouts.dart';
import '../utils/url_utils.dart';
/// Result of a successful MediaBrowser server URL probe (`/System/Info/Public`).
class JellyfinServerInfo {
final String serverName;
/// Server's `Id` field — its stable machine identifier.
final String machineId;
/// Server's reported version string.
final String version;
/// Dialect detected from public system info, or `null` when the response has
/// no trustworthy Jellyfin/Emby discriminator.
final MediaBrowserDialect? dialect;
const JellyfinServerInfo({required this.serverName, required this.machineId, required this.version, this.dialect});
}
class JellyfinEndpointRaceResult {
final String activeBaseUrl;
/// Active-first endpoints selected for persistence. Candidates that reported
/// another machine ID are excluded; candidates that returned no trustworthy
/// identity are retained for a later retry.
final List<String> baseUrls;
final JellyfinServerInfo serverInfo;
final Map<String, String> _verifiedEffectiveBaseUrls;
final Set<String> _machineMismatchBaseUrls;
const JellyfinEndpointRaceResult._({
required this.activeBaseUrl,
required this.baseUrls,
required this.serverInfo,
required this._verifiedEffectiveBaseUrls,
required this._machineMismatchBaseUrls,
});
/// Reconciles endpoints from an existing authenticated connection after a
/// partial race.
///
/// Same-machine candidates are replaced with their verified effective URL,
/// candidates that reported another machine ID are removed, and candidates
/// that returned no trustworthy identity are retained for a later retry.
/// Fresh user input must continue to use [baseUrls] instead.
List<String> reconcilePreviouslyStoredBaseUrls(Iterable<String> storedBaseUrls) {
final retained = <String>[];
for (final url in JellyfinEndpointDiscovery.normalizeBaseUrls(storedBaseUrls)) {
final verifiedEffectiveUrl = _verifiedEffectiveBaseUrls[url];
if (verifiedEffectiveUrl != null) {
retained.add(verifiedEffectiveUrl);
} else if (!_machineMismatchBaseUrls.contains(url)) {
retained.add(url);
}
}
return JellyfinEndpointDiscovery._activeFirst(activeBaseUrl, retained);
}
}
class JellyfinEndpointProbeResult {
final bool success;
final int latencyMs;
final JellyfinServerInfo? serverInfo;
final String? effectiveBaseUrl;
final String? failureType;
const JellyfinEndpointProbeResult({
required this.success,
required this.latencyMs,
this.serverInfo,
this.effectiveBaseUrl,
this.failureType,
});
}
class JellyfinEndpointCandidate {
final String url;
final int index;
const JellyfinEndpointCandidate({required this.url, required this.index});
}
class JellyfinEndpointUserInputCandidates {
final List<String> probeBaseUrls;
final List<String> explicitBaseUrls;
final List<List<String>> validationBaseUrlGroups;
const JellyfinEndpointUserInputCandidates({
required this.probeBaseUrls,
required this.explicitBaseUrls,
required this.validationBaseUrlGroups,
});
}
class JellyfinEndpointDiscovery {
static const int defaultPort = 8096;
JellyfinEndpointDiscovery({this.dialect = MediaBrowserDialect.jellyfin, this._testHttpClientFactory});
final MediaBrowserDialect dialect;
final http.Client Function()? _testHttpClientFactory;
MediaServerHttpClient _buildHttpClient({required String baseUrl}) {
LogRedactionManager.registerServerUrl(baseUrl);
return MediaServerHttpClient(baseUrl: baseUrl, client: _testHttpClientFactory?.call());
}
/// Probe the server identified by [baseUrl] without authenticating.
Future<JellyfinServerInfo> probe(
String baseUrl, {
Duration timeout = MediaServerTimeouts.jellyfinProbe,
AbortController? abort,
}) async {
final result = await _probeServer(baseUrl, timeout: timeout, abort: abort);
return result.serverInfo;
}
Future<({JellyfinServerInfo serverInfo, String effectiveBaseUrl})> _probeServer(
String baseUrl, {
required Duration timeout,
AbortController? abort,
}) async {
final normalised = normalizeBaseUrl(baseUrl);
final client = _buildHttpClient(baseUrl: normalised);
try {
final response = await client.get('/System/Info/Public', timeout: timeout, abort: abort);
throwIfHttpError(response);
final effectiveBaseUrl = _resolveEffectiveBaseUrl(normalised, response);
if (effectiveBaseUrl != normalised) {
LogRedactionManager.registerServerUrl(effectiveBaseUrl);
}
final data = response.data;
if (data is! Map<String, dynamic>) {
throw MediaServerUrlException('Server response was not JSON', display: t.addServer.responseNotJson);
}
final id = data['Id'];
final name = data['ServerName'] ?? data['LocalAddress'];
if (id is! String || name is! String) {
throw MediaServerUrlException(
'Server response missing Id/ServerName — not a ${dialect.productName} server?',
display: t.addServer.responseMissingIdentity(product: dialect.productName),
);
}
return (
serverInfo: JellyfinServerInfo(
serverName: name,
machineId: id,
version: data['Version'] as String? ?? '',
dialect: MediaBrowserDialect.detectFromPublicSystemInfo(data),
),
effectiveBaseUrl: effectiveBaseUrl,
);
} on MediaServerUrlException {
rethrow;
} on MediaServerHttpException catch (e) {
if (e.isCancellation) rethrow;
throw MediaServerUrlException(
'Server probe failed: ${e.message}',
display: t.addServer.probeFailed(error: e.message),
);
} on TimeoutException {
throw MediaServerUrlException('Server did not respond in time', display: t.addServer.serverTimedOut);
} catch (e) {
throw MediaServerUrlException('Server probe failed: $e', display: t.addServer.probeFailed(error: e));
} finally {
client.close();
}
}
/// Races public MediaBrowser server probes and returns persistence-safe endpoints.
///
/// [baseUrlsToPersist] contains caller-selected persistence candidates.
/// Candidates that reported another machine are excluded; candidates that
/// returned no trustworthy identity are retained for a later retry.
Future<JellyfinEndpointRaceResult> raceEndpoints(
Iterable<String> baseUrls, {
String? preferredUrl,
String? expectedMachineId,
Iterable<String>? baseUrlsToPersist,
Iterable<String>? baseUrlsToValidate,
Iterable<Iterable<String>>? baseUrlValidationGroups,
}) async {
final urls = normalizeBaseUrls(baseUrls);
if (urls.isEmpty) {
throw MediaServerUrlException(
'Enter at least one ${dialect.productName} server URL',
display: t.addServer.enterAtLeastOneUrl(product: dialect.productName),
);
}
final persistUrls = baseUrlsToPersist == null ? urls : normalizeBaseUrls(baseUrlsToPersist);
final validateUrls = baseUrlsToValidate == null ? urls : normalizeBaseUrls(baseUrlsToValidate);
final validateUrlSet = validateUrls.toSet();
final validationGroups = baseUrlValidationGroups == null ? null : _normalizeBaseUrlGroups(baseUrlValidationGroups);
final preferred = preferredUrl == null || preferredUrl.trim().isEmpty ? null : normalizeBaseUrl(preferredUrl);
final candidates = [for (var i = 0; i < urls.length; i++) JellyfinEndpointCandidate(url: urls[i], index: i)];
final identityResults = <JellyfinEndpointCandidate, JellyfinEndpointProbeResult>{};
final phaseOneIdentityProbes = <Future<JellyfinEndpointProbeResult>>[];
JellyfinEndpointProbeResult recordIdentity(
JellyfinEndpointCandidate candidate,
JellyfinEndpointProbeResult result,
) {
if (result.serverInfo != null) identityResults[candidate] = result;
return result;
}
EndpointRaceSelection<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>? firstSelection;
EndpointRaceSelection<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>? bestSelection;
await for (final selection in raceEndpointCandidates<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>(
label: '${dialect.productName} server URL',
candidates: candidates,
preferredUrl: preferred,
urlOf: (candidate) => candidate.url,
failureLogFields: (candidate, result) => {'failureType': result.failureType, 'latencyMs': result.latencyMs},
probe: (candidate, timeout) {
final identityProbe = _probeWithLatency(
candidate.url,
timeout: timeout,
).then((result) => recordIdentity(candidate, result));
phaseOneIdentityProbes.add(identityProbe);
return identityProbe;
},
measure: (candidate) async =>
recordIdentity(candidate, await _probeWithAverageLatency(candidate.url, attempts: 2)),
isSuccess: (result) => result.success,
selectBestCandidate: (results) => _selectLowestLatencyCandidate(results),
)) {
if (selection.phase == EndpointRacePhase.first) {
firstSelection = selection;
} else {
bestSelection = selection;
}
}
// The first-success race deliberately returns while slower phase-one
// probes are still running. Each probe already carries the race timeout;
// wait for those bounded results before deciding which persisted
// fallbacks proved the expected machine identity.
await Future.wait(phaseOneIdentityProbes);
final selected = bestSelection ?? firstSelection;
if (selected == null || selected.result.serverInfo == null) {
throw MediaServerUrlException(
'No reachable ${dialect.productName} server found',
display: t.addServer.noReachableServer(product: dialect.productName),
);
}
final Map<JellyfinEndpointCandidate, JellyfinEndpointProbeResult> successfulResults =
bestSelection?.successfulResults ?? firstSelection?.successfulResults ?? const {};
var selectedCandidate = selected.candidate;
var selectedResult = selected.result;
final expectedMachineIdTrimmed = expectedMachineId?.trim();
final hasExpectedMachineId = expectedMachineIdTrimmed?.isNotEmpty == true;
if (hasExpectedMachineId) {
final matchingResults = Map<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>.fromEntries(
successfulResults.entries.where((entry) => entry.value.serverInfo?.machineId == expectedMachineIdTrimmed),
);
final matchingCandidate = _selectLowestLatencyCandidate(matchingResults);
final matchingResult = matchingCandidate == null ? null : matchingResults[matchingCandidate];
if (matchingCandidate != null && matchingResult != null) {
selectedCandidate = matchingCandidate;
selectedResult = matchingResult;
}
}
final selectedInfo = selectedResult.serverInfo;
if (selectedInfo == null) {
throw MediaServerUrlException(
'No reachable ${dialect.productName} server found',
display: t.addServer.noReachableServer(product: dialect.productName),
);
}
final expected = hasExpectedMachineId ? expectedMachineIdTrimmed! : selectedInfo.machineId;
if (validationGroups != null) {
if (validationGroups.length > 1) {
for (final group in validationGroups) {
final groupSet = group.toSet();
final groupResults = Map<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>.fromEntries(
identityResults.entries.where((entry) => groupSet.contains(entry.key.url)),
);
final candidate = _selectValidationCandidate(groupResults, expectedMachineId: expectedMachineIdTrimmed);
final info = candidate == null ? null : groupResults[candidate]?.serverInfo;
if (info != null && info.machineId != expected) {
throw MediaServerUrlException(
'The URLs point to different ${dialect.productName} servers',
display: t.addServer.urlsPointToDifferentServers(product: dialect.productName),
);
}
}
}
} else {
for (final entry in identityResults.entries) {
if (!validateUrlSet.contains(entry.key.url)) continue;
final info = entry.value.serverInfo;
if (info != null && info.machineId != expected) {
throw MediaServerUrlException(
'The URLs point to different ${dialect.productName} servers',
display: t.addServer.urlsPointToDifferentServers(product: dialect.productName),
);
}
}
}
if (selectedInfo.machineId != expected) {
throw MediaServerUrlException(
'The URL does not match this ${dialect.productName} server',
display: t.addServer.urlDoesNotMatchServer(product: dialect.productName),
);
}
final effectiveUrls = <String, String>{};
final machineMismatchBaseUrls = <String>{};
for (final entry in identityResults.entries) {
if (entry.value.serverInfo?.machineId != expected) {
machineMismatchBaseUrls.add(entry.key.url);
continue;
}
final effectiveBaseUrl = entry.value.effectiveBaseUrl;
if (effectiveBaseUrl != null) {
effectiveUrls[entry.key.url] = effectiveBaseUrl;
}
}
final activeBaseUrl = selectedResult.effectiveBaseUrl ?? selectedCandidate.url;
effectiveUrls[selectedCandidate.url] = activeBaseUrl;
final persistedUrls = [
for (final url in persistUrls)
if (!machineMismatchBaseUrls.contains(url)) effectiveUrls[url] ?? url,
];
return JellyfinEndpointRaceResult._(
activeBaseUrl: activeBaseUrl,
baseUrls: _activeFirst(activeBaseUrl, persistedUrls),
serverInfo: selectedInfo,
verifiedEffectiveBaseUrls: Map.unmodifiable(effectiveUrls),
machineMismatchBaseUrls: Set.unmodifiable(machineMismatchBaseUrls),
);
}
Future<JellyfinEndpointProbeResult> _probeWithLatency(String baseUrl, {required Duration timeout}) async {
final stopwatch = Stopwatch()..start();
try {
final probe = await _probeServer(baseUrl, timeout: timeout);
stopwatch.stop();
return JellyfinEndpointProbeResult(
success: true,
latencyMs: stopwatch.elapsedMilliseconds,
serverInfo: probe.serverInfo,
effectiveBaseUrl: probe.effectiveBaseUrl,
);
} catch (e) {
stopwatch.stop();
return JellyfinEndpointProbeResult(
success: false,
latencyMs: stopwatch.elapsedMilliseconds,
failureType: e.runtimeType.toString(),
);
}
}
Future<JellyfinEndpointProbeResult> _probeWithAverageLatency(String baseUrl, {required int attempts}) async {
final results = <JellyfinEndpointProbeResult>[];
JellyfinServerInfo? info;
String? effectiveBaseUrl;
for (var i = 0; i < attempts; i++) {
final result = await _probeWithLatency(baseUrl, timeout: MediaServerTimeouts.connectionRace);
if (!result.success) {
return JellyfinEndpointProbeResult(
success: false,
latencyMs: result.latencyMs,
failureType: result.failureType,
serverInfo: info,
effectiveBaseUrl: effectiveBaseUrl,
);
}
info = result.serverInfo;
effectiveBaseUrl = result.effectiveBaseUrl;
results.add(result);
}
final avgLatency = results.map((result) => result.latencyMs).reduce((a, b) => a + b) ~/ results.length;
return JellyfinEndpointProbeResult(
success: true,
latencyMs: avgLatency,
serverInfo: info,
effectiveBaseUrl: effectiveBaseUrl,
);
}
JellyfinEndpointCandidate? _selectLowestLatencyCandidate(
Map<JellyfinEndpointCandidate, JellyfinEndpointProbeResult> results,
) {
if (results.isEmpty) return null;
final entries = results.entries.toList()
..sort((a, b) {
final latency = a.value.latencyMs.compareTo(b.value.latencyMs);
if (latency != 0) return latency;
return a.key.index.compareTo(b.key.index);
});
return entries.first.key;
}
JellyfinEndpointCandidate? _selectValidationCandidate(
Map<JellyfinEndpointCandidate, JellyfinEndpointProbeResult> results, {
required String? expectedMachineId,
}) {
if (expectedMachineId?.isNotEmpty == true) {
final matchingResults = Map<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>.fromEntries(
results.entries.where((entry) => entry.value.serverInfo?.machineId == expectedMachineId),
);
final match = _selectLowestLatencyCandidate(matchingResults);
if (match != null) return match;
}
return _selectLowestLatencyCandidate(results);
}
String _resolveEffectiveBaseUrl(String requestedBaseUrl, MediaServerResponse response) {
final requestedUri = response.requestUri;
final effectiveUri = response.effectiveUri;
if (requestedUri == null || effectiveUri == null || effectiveUri == requestedUri) {
return requestedBaseUrl;
}
final requestedBaseUri = Uri.tryParse(requestedBaseUrl);
final effectiveScheme = effectiveUri.scheme.toLowerCase();
if (requestedBaseUri == null ||
requestedBaseUri.host.isEmpty ||
(effectiveScheme != 'http' && effectiveScheme != 'https')) {
throw MediaServerUrlException(
'Server redirected to an unsupported URL',
display: t.addServer.redirectUnsupported,
);
}
if (requestedBaseUri.host.toLowerCase() != effectiveUri.host.toLowerCase()) {
throw MediaServerUrlException(
'Server redirected to a different host. Enter the final ${dialect.productName} URL directly',
display: t.addServer.redirectDifferentHost(product: dialect.productName),
);
}
if (requestedBaseUri.scheme.toLowerCase() == 'https' && effectiveScheme != 'https') {
throw MediaServerUrlException(
'Server redirected from HTTPS to an insecure URL',
display: t.addServer.redirectInsecure,
);
}
const publicInfoPath = '/System/Info/Public';
if (!effectiveUri.path.endsWith(publicInfoPath)) {
throw MediaServerUrlException(
'Server redirected to an unsupported URL. Enter the final ${dialect.productName} URL directly',
display: t.addServer.redirectUnsupportedEnterFinal(product: dialect.productName),
);
}
final basePath = effectiveUri.path.substring(0, effectiveUri.path.length - publicInfoPath.length);
return normalizeBaseUrl(effectiveUri.replace(path: basePath, query: null, fragment: null).toString());
}
/// Normalizes a concrete MediaBrowser base URL without inventing a scheme or port.
static String normalizeBaseUrl(String input) => canonicalizeBaseUrl(input);
/// Expands a user-typed add/edit form entry into temporary probe candidates.
/// These guesses are for discovery only; failed guesses should not be stored.
static List<String> expandInputToBaseUrls(
String input, {
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
}) {
final trimmed = canonicalizeBaseUrl(input);
if (trimmed.isEmpty) return const [];
if (_hasScheme(trimmed)) return [trimmed];
final parsed = Uri.tryParse('http://$trimmed');
if (parsed == null || parsed.host.isEmpty) return [trimmed];
final result = <String>[];
final seen = <String>{};
void add(Uri uri) {
final normalized = stripTrailingSlash(uri.replace(query: null, fragment: null).toString());
if (normalized.isEmpty || !seen.add(normalized)) return;
result.add(normalized);
}
if (parsed.hasPort) {
add(parsed.replace(scheme: 'http'));
add(parsed.replace(scheme: 'https'));
} else {
add(parsed.replace(scheme: 'http', port: defaultPort));
add(parsed.replace(scheme: 'https'));
for (final port in dialect.httpsPortGuesses) {
add(parsed.replace(scheme: 'https', port: port));
}
add(parsed.replace(scheme: 'http'));
}
return List.unmodifiable(result);
}
/// Splits a raw add/edit form field into the individual URLs the user typed.
/// Entries are separated by newlines and/or commas; blanks are dropped.
static List<String> parseUserEnteredUrls(String raw) {
return raw.split(RegExp(r'[\n,]+')).map((url) => url.trim()).where((url) => url.isNotEmpty).toList(growable: false);
}
static JellyfinEndpointUserInputCandidates buildUserInputCandidates(
Iterable<String> input, {
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
}) {
final probeBaseUrls = <String>[];
final explicitBaseUrls = <String>[];
final validationBaseUrlGroups = <List<String>>[];
final seenProbe = <String>{};
final seenExplicit = <String>{};
void addProbe(String url) {
final normalized = normalizeBaseUrl(url);
if (normalized.isEmpty || !seenProbe.add(normalized)) return;
probeBaseUrls.add(normalized);
}
void addExplicit(String url) {
final normalized = normalizeBaseUrl(url);
if (normalized.isEmpty || !seenExplicit.add(normalized)) return;
explicitBaseUrls.add(normalized);
}
for (final raw in input) {
final normalized = normalizeBaseUrl(raw);
if (normalized.isEmpty) continue;
if (_hasScheme(normalized)) {
addProbe(normalized);
addExplicit(normalized);
validationBaseUrlGroups.add([normalized]);
} else {
final group = <String>[];
for (final candidate in expandInputToBaseUrls(normalized, dialect: dialect)) {
addProbe(candidate);
group.add(candidate);
}
if (group.isNotEmpty) {
validationBaseUrlGroups.add(List.unmodifiable(group));
}
}
}
return JellyfinEndpointUserInputCandidates(
probeBaseUrls: List.unmodifiable(probeBaseUrls),
explicitBaseUrls: List.unmodifiable(explicitBaseUrls),
validationBaseUrlGroups: List.unmodifiable(validationBaseUrlGroups),
);
}
static List<String> normalizeBaseUrls(Iterable<String> input) {
final result = <String>[];
final seen = <String>{};
for (final raw in input) {
final normalized = normalizeBaseUrl(raw);
if (normalized.isEmpty || !seen.add(normalized)) continue;
result.add(normalized);
}
return List.unmodifiable(result);
}
static List<List<String>> _normalizeBaseUrlGroups(Iterable<Iterable<String>> groups) {
final result = <List<String>>[];
for (final group in groups) {
final normalized = normalizeBaseUrls(group);
if (normalized.isNotEmpty) result.add(normalized);
}
return List.unmodifiable(result);
}
static bool _hasScheme(String input) => RegExp(r'^[a-zA-Z][a-zA-Z\d+.-]*://').hasMatch(input);
static List<String> _activeFirst(String activeBaseUrl, List<String> urls) {
final result = <String>[];
final seen = <String>{};
void add(String url) {
if (url.isEmpty || !seen.add(url)) return;
result.add(url);
}
add(activeBaseUrl);
for (final url in urls) {
add(url);
}
return List.unmodifiable(result);
}
}