refactor(discovery): expand server url candidates through one shared helper

Turning what a user typed into URLs worth probing lived inside
JellyfinEndpointDiscovery, next to a private copy of the scheme-detection
regex that url_utils already had. A second backend needs the same rules
with a different default port, and the existing copy carried two latent
defects: input with no host came back unchanged, so a candidate that can
never resolve went to the prober, and a query string survived into the
base URL that request paths are appended to.

expandBaseUrlCandidates takes the ordered guesses a backend wants — a
scheme plus the port to try when the user typed none — and owns the rest.
A typed port beats a guessed one, so host:8096 collapses to one candidate
per scheme and the same guess list covers both bare hosts and hosts with
ports. Candidates are built field by field rather than through
Uri.replace, whose null query means "keep mine", so query and fragment
are dropped for real. Blank input and input with no host expand to
nothing.

JellyfinEndpointDiscovery passes its existing order, so its probe and
persistence behaviour is unchanged, and hasUrlScheme replaces the regex
copy.
This commit is contained in:
edde746
2026-08-25 03:07:14 +02:00
parent bd2d19a1c1
commit 75279ca196
3 changed files with 111 additions and 31 deletions
+11 -31
View File
@@ -478,35 +478,17 @@ class JellyfinEndpointDiscovery {
static List<String> expandInputToBaseUrls(
String input, {
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
}) {
final trimmed = canonicalizeBaseUrl(input);
if (trimmed.isEmpty) return const [];
if (_hasScheme(trimmed)) return [trimmed];
}) => expandBaseUrlCandidates(input, guesses: _schemelessGuesses(dialect));
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);
}
/// Ordered guesses for a schemeless entry: the default HTTP install port
/// first (the overwhelmingly common LAN case), then TLS on the default port
/// and on the dialect's TLS ports, then plain HTTP on port 80.
static List<BaseUrlGuess> _schemelessGuesses(MediaBrowserDialect dialect) => [
(scheme: 'http', port: defaultPort),
(scheme: 'https', port: null),
for (final port in dialect.httpsPortGuesses) (scheme: 'https', port: port),
(scheme: 'http', port: null),
];
/// 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.
@@ -539,7 +521,7 @@ class JellyfinEndpointDiscovery {
for (final raw in input) {
final normalized = normalizeBaseUrl(raw);
if (normalized.isEmpty) continue;
if (_hasScheme(normalized)) {
if (hasUrlScheme(normalized)) {
addProbe(normalized);
addExplicit(normalized);
validationBaseUrlGroups.add([normalized]);
@@ -582,8 +564,6 @@ class JellyfinEndpointDiscovery {
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>{};
+49
View File
@@ -58,3 +58,52 @@ String canonicalizeBaseUrl(String input) {
if (match == null) return stripped;
return stripped.replaceRange(0, match.end, match.group(0)!.toLowerCase());
}
/// One guess applied to schemeless user input: a scheme plus the port to try
/// when the user typed none. A null [port] means the scheme's default port.
typedef BaseUrlGuess = ({String scheme, int? port});
/// Whether [input] already carries an explicit `scheme://`.
bool hasUrlScheme(String input) => _schemePattern.hasMatch(input);
/// Expands a user-typed server address into ordered, de-duplicated probe
/// candidates.
///
/// An explicit scheme is authoritative and comes back alone — the user told us
/// where the server is. Otherwise every entry of [guesses] is applied in
/// order, with a port the user typed always beating the guess's port, so
/// `host:8096` collapses to one candidate per scheme while a bare `host` also
/// picks up the guessed install ports. Query and fragment are dropped; a
/// sub-path is preserved on every candidate.
///
/// Candidates are guesses for discovery only: persist the one that answered,
/// never the whole list. Blank input and input with no host (`/seerr`) return
/// empty — no candidate built from those could reach anything.
List<String> expandBaseUrlCandidates(String input, {required List<BaseUrlGuess> guesses}) {
final trimmed = canonicalizeBaseUrl(input);
if (trimmed.isEmpty) return const [];
if (hasUrlScheme(trimmed)) return List.unmodifiable([trimmed]);
final parsed = Uri.tryParse('http://$trimmed');
if (parsed == null || parsed.host.isEmpty) return const [];
final candidates = <String>[];
final seen = <String>{};
for (final guess in guesses) {
// Built field by field rather than with `replace`, which treats a null
// query/fragment as "keep mine" — and a base URL carrying either can't
// have request paths appended to it.
final candidate = Uri(
scheme: guess.scheme,
userInfo: parsed.userInfo,
host: parsed.host,
// A port the user typed is authoritative; null leaves the scheme's own.
port: parsed.hasPort ? parsed.port : guess.port,
path: parsed.path,
);
final normalized = stripTrailingSlash(candidate.toString());
if (normalized.isEmpty || !seen.add(normalized)) continue;
candidates.add(normalized);
}
return List.unmodifiable(candidates);
}
+51
View File
@@ -35,4 +35,55 @@ void main() {
expect(canonicalizeBaseUrl(''), '');
});
});
group('expandBaseUrlCandidates', () {
const guesses = <BaseUrlGuess>[
(scheme: 'https', port: null),
(scheme: 'http', port: null),
(scheme: 'http', port: 5055),
];
test('an explicit scheme is authoritative and comes back alone', () {
expect(expandBaseUrlCandidates('HTTPS://seerr.example.com/', guesses: guesses), ['https://seerr.example.com']);
expect(expandBaseUrlCandidates('http://192.168.1.5:5055', guesses: guesses), ['http://192.168.1.5:5055']);
});
test('applies every guess in order to a bare host', () {
expect(expandBaseUrlCandidates('seerr.example.com', guesses: guesses), [
'https://seerr.example.com',
'http://seerr.example.com',
'http://seerr.example.com:5055',
]);
});
test('a typed port beats the guessed one and collapses the duplicates', () {
expect(expandBaseUrlCandidates('192.168.1.5:5055', guesses: guesses), [
'https://192.168.1.5:5055',
'http://192.168.1.5:5055',
]);
});
test('keeps a sub-path on every candidate and drops query and fragment', () {
expect(expandBaseUrlCandidates('example.com/seerr?a=1#top', guesses: guesses), [
'https://example.com/seerr',
'http://example.com/seerr',
'http://example.com:5055/seerr',
]);
});
test('input with nothing to reach expands to nothing', () {
expect(expandBaseUrlCandidates(' ', guesses: guesses), isEmpty);
// Hostless: no guess could build a URL that resolves anywhere.
expect(expandBaseUrlCandidates('/seerr', guesses: guesses), isEmpty);
});
});
group('hasUrlScheme', () {
test('recognizes only a real leading scheme', () {
expect(hasUrlScheme('https://host'), isTrue);
expect(hasUrlScheme('jellyfin+tls://host'), isTrue);
expect(hasUrlScheme('host:8096'), isFalse);
expect(hasUrlScheme('host/https://nested'), isFalse);
});
});
}