Compare commits

..

3 Commits

Author SHA1 Message Date
Lance Chant
36d18e2bec wip: initial implementation of seerr for TV
Allowed the tv to login manually and discover content via search.

Still work to do, I.E centralize the logic for mobile and tv

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-05 13:24:11 +02:00
Lance Chant
326956dfda fix: search page crash on android
Ensured the search module is only apple specific to stop android
crashing

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-05 08:24:40 +02:00
Lance Chant
7528274249 fix: the home recommendations
there was an issue where the home recommendations was deleted and then
not shown again because of google TV policies

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-05 08:08:03 +02:00
25 changed files with 596 additions and 807 deletions

View File

@@ -3,7 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { Directory, Paths } from "expo-file-system"; import { Directory, Paths } from "expo-file-system";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, ScrollView, View } from "react-native"; import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -21,6 +21,8 @@ import {
TVSettingsToggle, TVSettingsToggle,
} from "@/components/tv"; } from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrConnect } from "@/hooks/useJellyseerrConnect";
import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal"; import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n"; import { APP_LANGUAGES } from "@/i18n";
@@ -59,6 +61,37 @@ export default function SettingsTV() {
const { showUserSwitchModal } = useTVUserSwitchModal(); const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { jellyseerrApi, clearAllJellyseerData } = useJellyseerr();
const { connecting: jellyseerrConnecting, connect: jellyseerrConnect } =
useJellyseerrConnect();
// Jellyseerr state
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
settings.jellyseerrServerUrl || "",
);
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
const { pluginSettings } = useSettings();
const isJellyseerrLocked =
pluginSettings?.jellyseerrServerUrl?.locked === true;
const isJellyseerrConnected = !!jellyseerrApi;
const handleJellyseerrUrlBlur = useCallback(() => {
const url = jellyseerrServerUrl.trim();
updateSettings({ jellyseerrServerUrl: url || undefined });
}, [jellyseerrServerUrl, updateSettings]);
const handleJellyseerrConnect = useCallback(async () => {
const url = jellyseerrServerUrl.trim();
if (!url) return;
await jellyseerrConnect(url, jellyseerrPassword);
}, [jellyseerrServerUrl, jellyseerrPassword, jellyseerrConnect]);
const handleDisconnectJellyseerr = useCallback(() => {
clearAllJellyseerData();
setJellyseerrServerUrl("");
setJellyseerrPassword("");
}, [clearAllJellyseerData]);
// Local state for OpenSubtitles API key (only commit on blur) // Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState( const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
@@ -883,6 +916,81 @@ export default function SettingsTV() {
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })} onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/> />
{/* seerr Section */}
<TVSectionHeader title='seerr' />
<Text
style={{
color: "#9CA3AF",
fontSize: typography.callout - 2,
marginBottom: 16,
marginLeft: 8,
}}
>
{t("home.settings.plugins.jellyseerr.server_url_hint") ||
"Enter your Jellyseerr server URL to enable discover and request features."}
</Text>
<TVSettingsTextInput
label={
t("home.settings.plugins.jellyseerr.server_url") || "Server URL"
}
value={jellyseerrServerUrl}
placeholder={
t("home.settings.plugins.jellyseerr.server_url_placeholder") ||
"https://jellyseerr.example.com"
}
onChangeText={setJellyseerrServerUrl}
onBlur={handleJellyseerrUrlBlur}
disabled={isJellyseerrLocked || jellyseerrConnecting}
/>
{!isJellyseerrConnected && !isJellyseerrLocked && (
<>
<TVSettingsTextInput
label={
t("home.settings.plugins.jellyseerr.password") || "Password"
}
value={jellyseerrPassword}
placeholder={
t("home.settings.plugins.jellyseerr.password_placeholder", {
username: user?.Name,
}) || `Jellyfin password`
}
onChangeText={setJellyseerrPassword}
secureTextEntry
disabled={jellyseerrConnecting}
/>
<TVSettingsOptionButton
label={
jellyseerrConnecting
? t("common.connecting", "Connecting...") || "Connecting..."
: t("common.connect", "Connect") || "Connect"
}
value=''
onPress={handleJellyseerrConnect}
disabled={jellyseerrConnecting}
/>
</>
)}
<TVSettingsRow
label={
isJellyseerrConnected
? t("common.connected", "Connected") || "Connected"
: t("common.not_connected", "Not connected") || "Not connected"
}
value=''
showChevron={false}
/>
{isJellyseerrConnected && !isJellyseerrLocked && (
<TVSettingsOptionButton
label={
t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
) || "Disconnect"
}
value=''
onPress={handleDisconnectJellyseerr}
/>
)}
{/* Storage Section */} {/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} /> <TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton <TVSettingsOptionButton

View File

@@ -11,15 +11,12 @@ import {
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { ServerUrlStatusText } from "@/components/common/ServerUrlStatusText";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useServerUrlResolver } from "@/hooks/useServerUrlResolver";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { reachabilityProbe } from "@/utils/serverUrl/probes/reachability";
export default function MarlinSearchPage() { export default function MarlinSearchPage() {
const navigation = useNavigation(); const navigation = useNavigation();
@@ -32,7 +29,6 @@ export default function MarlinSearchPage() {
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || ""); const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
const urlResolver = useServerUrlResolver(reachabilityProbe);
const onSave = (val: string) => { const onSave = (val: string) => {
updateSettings({ updateSettings({
@@ -131,17 +127,8 @@ export default function MarlinSearchPage() {
autoCapitalize='none' autoCapitalize='none'
textContentType='URL' textContentType='URL'
onChangeText={(text) => setValue(text)} onChangeText={(text) => setValue(text)}
onBlur={() => {
const candidate = value.trim();
if (candidate) {
urlResolver.resolve(candidate).then((r) => {
if (r.ok) setValue(r.url);
});
}
}}
/> />
</View> </View>
<ServerUrlStatusText state={urlResolver} className='mt-1' />
</DisabledSetting> </DisabledSetting>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "} {t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}

View File

@@ -11,14 +11,11 @@ import {
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { ServerUrlStatusText } from "@/components/common/ServerUrlStatusText";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useServerUrlResolver } from "@/hooks/useServerUrlResolver";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { reachabilityProbe } from "@/utils/serverUrl/probes/reachability";
export default function StreamystatsPage() { export default function StreamystatsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -35,7 +32,6 @@ export default function StreamystatsPage() {
// Local state for all editable fields // Local state for all editable fields
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || ""); const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
const urlResolver = useServerUrlResolver(reachabilityProbe);
const [useForSearch, setUseForSearch] = useState<boolean>( const [useForSearch, setUseForSearch] = useState<boolean>(
settings?.searchEngine === "Streamystats", settings?.searchEngine === "Streamystats",
); );
@@ -156,20 +152,9 @@ export default function StreamystatsPage() {
autoCapitalize='none' autoCapitalize='none'
textContentType='URL' textContentType='URL'
onChangeText={setUrl} onChangeText={setUrl}
onBlur={() => {
const candidate = url.trim();
if (candidate) {
urlResolver.resolve(candidate).then((r) => {
if (r.ok) setUrl(r.url);
});
}
}}
/> />
</ListItem> </ListItem>
</ListGroup> </ListGroup>
<View className='px-4 mt-1'>
<ServerUrlStatusText state={urlResolver} />
</View>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "} {t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "}

View File

@@ -1,99 +0,0 @@
import { useCallback, useRef } from "react";
import { View } from "react-native";
import { useServerUrlResolver } from "@/hooks/useServerUrlResolver";
import type { ResolveOptions } from "@/utils/serverUrl/resolve";
import type { ServerProbe } from "@/utils/serverUrl/types";
import { Input } from "./Input";
import { ServerUrlStatusText } from "./ServerUrlStatusText";
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;
editable?: boolean;
resolveOptions?: ResolveOptions;
}
/**
* Unified server-URL input: the user types a loose address (`media.example.com`,
* `https://…`, `host:port`); on blur it auto-resolves via the given probe,
* adopts the canonical URL into the field, and persists it. A small status line
* (checking / resolved / error) shows underneath.
*/
export function ServerUrlField({
value,
onChangeText,
probe,
onResolved,
label,
hint,
placeholder,
editable = true,
resolveOptions,
}: ServerUrlFieldProps) {
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) {
onChangeText(result.url); // adopt the canonical URL into the field
onResolved?.(result.url, result.meta);
}
}, [value, resolver, onChangeText, 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 status.
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}
<Input
value={value}
onChangeText={handleChange}
onBlur={handleBlur}
onSubmitEditing={runResolve}
placeholder={placeholder}
editable={editable}
extraClassName='border border-neutral-800'
keyboardType='url'
autoCapitalize='none'
autoCorrect={false}
returnKeyType='go'
textContentType='URL'
clearButtonMode='never'
/>
<ServerUrlStatusText state={resolver} className='mt-2' />
</View>
);
}

View File

@@ -1,49 +0,0 @@
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,
className = "",
}: {
state: ServerUrlResolverState;
className?: string;
}) {
const { t } = useTranslation();
if (state.status === "idle") return null;
if (state.status === "resolving") {
return (
<View className={`flex-row items-center ${className}`}>
<ActivityIndicator size='small' color='#9ca3af' />
<Text className='text-xs text-neutral-400 ml-2'>
{t("server_url.resolving")}
</Text>
</View>
);
}
if (state.status === "ok") {
return (
<Text className={`text-xs text-green-500 ${className}`}>
{t("server_url.resolved", { url: state.resolvedUrl })}
</Text>
);
}
const message =
state.reason === "wrong-service"
? t("server_url.wrong_service")
: state.reason === "invalid"
? t("server_url.invalid_url")
: t("server_url.unreachable");
return <Text className={`text-xs text-red-500 ${className}`}>{message}</Text>;
}

View File

@@ -11,13 +11,10 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { ServerUrlStatusText } from "@/components/common/ServerUrlStatusText";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useServerUrlResolver } from "@/hooks/useServerUrlResolver";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { sendCredentialsToTV } from "@/utils/pairingService"; import { sendCredentialsToTV } from "@/utils/pairingService";
import { jellyfinProbe } from "@/utils/serverUrl/probes/jellyfin";
type ScreenState = type ScreenState =
| "scanning" | "scanning"
@@ -52,7 +49,6 @@ export const CompanionLoginScreen: React.FC = () => {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const serverResolver = useServerUrlResolver(jellyfinProbe);
// Pre-fill server URL and username from current session // Pre-fill server URL and username from current session
useEffect(() => { useEffect(() => {
@@ -409,16 +405,7 @@ export const CompanionLoginScreen: React.FC = () => {
autoCorrect={false} autoCorrect={false}
keyboardType='url' keyboardType='url'
returnKeyType='next' returnKeyType='next'
onBlur={() => {
const candidate = serverUrl.trim();
if (candidate) {
serverResolver.resolve(candidate).then((r) => {
if (r.ok) setServerUrl(r.url);
});
}
}}
/> />
<ServerUrlStatusText state={serverResolver} className='mt-2' />
</View> </View>
<View className='mb-5'> <View className='mb-5'>

View File

@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native"; import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { import {
@@ -22,8 +23,6 @@ import type {
TvResult, TvResult,
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
const SCALE_PADDING = 20;
interface TVDiscoverPosterProps { interface TVDiscoverPosterProps {
item: MovieResult | TvResult; item: MovieResult | TvResult;
isFirstItem?: boolean; isFirstItem?: boolean;
@@ -34,6 +33,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
isFirstItem = false, isFirstItem = false,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const router = useRouter(); const router = useRouter();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
@@ -50,6 +50,8 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
item.mediaInfo?.status === MediaStatus.AVAILABLE || item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE; item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
const posterWidth = sizes.posters.poster;
const handlePress = () => { const handlePress = () => {
router.push({ router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page", pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
@@ -71,7 +73,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
style={[ style={[
animatedStyle, animatedStyle,
{ {
width: 210, width: posterWidth,
shadowColor: "#fff", shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0, shadowOpacity: focused ? 0.6 : 0,
@@ -81,9 +83,9 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
> >
<View <View
style={{ style={{
width: 210, width: posterWidth,
aspectRatio: 10 / 15, aspectRatio: 10 / 15,
borderRadius: 24, borderRadius: sizes.gaps.small,
overflow: "hidden", overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)", backgroundColor: "rgba(255,255,255,0.1)",
}} }}
@@ -140,12 +142,12 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
> >
{title} {title}
</Text> </Text>
{year && ( {year != null && (
<Text <Text
style={{ style={{
fontSize: typography.callout, fontSize: typography.callout,
color: "#9CA3AF", color: "#9CA3AF",
marginTop: 2, marginTop: sizes.gaps.small,
}} }}
> >
{year} {year}
@@ -166,6 +168,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
isFirstSlide = false, isFirstSlide = false,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation(); const { t } = useTranslation();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
@@ -231,14 +234,14 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
if (!flatData || flatData.length === 0) return null; if (!flatData || flatData.length === 0) return null;
return ( return (
<View style={{ marginBottom: 24 }}> <View style={{ marginBottom: sizes.gaps.section }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 16, marginBottom: sizes.gaps.small,
marginLeft: SCALE_PADDING, marginLeft: sizes.padding.scale,
}} }}
> >
{slideTitle} {slideTitle}
@@ -249,9 +252,9 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: SCALE_PADDING, paddingHorizontal: sizes.padding.scale,
paddingVertical: SCALE_PADDING, paddingVertical: sizes.padding.scale,
gap: 20, gap: sizes.gaps.item,
}} }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
onEndReached={() => { onEndReached={() => {

View File

@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native"; import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media"; import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
@@ -14,8 +15,6 @@ import type {
TvResult, TvResult,
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
const SCALE_PADDING = 20;
interface TVJellyseerrPosterProps { interface TVJellyseerrPosterProps {
item: MovieResult | TvResult; item: MovieResult | TvResult;
onPress: () => void; onPress: () => void;
@@ -28,6 +27,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
isFirstItem = false, isFirstItem = false,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 }); useTVFocusAnimation({ scaleAmount: 1.05 });
@@ -43,6 +43,8 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
item.mediaInfo?.status === MediaStatus.AVAILABLE || item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE; item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
const posterWidth = sizes.posters.poster;
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
@@ -54,7 +56,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
style={[ style={[
animatedStyle, animatedStyle,
{ {
width: 210, width: posterWidth,
shadowColor: "#fff", shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0, shadowOpacity: focused ? 0.6 : 0,
@@ -64,9 +66,9 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
> >
<View <View
style={{ style={{
width: 210, width: posterWidth,
aspectRatio: 10 / 15, aspectRatio: 10 / 15,
borderRadius: 24, borderRadius: sizes.gaps.small,
overflow: "hidden", overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)", backgroundColor: "rgba(255,255,255,0.1)",
}} }}
@@ -117,13 +119,13 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
fontSize: typography.callout, fontSize: typography.callout,
color: "#fff", color: "#fff",
fontWeight: "600", fontWeight: "600",
marginTop: 12, marginTop: sizes.gaps.small,
}} }}
numberOfLines={2} numberOfLines={2}
> >
{title} {title}
</Text> </Text>
{year && ( {year != null && (
<Text <Text
style={{ style={{
fontSize: typography.callout, fontSize: typography.callout,
@@ -149,6 +151,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
onPress, onPress,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation(); useTVFocusAnimation();
@@ -157,13 +160,15 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
? jellyseerrApi?.imageProxy(item.profilePath, "w185") ? jellyseerrApi?.imageProxy(item.profilePath, "w185")
: null; : null;
const avatarSize = Math.round(sizes.posters.poster * 0.67);
return ( return (
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}> <Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
<Animated.View <Animated.View
style={[ style={[
animatedStyle, animatedStyle,
{ {
width: 160, width: avatarSize,
alignItems: "center", alignItems: "center",
shadowColor: "#fff", shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
@@ -174,9 +179,9 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
> >
<View <View
style={{ style={{
width: 140, width: avatarSize,
height: 140, height: avatarSize,
borderRadius: 70, borderRadius: avatarSize / 2,
overflow: "hidden", overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)", backgroundColor: "rgba(255,255,255,0.1)",
borderWidth: focused ? 3 : 0, borderWidth: focused ? 3 : 0,
@@ -198,7 +203,11 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
alignItems: "center", alignItems: "center",
}} }}
> >
<Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' /> <Ionicons
name='person'
size={Math.round(avatarSize * 0.35)}
color='rgba(255,255,255,0.4)'
/>
</View> </View>
)} )}
</View> </View>
@@ -207,7 +216,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
fontSize: typography.callout, fontSize: typography.callout,
color: focused ? "#fff" : "rgba(255,255,255,0.9)", color: focused ? "#fff" : "rgba(255,255,255,0.9)",
fontWeight: "600", fontWeight: "600",
marginTop: 12, marginTop: sizes.gaps.small,
textAlign: "center", textAlign: "center",
}} }}
numberOfLines={2} numberOfLines={2}
@@ -233,17 +242,18 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
onItemPress, onItemPress,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
return ( return (
<View style={{ marginBottom: 24 }}> <View style={{ marginBottom: sizes.gaps.section }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 16, marginBottom: sizes.gaps.small,
marginLeft: SCALE_PADDING, marginLeft: sizes.padding.scale,
}} }}
> >
{title} {title}
@@ -254,9 +264,9 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: SCALE_PADDING, paddingHorizontal: sizes.padding.scale,
paddingVertical: SCALE_PADDING, paddingVertical: sizes.padding.scale,
gap: 20, gap: sizes.gaps.item,
}} }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
@@ -285,17 +295,18 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
onItemPress, onItemPress,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
return ( return (
<View style={{ marginBottom: 24 }}> <View style={{ marginBottom: sizes.gaps.section }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 16, marginBottom: sizes.gaps.small,
marginLeft: SCALE_PADDING, marginLeft: sizes.padding.scale,
}} }}
> >
{title} {title}
@@ -306,9 +317,9 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: SCALE_PADDING, paddingHorizontal: sizes.padding.scale,
paddingVertical: SCALE_PADDING, paddingVertical: sizes.padding.scale,
gap: 20, gap: sizes.gaps.item,
}} }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
@@ -337,17 +348,18 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
onItemPress, onItemPress,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
return ( return (
<View style={{ marginBottom: 24 }}> <View style={{ marginBottom: sizes.gaps.section }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 16, marginBottom: sizes.gaps.small,
marginLeft: SCALE_PADDING, marginLeft: sizes.padding.scale,
}} }}
> >
{title} {title}
@@ -358,9 +370,9 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: SCALE_PADDING, paddingHorizontal: sizes.padding.scale,
paddingVertical: SCALE_PADDING, paddingVertical: sizes.padding.scale,
gap: 20, gap: sizes.gaps.item,
}} }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
renderItem={({ item }) => ( renderItem={({ item }) => (
@@ -400,6 +412,7 @@ export const TVJellyseerrSearchResults: React.FC<
onPersonPress, onPersonPress,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const typography = useScaledTVTypography();
if (loading) { if (loading) {
return null; return null;
@@ -410,7 +423,7 @@ export const TVJellyseerrSearchResults: React.FC<
<View style={{ alignItems: "center", paddingTop: 40 }}> <View style={{ alignItems: "center", paddingTop: 40 }}>
<Text <Text
style={{ style={{
fontSize: 24, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 8, marginBottom: 8,
@@ -418,7 +431,9 @@ export const TVJellyseerrSearchResults: React.FC<
> >
{t("search.no_results_found_for")} {t("search.no_results_found_for")}
</Text> </Text>
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}> <Text
style={{ fontSize: typography.body, color: "rgba(255,255,255,0.6)" }}
>
"{searchQuery}" "{searchQuery}"
</Text> </Text>
</View> </View>

View File

@@ -1,8 +1,8 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native"; import { Platform, ScrollView, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover"; import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
@@ -166,6 +166,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [isSearchFocused, setIsSearchFocused] = useState(false);
// Image URL getter for music items // Image URL getter for music items
const getImageUrl = useMemo(() => { const getImageUrl = useMemo(() => {
@@ -231,26 +232,51 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
paddingTop: insets.top + TOP_PADDING, paddingTop: insets.top + TOP_PADDING,
}} }}
> >
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search` {/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
module). It renders the native search bar + grid keyboard and TextInput fallback on Android TV (the native module is Apple-only). */}
forwards typed text into the existing query pipeline via setSearch; {Platform.OS === "ios" ? (
our own results grid renders below. */} <View
{/* No horizontal margin here: the native tvOS search bar centers itself style={{
and renders a trailing "Hold to Dictate in <Language>" hint. Extra marginBottom: 24,
margins squeeze the bar's width and clip that trailing hint, so let height: SEARCH_AREA_HEIGHT,
the native view span the full width and own its own insets. */} }}
<View >
style={{ {/* No horizontal margin here: the native tvOS search bar centers
marginBottom: 24, itself and renders a trailing "Hold to Dictate" hint. */}
height: SEARCH_AREA_HEIGHT, <TvSearchView
}} style={{ width: "100%", height: "100%" }}
> placeholder={t("search.search")}
<TvSearchView onChangeText={(e) => setSearch(e.nativeEvent.text)}
style={{ width: "100%", height: "100%" }} />
placeholder={t("search.search")} </View>
onChangeText={(e) => setSearch(e.nativeEvent.text)} ) : (
/> <View
</View> style={{
marginHorizontal: HORIZONTAL_PADDING,
marginBottom: 24,
}}
>
<TextInput
style={{
height: 56,
width: "100%",
backgroundColor: "#262626",
borderRadius: 12,
paddingHorizontal: 20,
fontSize: 28,
color: "#fff",
}}
placeholder={t("search.search")}
placeholderTextColor='rgba(255,255,255,0.4)'
onChangeText={setSearch}
defaultValue=''
autoFocus={false}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
hasTVPreferredFocus
/>
</View>
)}
</View> </View>
<ScrollView <ScrollView
@@ -268,6 +294,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
searchType={searchType} searchType={searchType}
setSearchType={setSearchType} setSearchType={setSearchType}
showDiscover={showDiscover} showDiscover={showDiscover}
disabled={isSearchFocused}
/> />
</View> </View>
)} )}
@@ -294,6 +321,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
// every keystroke as results re-render. User navigates down to the // every keystroke as results re-render. User navigates down to the
// grid manually. // grid manually.
isFirstSection={false} isFirstSection={false}
disabled={isSearchFocused}
onItemPress={onItemPress} onItemPress={onItemPress}
onItemLongPress={onItemLongPress} onItemLongPress={onItemLongPress}
imageUrlGetter={ imageUrlGetter={

View File

@@ -7,11 +7,8 @@ import { toast } from "sonner-native";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { jellyseerrProbe } from "@/utils/serverUrl/probes/jellyseerr";
import { resolveServerUrl } from "@/utils/serverUrl/resolve";
import { Button } from "../Button"; import { Button } from "../Button";
import { Input } from "../common/Input"; import { Input } from "../common/Input";
import { ServerUrlField } from "../common/ServerUrlField";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
@@ -29,44 +26,26 @@ export const JellyseerrSettings = () => {
string | undefined string | undefined
>(undefined); >(undefined);
const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<string>( const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<
settings?.jellyseerrServerUrl ?? "", string | undefined
); >(settings?.jellyseerrServerUrl || undefined);
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>(
settings?.jellyseerrServerUrl ?? undefined,
);
const loginToJellyseerrMutation = useMutation({ const loginToJellyseerrMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
throw new Error("Missing server url");
if (!user?.Name) if (!user?.Name)
throw new Error("Missing required information for login"); throw new Error("Missing required information for login");
const jellyseerrTempApi = new JellyseerrApi(
// Prefer the already-resolved URL; otherwise resolve the raw input now jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
// (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(); const testResult = await jellyseerrTempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url"); if (!testResult.isValid) throw new Error("Invalid server url");
const loggedInUser = await jellyseerrTempApi.login( return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
user.Name,
jellyseerrPassword || "",
);
return { user: loggedInUser, url: finalUrl };
}, },
onSuccess: ({ user: loggedInUser, url }) => { onSuccess: (user) => {
setJellyseerrUser(loggedInUser); setJellyseerrUser(user);
setResolvedUrl(url); updateSettings({ jellyseerrServerUrl });
updateSettings({ jellyseerrServerUrl: url });
}, },
onError: () => { onError: () => {
toast.error(t("jellyseerr.failed_to_login")); toast.error(t("jellyseerr.failed_to_login"));
@@ -80,8 +59,7 @@ export const JellyseerrSettings = () => {
clearAllJellyseerData().finally(() => { clearAllJellyseerData().finally(() => {
setJellyseerrUser(undefined); setJellyseerrUser(undefined);
setJellyseerrPassword(undefined); setJellyseerrPassword(undefined);
setjellyseerrServerUrl(""); setjellyseerrServerUrl(undefined);
setResolvedUrl(undefined);
}); });
}; };
@@ -140,20 +118,30 @@ export const JellyseerrSettings = () => {
<Text className='text-xs text-red-600 mb-2'> <Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")} {t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text> </Text>
<View className='mb-2'> <Text className='font-bold mb-1'>
<ServerUrlField {t("home.settings.plugins.jellyseerr.server_url")}
value={jellyseerrServerUrl} </Text>
onChangeText={setjellyseerrServerUrl} <View className='flex flex-col shrink mb-2'>
onResolved={(url) => setResolvedUrl(url)} <Text className='text-xs text-gray-600'>
probe={jellyseerrProbe} {t("home.settings.plugins.jellyseerr.server_url_hint")}
label={t("home.settings.plugins.jellyseerr.server_url")} </Text>
hint={t("home.settings.plugins.jellyseerr.server_url_hint")}
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder",
)}
editable={!loginToJellyseerrMutation.isPending}
/>
</View> </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> <View>
<Text className='font-bold mb-2'> <Text className='font-bold mb-2'>
{t("home.settings.plugins.jellyseerr.password")} {t("home.settings.plugins.jellyseerr.password")}

View File

@@ -12,9 +12,8 @@ import {
type LocalNetworkConfig, type LocalNetworkConfig,
updateServerLocalConfig, updateServerLocalConfig,
} from "@/utils/secureCredentials"; } from "@/utils/secureCredentials";
import { jellyfinProbe } from "@/utils/serverUrl/probes/jellyfin";
import { Button } from "../Button"; import { Button } from "../Button";
import { ServerUrlField } from "../common/ServerUrlField"; import { Input } from "../common/Input";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
@@ -163,12 +162,13 @@ export function LocalNetworkSettings(): React.ReactElement | null {
} }
> >
<View className=''> <View className=''>
<ServerUrlField <Input
placeholder={t("home.settings.network.local_url_placeholder")}
value={config.localUrl} value={config.localUrl}
onChangeText={handleLocalUrlChange} onChangeText={handleLocalUrlChange}
onResolved={(url) => saveConfig({ ...config, localUrl: url })} keyboardType='url'
probe={jellyfinProbe} autoCapitalize='none'
placeholder={t("home.settings.network.local_url_placeholder")} autoCorrect={false}
/> />
</View> </View>
</ListGroup> </ListGroup>

View File

@@ -48,7 +48,6 @@ import type {
TvDetails, TvDetails,
} from "@/utils/jellyseerr/server/models/Tv"; } from "@/utils/jellyseerr/server/models/Tv";
import { writeErrorLog } from "@/utils/log"; import { writeErrorLog } from "@/utils/log";
import { isVersionBelow } from "@/utils/serverUrl/semver";
interface SearchParams { interface SearchParams {
query: string; query: string;
@@ -142,13 +141,10 @@ export class JellyseerrApi {
.then((response) => { .then((response) => {
const { status, headers, data } = response; const { status, headers, data } = response;
if (inRange(status, 200, 299)) { if (inRange(status, 200, 299)) {
if (data.version && isVersionBelow(data.version, "2.0.0")) { if (data.version < "2.0.0") {
const error = t( const error = t(
"jellyseerr.toasts.jellyseer_does_not_meet_requirements", "jellyseerr.toasts.jellyseer_does_not_meet_requirements",
); );
writeErrorLog(
`Jellyseerr version ${data.version} is below the required 2.0.0`,
);
toast.error(error); toast.error(error);
throw Error(error); throw Error(error);
} }

View File

@@ -1,65 +0,0 @@
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 };
/**
* 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 },
);
}
return result;
},
[probe, options],
);
const reset = useCallback(() => {
abortRef.current?.abort();
setState({ status: "idle" });
}, []);
useEffect(() => () => abortRef.current?.abort(), []);
return { ...state, resolve, reset };
}

View File

@@ -1,10 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<application> <application>
<receiver <receiver android:name=".TvRecommendationsReceiver" android:exported="true">
android:name=".TvRecommendationsReceiver"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" /> <action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" />
</intent-filter> </intent-filter>
</receiver> </receiver>
</application> </application>

View File

@@ -61,31 +61,61 @@ internal object TvRecommendationsPublisher {
fun clear(context: Context): Boolean { fun clear(context: Context): Boolean {
val prefs = preferences(context) val prefs = preferences(context)
val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
if (programIds != null) { // KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
if (allProgramIds != null) {
var deletedPrograms = 0 var deletedPrograms = 0
val keys = programIds.keys() val channelKeys = allProgramIds.keys()
while (keys.hasNext()) { while (channelKeys.hasNext()) {
val key = keys.next() val channelIdStr = channelKeys.next()
val programId = programIds.optLong(key, -1L) val programIdsJson = allProgramIds.optString(channelIdStr)
if (programId > 0L) { if (programIdsJson.isBlank()) continue
contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId), try {
null, val programIds = JSONObject(programIdsJson)
null val keys = programIds.keys()
) while (keys.hasNext()) {
deletedPrograms += 1 val providerId = keys.next()
val programId = programIds.optLong(providerId, -1L)
if (programId > 0L) {
deletePreviewProgram(contentResolver, programId)
deletedPrograms += 1
}
}
} catch (e: Exception) {
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
} }
// Notify the channel
val channelId = channelIdStr.toLongOrNull() ?: -1L
if (channelId > 0L) {
try {
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
} catch (e: SecurityException) {
Log.w(TAG, "clear(): lost provider permission, cannot notify channel $channelId", e)
}
}
// Remove per-channel pref
prefs.edit().remove("programIds_$channelIdStr").apply()
} }
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)") Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
} }
if (channelId > 0L) { // Also handle legacy format (flat { providerId: programId }) for migration
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null) val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
Log.d(TAG, "clear(): notified channel $channelId") if (legacyProgramIds != null) {
val keys = legacyProgramIds.keys()
while (keys.hasNext()) {
val key = keys.next()
val programId = legacyProgramIds.optLong(key, -1L)
if (programId > 0L) {
deletePreviewProgram(contentResolver, programId)
}
}
prefs.edit().remove("legacy_" + KEY_PROGRAM_IDS).apply()
} }
prefs.edit() prefs.edit()
@@ -96,126 +126,262 @@ internal object TvRecommendationsPublisher {
return true return true
} }
/**
* Delete a single preview program from the TvProvider.
* Called by clear(), synchronize() (stale programs), and TvRecommendationsReceiver (user removed).
*/
fun deletePreviewProgram(context: Context, programId: Long) {
try {
context.contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
Log.d(TAG, "deletePreviewProgram(): deleted programId=$programId")
// Also remove from stored programIds prefs
removeProgramFromPrefs(context, programId)
} catch (e: SecurityException) {
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
}
}
private fun deletePreviewProgram(contentResolver: android.content.ContentResolver, programId: Long) {
try {
contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
} catch (e: SecurityException) {
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
}
}
private fun removeProgramFromPrefs(context: Context, programId: Long) {
val prefs = preferences(context)
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
try {
val programIds = JSONObject(programIdsJson)
val keys = programIds.keys()
while (keys.hasNext()) {
val key = keys.next()
if (programIds.optLong(key, -1L) == programId) {
programIds.remove(key)
break
}
}
prefs.edit().putString(KEY_PROGRAM_IDS, programIds.toString()).apply()
} catch (e: Exception) {
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
}
}
private fun synchronize(context: Context, payload: JSONObject): Boolean { private fun synchronize(context: Context, payload: JSONObject): Boolean {
val sections = payload.optJSONArray("sections") ?: JSONArray() val sections = payload.optJSONArray("sections") ?: JSONArray()
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null if (sections.length() == 0) {
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME Log.w(TAG, "synchronize(): no sections in payload")
val items = firstSection?.optJSONArray("items") ?: JSONArray()
Log.d(
TAG,
"synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)"
)
val channelId = getOrCreateChannel(context, sectionTitle)
if (channelId <= 0L) {
Log.w(TAG, "synchronize(): failed to get or create preview channel")
return false return false
} }
Log.d(TAG, "synchronize(): publishing into channelId=$channelId") val prefs = preferences(context)
val allNextProgramIds = JSONObject()
var totalActive = 0
var totalDeleted = 0
val previousProgramIds = preferences(context) for (sectionIndex in 0 until sections.length()) {
.getString(KEY_PROGRAM_IDS, null) val section = sections.optJSONObject(sectionIndex) ?: continue
?.let(::JSONObject) val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
?: JSONObject() val items = section.optJSONArray("items") ?: JSONArray()
val nextProgramIds = JSONObject()
val activeProviderIds = mutableSetOf<String>()
for (index in 0 until items.length()) { Log.d(
val item = items.optJSONObject(index) ?: continue TAG,
val providerId = item.optString("id") "synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
) )
if (programId > 0L) { val channelId = getOrCreateChannel(context, sectionTitle)
activeProviderIds += providerId if (channelId <= 0L) {
nextProgramIds.put(providerId, programId) Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId") continue
} }
}
var deletedPrograms = 0 // Per Android docs: check channel.isBrowsable() and request if needed.
val previousKeys = previousProgramIds.keys() if (!isChannelBrowsable(context, channelId)) {
while (previousKeys.hasNext()) { Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
val providerId = previousKeys.next() TvContractCompat.requestChannelBrowsable(context, channelId)
if (activeProviderIds.contains(providerId)) continue }
val programId = previousProgramIds.optLong(providerId, -1L) val prefKey = "programIds_$channelId"
if (programId > 0L) { val previousProgramIds = prefs.getString(prefKey, null)
context.contentResolver.delete( ?.let(::JSONObject)
TvContractCompat.buildPreviewProgramUri(programId), ?: JSONObject()
null, val nextProgramIds = JSONObject()
null val activeProviderIds = mutableSetOf<String>()
for (index in 0 until items.length()) {
val item = items.optJSONObject(index) ?: continue
val providerId = item.optString("id")
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
) )
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId") if (programId > 0L) {
activeProviderIds += providerId
nextProgramIds.put(providerId, programId)
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
}
} }
var deletedPrograms = 0
val previousKeys = previousProgramIds.keys()
while (previousKeys.hasNext()) {
val providerId = previousKeys.next()
if (activeProviderIds.contains(providerId)) continue
val programId = previousProgramIds.optLong(providerId, -1L)
if (programId > 0L) {
deletePreviewProgram(context, programId)
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
}
}
allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
totalActive += activeProviderIds.size
totalDeleted += deletedPrograms
logProviderState(context, channelId)
} }
preferences(context) // Store all channel program IDs for clear() to use
.edit() prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
.putLong(KEY_CHANNEL_ID, channelId)
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
.apply()
logProviderState(context, channelId)
Log.d( Log.d(
TAG, TAG,
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)" "synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
) )
return true return true
} }
/**
* Query provider to check if a channel is browsable.
* Per Android docs: "check channel.isBrowsable() before updating programs."
*/
private fun isChannelBrowsable(context: Context, channelId: Long): Boolean {
return try {
context.contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
if (browsableIndex >= 0) cursor.getInt(browsableIndex) == 1 else true
} else {
false
}
} ?: false
} catch (e: SecurityException) {
Log.w(TAG, "isChannelBrowsable(): lost provider permission for channelId=$channelId", e)
true // Assume browsable if we can't check, to avoid blocking updates
}
}
/**
* Query provider to verify a channel actually exists.
* Prevents the channel-delete-recreate bug: if update() returns 0 rows
* we must first check whether the channel was deleted by the system
* or if the update simply failed for another reason.
*/
private fun channelExistsInProvider(context: Context, channelId: Long): Boolean {
return try {
context.contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
cursor.moveToFirst()
} ?: false
} catch (e: SecurityException) {
Log.w(TAG, "channelExistsInProvider(): lost provider permission for channelId=$channelId", e)
false
}
}
private fun getOrCreateChannel(context: Context, displayName: String): Long { private fun getOrCreateChannel(context: Context, displayName: String): Long {
val prefs = preferences(context) val prefs = preferences(context)
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L) val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
if (existingChannelId > 0L) { if (existingChannelId > 0L) {
val updated = Channel.Builder() // Query provider first to verify channel actually exists (prevents recreate bug)
.setType(TvContractCompat.Channels.TYPE_PREVIEW) val exists = channelExistsInProvider(context, existingChannelId)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
val updatedRows = contentResolver.update( if (exists) {
TvContractCompat.buildChannelUri(existingChannelId), // Channel exists — update it in place, never recreate
updated.toContentValues(), val updated = Channel.Builder()
null, .setType(TvContractCompat.Channels.TYPE_PREVIEW)
null .setDisplayName(displayName)
) .setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
if (updatedRows > 0) { try {
TvContractCompat.requestChannelBrowsable(context, existingChannelId) val updatedRows = contentResolver.update(
storeChannelLogo(context, existingChannelId) TvContractCompat.buildChannelUri(existingChannelId),
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable") updated.toContentValues(),
return existingChannelId null,
null
)
if (updatedRows > 0) {
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
storeChannelLogo(context, existingChannelId)
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
return existingChannelId
}
// Update returned 0 rows but channel exists — log and return existing ID, don't recreate
Log.e(TAG, "getOrCreateChannel(): update returned 0 for existing channelId=$existingChannelId but channel exists — not recreating")
return existingChannelId
} catch (e: SecurityException) {
Log.w(TAG, "getOrCreateChannel(): lost provider permission updating channelId=$existingChannelId", e)
return existingChannelId
}
} }
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating") // Channel truly doesn't exist in provider — recreate
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
prefs.edit().remove(KEY_CHANNEL_ID).apply() prefs.edit().remove(KEY_CHANNEL_ID).apply()
} }
// Create a new channel
val channel = Channel.Builder() val channel = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW) .setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName) .setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://")) .setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build() .build()
val channelUri = contentResolver.insert( val channelUri = try {
TvContractCompat.Channels.CONTENT_URI, contentResolver.insert(
channel.toContentValues() TvContractCompat.Channels.CONTENT_URI,
) ?: return -1L channel.toContentValues()
)
} catch (e: SecurityException) {
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
null
} ?: return -1L
val channelId = ContentUris.parseId(channelUri) val channelId = ContentUris.parseId(channelUri)
TvContractCompat.requestChannelBrowsable(context, channelId) TvContractCompat.requestChannelBrowsable(context, channelId)
@@ -249,42 +415,62 @@ internal object TvRecommendationsPublisher {
builder.setDescription(it) builder.setDescription(it)
} }
// Per Android docs: use unique URIs for all images to avoid stale cache
imageUrl.takeIf { it.isNotBlank() }?.let { imageUrl.takeIf { it.isNotBlank() }?.let {
val imageUri = Uri.parse(it) val uniqueImageUrl = appendCacheBuster(it)
val imageUri = Uri.parse(uniqueImageUrl)
builder.setPosterArtUri(imageUri) builder.setPosterArtUri(imageUri)
builder.setThumbnailUri(imageUri) builder.setThumbnailUri(imageUri)
} }
val contentValues = builder.build().toContentValues() val contentValues = builder.build().toContentValues()
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
if (previousProgramId > 0L) { if (previousProgramId > 0L) {
val updatedRows = contentResolver.update( try {
TvContractCompat.buildPreviewProgramUri(previousProgramId), val updatedRows = contentResolver.update(
contentValues, TvContractCompat.buildPreviewProgramUri(previousProgramId),
null, contentValues,
null null,
) null
)
if (updatedRows > 0) { if (updatedRows > 0) {
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId") Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
return previousProgramId return previousProgramId
}
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission for programId=$previousProgramId", e)
} }
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
} }
val insertedUri = contentResolver.insert( val insertedUri = try {
TvContractCompat.PreviewPrograms.CONTENT_URI, contentResolver.insert(
contentValues TvContractCompat.PreviewPrograms.CONTENT_URI,
) ?: return -1L contentValues
)
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
null
} ?: return -1L
val programId = ContentUris.parseId(insertedUri) val programId = ContentUris.parseId(insertedUri)
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId") Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
return programId return programId
} }
/**
* Append a cache-busting parameter to ensure unique URIs when images change.
* Per Android docs: "Use unique Uris for all images... the old image will
* continue to appear if you don't change the Uri."
*/
private fun appendCacheBuster(imageUrl: String): String {
val separator = if (imageUrl.contains("?")) "&" else "?"
return "$imageUrl${separator}_t=${System.currentTimeMillis()}"
}
private fun buildIntentUri(context: Context, deepLink: String): Uri { private fun buildIntentUri(context: Context, deepLink: String): Uri {
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(deepLink) data = Uri.parse(deepLink)
@@ -306,13 +492,17 @@ internal object TvRecommendationsPublisher {
private fun storeChannelLogo(context: Context, channelId: Long) { private fun storeChannelLogo(context: Context, channelId: Long) {
val bitmap = applicationIconBitmap(context) ?: return val bitmap = applicationIconBitmap(context) ?: return
val outputStream = context.contentResolver.openOutputStream( try {
TvContractCompat.buildChannelLogoUri(channelId) val outputStream = context.contentResolver.openOutputStream(
) ?: return TvContractCompat.buildChannelLogoUri(channelId)
) ?: return
outputStream.use { stream -> outputStream.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.flush() stream.flush()
}
} catch (e: SecurityException) {
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
} }
} }
@@ -341,9 +531,14 @@ internal object TvRecommendationsPublisher {
return bitmap return bitmap
} }
fun getChannelId(context: Context): Long {
return preferences(context).getLong(KEY_CHANNEL_ID, -1L)
}
private fun preferences(context: Context): SharedPreferences { private fun preferences(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
} }
private fun logProviderState(context: Context, channelId: Long) { private fun logProviderState(context: Context, channelId: Long) {
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
@@ -372,8 +567,8 @@ internal object TvRecommendationsPublisher {
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false") Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
} }
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId") } ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
} catch (error: Exception) { } catch (error: SecurityException) {
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error) Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
} }
} }
} }

View File

@@ -1,12 +1,19 @@
import { requireNativeView } from "expo"; import { requireNativeView } from "expo";
import * as React from "react"; import * as React from "react";
import type { View } from "react-native"; import type { View } from "react-native";
import { Platform } from "react-native";
import type { TvSearchViewProps } from "./TvSearchView.types"; import type { TvSearchViewProps } from "./TvSearchView.types";
// The native TvSearchModule is Apple-only (tvOS SwiftUI `.searchable`).
// On Android the component is never rendered, but we must avoid calling
// `requireNativeView` at module-scope because it would crash on import.
const NativeView: React.ComponentType< const NativeView: React.ComponentType<
TvSearchViewProps & React.RefAttributes<View> TvSearchViewProps & React.RefAttributes<View>
> = requireNativeView("TvSearchModule"); > =
Platform.OS === "ios"
? requireNativeView("TvSearchModule")
: ((() => null) as any);
/** /**
* Forwards its ref to the underlying native view so it can be used as a * Forwards its ref to the underlying native view so it can be used as a

View File

@@ -1,12 +1,4 @@
{ {
"server_url": {
"resolving": "Checking…",
"resolved": "→ {{url}}",
"connected": "Connected to {{url}}",
"unreachable": "Server unreachable",
"wrong_service": "Reachable, but not the expected server",
"invalid_url": "Enter a valid address"
},
"login": { "login": {
"username_required": "Username Is Required", "username_required": "Username Is Required",
"error_title": "Error", "error_title": "Error",
@@ -600,7 +592,8 @@
"continue": "Continue", "continue": "Continue",
"verifying": "Verifying...", "verifying": "Verifying...",
"login": "Login", "login": "Login",
"refresh": "Refresh" "refresh": "Refresh",
"loading": "Loading..."
}, },
"search": { "search": {
"search": "Search...", "search": "Search...",

View File

@@ -1,75 +0,0 @@
/**
* 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));
}

View File

@@ -1,16 +0,0 @@
export {
getServerUrlCandidates,
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,
type ResolveResult,
resolveServerUrl,
} from "./resolve";
export { isVersionBelow } from "./semver";
export type { ServerProbe, ServerProbeOutcome } from "./types";

View File

@@ -1,24 +0,0 @@
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" };
}
};

View File

@@ -1,30 +0,0 @@
import axios from "axios";
import type { ServerProbe } from "../types";
/**
* 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. The minimum-version requirement is enforced at login
* time (see JellyseerrApi.test) — not surfaced here, to keep the field UI clean.
*/
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" };
// A JSON body carrying version/commitTag identifies a real jellyseerr.
const looksLikeJellyseerr =
!!data &&
typeof data === "object" &&
(typeof data.version === "string" || "commitTag" in data);
if (!looksLikeJellyseerr) return { status: "wrong-service" };
return { status: "ok", meta: { version: data.version } };
} catch {
return { status: "unreachable" };
}
};

View File

@@ -1,23 +0,0 @@
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" };
}
};

View File

@@ -1,88 +0,0 @@
import { getServerUrlCandidates } from "./candidates";
import type { ServerProbe, ServerProbeOutcome } from "./types";
export type ResolveFailureReason =
| "empty"
| "invalid"
| "wrong-service"
| "unreachable";
export type ResolveResult =
| { ok: true; url: string; meta?: Record<string, unknown> }
| { ok: false; reason: ResolveFailureReason };
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 = [
"wrong-service",
"unreachable",
] as const satisfies ReadonlyArray<ResolveFailureReason>;
/**
* 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<ResolveResult> {
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 };
}
}
return { ok: false, reason: "unreachable" };
}
async function runProbe(
url: string,
probe: ServerProbe,
timeoutMs: number,
parentSignal?: AbortSignal,
): Promise<ServerProbeOutcome> {
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);
}
}

View File

@@ -1,22 +0,0 @@
/**
* 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;
}

View File

@@ -1,15 +0,0 @@
/** Result of probing a single candidate URL for a specific service. */
export type ServerProbeOutcome =
| { status: "ok"; meta?: Record<string, unknown> }
| { 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<ServerProbeOutcome>;