diff --git a/lib/services/jellyfin_endpoint_discovery.dart b/lib/services/jellyfin_endpoint_discovery.dart index 12d8b6525..0a1c7cb4e 100644 --- a/lib/services/jellyfin_endpoint_discovery.dart +++ b/lib/services/jellyfin_endpoint_discovery.dart @@ -478,35 +478,17 @@ class JellyfinEndpointDiscovery { static List 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 = []; - final seen = {}; - 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 _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 _activeFirst(String activeBaseUrl, List urls) { final result = []; final seen = {}; diff --git a/lib/utils/url_utils.dart b/lib/utils/url_utils.dart index e1fae7491..44fc31c32 100644 --- a/lib/utils/url_utils.dart +++ b/lib/utils/url_utils.dart @@ -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 expandBaseUrlCandidates(String input, {required List 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 = []; + final seen = {}; + 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); +} diff --git a/test/utils/url_utils_test.dart b/test/utils/url_utils_test.dart index 505fc3eda..2fbf8c174 100644 --- a/test/utils/url_utils_test.dart +++ b/test/utils/url_utils_test.dart @@ -35,4 +35,55 @@ void main() { expect(canonicalizeBaseUrl(''), ''); }); }); + + group('expandBaseUrlCandidates', () { + const guesses = [ + (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); + }); + }); }