diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx index 3ce2c81c3..0a74e0672 100644 --- a/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx @@ -11,12 +11,15 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { toast } from "sonner-native"; +import { ServerUrlStatusText } from "@/components/common/ServerUrlStatusText"; import { Text } from "@/components/common/Text"; import { ListGroup } from "@/components/list/ListGroup"; import { ListItem } from "@/components/list/ListItem"; import DisabledSetting from "@/components/settings/DisabledSetting"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; +import { useServerUrlResolver } from "@/hooks/useServerUrlResolver"; import { useSettings } from "@/utils/atoms/settings"; +import { reachabilityProbe } from "@/utils/serverUrl/probes/reachability"; export default function MarlinSearchPage() { const navigation = useNavigation(); @@ -29,6 +32,7 @@ export default function MarlinSearchPage() { const queryClient = useNetworkAwareQueryClient(); const [value, setValue] = useState(settings?.marlinServerUrl || ""); + const urlResolver = useServerUrlResolver(reachabilityProbe); const onSave = (val: string) => { updateSettings({ @@ -127,8 +131,17 @@ export default function MarlinSearchPage() { autoCapitalize='none' textContentType='URL' onChangeText={(text) => setValue(text)} + onBlur={() => { + const candidate = value.trim(); + if (candidate) { + urlResolver.resolve(candidate).then((r) => { + if (r.ok) setValue(r.url); + }); + } + }} /> + {t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "} diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx index 8f0a2c931..7ac500e11 100644 --- a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx @@ -11,11 +11,14 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { toast } from "sonner-native"; +import { ServerUrlStatusText } from "@/components/common/ServerUrlStatusText"; import { Text } from "@/components/common/Text"; import { ListGroup } from "@/components/list/ListGroup"; import { ListItem } from "@/components/list/ListItem"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; +import { useServerUrlResolver } from "@/hooks/useServerUrlResolver"; import { useSettings } from "@/utils/atoms/settings"; +import { reachabilityProbe } from "@/utils/serverUrl/probes/reachability"; export default function StreamystatsPage() { const { t } = useTranslation(); @@ -32,6 +35,7 @@ export default function StreamystatsPage() { // Local state for all editable fields const [url, setUrl] = useState(settings?.streamyStatsServerUrl || ""); + const urlResolver = useServerUrlResolver(reachabilityProbe); const [useForSearch, setUseForSearch] = useState( settings?.searchEngine === "Streamystats", ); @@ -152,9 +156,20 @@ export default function StreamystatsPage() { autoCapitalize='none' textContentType='URL' onChangeText={setUrl} + onBlur={() => { + const candidate = url.trim(); + if (candidate) { + urlResolver.resolve(candidate).then((r) => { + if (r.ok) setUrl(r.url); + }); + } + }} /> + + + {t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "} diff --git a/components/common/ServerUrlStatusText.tsx b/components/common/ServerUrlStatusText.tsx new file mode 100644 index 000000000..13c521272 --- /dev/null +++ b/components/common/ServerUrlStatusText.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, View } from "react-native"; +import type { ServerUrlResolverState } from "@/hooks/useServerUrlResolver"; +import { Text } from "./Text"; + +/** + * Compact status line for the server-URL resolver, for screens whose layout + * (e.g. ListItem rows) doesn't fit the full `ServerUrlField`. Renders nothing + * while idle. + */ +export function ServerUrlStatusText({ + state, + minVersion, + className = "", +}: { + state: ServerUrlResolverState; + minVersion?: string; + className?: string; +}) { + const { t } = useTranslation(); + + if (state.status === "idle") return null; + + if (state.status === "resolving") { + return ( + + + + {t("server_url.resolving")} + + + ); + } + + if (state.status === "ok") { + return ( + + {t("server_url.resolved", { url: state.resolvedUrl })} + + ); + } + + const message = + state.reason === "version-too-low" + ? t("server_url.version_too_low", { + version: state.version ?? "?", + min: minVersion ?? "", + }) + : state.reason === "wrong-service" + ? t("server_url.wrong_service") + : state.reason === "invalid" + ? t("server_url.invalid_url") + : t("server_url.unreachable"); + + return {message}; +} diff --git a/utils/serverUrl/index.ts b/utils/serverUrl/index.ts index 7cdb3e00b..235d97eb7 100644 --- a/utils/serverUrl/index.ts +++ b/utils/serverUrl/index.ts @@ -3,7 +3,9 @@ export { type ParsedServerInput, parseServerInput, } from "./candidates"; +export { jellyfinProbe } from "./probes/jellyfin"; export { jellyseerrProbe } from "./probes/jellyseerr"; +export { reachabilityProbe } from "./probes/reachability"; export { type ResolveFailureReason, type ResolveOptions, diff --git a/utils/serverUrl/probes/jellyfin.ts b/utils/serverUrl/probes/jellyfin.ts new file mode 100644 index 000000000..ad8da98ea --- /dev/null +++ b/utils/serverUrl/probes/jellyfin.ts @@ -0,0 +1,24 @@ +import axios from "axios"; +import type { ServerProbe } from "../types"; + +/** Public, unauthenticated Jellyfin endpoint; `ProductName` confirms the service. */ +const PRODUCT_NAME = "Jellyfin Server"; + +export const jellyfinProbe: ServerProbe = async (url, signal) => { + try { + const { status, data } = await axios.get(`${url}/System/Info/Public`, { + signal, + timeout: 8000, // backstop; the resolver aborts via signal first + }); + + if (status < 200 || status >= 300) return { status: "unreachable" }; + if (data?.ProductName !== PRODUCT_NAME) return { status: "wrong-service" }; + + return { + status: "ok", + meta: { version: data?.Version, serverName: data?.ServerName }, + }; + } catch { + return { status: "unreachable" }; + } +}; diff --git a/utils/serverUrl/probes/reachability.ts b/utils/serverUrl/probes/reachability.ts new file mode 100644 index 000000000..c25d4b0af --- /dev/null +++ b/utils/serverUrl/probes/reachability.ts @@ -0,0 +1,23 @@ +import axios from "axios"; +import type { ServerProbe } from "../types"; + +/** + * Minimal probe for services without a known/unauthenticated health endpoint + * (e.g. Marlin Search, Streamystats). Any HTTP response — even 4xx — proves the + * host is up and speaking HTTP at this protocol/port, which is enough to pick + * https vs http. It cannot detect a "wrong service". + */ +export const reachabilityProbe: ServerProbe = async (url, signal) => { + try { + await axios.get(url, { + signal, + timeout: 8000, + validateStatus: () => true, // any status = the server answered + }); + return { status: "ok" }; + } catch (error) { + // A delivered response that still threw counts as reachable. + if ((error as { response?: unknown })?.response) return { status: "ok" }; + return { status: "unreachable" }; + } +};