Files
streamyfin/hooks/useServerUrlResolver.ts
Gauvain 0f29457ff8 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.
2026-06-04 20:13:10 +02:00

70 lines
2.0 KiB
TypeScript

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<string, unknown> }
| { 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<ServerUrlResolverState>({
status: "idle",
});
const abortRef = useRef<AbortController | null>(null);
const resolve = useCallback(
async (input: string): Promise<ResolveResult> => {
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 };
}