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:
@@ -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>{};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user