feat(settings): unified server-URL resolver + field; adopt in Jellyseerr

Type a loose address (media.example.com, https://…, host:port) and the app finds the working, canonical URL.

- utils/serverUrl: generic candidate generator (https-first, port/path preserved, no Jellyfin-specific ports), parallel-probe resolver, numeric semver compare, and a Jellyseerr probe (/api/v1/status, min 2.0.0).

- useServerUrlResolver: idle -> resolving -> ok | error state machine with cancellation.

- ServerUrlField: shared input that auto-resolves on blur, inline status chip (tap to re-test) + resolved URL, persists the canonical URL.

- Jellyseerr settings adopt the field and log in with the resolved URL. Probe contract makes Streamystats/Jellyfin/Merlin a drop-in follow-up.
This commit is contained in:
Gauvain
2026-06-04 20:13:10 +02:00
parent 0d796d01b8
commit 0f29457ff8
10 changed files with 566 additions and 36 deletions

View File

@@ -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<string, unknown>) => 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, unknown>) => 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<string | null>(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 (
<View>
{label ? <Text className='font-bold mb-1'>{label}</Text> : null}
{hint ? <Text className='text-xs text-gray-500 mb-2'>{hint}</Text> : null}
<View className='relative justify-center'>
<Input
value={value}
onChangeText={handleChange}
onBlur={handleBlur}
onSubmitEditing={runResolve}
placeholder={placeholder}
editable={editable}
extraClassName='pr-12 border border-neutral-800'
keyboardType='url'
autoCapitalize='none'
autoCorrect={false}
returnKeyType='done'
textContentType='URL'
clearButtonMode='never'
/>
<View className='absolute right-3 top-0 bottom-0 justify-center'>
<StatusChip state={resolver} onRetry={runResolve} />
</View>
</View>
{resolver.status === "ok" ? (
<Text className='text-xs text-green-500 mt-2'>
{t("server_url.resolved", { url: resolver.resolvedUrl })}
</Text>
) : null}
{resolver.status === "error" ? (
<Text className='text-xs text-red-500 mt-2'>
{errorMessage(t, resolver.reason, resolver.version, minVersion)}
</Text>
) : null}
</View>
);
}
function StatusChip({
state,
onRetry,
}: {
state: ServerUrlResolverState;
onRetry: () => void;
}) {
if (state.status === "resolving") {
return <ActivityIndicator size='small' color='#9ca3af' />;
}
if (state.status === "ok") {
const scheme = state.resolvedUrl.startsWith("https") ? "https" : "http";
return (
<Pressable
onPress={onRetry}
hitSlop={8}
className='flex-row items-center'
>
<Ionicons name='checkmark-circle' size={18} color='#22c55e' />
<Text className='text-xs text-green-500 ml-1'>{scheme}</Text>
</Pressable>
);
}
if (state.status === "error") {
return (
<Pressable onPress={onRetry} hitSlop={8}>
<Ionicons name='refresh' size={18} color='#f59e0b' />
</Pressable>
);
}
return null;
}

View File

@@ -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<string>(
settings?.jellyseerrServerUrl ?? "",
);
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>(
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 = () => {
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")}
</Text>
<View className='flex flex-col shrink mb-2'>
<Text className='text-xs text-gray-600'>
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
<View className='mb-2'>
<ServerUrlField
value={jellyseerrServerUrl}
onChangeText={setjellyseerrServerUrl}
onResolved={(url) => 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}
/>
</View>
<Input
className='border border-neutral-800 mb-2'
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder",
)}
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={setjellyseerrServerUrl}
editable={!loginToJellyseerrMutation.isPending}
/>
<View>
<Text className='font-bold mb-2'>
{t("home.settings.plugins.jellyseerr.password")}