mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-05 05:28:37 +01:00
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:
177
components/common/ServerUrlField.tsx
Normal file
177
components/common/ServerUrlField.tsx
Normal 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;
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user