diff --git a/components/common/ServerUrlField.tsx b/components/common/ServerUrlField.tsx new file mode 100644 index 000000000..0ef774cc0 --- /dev/null +++ b/components/common/ServerUrlField.tsx @@ -0,0 +1,177 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useCallback, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, Pressable, View } from "react-native"; +import { + type ServerUrlResolverState, + useServerUrlResolver, +} from "@/hooks/useServerUrlResolver"; +import type { + ResolveFailureReason, + ResolveOptions, +} from "@/utils/serverUrl/resolve"; +import type { ServerProbe } from "@/utils/serverUrl/types"; +import { Input } from "./Input"; +import { Text } from "./Text"; + +interface ServerUrlFieldProps { + /** Raw user input (controlled). */ + value: string; + onChangeText: (text: string) => void; + /** Service-specific validator. Pass a stable (module-level) reference. */ + probe: ServerProbe; + /** Called with the canonical URL once a candidate validates. */ + onResolved?: (url: string, meta?: Record) => void; + label?: string; + hint?: string; + placeholder?: string; + /** Shown in the "version too low" message. */ + minVersion?: string; + editable?: boolean; + resolveOptions?: ResolveOptions; +} + +function errorMessage( + t: (key: string, opts?: Record) => string, + reason: ResolveFailureReason, + version?: string, + minVersion?: string, +): string { + switch (reason) { + case "version-too-low": + return t("server_url.version_too_low", { + version: version ?? "?", + min: minVersion ?? "", + }); + case "wrong-service": + return t("server_url.wrong_service"); + case "invalid": + return t("server_url.invalid_url"); + default: + return t("server_url.unreachable"); + } +} + +/** + * Unified server-URL input: the user types a loose address (`media.example.com`, + * `https://…`, `host:port`), it auto-resolves on blur via the given probe and + * persists the canonical URL. Inline status chip (tap to re-test) + resolved URL. + */ +export function ServerUrlField({ + value, + onChangeText, + probe, + onResolved, + label, + hint, + placeholder, + minVersion, + editable = true, + resolveOptions, +}: ServerUrlFieldProps) { + const { t } = useTranslation(); + const resolver = useServerUrlResolver(probe, resolveOptions); + const lastResolvedInput = useRef(null); + + const runResolve = useCallback(async () => { + const input = value.trim(); + if (!input) { + resolver.reset(); + lastResolvedInput.current = null; + return; + } + lastResolvedInput.current = input; + const result = await resolver.resolve(input); + if (result.ok) onResolved?.(result.url, result.meta); + }, [value, resolver, onResolved]); + + const handleBlur = useCallback(() => { + const input = value.trim(); + if (input && input !== lastResolvedInput.current) runResolve(); + }, [value, runResolve]); + + const handleChange = useCallback( + (text: string) => { + onChangeText(text); + // Editing invalidates a previous result; drop the stale chip. + if (resolver.status !== "idle") resolver.reset(); + lastResolvedInput.current = null; + }, + [onChangeText, resolver], + ); + + return ( + + {label ? {label} : null} + {hint ? {hint} : null} + + + + + + + + + {resolver.status === "ok" ? ( + + {t("server_url.resolved", { url: resolver.resolvedUrl })} + + ) : null} + {resolver.status === "error" ? ( + + {errorMessage(t, resolver.reason, resolver.version, minVersion)} + + ) : null} + + ); +} + +function StatusChip({ + state, + onRetry, +}: { + state: ServerUrlResolverState; + onRetry: () => void; +}) { + if (state.status === "resolving") { + return ; + } + + if (state.status === "ok") { + const scheme = state.resolvedUrl.startsWith("https") ? "https" : "http"; + return ( + + + {scheme} + + ); + } + + if (state.status === "error") { + return ( + + + + ); + } + + return null; +} diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx index 470d40a2e..e0f65b0de 100644 --- a/components/settings/Jellyseerr.tsx +++ b/components/settings/Jellyseerr.tsx @@ -7,8 +7,11 @@ import { toast } from "sonner-native"; import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; import { userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; +import { jellyseerrProbe } from "@/utils/serverUrl/probes/jellyseerr"; +import { resolveServerUrl } from "@/utils/serverUrl/resolve"; import { Button } from "../Button"; import { Input } from "../common/Input"; +import { ServerUrlField } from "../common/ServerUrlField"; import { Text } from "../common/Text"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; @@ -26,26 +29,44 @@ export const JellyseerrSettings = () => { string | undefined >(undefined); - const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState< - string | undefined - >(settings?.jellyseerrServerUrl || undefined); + const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState( + settings?.jellyseerrServerUrl ?? "", + ); + const [resolvedUrl, setResolvedUrl] = useState( + settings?.jellyseerrServerUrl ?? undefined, + ); const loginToJellyseerrMutation = useMutation({ mutationFn: async () => { - if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl) - throw new Error("Missing server url"); if (!user?.Name) throw new Error("Missing required information for login"); - const jellyseerrTempApi = new JellyseerrApi( - jellyseerrServerUrl || settings.jellyseerrServerUrl || "", - ); + + // Prefer the already-resolved URL; otherwise resolve the raw input now + // (covers tapping Login before the field's on-blur resolve settled). + let finalUrl = resolvedUrl || settings?.jellyseerrServerUrl || ""; + if (!finalUrl && jellyseerrServerUrl) { + const resolved = await resolveServerUrl( + jellyseerrServerUrl, + jellyseerrProbe, + ); + if (!resolved.ok) throw new Error("Invalid server url"); + finalUrl = resolved.url; + } + if (!finalUrl) throw new Error("Missing server url"); + + const jellyseerrTempApi = new JellyseerrApi(finalUrl); const testResult = await jellyseerrTempApi.test(); if (!testResult.isValid) throw new Error("Invalid server url"); - return jellyseerrTempApi.login(user.Name, jellyseerrPassword || ""); + const loggedInUser = await jellyseerrTempApi.login( + user.Name, + jellyseerrPassword || "", + ); + return { user: loggedInUser, url: finalUrl }; }, - onSuccess: (user) => { - setJellyseerrUser(user); - updateSettings({ jellyseerrServerUrl }); + onSuccess: ({ user: loggedInUser, url }) => { + setJellyseerrUser(loggedInUser); + setResolvedUrl(url); + updateSettings({ jellyseerrServerUrl: url }); }, onError: () => { toast.error(t("jellyseerr.failed_to_login")); @@ -59,7 +80,8 @@ export const JellyseerrSettings = () => { clearAllJellyseerData().finally(() => { setJellyseerrUser(undefined); setJellyseerrPassword(undefined); - setjellyseerrServerUrl(undefined); + setjellyseerrServerUrl(""); + setResolvedUrl(undefined); }); }; @@ -118,30 +140,21 @@ export const JellyseerrSettings = () => { {t("home.settings.plugins.jellyseerr.jellyseerr_warning")} - - {t("home.settings.plugins.jellyseerr.server_url")} - - - - {t("home.settings.plugins.jellyseerr.server_url_hint")} - + + setResolvedUrl(url)} + probe={jellyseerrProbe} + minVersion='2.0.0' + label={t("home.settings.plugins.jellyseerr.server_url")} + hint={t("home.settings.plugins.jellyseerr.server_url_hint")} + placeholder={t( + "home.settings.plugins.jellyseerr.server_url_placeholder", + )} + editable={!loginToJellyseerrMutation.isPending} + /> - {t("home.settings.plugins.jellyseerr.password")} diff --git a/hooks/useServerUrlResolver.ts b/hooks/useServerUrlResolver.ts new file mode 100644 index 000000000..736fa978e --- /dev/null +++ b/hooks/useServerUrlResolver.ts @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + type ResolveFailureReason, + type ResolveOptions, + type ResolveResult, + resolveServerUrl, +} from "@/utils/serverUrl/resolve"; +import type { ServerProbe } from "@/utils/serverUrl/types"; + +export type ServerUrlResolverState = + | { status: "idle" } + | { status: "resolving" } + | { status: "ok"; resolvedUrl: string; meta?: Record } + | { status: "error"; reason: ResolveFailureReason; version?: string }; + +/** + * Stateful wrapper around `resolveServerUrl` for screens. + * + * `resolve(input)` cancels any in-flight resolution, drives the state machine + * (idle → resolving → ok | error) and returns the raw result. Pass a stable + * (module-level) probe; memoize `options` if you supply one. + */ +export function useServerUrlResolver( + probe: ServerProbe, + options?: ResolveOptions, +) { + const [state, setState] = useState({ + status: "idle", + }); + const abortRef = useRef(null); + + const resolve = useCallback( + async (input: string): Promise => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setState({ status: "resolving" }); + + const result = await resolveServerUrl(input, probe, { + ...options, + signal: controller.signal, + }); + + // Ignore results from a resolution that was superseded/cancelled. + if (!controller.signal.aborted) { + setState( + result.ok + ? { status: "ok", resolvedUrl: result.url, meta: result.meta } + : { + status: "error", + reason: result.reason, + version: result.version, + }, + ); + } + return result; + }, + [probe, options], + ); + + const reset = useCallback(() => { + abortRef.current?.abort(); + setState({ status: "idle" }); + }, []); + + useEffect(() => () => abortRef.current?.abort(), []); + + return { ...state, resolve, reset }; +} diff --git a/translations/en.json b/translations/en.json index b8e64df03..0e8571e75 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1,4 +1,13 @@ { + "server_url": { + "resolving": "Checking…", + "resolved": "→ {{url}}", + "connected": "Connected to {{url}}", + "unreachable": "Server unreachable", + "wrong_service": "Reachable, but not the expected server", + "version_too_low": "Version {{version}} is too old (minimum {{min}})", + "invalid_url": "Enter a valid address" + }, "login": { "username_required": "Username Is Required", "error_title": "Error", diff --git a/utils/serverUrl/candidates.ts b/utils/serverUrl/candidates.ts new file mode 100644 index 000000000..4b210889e --- /dev/null +++ b/utils/serverUrl/candidates.ts @@ -0,0 +1,75 @@ +/** + * Generic server-URL candidate generator. + * + * Turns loose user input (`media.uruk.dev`, `https://media.uruk.dev`, + * `host:8096`, `http://10.0.0.5:3000/path`) into an ordered list of full URLs + * to probe — https first, http as fallback — while preserving any explicit + * port and path. Service-agnostic: unlike the Jellyfin SDK's `getAddressCandidates` + * it adds no Jellyfin-specific ports, so it suits Jellyseerr/Streamystats/etc. + */ + +// scheme? host (port)? (path/query/hash)? +const URL_RE = /^(?:(https?):\/\/)?([^/:\s?#]+)(?::(\d+))?([/?#].*)?$/i; + +export interface ParsedServerInput { + scheme?: "http" | "https"; + host: string; + port?: string; + /** Normalized path+query+hash, without a trailing slash; "" when none. */ + path: string; +} + +function normalizePath(path?: string): string { + if (!path || path === "/") return ""; + return path.replace(/\/+$/, ""); +} + +/** Parse loose user input. Returns null when it can't be understood. */ +export function parseServerInput(input: string): ParsedServerInput | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + const match = URL_RE.exec(trimmed); + if (!match) return null; + + const [, scheme, host, port, rawPath] = match; + return { + scheme: scheme ? (scheme.toLowerCase() as "http" | "https") : undefined, + host: host.toLowerCase(), + port, + path: normalizePath(rawPath), + }; +} + +function buildUrl( + scheme: "http" | "https", + host: string, + port: string | undefined, + path: string, +): string { + return `${scheme}://${host}${port ? `:${port}` : ""}${path}`; +} + +/** + * Ordered, de-duplicated candidate URLs for the given input. + * + * - Explicit scheme AND port → trusted as-is (single candidate). + * - Otherwise https is tried before http (prefer secure), keeping any port/path. + * + * @returns [] when the input can't be parsed. + */ +export function getServerUrlCandidates(input: string): string[] { + const parsed = parseServerInput(input); + if (!parsed) return []; + + const { scheme, host, port, path } = parsed; + + // Fully specified: don't second-guess the user. + if (scheme && port) return [buildUrl(scheme, host, port, path)]; + + // Secure-first; the typed scheme (if any) is still covered by this set. + const candidates = (["https", "http"] as const).map((s) => + buildUrl(s, host, port, path), + ); + return Array.from(new Set(candidates)); +} diff --git a/utils/serverUrl/index.ts b/utils/serverUrl/index.ts new file mode 100644 index 000000000..7cdb3e00b --- /dev/null +++ b/utils/serverUrl/index.ts @@ -0,0 +1,14 @@ +export { + getServerUrlCandidates, + type ParsedServerInput, + parseServerInput, +} from "./candidates"; +export { jellyseerrProbe } from "./probes/jellyseerr"; +export { + type ResolveFailureReason, + type ResolveOptions, + type ResolveResult, + resolveServerUrl, +} from "./resolve"; +export { isVersionBelow } from "./semver"; +export type { ServerProbe, ServerProbeOutcome } from "./types"; diff --git a/utils/serverUrl/probes/jellyseerr.ts b/utils/serverUrl/probes/jellyseerr.ts new file mode 100644 index 000000000..cf4ba20e8 --- /dev/null +++ b/utils/serverUrl/probes/jellyseerr.ts @@ -0,0 +1,41 @@ +import axios from "axios"; +import { isVersionBelow } from "../semver"; +import type { ServerProbe } from "../types"; + +/** Jellyseerr/Overseerr minimum supported version. */ +const MIN_VERSION = "2.0.0"; + +/** + * Probe for a Jellyseerr server. `/api/v1/status` is jellyseerr/overseerr + * specific and unauthenticated, so it both proves reachability and confirms + * we hit the right service. + */ +export const jellyseerrProbe: ServerProbe = async (url, signal) => { + try { + const { status, data } = await axios.get(`${url}/api/v1/status`, { + signal, + timeout: 8000, // backstop; the resolver aborts via signal first + }); + + if (status < 200 || status >= 300) return { status: "unreachable" }; + + const version: string | undefined = + typeof data?.version === "string" ? data.version : undefined; + + // A JSON body carrying version/commitTag identifies a real jellyseerr. + if ( + !version && + !(data && typeof data === "object" && "commitTag" in data) + ) { + return { status: "wrong-service" }; + } + + if (version && isVersionBelow(version, MIN_VERSION)) { + return { status: "version-too-low", version }; + } + + return { status: "ok", meta: { version } }; + } catch { + return { status: "unreachable" }; + } +}; diff --git a/utils/serverUrl/resolve.ts b/utils/serverUrl/resolve.ts new file mode 100644 index 000000000..8cf5b234b --- /dev/null +++ b/utils/serverUrl/resolve.ts @@ -0,0 +1,94 @@ +import { getServerUrlCandidates } from "./candidates"; +import type { ServerProbe, ServerProbeOutcome } from "./types"; + +export type ResolveFailureReason = + | "empty" + | "invalid" + | "version-too-low" + | "wrong-service" + | "unreachable"; + +export type ResolveResult = + | { ok: true; url: string; meta?: Record } + | { ok: false; reason: ResolveFailureReason; version?: string }; + +export interface ResolveOptions { + /** Per-candidate probe timeout in ms. Default 5000. */ + timeoutMs?: number; + /** Abort the whole resolution (cancels every in-flight probe). */ + signal?: AbortSignal; +} + +// Order in which to surface a failure when no candidate validated: +// the more specific/actionable the reason, the earlier it is reported. +const FAILURE_PRIORITY = [ + "version-too-low", + "wrong-service", + "unreachable", +] as const satisfies ReadonlyArray; + +/** + * Resolve loose user input to a single working, canonical server URL. + * + * Generates candidates (https-first), probes them in parallel with a per-candidate + * timeout, and returns the first candidate (in preference order) the probe + * accepted. When none work, the most actionable failure is returned. + */ +export async function resolveServerUrl( + input: string, + probe: ServerProbe, + options: ResolveOptions = {}, +): Promise { + const { timeoutMs = 5000, signal } = options; + + if (!input.trim()) return { ok: false, reason: "empty" }; + + const candidates = getServerUrlCandidates(input); + if (candidates.length === 0) return { ok: false, reason: "invalid" }; + + const outcomes = await Promise.all( + candidates.map((url) => runProbe(url, probe, timeoutMs, signal)), + ); + + // Prefer the first candidate (https-first) that validated. + for (let i = 0; i < candidates.length; i++) { + const outcome = outcomes[i]; + if (outcome.status === "ok") { + return { ok: true, url: candidates[i], meta: outcome.meta }; + } + } + + // Nothing validated: report the most useful failure. + for (const reason of FAILURE_PRIORITY) { + const hit = outcomes.find((outcome) => outcome.status === reason); + if (hit) { + return { + ok: false, + reason, + version: hit.status === "version-too-low" ? hit.version : undefined, + }; + } + } + return { ok: false, reason: "unreachable" }; +} + +async function runProbe( + url: string, + probe: ServerProbe, + timeoutMs: number, + parentSignal?: AbortSignal, +): Promise { + const controller = new AbortController(); + const abort = () => controller.abort(); + parentSignal?.addEventListener("abort", abort); + const timer = setTimeout(abort, timeoutMs); + + try { + return await probe(url, controller.signal); + } catch { + return { status: "unreachable" }; + } finally { + clearTimeout(timer); + parentSignal?.removeEventListener("abort", abort); + } +} diff --git a/utils/serverUrl/semver.ts b/utils/serverUrl/semver.ts new file mode 100644 index 000000000..be34e48d9 --- /dev/null +++ b/utils/serverUrl/semver.ts @@ -0,0 +1,22 @@ +/** + * Strict numeric "below" comparison for dotted versions. + * + * Avoids the string-comparison bug (`"1.9.9" < "2.0.0"` works by luck but + * `"2.10.0" < "2.0.0"` is wrongly true). Non-numeric/pre-release suffixes on a + * segment are ignored (e.g. `2.0.0-beta` → 2.0.0). + */ +export function isVersionBelow(version: string, minimum: string): boolean { + const parse = (v: string) => + v.split(".").map((segment) => Number.parseInt(segment, 10) || 0); + + const a = parse(version); + const b = parse(minimum); + const length = Math.max(a.length, b.length); + + for (let i = 0; i < length; i++) { + const x = a[i] ?? 0; + const y = b[i] ?? 0; + if (x !== y) return x < y; + } + return false; +} diff --git a/utils/serverUrl/types.ts b/utils/serverUrl/types.ts new file mode 100644 index 000000000..f95658924 --- /dev/null +++ b/utils/serverUrl/types.ts @@ -0,0 +1,16 @@ +/** Result of probing a single candidate URL for a specific service. */ +export type ServerProbeOutcome = + | { status: "ok"; meta?: Record } + | { status: "version-too-low"; version?: string } + | { status: "wrong-service" } + | { status: "unreachable" }; + +/** + * Validates one fully-qualified candidate URL for a given service. + * Implementations must resolve (never reject) — map errors to "unreachable". + * The provided signal is aborted on timeout or cancellation. + */ +export type ServerProbe = ( + url: string, + signal: AbortSignal, +) => Promise;