feat: [StreamyfinPlugin] Jellyseerr, Search Engine, & Download settings

- Added DisabledSetting.tsx component
- Added DownloadMethod enum
- cleanup
This commit is contained in:
herrrta
2025-01-10 22:20:10 -05:00
parent 9dfcc01f17
commit 2c6823eb53
12 changed files with 193 additions and 179 deletions

View File

@@ -4,7 +4,7 @@ import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai";
@@ -96,7 +96,7 @@ export default function page() {
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && (
{settings?.downloadMethod === DownloadMethod.Remux && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text>
<Text className="text-xs opacity-70 text-red-600">

View File

@@ -15,7 +15,7 @@ import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { useHaptic } from "@/hooks/useHaptic";
import { useNavigation, useRouter } from "expo-router";
import { useEffect } from "react";
import React, { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { storage } from "@/utils/mmkv";

View File

@@ -1,78 +1,16 @@
import { Text } from "@/components/common/Text";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import { getStatistics } from "@/utils/optimize-server";
import { useMutation } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
const [api] = useAtom(apiAtom);
const [settings, updateSettings] = useSettings();
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState<string>(settings?.optimizedVersionsServerUrl || "");
const saveMutation = useMutation({
mutationFn: async (newVal: string) => {
if (newVal.length === 0 || !newVal.startsWith("http")) {
toast.error("Invalid URL");
return;
}
const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
updateSettings({
optimizedVersionsServerUrl: updatedUrl,
});
return await getStatistics({
url: settings?.optimizedVersionsServerUrl,
authHeader: api?.accessToken,
deviceId: getOrSetDeviceId(),
});
},
onSuccess: (data) => {
if (data) {
toast.success("Connected");
} else {
toast.error("Could not connect");
}
},
onError: () => {
toast.error("Could not connect");
},
});
const onSave = (newVal: string) => {
saveMutation.mutate(newVal);
};
// useEffect(() => {
// navigation.setOptions({
// title: "Optimized Server",
// headerRight: () =>
// saveMutation.isPending ? (
// <ActivityIndicator size={"small"} color={"white"} />
// ) : (
// <TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
// <Text className="text-blue-500">Save</Text>
// </TouchableOpacity>
// ),
// });
// }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
const [settings, updateSettings, pluginSettings] = useSettings();
return (
<View className="p-4">
<DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className="p-4"
>
<JellyseerrSettings />
</View>
</DisabledSetting>
);
}

View File

@@ -1,12 +1,10 @@
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import React, {useEffect, useMemo, useState} from "react";
import {
Linking,
Switch,
@@ -15,11 +13,12 @@ import {
View,
} from "react-native";
import { toast } from "sonner-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
const [settings, updateSettings] = useSettings();
const [settings, updateSettings, pluginSettings] = useSettings();
const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
@@ -35,69 +34,81 @@ export default function page() {
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
};
const disabled = useMemo(() => {
return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
}, [pluginSettings]);
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)}>
<Text className="text-blue-500">Save</Text>
</TouchableOpacity>
),
});
if (!pluginSettings?.marlinServerUrl?.locked) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)}>
<Text className="text-blue-500">Save</Text>
</TouchableOpacity>
),
});
}
}, [navigation, value]);
if (!settings) return null;
return (
<View className="px-4">
<DisabledSetting
disabled={disabled}
className="px-4"
>
<ListGroup>
<ListItem
title={"Enable Marlin Search"}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
<DisabledSetting
disabled={pluginSettings?.searchEngine?.locked === true}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<Switch
value={settings.searchEngine === "Marlin"}
onValueChange={(value) => {
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
<ListItem
title={"Enable Marlin Search"}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
>
<Switch
value={settings.searchEngine === "Marlin"}
onValueChange={(value) => {
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup>
<View
className={`mt-2 ${
settings.searchEngine === "Marlin" ? "" : "opacity-50"
}`}
<DisabledSetting
disabled={pluginSettings?.marlinServerUrl?.locked === true}
showText={!pluginSettings?.searchEngine?.locked}
className="mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"
>
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
<View
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
>
<Text className="mr-4">URL</Text>
<TextInput
editable={settings.searchEngine === "Marlin"}
className="text-white"
placeholder="http(s)://domain.org:port"
value={value}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setValue(text)}
/>
</View>
<View
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
>
<Text className="mr-4">URL</Text>
<TextInput
editable={settings.searchEngine === "Marlin"}
className="text-white"
placeholder="http(s)://domain.org:port"
value={value}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setValue(text)}
/>
</View>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Enter the URL for the Marlin server. The URL should include http or
https and optionally the port.{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
Read more about Marlin.
</Text>
</DisabledSetting>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Enter the URL for the Marlin server. The URL should include http or
https and optionally the port.{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
Read more about Marlin.
</Text>
</View>
</View>
</Text>
</DisabledSetting>
);
}

View File

@@ -10,12 +10,13 @@ import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
const [api] = useAtom(apiAtom);
const [settings, updateSettings] = useSettings();
const [settings, updateSettings, pluginSettings] = useSettings();
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState<string>(settings?.optimizedVersionsServerUrl || "");
@@ -56,25 +57,30 @@ export default function page() {
};
useEffect(() => {
navigation.setOptions({
title: "Optimized Server",
headerRight: () =>
saveMutation.isPending ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
<Text className="text-blue-500">Save</Text>
</TouchableOpacity>
),
});
if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
navigation.setOptions({
title: "Optimized Server",
headerRight: () =>
saveMutation.isPending ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
<Text className="text-blue-500">Save</Text>
</TouchableOpacity>
),
});
}
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
return (
<View className="p-4">
<DisabledSetting
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
className="p-4"
>
<OptimizedServerForm
value={optimizedVersionsServerUrl}
onChangeValue={setOptimizedVersionsServerUrl}
/>
</View>
</DisabledSetting>
);
}

View File

@@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
@@ -74,7 +74,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[user]
);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === "optimized",
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings]
);

View File

@@ -1,7 +1,6 @@
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { JobStatus } from "@/utils/optimize-server";
import { formatTimeString } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
@@ -9,7 +8,6 @@ import { checkForExistingDownloads } from "@kesha-antonov/react-native-backgroun
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { FFmpegKit } from "ffmpeg-kit-react-native";
import { useAtom } from "jotai";
import {
ActivityIndicator,
TouchableOpacity,
@@ -62,7 +60,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
mutationFn: async (id: string) => {
if (!process) throw new Error("No active download");
if (settings?.downloadMethod === "optimized") {
if (settings?.downloadMethod === DownloadMethod.Optimized) {
try {
const tasks = await checkForExistingDownloads();
for (const task of tasks) {

View File

@@ -0,0 +1,26 @@
import {View, ViewProps} from "react-native";
import {Text} from "@/components/common/Text";
const DisabledSetting: React.FC<{disabled: boolean, showText?: boolean, text?: string} & ViewProps> = ({
disabled = false,
showText = true,
text,
children,
...props
}) => (
<View
pointerEvents={disabled ? "none" : "auto"}
style={{
opacity: disabled ? 0.5 : 1,
}}
>
<View {...props}>
{disabled && showText &&
<Text className="text-center text-red-700 my-4">{text ?? "Currently disabled by admin."}</Text>
}
{children}
</View>
</View>
)
export default DisabledSetting;

View File

@@ -1,33 +1,47 @@
import { Stepper } from "@/components/inputs/Stepper";
import { useDownload } from "@/providers/DownloadProvider";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React from "react";
import { Switch, TouchableOpacity, View } from "react-native";
import {Stepper} from "@/components/inputs/Stepper";
import {useDownload} from "@/providers/DownloadProvider";
import {DownloadMethod, Settings, useSettings} from "@/utils/atoms/settings";
import {Ionicons} from "@expo/vector-icons";
import {useQueryClient} from "@tanstack/react-query";
import {useRouter} from "expo-router";
import React, {useMemo} from "react";
import {Switch, TouchableOpacity} from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import {Text} from "../common/Text";
import {ListGroup} from "../list/ListGroup";
import {ListItem} from "../list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
export const DownloadSettings: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const [settings, updateSettings, pluginSettings] = useSettings();
const { setProcesses } = useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const disabled = useMemo(() => (
pluginSettings?.downloadMethod?.locked === true &&
pluginSettings?.remuxConcurrentLimit?.locked === true &&
pluginSettings?.autoDownload.locked === true
), [pluginSettings])
if (!settings) return null;
return (
<View {...props} className="mb-4">
<DisabledSetting
disabled={disabled}
{...props}
className="mb-4"
>
<ListGroup title="Downloads">
<ListItem title="Download method">
<ListItem
title="Download method"
disabled={pluginSettings?.downloadMethod?.locked}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings.downloadMethod === "remux"
{settings.downloadMethod === DownloadMethod.Remux
? "Default"
: "Optimized"}
</Text>
@@ -51,7 +65,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ downloadMethod: "remux" });
updateSettings({ downloadMethod: DownloadMethod.Remux });
setProcesses([]);
}}
>
@@ -60,7 +74,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ downloadMethod: "optimized" });
updateSettings({ downloadMethod: DownloadMethod.Optimized });
setProcesses([]);
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
@@ -73,7 +87,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
<ListItem
title="Remux max download"
disabled={settings.downloadMethod !== "remux"}
disabled={pluginSettings?.remuxConcurrentLimit?.locked || settings.downloadMethod !== DownloadMethod.Remux}
>
<Stepper
value={settings.remuxConcurrentLimit}
@@ -90,22 +104,22 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
<ListItem
title="Auto download"
disabled={settings.downloadMethod !== "optimized"}
disabled={pluginSettings?.autoDownload?.locked || settings.downloadMethod !== DownloadMethod.Optimized}
>
<Switch
disabled={settings.downloadMethod !== "optimized"}
disabled={pluginSettings?.autoDownload?.locked || settings.downloadMethod !== DownloadMethod.Optimized}
value={settings.autoDownload}
onValueChange={(value) => updateSettings({ autoDownload: value })}
/>
</ListItem>
<ListItem
disabled={settings.downloadMethod !== "optimized"}
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked || settings.downloadMethod !== DownloadMethod.Optimized}
onPress={() => router.push("/settings/optimized-server/page")}
showArrow
title="Optimized Versions Server"
></ListItem>
</ListGroup>
</View>
</DisabledSetting>
);
};

View File

@@ -21,7 +21,7 @@ export const JellyseerrSettings = () => {
} = useJellyseerr();
const [user] = useAtom(userAtom);
const [settings, updateSettings] = useSettings();
const [settings, updateSettings, pluginSettings] = useSettings();
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
useState<boolean>(false);

View File

@@ -1,4 +1,4 @@
import { useSettings } from "@/utils/atoms/settings";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import { useLog, writeToLog } from "@/utils/log";
import {
@@ -106,7 +106,7 @@ function useDownloadProvider() {
const url = settings?.optimizedVersionsServerUrl;
if (
settings?.downloadMethod !== "optimized" ||
settings?.downloadMethod !== DownloadMethod.Optimized ||
!url ||
!deviceId ||
!authHeader
@@ -166,7 +166,7 @@ function useDownloadProvider() {
},
staleTime: 0,
refetchInterval: 2000,
enabled: settings?.downloadMethod === "optimized",
enabled: settings?.downloadMethod === DownloadMethod.Optimized,
});
useEffect(() => {

View File

@@ -1,5 +1,5 @@
import { atom, useAtom } from "jotai";
import { useCallback, useEffect } from "react";
import {useCallback, useEffect, useMemo} from "react";
import * as ScreenOrientation from "expo-screen-orientation";
import { storage } from "../mmkv";
import { Platform } from "react-native";
@@ -12,7 +12,7 @@ import {apiAtom} from "@/providers/JellyfinProvider";
import {getPluginsApi} from "@jellyfin/sdk/lib/utils/api";
import {writeErrorLog} from "@/utils/log";
const STREAMYFIN_PLUGIN_ID = "1e9e5d38-6e67-4615-8719-e98a5c34f004"
const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004"
const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS"
export type DownloadQuality = "original" | "high" | "low";
@@ -66,6 +66,11 @@ export type DefaultLanguageOption = {
label: string;
};
export enum DownloadMethod {
Remux = "remux",
Optimized = "optimized"
}
export type Settings = {
autoRotate?: boolean;
forceLandscapeInVideoPlayer?: boolean;
@@ -88,7 +93,7 @@ export type Settings = {
forwardSkipTime: number;
rewindSkipTime: number;
optimizedVersionsServerUrl?: string | null;
downloadMethod: "optimized" | "remux";
downloadMethod: DownloadMethod;
autoDownload: boolean;
showCustomMenuLinks: boolean;
disableHapticFeedback: boolean;
@@ -100,7 +105,7 @@ export type Settings = {
};
export interface Lockable<T> {
lockable: boolean;
locked: boolean;
value: T
}
@@ -138,7 +143,7 @@ const loadSettings = (): Settings => {
forwardSkipTime: 30,
rewindSkipTime: 10,
optimizedVersionsServerUrl: null,
downloadMethod: "remux",
downloadMethod: DownloadMethod.Remux,
autoDownload: false,
showCustomMenuLinks: false,
disableHapticFeedback: false,
@@ -171,15 +176,15 @@ export const pluginSettingsAtom = atom(storage.get<PluginLockableSettings>(STREA
export const useSettings = () => {
const [api] = useAtom(apiAtom);
const [settings, setSettings] = useAtom(settingsAtom);
const [_settings, setSettings] = useAtom(settingsAtom);
const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom);
useEffect(() => {
if (settings === null) {
if (_settings === null) {
const loadedSettings = loadSettings();
setSettings(loadedSettings);
}
}, [settings, setSettings]);
}, [_settings, setSettings]);
const setPluginSettings = useCallback((settings: PluginLockableSettings | undefined) => {
storage.setAny(STREAMYFIN_PLUGIN_SETTINGS, settings)
@@ -217,6 +222,22 @@ export const useSettings = () => {
[api]
)
// We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
const settings: Settings = useMemo(() => {
const overrideSettings = Object.entries(pluginSettings || {})
.reduce((acc, [key, value]) => {
if (value) {
acc = Object.assign(acc, {[key]: value.value})
}
return acc
}, {} as Settings)
return {
..._settings,
...overrideSettings
}
}, [_settings, setSettings, pluginSettings, _setPluginSettings, setPluginSettings])
const updateSettings = (update: Partial<Settings>) => {
if (settings) {
const newSettings = { ...settings, ...update };