mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-10 15:35:55 +01:00
Merge pull request #1032 from ajnart/trpc
🏗️ Migrate api endpoints to tRPC
This commit is contained in:
@@ -48,6 +48,10 @@
|
||||
"@tanstack/react-query": "^4.2.1",
|
||||
"@tanstack/react-query-devtools": "^4.24.4",
|
||||
"@tanstack/react-query-persist-client": "^4.28.0",
|
||||
"@trpc/client": "^10.29.1",
|
||||
"@trpc/next": "^10.29.1",
|
||||
"@trpc/react-query": "^10.29.1",
|
||||
"@trpc/server": "^10.29.1",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"axios": "^1.0.0",
|
||||
"consola": "^3.0.0",
|
||||
@@ -111,6 +115,7 @@
|
||||
"turbo": "latest",
|
||||
"typescript": "^5.0.4",
|
||||
"video.js": "^8.0.3",
|
||||
"vite-tsconfig-paths": "^4.2.0",
|
||||
"vitest": "^0.32.0",
|
||||
"vitest-fetch-mock": "^0.2.2"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Center, Dialog, Loader, Notification, Select, Tooltip } from '@mantine/core';
|
||||
import { useToggle } from '@mantine/hooks';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import { setCookie } from 'cookies-next';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import { api } from '~/utils/api';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
|
||||
export default function ConfigChanger() {
|
||||
@@ -95,10 +95,4 @@ export default function ConfigChanger() {
|
||||
);
|
||||
}
|
||||
|
||||
const useConfigsQuery = () =>
|
||||
useQuery({
|
||||
queryKey: ['config/get-all'],
|
||||
queryFn: fetchConfigs,
|
||||
});
|
||||
|
||||
const fetchConfigs = async () => (await (await fetch('/api/configs')).json()) as string[];
|
||||
const useConfigsQuery = () => api.config.all.useQuery();
|
||||
|
||||
@@ -4,18 +4,20 @@ import { showNotification } from '@mantine/notifications';
|
||||
import { IconCheck as Check, IconPhoto, IconUpload, IconX, IconX as X } from '@tabler/icons-react';
|
||||
import { setCookie } from 'cookies-next';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useConfigStore } from '../../config/store';
|
||||
import { ConfigType } from '../../types/config';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
export const LoadConfigComponent = () => {
|
||||
const { addConfig } = useConfigStore();
|
||||
const theme = useMantineTheme();
|
||||
const { t } = useTranslation('settings/general/config-changer');
|
||||
const { mutateAsync: loadAsync } = useLoadConfig();
|
||||
|
||||
return (
|
||||
<Dropzone.FullScreen
|
||||
onDrop={async (files) => {
|
||||
const fileName = files[0].name.replaceAll('.json', '');
|
||||
const configName = files[0].name.replaceAll('.json', '');
|
||||
const fileText = await files[0].text();
|
||||
|
||||
try {
|
||||
@@ -32,26 +34,7 @@ export const LoadConfigComponent = () => {
|
||||
}
|
||||
|
||||
const newConfig: ConfigType = JSON.parse(fileText);
|
||||
|
||||
await addConfig(fileName, newConfig, true);
|
||||
showNotification({
|
||||
autoClose: 5000,
|
||||
radius: 'md',
|
||||
title: (
|
||||
<Text>
|
||||
{t('dropzone.notifications.loadedSuccessfully.title', {
|
||||
configName: fileName,
|
||||
})}
|
||||
</Text>
|
||||
),
|
||||
color: 'green',
|
||||
icon: <Check />,
|
||||
message: undefined,
|
||||
});
|
||||
setCookie('config-name', fileName, {
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
await loadAsync({ name: configName, config: newConfig });
|
||||
}}
|
||||
accept={['application/json']}
|
||||
>
|
||||
@@ -89,3 +72,34 @@ export const LoadConfigComponent = () => {
|
||||
</Dropzone.FullScreen>
|
||||
);
|
||||
};
|
||||
|
||||
const useLoadConfig = () => {
|
||||
const { t } = useTranslation('settings/general/config-changer');
|
||||
const { addConfig } = useConfigStore();
|
||||
const router = useRouter();
|
||||
return api.config.save.useMutation({
|
||||
async onSuccess(_data, variables) {
|
||||
await addConfig(variables.name, variables.config);
|
||||
|
||||
showNotification({
|
||||
autoClose: 5000,
|
||||
radius: 'md',
|
||||
title: (
|
||||
<Text>
|
||||
{t('dropzone.notifications.loadedSuccessfully.title', {
|
||||
configName: variables.name,
|
||||
})}
|
||||
</Text>
|
||||
),
|
||||
color: 'green',
|
||||
icon: <Check />,
|
||||
message: undefined,
|
||||
});
|
||||
setCookie('config-name', variables.name, {
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
router.push(`/${variables.name}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Indicator, Tooltip } from '@mantine/core';
|
||||
import Consola from 'consola';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { AppType } from '../../../../types/app';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
interface AppPingProps {
|
||||
app: AppType;
|
||||
@@ -16,18 +16,7 @@ export const AppPing = ({ app }: AppPingProps) => {
|
||||
const active =
|
||||
(config?.settings.customization.layout.enabledPing && app.network.enabledStatusChecker) ??
|
||||
false;
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ping', { id: app.id, name: app.name }],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/modules/ping?url=${encodeURI(app.url)}`);
|
||||
const isOk = getIsOk(app, response.status);
|
||||
return {
|
||||
status: response.status,
|
||||
state: isOk ? 'online' : 'down',
|
||||
};
|
||||
},
|
||||
enabled: active,
|
||||
});
|
||||
const { data, isLoading, error } = usePingQuery(app, active);
|
||||
|
||||
const isOnline = data?.state === 'online';
|
||||
|
||||
@@ -49,7 +38,7 @@ export const AppPing = ({ app }: AppPingProps) => {
|
||||
? t('states.loading')
|
||||
: isOnline
|
||||
? t('states.online', { response: data.status })
|
||||
: t('states.offline', { response: data?.status })
|
||||
: t('states.offline', { response: data?.status ?? error?.data?.httpStatus })
|
||||
}
|
||||
>
|
||||
<Indicator
|
||||
@@ -62,11 +51,29 @@ export const AppPing = ({ app }: AppPingProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const usePingQuery = (app: AppType, isEnabled: boolean) =>
|
||||
api.app.ping.useQuery(
|
||||
{
|
||||
url: app.url,
|
||||
},
|
||||
{
|
||||
enabled: isEnabled,
|
||||
select: (data) => {
|
||||
const statusCode = data.status;
|
||||
const isOk = getIsOk(app, statusCode);
|
||||
return {
|
||||
status: statusCode,
|
||||
state: isOk ? ('online' as const) : ('down' as const),
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const getIsOk = (app: AppType, status: number) => {
|
||||
if (app.network.okStatus === undefined || app.network.statusCodes.length >= 1) {
|
||||
Consola.log('Using new status codes');
|
||||
return app.network.statusCodes.includes(status.toString());
|
||||
}
|
||||
Consola.warn('Using deprecated okStatus');
|
||||
return app.network.okStatus.includes(status);
|
||||
if (app.network.okStatus === undefined || app.network.statusCodes.length >= 1) {
|
||||
Consola.log('Using new status codes');
|
||||
return app.network.statusCodes.includes(status.toString());
|
||||
}
|
||||
Consola.warn('Using deprecated okStatus');
|
||||
return app.network.okStatus.includes(status);
|
||||
};
|
||||
|
||||
@@ -15,9 +15,9 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useGetDashboardIcons } from '../../hooks/icons/useGetDashboardIcons';
|
||||
import { humanFileSize } from '../../tools/humanFileSize';
|
||||
import { DebouncedImage } from './DebouncedImage';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
export const IconSelector = forwardRef(
|
||||
(
|
||||
@@ -175,3 +175,12 @@ interface ItemProps extends SelectItemProps {
|
||||
size: number;
|
||||
copyright: string | undefined;
|
||||
}
|
||||
|
||||
const useGetDashboardIcons = () =>
|
||||
api.icon.all.useQuery(undefined, {
|
||||
refetchOnMount: false,
|
||||
// Cache for infinity, refetch every so often.
|
||||
cacheTime: Infinity,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
@@ -10,23 +10,28 @@ import {
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { openConfirmModal } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconAlertTriangle, IconCheck, IconCopy, IconDownload, IconTrash } from '@tabler/icons-react';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import fileDownload from 'js-file-download';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
import { useDeleteConfigMutation } from '../../../../tools/config/mutations/useDeleteConfigMutation';
|
||||
import Tip from '../../../layout/Tip';
|
||||
import { CreateConfigCopyModal } from './CreateCopyModal';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
export default function ConfigActions() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation(['settings/general/config-changer', 'settings/common', 'common']);
|
||||
const [createCopyModalOpened, createCopyModal] = useDisclosure(false);
|
||||
const { config } = useConfigContext();
|
||||
const { removeConfig } = useConfigStore();
|
||||
const { mutateAsync } = useDeleteConfigMutation(config?.configProperties.name ?? 'default');
|
||||
const { mutateAsync } = useDeleteConfigMutation();
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
@@ -61,28 +66,9 @@ export default function ConfigActions() {
|
||||
},
|
||||
zIndex: 201,
|
||||
onConfirm: async () => {
|
||||
const response = await mutateAsync();
|
||||
|
||||
if (response.error) {
|
||||
showNotification({
|
||||
title: t('buttons.delete.notifications.deleteFailedDefaultConfig.title'),
|
||||
message: t('buttons.delete.notifications.deleteFailedDefaultConfig.message'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showNotification({
|
||||
title: t('buttons.delete.notifications.deleted.title'),
|
||||
icon: <IconCheck />,
|
||||
color: 'green',
|
||||
autoClose: 1500,
|
||||
radius: 'md',
|
||||
message: t('buttons.delete.notifications.deleted.message'),
|
||||
const response = await mutateAsync({
|
||||
name: config?.configProperties.name ?? 'default',
|
||||
});
|
||||
|
||||
removeConfig(config?.configProperties.name ?? 'default');
|
||||
|
||||
router.push('/');
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -124,6 +110,49 @@ export default function ConfigActions() {
|
||||
);
|
||||
}
|
||||
|
||||
const useDeleteConfigMutation = () => {
|
||||
const { t } = useTranslation(['settings/general/config-changer']);
|
||||
const router = useRouter();
|
||||
const { removeConfig } = useConfigStore();
|
||||
|
||||
return api.config.delete.useMutation({
|
||||
onError(error) {
|
||||
if (error.data?.code === 'FORBIDDEN') {
|
||||
showNotification({
|
||||
title: t('buttons.delete.notifications.deleteFailedDefaultConfig.title'),
|
||||
icon: <IconX />,
|
||||
color: 'red',
|
||||
autoClose: 1500,
|
||||
radius: 'md',
|
||||
message: t('buttons.delete.notifications.deleteFailedDefaultConfig.message'),
|
||||
});
|
||||
}
|
||||
showNotification({
|
||||
title: t('buttons.delete.notifications.deleteFailed.title'),
|
||||
icon: <IconX />,
|
||||
color: 'red',
|
||||
autoClose: 1500,
|
||||
radius: 'md',
|
||||
message: t('buttons.delete.notifications.deleteFailed.message'),
|
||||
});
|
||||
},
|
||||
onSuccess(data, variables) {
|
||||
showNotification({
|
||||
title: t('buttons.delete.notifications.deleted.title'),
|
||||
icon: <IconCheck />,
|
||||
color: 'green',
|
||||
autoClose: 1500,
|
||||
radius: 'md',
|
||||
message: t('buttons.delete.notifications.deleted.message'),
|
||||
});
|
||||
|
||||
removeConfig(variables.name);
|
||||
|
||||
router.push('/');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
actionIcon: {
|
||||
width: 'auto',
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Button, Group, Modal, TextInput, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { IconCheck, IconX } from '@tabler/icons-react';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
import { useCopyConfigMutation } from '../../../../tools/config/mutations/useCopyConfigMutation';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
interface CreateConfigCopyModalProps {
|
||||
opened: boolean;
|
||||
@@ -16,6 +19,7 @@ export const CreateConfigCopyModal = ({
|
||||
initialConfigName,
|
||||
}: CreateConfigCopyModalProps) => {
|
||||
const { configs } = useConfigStore();
|
||||
const { config } = useConfigContext();
|
||||
const { t } = useTranslation(['settings/general/config-changer']);
|
||||
|
||||
const form = useForm({
|
||||
@@ -40,7 +44,7 @@ export const CreateConfigCopyModal = ({
|
||||
validateInputOnBlur: true,
|
||||
});
|
||||
|
||||
const { mutateAsync } = useCopyConfigMutation(form.values.configName);
|
||||
const { mutateAsync } = useCopyConfigMutation();
|
||||
|
||||
const handleClose = () => {
|
||||
form.setFieldValue('configName', initialConfigName);
|
||||
@@ -50,7 +54,17 @@ export const CreateConfigCopyModal = ({
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
if (!form.isValid) return;
|
||||
|
||||
await mutateAsync();
|
||||
if (!config) {
|
||||
throw new Error('config is not defiend');
|
||||
}
|
||||
|
||||
const copiedConfig = config;
|
||||
copiedConfig.configProperties.name = form.values.configName;
|
||||
|
||||
await mutateAsync({
|
||||
name: form.values.configName,
|
||||
config: copiedConfig,
|
||||
});
|
||||
closeModal();
|
||||
};
|
||||
|
||||
@@ -76,3 +90,33 @@ export const CreateConfigCopyModal = ({
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const useCopyConfigMutation = () => {
|
||||
const { t } = useTranslation(['settings/general/config-changer']);
|
||||
const utils = api.useContext();
|
||||
|
||||
return api.config.save.useMutation({
|
||||
onSuccess(_data, variables) {
|
||||
showNotification({
|
||||
title: t('modal.copy.events.configCopied.title'),
|
||||
icon: <IconCheck />,
|
||||
color: 'green',
|
||||
autoClose: 1500,
|
||||
radius: 'md',
|
||||
message: t('modal.copy.events.configCopied.message', { configName: variables.name }),
|
||||
});
|
||||
// Invalidate a query to fetch new config
|
||||
utils.config.all.invalidate();
|
||||
},
|
||||
onError(_error, variables) {
|
||||
showNotification({
|
||||
title: t('modal.events.configNotCopied.title'),
|
||||
icon: <IconX />,
|
||||
color: 'red',
|
||||
autoClose: 1500,
|
||||
radius: 'md',
|
||||
message: t('modal.events.configNotCopied.message', { configName: variables.name }),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,13 +2,13 @@ import { ActionIcon, Button, Group, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { useHotkeys, useWindowEvent } from '@mantine/hooks';
|
||||
import { hideNotification, showNotification } from '@mantine/notifications';
|
||||
import { IconEditCircle, IconEditCircleOff } from '@tabler/icons-react';
|
||||
import axios from 'axios';
|
||||
import Consola from 'consola';
|
||||
import { getCookie } from 'cookies-next';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
import { useConfigContext } from '../../../../../config/provider';
|
||||
import { useScreenSmallerThan } from '../../../../../hooks/useScreenSmallerThan';
|
||||
|
||||
import { api } from '~/utils/api';
|
||||
import { useEditModeStore } from '../../../../Dashboard/Views/useEditModeStore';
|
||||
import { useNamedWrapperColumnCount } from '../../../../Dashboard/Wrappers/gridstack/store';
|
||||
import { useCardStyles } from '../../../useCardStyles';
|
||||
@@ -28,6 +28,7 @@ export const ToggleEditModeAction = () => {
|
||||
const smallerThanSm = useScreenSmallerThan('sm');
|
||||
const { config } = useConfigContext();
|
||||
const { classes } = useCardStyles(true);
|
||||
const { mutateAsync: saveConfig } = api.config.save.useMutation();
|
||||
|
||||
useHotkeys([['mod+E', toggleEditMode]]);
|
||||
|
||||
@@ -41,11 +42,12 @@ export const ToggleEditModeAction = () => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const toggleButtonClicked = () => {
|
||||
const toggleButtonClicked = async () => {
|
||||
toggleEditMode();
|
||||
if (enabled || config === undefined || config?.schemaVersion === undefined) {
|
||||
if (config === undefined || config?.schemaVersion === undefined) return;
|
||||
if (enabled) {
|
||||
const configName = getCookie('config-name')?.toString() ?? 'default';
|
||||
axios.put(`/api/configs/${configName}`, { ...config });
|
||||
await saveConfig({ name: configName, config });
|
||||
Consola.log('Saved config to server', configName);
|
||||
hideNotification('toggle-edit-mode');
|
||||
} else if (!enabled) {
|
||||
|
||||
@@ -13,10 +13,9 @@ import {
|
||||
import { useDebouncedValue, useHotkeys } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconBrandYoutube, IconDownload, IconMovie, IconSearch } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import React, { forwardRef, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '~/utils/api';
|
||||
import { useConfigContext } from '../../../config/provider';
|
||||
import { OverseerrMediaDisplay } from '../../../modules/common';
|
||||
import { IModule } from '../../../modules/ModuleTypes';
|
||||
@@ -141,26 +140,12 @@ export function Search() {
|
||||
const openTarget = getOpenTarget(config);
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const {
|
||||
data: OverseerrResults,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery(
|
||||
['overseerr', debounced],
|
||||
async () => {
|
||||
const res = await axios.get(`/api/modules/overseerr?query=${debounced}`);
|
||||
return res.data.results ?? [];
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
isOverseerrEnabled === true &&
|
||||
selectedSearchEngine.value === 'overseerr' &&
|
||||
debounced.length > 3,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchInterval: false,
|
||||
}
|
||||
);
|
||||
const isOverseerrSearchEnabled =
|
||||
isOverseerrEnabled === true &&
|
||||
selectedSearchEngine.value === 'overseerr' &&
|
||||
debounced.length > 3;
|
||||
|
||||
const { data: overseerrResults } = useOverseerrSearchQuery(debounced, isOverseerrSearchEnabled);
|
||||
|
||||
const isModuleEnabled = config?.settings.customization.layout.enabledSearchbar;
|
||||
if (!isModuleEnabled) {
|
||||
@@ -173,7 +158,7 @@ export function Search() {
|
||||
<Box style={{ width: '100%', maxWidth: 400 }}>
|
||||
<Popover
|
||||
opened={
|
||||
(OverseerrResults && OverseerrResults.length > 0 && opened && searchQuery.length > 3) ??
|
||||
(overseerrResults && overseerrResults.length > 0 && opened && searchQuery.length > 3) ??
|
||||
false
|
||||
}
|
||||
position="bottom"
|
||||
@@ -224,11 +209,11 @@ export function Search() {
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<ScrollArea style={{ height: '80vh', maxWidth: '90vw' }} offsetScrollbars>
|
||||
{OverseerrResults &&
|
||||
OverseerrResults.slice(0, 4).map((result: any, index: number) => (
|
||||
{overseerrResults &&
|
||||
overseerrResults.slice(0, 4).map((result: any, index: number) => (
|
||||
<React.Fragment key={index}>
|
||||
<OverseerrMediaDisplay key={result.id} media={result} />
|
||||
{index < OverseerrResults.length - 1 && index < 3 && (
|
||||
{index < overseerrResults.length - 1 && index < 3 && (
|
||||
<Divider variant="dashed" my="xs" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
@@ -312,3 +297,19 @@ const getOpenTarget = (config: ConfigType | undefined): '_blank' | '_self' => {
|
||||
|
||||
return config.settings.common.searchEngine.properties.openInNewTab ? '_blank' : '_self';
|
||||
};
|
||||
|
||||
const useOverseerrSearchQuery = (query: string, isEnabled: boolean) => {
|
||||
const { name: configName } = useConfigContext();
|
||||
return api.overseerr.all.useQuery(
|
||||
{
|
||||
query,
|
||||
configName: configName!,
|
||||
},
|
||||
{
|
||||
enabled: isEnabled,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchInterval: false,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { create } from 'zustand';
|
||||
import { trcpProxyClient } from '~/utils/api';
|
||||
import { ConfigType } from '../types/config';
|
||||
|
||||
export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
|
||||
@@ -13,7 +13,7 @@ export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
|
||||
],
|
||||
}));
|
||||
},
|
||||
addConfig: async (name: string, config: ConfigType, shouldSaveConfigToFileSystem = true) => {
|
||||
addConfig: async (name: string, config: ConfigType) => {
|
||||
set((old) => ({
|
||||
...old,
|
||||
configs: [
|
||||
@@ -21,11 +21,6 @@ export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
|
||||
{ value: config, increaseVersion: () => {} },
|
||||
],
|
||||
}));
|
||||
|
||||
if (!shouldSaveConfigToFileSystem) {
|
||||
return;
|
||||
}
|
||||
axios.put(`/api/configs/${name}`, { ...config });
|
||||
},
|
||||
removeConfig: (name: string) => {
|
||||
set((old) => ({
|
||||
@@ -66,7 +61,10 @@ export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
|
||||
}
|
||||
|
||||
if (shouldSaveConfigToFileSystem) {
|
||||
axios.put(`/api/configs/${name}`, { ...updatedConfig });
|
||||
trcpProxyClient.config.save.mutate({
|
||||
name,
|
||||
config: updatedConfig,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -74,11 +72,7 @@ export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
|
||||
interface UseConfigStoreType {
|
||||
configs: { increaseVersion: () => void; value: ConfigType }[];
|
||||
initConfig: (name: string, config: ConfigType, increaseVersion: () => void) => void;
|
||||
addConfig: (
|
||||
name: string,
|
||||
config: ConfigType,
|
||||
shouldSaveConfigToFileSystem: boolean
|
||||
) => Promise<void>;
|
||||
addConfig: (name: string, config: ConfigType) => Promise<void>;
|
||||
removeConfig: (name: string) => void;
|
||||
updateConfig: (
|
||||
name: string,
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { NormalizedIconRepositoryResult } from '../../tools/server/images/abstract-icons-repository';
|
||||
|
||||
export const useGetDashboardIcons = () =>
|
||||
useQuery({
|
||||
queryKey: ['repository-icons'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/icons/');
|
||||
const data = await response.json();
|
||||
return data as NormalizedIconRepositoryResult[];
|
||||
},
|
||||
refetchOnMount: false,
|
||||
// Cache for infinity, refetch every so often.
|
||||
cacheTime: Infinity,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
@@ -1,154 +1,94 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { Results } from 'sabnzbd-api';
|
||||
import type {
|
||||
UsenetQueueRequestParams,
|
||||
UsenetQueueResponse,
|
||||
} from '../../../pages/api/modules/usenet/queue';
|
||||
import type {
|
||||
UsenetHistoryRequestParams,
|
||||
UsenetHistoryResponse,
|
||||
} from '../../../pages/api/modules/usenet/history';
|
||||
import { UsenetInfoRequestParams, UsenetInfoResponse } from '../../../pages/api/modules/usenet';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { RouterInputs, api } from '~/utils/api';
|
||||
import { UsenetInfoRequestParams } from '../../../pages/api/modules/usenet';
|
||||
import type { UsenetHistoryRequestParams } from '../../../pages/api/modules/usenet/history';
|
||||
import { UsenetPauseRequestParams } from '../../../pages/api/modules/usenet/pause';
|
||||
import { queryClient } from '../../../tools/server/configurations/tanstack/queryClient.tool';
|
||||
import type { UsenetQueueRequestParams } from '../../../pages/api/modules/usenet/queue';
|
||||
import { UsenetResumeRequestParams } from '../../../pages/api/modules/usenet/resume';
|
||||
|
||||
const POLLING_INTERVAL = 2000;
|
||||
|
||||
export const useGetUsenetInfo = (params: UsenetInfoRequestParams) =>
|
||||
useQuery(
|
||||
['usenetInfo', params.appId],
|
||||
async () =>
|
||||
(
|
||||
await axios.get<UsenetInfoResponse>('/api/modules/usenet', {
|
||||
params,
|
||||
})
|
||||
).data,
|
||||
export const useGetUsenetInfo = ({ appId }: UsenetInfoRequestParams) => {
|
||||
const { name: configName } = useConfigContext();
|
||||
|
||||
return api.usenet.info.useQuery(
|
||||
{
|
||||
appId,
|
||||
configName: configName!,
|
||||
},
|
||||
{
|
||||
refetchInterval: POLLING_INTERVAL,
|
||||
keepPreviousData: true,
|
||||
retry: 2,
|
||||
enabled: Boolean(params.appId),
|
||||
enabled: !!appId,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const useGetUsenetDownloads = (params: UsenetQueueRequestParams) =>
|
||||
useQuery(
|
||||
['usenetDownloads', ...Object.values(params)],
|
||||
async () =>
|
||||
(
|
||||
await axios.get<UsenetQueueResponse>('/api/modules/usenet/queue', {
|
||||
params,
|
||||
})
|
||||
).data,
|
||||
export const useGetUsenetDownloads = (params: UsenetQueueRequestParams) => {
|
||||
const { name: configName } = useConfigContext();
|
||||
return api.usenet.queue.useQuery(
|
||||
{
|
||||
configName: configName!,
|
||||
...params,
|
||||
},
|
||||
{
|
||||
refetchInterval: POLLING_INTERVAL,
|
||||
keepPreviousData: true,
|
||||
retry: 2,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const useGetUsenetHistory = (params: UsenetHistoryRequestParams) =>
|
||||
useQuery(
|
||||
['usenetHistory', ...Object.values(params)],
|
||||
async () =>
|
||||
(
|
||||
await axios.get<UsenetHistoryResponse>('/api/modules/usenet/history', {
|
||||
params,
|
||||
})
|
||||
).data,
|
||||
export const useGetUsenetHistory = (params: UsenetHistoryRequestParams) => {
|
||||
const { name: configName } = useConfigContext();
|
||||
return api.usenet.history.useQuery(
|
||||
{
|
||||
configName: configName!,
|
||||
...params,
|
||||
},
|
||||
{
|
||||
refetchInterval: POLLING_INTERVAL,
|
||||
keepPreviousData: true,
|
||||
retry: 2,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const usePauseUsenetQueue = (params: UsenetPauseRequestParams) =>
|
||||
useMutation(
|
||||
['usenetPause', ...Object.values(params)],
|
||||
async () =>
|
||||
(
|
||||
await axios.post<Results>(
|
||||
'/api/modules/usenet/pause',
|
||||
{},
|
||||
{
|
||||
params,
|
||||
}
|
||||
)
|
||||
).data,
|
||||
{
|
||||
async onMutate() {
|
||||
await queryClient.cancelQueries(['usenetInfo', params.appId]);
|
||||
const previousInfo = queryClient.getQueryData<UsenetInfoResponse>([
|
||||
'usenetInfo',
|
||||
params.appId,
|
||||
]);
|
||||
export const usePauseUsenetQueueMutation = (params: UsenetPauseRequestParams) => {
|
||||
const { name: configName } = useConfigContext();
|
||||
const { mutateAsync } = api.usenet.pause.useMutation();
|
||||
const utils = api.useContext();
|
||||
return async (variables: Omit<RouterInputs['usenet']['pause'], 'configName'>) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
configName: configName!,
|
||||
...variables,
|
||||
},
|
||||
{
|
||||
onSettled() {
|
||||
utils.usenet.info.invalidate({ appId: params.appId });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
if (previousInfo) {
|
||||
queryClient.setQueryData<UsenetInfoResponse>(['usenetInfo', params.appId], {
|
||||
...previousInfo,
|
||||
paused: true,
|
||||
});
|
||||
}
|
||||
|
||||
return { previousInfo };
|
||||
export const useResumeUsenetQueueMutation = (params: UsenetResumeRequestParams) => {
|
||||
const { name: configName } = useConfigContext();
|
||||
const { mutateAsync } = api.usenet.resume.useMutation();
|
||||
const utils = api.useContext();
|
||||
return async (variables: Omit<RouterInputs['usenet']['resume'], 'configName'>) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
configName: configName!,
|
||||
...variables,
|
||||
},
|
||||
onError(err, _, context) {
|
||||
if (context?.previousInfo) {
|
||||
queryClient.setQueryData<UsenetInfoResponse>(
|
||||
['usenetInfo', params.appId],
|
||||
context.previousInfo
|
||||
);
|
||||
}
|
||||
},
|
||||
onSettled() {
|
||||
queryClient.invalidateQueries(['usenetInfo', params.appId]);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export const useResumeUsenetQueue = (params: UsenetResumeRequestParams) =>
|
||||
useMutation(
|
||||
['usenetResume', ...Object.values(params)],
|
||||
async () =>
|
||||
(
|
||||
await axios.post<Results>(
|
||||
'/api/modules/usenet/resume',
|
||||
{},
|
||||
{
|
||||
params,
|
||||
}
|
||||
)
|
||||
).data,
|
||||
{
|
||||
async onMutate() {
|
||||
await queryClient.cancelQueries(['usenetInfo', params.appId]);
|
||||
const previousInfo = queryClient.getQueryData<UsenetInfoResponse>([
|
||||
'usenetInfo',
|
||||
params.appId,
|
||||
]);
|
||||
|
||||
if (previousInfo) {
|
||||
queryClient.setQueryData<UsenetInfoResponse>(['usenetInfo', params.appId], {
|
||||
...previousInfo,
|
||||
paused: false,
|
||||
});
|
||||
}
|
||||
|
||||
return { previousInfo };
|
||||
},
|
||||
onError(err, _, context) {
|
||||
if (context?.previousInfo) {
|
||||
queryClient.setQueryData<UsenetInfoResponse>(
|
||||
['usenetInfo', params.appId],
|
||||
context.previousInfo
|
||||
);
|
||||
}
|
||||
},
|
||||
onSettled() {
|
||||
queryClient.invalidateQueries(['usenetInfo', params.appId]);
|
||||
},
|
||||
}
|
||||
);
|
||||
{
|
||||
onSettled() {
|
||||
utils.usenet.info.invalidate({ appId: params.appId });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { NormalizedDownloadQueueResponse } from '../../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
export const useGetDownloadClientsQueue = () =>
|
||||
useQuery({
|
||||
queryKey: ['network-speed'],
|
||||
queryFn: async (): Promise<NormalizedDownloadQueueResponse> => {
|
||||
const response = await fetch('/api/modules/downloads');
|
||||
return response.json();
|
||||
export const useGetDownloadClientsQueue = () => {
|
||||
const { name: configName } = useConfigContext();
|
||||
return api.download.get.useQuery(
|
||||
{
|
||||
configName: configName!,
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
{
|
||||
refetchInterval: 3000,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { MediaServersResponseType } from '../../../types/api/media-server/response';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
interface GetMediaServersParams {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const useGetMediaServers = ({ enabled }: GetMediaServersParams) =>
|
||||
useQuery({
|
||||
queryKey: ['media-servers'],
|
||||
queryFn: async (): Promise<MediaServersResponseType> => {
|
||||
const response = await fetch('/api/modules/media-server');
|
||||
return response.json();
|
||||
export const useGetMediaServers = ({ enabled }: GetMediaServersParams) => {
|
||||
const { name: configName } = useConfigContext();
|
||||
|
||||
return api.mediaServer.all.useQuery(
|
||||
{
|
||||
configName: configName!,
|
||||
},
|
||||
enabled,
|
||||
refetchInterval: 10 * 1000,
|
||||
});
|
||||
{
|
||||
enabled,
|
||||
refetchInterval: 10 * 1000,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,56 +10,16 @@ import {
|
||||
IconRotateClockwise,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import axios from 'axios';
|
||||
import Dockerode from 'dockerode';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { RouterInputs, api } from '~/utils/api';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions';
|
||||
import { MatchingImages, ServiceType, tryMatchPort } from '../../tools/types';
|
||||
import { AppType } from '../../types/app';
|
||||
|
||||
function sendDockerCommand(
|
||||
action: string,
|
||||
containerId: string,
|
||||
containerName: string,
|
||||
reload: () => void,
|
||||
t: (key: string) => string,
|
||||
) {
|
||||
notifications.show({
|
||||
id: containerId,
|
||||
loading: true,
|
||||
title: `${t(`actions.${action}.start`)} ${containerName}`,
|
||||
message: undefined,
|
||||
autoClose: false,
|
||||
withCloseButton: false,
|
||||
});
|
||||
axios
|
||||
.get(`/api/docker/container/${containerId}?action=${action}`)
|
||||
.then((res) => {
|
||||
notifications.show({
|
||||
id: containerId,
|
||||
title: containerName,
|
||||
message: `${t(`actions.${action}.end`)} ${containerName}`,
|
||||
icon: <IconCheck />,
|
||||
autoClose: 2000,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.update({
|
||||
id: containerId,
|
||||
color: 'red',
|
||||
title: t('errors.unknownError.title'),
|
||||
message: err.response.data.reason,
|
||||
autoClose: 2000,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
reload();
|
||||
});
|
||||
}
|
||||
|
||||
export interface ContainerActionBarProps {
|
||||
selected: Dockerode.ContainerInfo[];
|
||||
reload: () => void;
|
||||
@@ -68,8 +28,9 @@ export interface ContainerActionBarProps {
|
||||
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
|
||||
const { t } = useTranslation('modules/docker');
|
||||
const [isLoading, setisLoading] = useState(false);
|
||||
const { name: configName, config } = useConfigContext();
|
||||
const { config } = useConfigContext();
|
||||
const getLowestWrapper = () => config?.wrappers.sort((a, b) => a.position - b.position)[0];
|
||||
const sendDockerCommand = useDockerActionMutation();
|
||||
|
||||
if (process.env.DISABLE_EDIT_MODE === 'true') {
|
||||
return null;
|
||||
@@ -96,11 +57,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
||||
<Button
|
||||
leftIcon={<IconRotateClockwise />}
|
||||
onClick={() =>
|
||||
Promise.all(
|
||||
selected.map((container) =>
|
||||
sendDockerCommand('restart', container.Id, container.Names[0].substring(1), reload, t)
|
||||
)
|
||||
)
|
||||
Promise.all(selected.map((container) => sendDockerCommand(container, 'restart')))
|
||||
}
|
||||
variant="light"
|
||||
color="orange"
|
||||
@@ -112,11 +69,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
||||
<Button
|
||||
leftIcon={<IconPlayerStop />}
|
||||
onClick={() =>
|
||||
Promise.all(
|
||||
selected.map((container) =>
|
||||
sendDockerCommand('stop', container.Id, container.Names[0].substring(1), reload, t)
|
||||
)
|
||||
)
|
||||
Promise.all(selected.map((container) => sendDockerCommand(container, 'stop')))
|
||||
}
|
||||
variant="light"
|
||||
color="red"
|
||||
@@ -128,11 +81,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
||||
<Button
|
||||
leftIcon={<IconPlayerPlay />}
|
||||
onClick={() =>
|
||||
Promise.all(
|
||||
selected.map((container) =>
|
||||
sendDockerCommand('start', container.Id, container.Names[0].substring(1), reload, t)
|
||||
)
|
||||
)
|
||||
Promise.all(selected.map((container) => sendDockerCommand(container, 'start')))
|
||||
}
|
||||
variant="light"
|
||||
color="green"
|
||||
@@ -147,11 +96,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
||||
variant="light"
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
Promise.all(
|
||||
selected.map((container) =>
|
||||
sendDockerCommand('remove', container.Id, container.Names[0].substring(1), reload, t)
|
||||
)
|
||||
)
|
||||
Promise.all(selected.map((container) => sendDockerCommand(container, 'remove')))
|
||||
}
|
||||
disabled={selected.length === 0}
|
||||
>
|
||||
@@ -210,6 +155,55 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
||||
);
|
||||
}
|
||||
|
||||
const useDockerActionMutation = () => {
|
||||
const { t } = useTranslation('modules/docker');
|
||||
const utils = api.useContext();
|
||||
const mutation = api.docker.action.useMutation();
|
||||
|
||||
return async (
|
||||
container: Dockerode.ContainerInfo,
|
||||
action: RouterInputs['docker']['action']['action']
|
||||
) => {
|
||||
const containerName = container.Names[0].substring(1);
|
||||
|
||||
notifications.show({
|
||||
id: container.Id,
|
||||
loading: true,
|
||||
title: `${t(`actions.${action}.start`)} ${containerName}`,
|
||||
message: undefined,
|
||||
autoClose: false,
|
||||
withCloseButton: false,
|
||||
});
|
||||
|
||||
await mutation.mutateAsync(
|
||||
{ action, id: container.Id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
id: container.Id,
|
||||
title: containerName,
|
||||
message: `${t(`actions.${action}.end`)} ${containerName}`,
|
||||
icon: <IconCheck />,
|
||||
autoClose: 2000,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.update({
|
||||
id: container.Id,
|
||||
color: 'red',
|
||||
title: t('errors.unknownError.title'),
|
||||
message: err.message,
|
||||
autoClose: 2000,
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.docker.containers.invalidate();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated legacy code
|
||||
*/
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { ActionIcon, Drawer, Tooltip } from '@mantine/core';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { IconBrandDocker } from '@tabler/icons-react';
|
||||
import axios from 'axios';
|
||||
import Docker from 'dockerode';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useCardStyles } from '../../components/layout/useCardStyles';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
|
||||
import { api } from '~/utils/api';
|
||||
import ContainerActionBar from './ContainerActionBar';
|
||||
import DockerTable from './DockerTable';
|
||||
|
||||
@@ -20,22 +19,13 @@ export default function DockerMenuButton(props: any) {
|
||||
|
||||
const dockerEnabled = config?.settings.customization.layout.enabledDocker || false;
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['containers'],
|
||||
queryFn: async () => {
|
||||
const containers = await axios.get('/api/docker/containers');
|
||||
return containers.data;
|
||||
},
|
||||
const { data, refetch } = api.docker.containers.useQuery(undefined, {
|
||||
enabled: dockerEnabled,
|
||||
});
|
||||
useHotkeys([['mod+B', () => setOpened(!opened)]]);
|
||||
|
||||
const { t } = useTranslation('modules/docker');
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [config?.settings]);
|
||||
|
||||
const reload = () => {
|
||||
refetch();
|
||||
setSelection([]);
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from './ping';
|
||||
export * from './overseerr';
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Alert, Button, Checkbox, createStyles, Group, Modal, Stack, Table } from '@mantine/core';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import { IconAlertCircle, IconCheck, IconDownload } from '@tabler/icons-react';
|
||||
import axios from 'axios';
|
||||
import Consola from 'consola';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { api } from '~/utils/api';
|
||||
import { useColorTheme } from '../../tools/color';
|
||||
import { MovieResult } from './Movie.d';
|
||||
import { MediaType, Result } from './SearchResult.d';
|
||||
import { Result } from './SearchResult.d';
|
||||
import { TvShowResult, TvShowResultSeason } from './TvShow.d';
|
||||
|
||||
interface RequestModalProps {
|
||||
@@ -27,20 +28,22 @@ const useStyles = createStyles((theme) => ({
|
||||
}));
|
||||
|
||||
export function RequestModal({ base, opened, setOpened }: RequestModalProps) {
|
||||
const [result, setResult] = useState<MovieResult | TvShowResult>();
|
||||
const { secondaryColor } = useColorTheme();
|
||||
const { name: configName } = useConfigContext();
|
||||
const { data: result } = api.overseerr.byId.useQuery(
|
||||
{
|
||||
id: base.id,
|
||||
type: base.mediaType,
|
||||
configName: configName!,
|
||||
},
|
||||
{
|
||||
enabled: opened,
|
||||
}
|
||||
);
|
||||
|
||||
function getResults(base: Result) {
|
||||
axios.get(`/api/modules/overseerr/${base.id}?type=${base.mediaType}`).then((res) => {
|
||||
setResult(res.data);
|
||||
});
|
||||
}
|
||||
if (opened && !result) {
|
||||
getResults(base);
|
||||
}
|
||||
if (!result || !opened) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return base.mediaType === 'movie' ? (
|
||||
<MovieRequestModal result={result as MovieResult} opened={opened} setOpened={setOpened} />
|
||||
) : (
|
||||
@@ -58,6 +61,7 @@ export function MovieRequestModal({
|
||||
setOpened: (opened: boolean) => void;
|
||||
}) {
|
||||
const { secondaryColor } = useColorTheme();
|
||||
const requestMediaAsync = useMediaRequestMutation();
|
||||
const { t } = useTranslation('modules/overseerr');
|
||||
|
||||
return (
|
||||
@@ -93,7 +97,7 @@ export function MovieRequestModal({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
askForMedia(MediaType.Movie, result.id, result.title, []);
|
||||
requestMediaAsync('movie', result.id, result.title);
|
||||
}}
|
||||
>
|
||||
{t('popup.item.buttons.request')}
|
||||
@@ -116,6 +120,7 @@ export function TvRequestModal({
|
||||
const [selection, setSelection] = useState<TvShowResultSeason[]>(result.seasons);
|
||||
const { classes, cx } = useStyles();
|
||||
const { t } = useTranslation('modules/overseerr');
|
||||
const requestMediaAsync = useMediaRequestMutation();
|
||||
|
||||
const toggleRow = (container: TvShowResultSeason) =>
|
||||
setSelection((current: TvShowResultSeason[]) =>
|
||||
@@ -196,8 +201,8 @@ export function TvRequestModal({
|
||||
variant="outline"
|
||||
disabled={selection.length === 0}
|
||||
onClick={() => {
|
||||
askForMedia(
|
||||
MediaType.Tv,
|
||||
requestMediaAsync(
|
||||
'tv',
|
||||
result.id,
|
||||
result.name,
|
||||
selection.map((s) => s.seasonNumber)
|
||||
@@ -212,37 +217,51 @@ export function TvRequestModal({
|
||||
);
|
||||
}
|
||||
|
||||
function askForMedia(type: MediaType, id: number, name: string, seasons?: number[]) {
|
||||
Consola.info(`Requesting ${type} ${id} ${name}`);
|
||||
showNotification({
|
||||
title: 'Request',
|
||||
id: id.toString(),
|
||||
message: `Requesting media ${name}`,
|
||||
color: 'orange',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
withCloseButton: false,
|
||||
icon: <IconAlertCircle />,
|
||||
});
|
||||
axios
|
||||
.post(`/api/modules/overseerr/${id}`, { type, seasons })
|
||||
.then(() => {
|
||||
updateNotification({
|
||||
id: id.toString(),
|
||||
title: '',
|
||||
color: 'green',
|
||||
message: ` ${name} requested`,
|
||||
icon: <IconCheck />,
|
||||
autoClose: 2000,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
updateNotification({
|
||||
id: id.toString(),
|
||||
color: 'red',
|
||||
title: 'There was an error',
|
||||
message: err.message,
|
||||
autoClose: 2000,
|
||||
});
|
||||
const useMediaRequestMutation = () => {
|
||||
const { name: configName } = useConfigContext();
|
||||
const { mutateAsync } = api.overseerr.request.useMutation();
|
||||
|
||||
return async (type: 'tv' | 'movie', id: number, name: string, seasons?: number[]) => {
|
||||
Consola.info(`Requesting ${type} ${id} ${name}`);
|
||||
showNotification({
|
||||
title: 'Request',
|
||||
id: id.toString(),
|
||||
message: `Requesting media ${name}`,
|
||||
color: 'orange',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
withCloseButton: false,
|
||||
icon: <IconAlertCircle />,
|
||||
});
|
||||
}
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
configName: configName!,
|
||||
id,
|
||||
type,
|
||||
seasons: seasons ?? [],
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
updateNotification({
|
||||
id: id.toString(),
|
||||
title: '',
|
||||
color: 'green',
|
||||
message: ` ${name} requested`,
|
||||
icon: <IconCheck />,
|
||||
autoClose: 2000,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
updateNotification({
|
||||
id: id.toString(),
|
||||
color: 'red',
|
||||
title: 'There was an error',
|
||||
message: err.message,
|
||||
autoClose: 2000,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
5
src/modules/overseerr/SearchResult.d.ts
vendored
5
src/modules/overseerr/SearchResult.d.ts
vendored
@@ -56,10 +56,7 @@ export interface MediaInfo {
|
||||
mediaUrl?: string;
|
||||
}
|
||||
|
||||
export enum MediaType {
|
||||
Movie = 'movie',
|
||||
Tv = 'tv',
|
||||
}
|
||||
export type MediaType = 'movie' | 'tv';
|
||||
|
||||
export enum OriginalLanguage {
|
||||
En = 'en',
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Indicator, Tooltip } from '@mantine/core';
|
||||
import { IconPlug as Plug } from '@tabler/icons-react';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { IModule } from '../ModuleTypes';
|
||||
|
||||
export const PingModule: IModule = {
|
||||
title: 'Ping Services',
|
||||
icon: Plug,
|
||||
component: PingComponent,
|
||||
id: 'ping',
|
||||
};
|
||||
|
||||
export default function PingComponent(props: any) {
|
||||
type State = 'loading' | 'down' | 'online';
|
||||
const { config } = useConfigContext();
|
||||
|
||||
const { url }: { url: string } = props;
|
||||
const [isOnline, setOnline] = useState<State>('loading');
|
||||
const [response, setResponse] = useState(500);
|
||||
const exists = config?.settings.customization.layout.enabledPing || false;
|
||||
|
||||
const { t } = useTranslation('modules/ping');
|
||||
|
||||
function statusCheck(response: AxiosResponse) {
|
||||
const { status }: { status: string[] } = props;
|
||||
//Default Status
|
||||
let acceptableStatus = ['200'];
|
||||
if (status !== undefined && status.length) {
|
||||
acceptableStatus = status;
|
||||
}
|
||||
// Checks if reported status is in acceptable status array
|
||||
if (acceptableStatus.indexOf(response.status.toString()) >= 0) {
|
||||
setOnline('online');
|
||||
setResponse(response.status);
|
||||
} else {
|
||||
setOnline('down');
|
||||
setResponse(response.status);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.get('/api/modules/ping', { params: { url } })
|
||||
.then((response) => {
|
||||
statusCheck(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
statusCheck(error.response);
|
||||
});
|
||||
}, [config?.settings.customization.layout.enabledPing]);
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
style={{ position: 'absolute', bottom: 20, right: 20 }}
|
||||
animate={{
|
||||
scale: isOnline === 'online' ? [1, 0.7, 1] : 1,
|
||||
}}
|
||||
transition={{ repeat: Infinity, duration: 2.5, ease: 'easeInOut' }}
|
||||
>
|
||||
<Tooltip
|
||||
withinPortal
|
||||
radius="lg"
|
||||
label={
|
||||
isOnline === 'loading'
|
||||
? t('states.loading')
|
||||
: isOnline === 'online'
|
||||
? t('states.online', { response })
|
||||
: t('states.offline', { response })
|
||||
}
|
||||
>
|
||||
<Indicator
|
||||
size={15}
|
||||
color={isOnline === 'online' ? 'green' : isOnline === 'down' ? 'red' : 'yellow'}
|
||||
>
|
||||
{null}
|
||||
</Indicator>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { PingModule } from './PingModule';
|
||||
@@ -33,15 +33,17 @@ import { theme } from '../tools/server/theme/theme';
|
||||
|
||||
import { useEditModeInformationStore } from '../hooks/useEditModeInformation';
|
||||
import '../styles/global.scss';
|
||||
import nextI18nextConfig from '../../next-i18next.config';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
function App(
|
||||
this: any,
|
||||
props: AppProps & {
|
||||
props: AppProps<{
|
||||
colorScheme: ColorScheme;
|
||||
packageAttributes: ServerSidePackageAttributesType;
|
||||
editModeEnabled: boolean;
|
||||
defaultColorScheme: ColorScheme;
|
||||
}
|
||||
}>
|
||||
) {
|
||||
const { Component, pageProps } = props;
|
||||
const [primaryColor, setPrimaryColor] = useState<MantineTheme['primaryColor']>('red');
|
||||
@@ -58,7 +60,7 @@ function App(
|
||||
|
||||
// hook will return either 'dark' or 'light' on client
|
||||
// and always 'light' during ssr as window.matchMedia is not available
|
||||
const preferredColorScheme = useColorScheme(props.defaultColorScheme);
|
||||
const preferredColorScheme = useColorScheme(props.pageProps.defaultColorScheme);
|
||||
const [colorScheme, setColorScheme] = useLocalStorage<ColorScheme>({
|
||||
key: 'mantine-color-scheme',
|
||||
defaultValue: preferredColorScheme,
|
||||
@@ -69,9 +71,9 @@ function App(
|
||||
const { setDisabled } = useEditModeInformationStore();
|
||||
|
||||
useEffect(() => {
|
||||
setInitialPackageAttributes(props.packageAttributes);
|
||||
setInitialPackageAttributes(props.pageProps.packageAttributes);
|
||||
|
||||
if (!props.editModeEnabled) {
|
||||
if (!props.pageProps.editModeEnabled) {
|
||||
setDisabled();
|
||||
}
|
||||
}, []);
|
||||
@@ -161,11 +163,13 @@ App.getInitialProps = ({ ctx }: { ctx: GetServerSidePropsContext }) => {
|
||||
const colorScheme: ColorScheme = (process.env.DEFAULT_COLOR_SCHEME as ColorScheme) ?? 'light';
|
||||
|
||||
return {
|
||||
colorScheme: getCookie('color-scheme', ctx) || 'light',
|
||||
packageAttributes: getServiceSidePackageAttributes(),
|
||||
editModeEnabled: !disableEditMode,
|
||||
defaultColorScheme: colorScheme,
|
||||
pageProps: {
|
||||
colorScheme: getCookie('color-scheme', ctx) || 'light',
|
||||
packageAttributes: getServiceSidePackageAttributes(),
|
||||
editModeEnabled: !disableEditMode,
|
||||
defaultColorScheme: colorScheme,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default appWithTranslation(App);
|
||||
export default appWithTranslation<any>(api.withTRPC(App), nextI18nextConfig as any);
|
||||
|
||||
@@ -5,8 +5,9 @@ import Consola from 'consola';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { AppIntegrationType } from '../../../types/app';
|
||||
import { AppIntegrationType, IntegrationType } from '../../../types/app';
|
||||
import { getConfig } from '../../../tools/config/getConfig';
|
||||
import { checkIntegrationsType } from '~/tools/client/app-properties';
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
// Filter out if the reuqest is a POST or a GET
|
||||
@@ -51,14 +52,14 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
|
||||
const calendar = config.widgets.find((w) => w.type === 'calendar' && w.id === widgetId);
|
||||
const useSonarrv4 = calendar?.properties.useSonarrv4 ?? false;
|
||||
|
||||
const mediaAppIntegrationTypes: AppIntegrationType['type'][] = [
|
||||
const mediaAppIntegrationTypes = [
|
||||
'sonarr',
|
||||
'radarr',
|
||||
'readarr',
|
||||
'lidarr',
|
||||
];
|
||||
const mediaApps = config.apps.filter(
|
||||
(app) => app.integration && mediaAppIntegrationTypes.includes(app.integration.type)
|
||||
] as const satisfies readonly IntegrationType[];
|
||||
const mediaApps = config.apps.filter((app) =>
|
||||
checkIntegrationsType(app.integration, mediaAppIntegrationTypes)
|
||||
);
|
||||
|
||||
const IntegrationTypeEndpointMap = new Map<AppIntegrationType['type'], string>([
|
||||
|
||||
@@ -13,8 +13,8 @@ import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { Client } from 'sabnzbd-api';
|
||||
|
||||
import { NzbgetClient } from '../usenet/nzbget/nzbget-client';
|
||||
import { NzbgetQueueItem, NzbgetStatus } from '../usenet/nzbget/types';
|
||||
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
|
||||
import { NzbgetQueueItem, NzbgetStatus } from '../../../../server/api/routers/usenet/nzbget/types';
|
||||
import { ConfigAppType, IntegrationField } from '../../../../types/app';
|
||||
import { getConfig } from '../../../../tools/config/getConfig';
|
||||
import { UsenetQueueItem } from '../../../../widgets/useNet/types';
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
NormalizedDownloadAppStat,
|
||||
NormalizedDownloadQueueResponse,
|
||||
} from '../../../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
||||
import { findAppProperty } from '~/tools/client/app-properties';
|
||||
|
||||
const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
||||
const configName = getCookie('config-name', { req: request });
|
||||
@@ -151,8 +152,8 @@ const GetDataFromClient = async (
|
||||
const options = {
|
||||
host: url.hostname,
|
||||
port: url.port,
|
||||
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
|
||||
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
|
||||
login: findAppProperty(app, 'username'),
|
||||
hash: findAppProperty(app, 'password'),
|
||||
};
|
||||
|
||||
const nzbGet = NzbgetClient(options);
|
||||
|
||||
@@ -5,13 +5,14 @@ import { getConfig } from '../../../../tools/config/getConfig';
|
||||
|
||||
import { MediaRequest } from '../../../../widgets/media-requests/media-request-types';
|
||||
import { MediaRequestListWidget } from '../../../../widgets/media-requests/MediaRequestListTile';
|
||||
import { checkIntegrationsType } from '~/tools/client/app-properties';
|
||||
|
||||
const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
||||
const configName = getCookie('config-name', { req: request });
|
||||
const config = getConfig(configName?.toString() ?? 'default');
|
||||
|
||||
const apps = config.apps.filter((app) =>
|
||||
['overseerr', 'jellyseerr'].includes(app.integration?.type ?? '')
|
||||
checkIntegrationsType(app.integration, ['overseerr', 'jellyseerr'])
|
||||
);
|
||||
|
||||
Consola.log(`Retrieving media requests from ${apps.length} apps`);
|
||||
@@ -24,11 +25,12 @@ const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
||||
})
|
||||
.then(async (response) => {
|
||||
const body = (await response.json()) as OverseerrResponse;
|
||||
const mediaWidget = config.widgets.find(
|
||||
(x) => x.type === 'media-requests-list') as MediaRequestListWidget | undefined;
|
||||
const mediaWidget = config.widgets.find((x) => x.type === 'media-requests-list') as
|
||||
| MediaRequestListWidget
|
||||
| undefined;
|
||||
if (!mediaWidget) {
|
||||
Consola.log('No media-requests-list found');
|
||||
return Promise.resolve([]);
|
||||
Consola.log('No media-requests-list found');
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
const appUrl = mediaWidget.properties.replaceLinksWithExternalHost
|
||||
? app.behaviour.externalUrl
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
GenericSessionInfo,
|
||||
} from '../../../../types/api/media-server/session-info';
|
||||
import { PlexClient } from '../../../../tools/server/sdk/plex/plexClient';
|
||||
import { checkIntegrationsType, findAppProperty } from '~/tools/client/app-properties';
|
||||
|
||||
const jellyfin = new Jellyfin({
|
||||
clientInfo: {
|
||||
@@ -35,7 +36,7 @@ const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
||||
const config = getConfig(configName?.toString() ?? 'default');
|
||||
|
||||
const apps = config.apps.filter((app) =>
|
||||
['jellyfin', 'plex'].includes(app.integration?.type ?? '')
|
||||
checkIntegrationsType(app.integration, ['jellyfin', 'plex'])
|
||||
);
|
||||
|
||||
const servers = await Promise.all(
|
||||
@@ -66,9 +67,9 @@ const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
||||
const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | undefined> => {
|
||||
switch (app.integration?.type) {
|
||||
case 'jellyfin': {
|
||||
const username = app.integration.properties.find((x) => x.field === 'username');
|
||||
const username = findAppProperty(app, 'username');
|
||||
|
||||
if (!username || !username.value) {
|
||||
if (!username) {
|
||||
return {
|
||||
appId: app.id,
|
||||
serverAddress: app.url,
|
||||
@@ -79,9 +80,9 @@ const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | un
|
||||
};
|
||||
}
|
||||
|
||||
const password = app.integration.properties.find((x) => x.field === 'password');
|
||||
const password = findAppProperty(app, 'password');
|
||||
|
||||
if (!password || !password.value) {
|
||||
if (!password) {
|
||||
return {
|
||||
appId: app.id,
|
||||
serverAddress: app.url,
|
||||
@@ -94,7 +95,7 @@ const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | un
|
||||
|
||||
const api = jellyfin.createApi(app.url);
|
||||
const infoApi = await getSystemApi(api).getPublicSystemInfo();
|
||||
await api.authenticateUserByName(username.value, password.value);
|
||||
await api.authenticateUserByName(username, password);
|
||||
const sessionApi = await getSessionApi(api);
|
||||
const sessions = await sessionApi.getSessions();
|
||||
return {
|
||||
@@ -166,9 +167,9 @@ const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | un
|
||||
};
|
||||
}
|
||||
case 'plex': {
|
||||
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey');
|
||||
const apiKey = findAppProperty(app, 'apiKey');
|
||||
|
||||
if (!apiKey || !apiKey.value) {
|
||||
if (!apiKey) {
|
||||
return {
|
||||
serverAddress: app.url,
|
||||
sessions: [],
|
||||
@@ -179,7 +180,7 @@ const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | un
|
||||
};
|
||||
}
|
||||
|
||||
const plexClient = new PlexClient(app.url, apiKey.value);
|
||||
const plexClient = new PlexClient(app.url, apiKey);
|
||||
const sessions = await plexClient.getSessions();
|
||||
return {
|
||||
serverAddress: app.url,
|
||||
|
||||
@@ -3,10 +3,11 @@ import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Client } from 'sabnzbd-api';
|
||||
import { NzbgetHistoryItem } from './nzbget/types';
|
||||
import { NzbgetClient } from './nzbget/nzbget-client';
|
||||
import { NzbgetHistoryItem } from '../../../../server/api/routers/usenet/nzbget/types';
|
||||
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
|
||||
import { getConfig } from '../../../../tools/config/getConfig';
|
||||
import { UsenetHistoryItem } from '../../../../widgets/useNet/types';
|
||||
import { findAppProperty } from '~/tools/client/app-properties';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
@@ -40,8 +41,8 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
|
||||
const options = {
|
||||
host: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
||||
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
|
||||
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
|
||||
login: findAppProperty(app, 'username'),
|
||||
hash: findAppProperty(app, 'password'),
|
||||
};
|
||||
|
||||
const nzbGet = NzbgetClient(options);
|
||||
@@ -77,7 +78,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
|
||||
case 'sabnzbd': {
|
||||
const { origin } = new URL(app.url);
|
||||
|
||||
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
|
||||
const apiKey = findAppProperty(app, 'apiKey');
|
||||
if (!apiKey) {
|
||||
throw new Error(`API Key for app "${app.name}" is missing`);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import duration from 'dayjs/plugin/duration';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Client } from 'sabnzbd-api';
|
||||
import { getConfig } from '../../../../tools/config/getConfig';
|
||||
import { NzbgetClient } from './nzbget/nzbget-client';
|
||||
import { NzbgetStatus } from './nzbget/types';
|
||||
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
|
||||
import { NzbgetStatus } from '../../../../server/api/routers/usenet/nzbget/types';
|
||||
import { findAppProperty } from '~/tools/client/app-properties';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
@@ -39,8 +40,8 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
|
||||
const options = {
|
||||
host: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
||||
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
|
||||
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
|
||||
login: findAppProperty(app, 'username'),
|
||||
hash: findAppProperty(app, 'password'),
|
||||
};
|
||||
|
||||
const nzbGet = NzbgetClient(options);
|
||||
@@ -70,7 +71,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
|
||||
break;
|
||||
}
|
||||
case 'sabnzbd': {
|
||||
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
|
||||
const apiKey = findAppProperty(app, 'apiKey');
|
||||
if (!apiKey) {
|
||||
throw new Error(`API Key for app "${app.name}" is missing`);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import duration from 'dayjs/plugin/duration';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Client } from 'sabnzbd-api';
|
||||
import { getConfig } from '../../../../tools/config/getConfig';
|
||||
import { NzbgetClient } from './nzbget/nzbget-client';
|
||||
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
|
||||
import { findAppProperty } from '~/tools/client/app-properties';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
@@ -31,8 +32,8 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
|
||||
const options = {
|
||||
host: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
||||
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
|
||||
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
|
||||
login: findAppProperty(app, 'username'),
|
||||
hash: findAppProperty(app, 'password'),
|
||||
};
|
||||
|
||||
const nzbGet = NzbgetClient(options);
|
||||
@@ -49,7 +50,7 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
|
||||
break;
|
||||
}
|
||||
case 'sabnzbd': {
|
||||
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
|
||||
const apiKey = findAppProperty(app, 'apiKey');
|
||||
if (!apiKey) {
|
||||
throw new Error(`API Key for app "${app.name}" is missing`);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Client } from 'sabnzbd-api';
|
||||
import { getConfig } from '../../../../tools/config/getConfig';
|
||||
import { UsenetQueueItem } from '../../../../widgets/useNet/types';
|
||||
import { NzbgetClient } from './nzbget/nzbget-client';
|
||||
import { NzbgetQueueItem, NzbgetStatus } from './nzbget/types';
|
||||
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
|
||||
import { NzbgetQueueItem, NzbgetStatus } from '../../../../server/api/routers/usenet/nzbget/types';
|
||||
import { findAppProperty } from '~/tools/client/app-properties';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
@@ -40,8 +41,8 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
|
||||
const options = {
|
||||
host: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
||||
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
|
||||
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
|
||||
login: findAppProperty(app, 'username'),
|
||||
hash: findAppProperty(app, 'password'),
|
||||
};
|
||||
|
||||
const nzbGet = NzbgetClient(options);
|
||||
@@ -91,7 +92,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
|
||||
break;
|
||||
}
|
||||
case 'sabnzbd': {
|
||||
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
|
||||
const apiKey = findAppProperty(app, 'apiKey');
|
||||
if (!apiKey) {
|
||||
throw new Error(`API Key for app "${app.name}" is missing`);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import duration from 'dayjs/plugin/duration';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Client } from 'sabnzbd-api';
|
||||
import { getConfig } from '../../../../tools/config/getConfig';
|
||||
import { NzbgetClient } from './nzbget/nzbget-client';
|
||||
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
|
||||
import { findAppProperty } from '~/tools/client/app-properties';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
@@ -32,8 +33,8 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
|
||||
const options = {
|
||||
host: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
||||
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
|
||||
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
|
||||
login: findAppProperty(app, 'username'),
|
||||
hash: findAppProperty(app, 'password'),
|
||||
};
|
||||
|
||||
const nzbGet = NzbgetClient(options);
|
||||
@@ -50,7 +51,7 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
|
||||
break;
|
||||
}
|
||||
case 'sabnzbd': {
|
||||
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
|
||||
const apiKey = findAppProperty(app, 'apiKey');
|
||||
if (!apiKey) {
|
||||
throw new Error(`API Key for app "${app.name}" is missing`);
|
||||
}
|
||||
|
||||
16
src/pages/api/trpc/[trpc].ts
Normal file
16
src/pages/api/trpc/[trpc].ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createNextApiHandler } from '@trpc/server/adapters/next';
|
||||
import Consola from 'consola';
|
||||
import { createTRPCContext } from '~/server/api/trpc';
|
||||
import { rootRouter } from '~/server/api/root';
|
||||
|
||||
// export API handler
|
||||
export default createNextApiHandler({
|
||||
router: rootRouter,
|
||||
createContext: createTRPCContext,
|
||||
onError:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? ({ path, error }) => {
|
||||
Consola.error(`❌ tRPC failed on ${path ?? '<no-path>'}: ${error.message}`);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
38
src/server/api/root.ts
Normal file
38
src/server/api/root.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createTRPCRouter } from '~/server/api/trpc';
|
||||
import { appRouter } from './routers/app';
|
||||
import { rssRouter } from './routers/rss';
|
||||
import { configRouter } from './routers/config';
|
||||
import { dockerRouter } from './routers/docker/router';
|
||||
import { iconRouter } from './routers/icon';
|
||||
import { dashDotRouter } from './routers/dash-dot';
|
||||
import { dnsHoleRouter } from './routers/dns-hole';
|
||||
import { downloadRouter } from './routers/download';
|
||||
import { mediaRequestsRouter } from './routers/media-request';
|
||||
import { mediaServerRouter } from './routers/media-server';
|
||||
import { overseerrRouter } from './routers/overseerr';
|
||||
import { usenetRouter } from './routers/usenet/router';
|
||||
import { calendarRouter } from './routers/calendar';
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
*
|
||||
* All routers added in /api/routers should be manually added here.
|
||||
*/
|
||||
export const rootRouter = createTRPCRouter({
|
||||
app: appRouter,
|
||||
rss: rssRouter,
|
||||
config: configRouter,
|
||||
docker: dockerRouter,
|
||||
icon: iconRouter,
|
||||
dashDot: dashDotRouter,
|
||||
dnsHole: dnsHoleRouter,
|
||||
download: downloadRouter,
|
||||
mediaRequest: mediaRequestsRouter,
|
||||
mediaServer: mediaServerRouter,
|
||||
overseerr: overseerrRouter,
|
||||
usenet: usenetRouter,
|
||||
calendar: calendarRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
export type RootRouter = typeof rootRouter;
|
||||
46
src/server/api/routers/app.ts
Normal file
46
src/server/api/routers/app.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from 'zod';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import https from 'https';
|
||||
import Consola from 'consola';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
ping: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
url: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||
const res = await axios
|
||||
.get(input.url, { httpsAgent: agent, timeout: 2000 })
|
||||
.then((response) => ({
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}))
|
||||
.catch((error: AxiosError) => {
|
||||
if (error.response) {
|
||||
Consola.warn(`Unexpected response: ${error.message}`);
|
||||
return {
|
||||
status: error.response.status,
|
||||
statusText: error.response.statusText,
|
||||
};
|
||||
}
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
throw new TRPCError({
|
||||
code: 'TIMEOUT',
|
||||
message: 'Request Timeout',
|
||||
});
|
||||
}
|
||||
|
||||
Consola.error(`Unexpected error: ${error.message}`);
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Internal Server Error',
|
||||
});
|
||||
});
|
||||
return res;
|
||||
}),
|
||||
});
|
||||
96
src/server/api/routers/calendar.ts
Normal file
96
src/server/api/routers/calendar.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import axios from 'axios';
|
||||
import Consola from 'consola';
|
||||
import { z } from 'zod';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { AppIntegrationType, IntegrationType } from '~/types/app';
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
import { checkIntegrationsType } from '~/tools/client/app-properties';
|
||||
|
||||
export const calendarRouter = createTRPCRouter({
|
||||
medias: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
month: z.number().min(1).max(12),
|
||||
year: z.number().min(1900).max(2300),
|
||||
options: z.object({
|
||||
useSonarrv4: z.boolean().optional().default(false),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const { configName, month, year, options } = input;
|
||||
const config = getConfig(configName);
|
||||
|
||||
const mediaAppIntegrationTypes = [
|
||||
'sonarr',
|
||||
'radarr',
|
||||
'readarr',
|
||||
'lidarr',
|
||||
] as const satisfies readonly IntegrationType[];
|
||||
const mediaApps = config.apps.filter((app) =>
|
||||
checkIntegrationsType(app.integration, mediaAppIntegrationTypes)
|
||||
);
|
||||
|
||||
const integrationTypeEndpointMap = new Map<AppIntegrationType['type'], string>([
|
||||
['sonarr', input.options.useSonarrv4 ? '/api/v3/calendar' : '/api/calendar'],
|
||||
['radarr', '/api/v3/calendar'],
|
||||
['lidarr', '/api/v1/calendar'],
|
||||
['readarr', '/api/v1/calendar'],
|
||||
]);
|
||||
|
||||
const promises = mediaApps.map(async (app) => {
|
||||
const integration = app.integration!;
|
||||
const endpoint = integrationTypeEndpointMap.get(integration.type);
|
||||
if (!endpoint) {
|
||||
return {
|
||||
type: integration.type,
|
||||
items: [],
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Get the origin URL
|
||||
let { href: origin } = new URL(app.url);
|
||||
if (origin.endsWith('/')) {
|
||||
origin = origin.slice(0, -1);
|
||||
}
|
||||
|
||||
const start = new Date(year, month - 1, 1); // First day of month
|
||||
const end = new Date(year, month, 0); // Last day of month
|
||||
|
||||
const apiKey = integration.properties.find((x) => x.field === 'apiKey')?.value;
|
||||
if (!apiKey) return { type: integration.type, items: [], success: false };
|
||||
return axios
|
||||
.get(
|
||||
`${origin}${endpoint}?apiKey=${apiKey}&end=${end.toISOString()}&start=${start.toISOString()}&includeSeries=true&includeEpisodeFile=true&includeEpisodeImages=true`
|
||||
)
|
||||
.then((x) => ({ type: integration.type, items: x.data as any[], success: true }))
|
||||
.catch((err) => {
|
||||
Consola.error(
|
||||
`failed to process request to app '${integration.type}' (${app.id}): ${err}`
|
||||
);
|
||||
return {
|
||||
type: integration.type,
|
||||
items: [],
|
||||
success: false,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const medias = await Promise.all(promises);
|
||||
|
||||
const countFailed = medias.filter((x) => !x.success).length;
|
||||
if (countFailed > 0) {
|
||||
Consola.warn(`A total of ${countFailed} apps for the calendar widget failed`);
|
||||
}
|
||||
|
||||
return {
|
||||
tvShows: medias.filter((m) => m.type === 'sonarr').flatMap((m) => m.items),
|
||||
movies: medias.filter((m) => m.type === 'radarr').flatMap((m) => m.items),
|
||||
books: medias.filter((m) => m.type === 'readarr').flatMap((m) => m.items),
|
||||
musics: medias.filter((m) => m.type === 'lidarr').flatMap((m) => m.items),
|
||||
totalCount: medias.reduce((p, c) => p + c.items.length, 0),
|
||||
};
|
||||
}),
|
||||
});
|
||||
162
src/server/api/routers/config.ts
Normal file
162
src/server/api/routers/config.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import Consola from 'consola';
|
||||
import { z } from 'zod';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
import { BackendConfigType, ConfigType } from '~/types/config';
|
||||
import { getConfig } from '../../../tools/config/getConfig';
|
||||
import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
|
||||
|
||||
export const configRouter = createTRPCRouter({
|
||||
all: publicProcedure.query(async () => {
|
||||
// Get all the configs in the /data/configs folder
|
||||
// All the files that end in ".json"
|
||||
const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json'));
|
||||
// Strip the .json extension from the file name
|
||||
return files.map((file) => file.replace('.json', ''));
|
||||
}),
|
||||
delete: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
if (input.name.toLowerCase() === 'default') {
|
||||
Consola.error("Rejected config deletion because default configuration can't be deleted");
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: "Default config can't be deleted",
|
||||
});
|
||||
}
|
||||
|
||||
// Loop over all the files in the /data/configs directory
|
||||
// Get all the configs in the /data/configs folder
|
||||
// All the files that end in ".json"
|
||||
const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json'));
|
||||
// Match one file if the configProperties.name is the same as the slug
|
||||
const matchedFile = files.find((file) => {
|
||||
const config = JSON.parse(fs.readFileSync(path.join('data/configs', file), 'utf8'));
|
||||
return config.configProperties.name === input.name;
|
||||
});
|
||||
|
||||
// If the target is not in the list of files, return an error
|
||||
if (!matchedFile) {
|
||||
Consola.error(
|
||||
`Rejected config deletion request because config name '${input.name}' was not included in present configurations`
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Target not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
fs.unlinkSync(path.join('data/configs', matchedFile));
|
||||
Consola.info(`Successfully deleted configuration '${input.name}' from your file system`);
|
||||
return {
|
||||
message: 'Configuration deleted with success',
|
||||
};
|
||||
}),
|
||||
save: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
config: z.custom<ConfigType>((x) => !!x && typeof x === 'object'),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
Consola.info(`Saving updated configuration of '${input.name}' config.`);
|
||||
|
||||
const previousConfig = getConfig(input.name);
|
||||
|
||||
let newConfig: BackendConfigType = {
|
||||
...input.config,
|
||||
apps: [
|
||||
...input.config.apps.map((app) => ({
|
||||
...app,
|
||||
network: {
|
||||
...app.network,
|
||||
statusCodes:
|
||||
app.network.okStatus === undefined
|
||||
? app.network.statusCodes
|
||||
: app.network.okStatus.map((x) => x.toString()),
|
||||
okStatus: undefined,
|
||||
},
|
||||
integration: {
|
||||
...app.integration,
|
||||
properties: app.integration.properties.map((property) => {
|
||||
if (property.type === 'public') {
|
||||
return {
|
||||
field: property.field,
|
||||
type: property.type,
|
||||
value: property.value,
|
||||
};
|
||||
}
|
||||
|
||||
const previousApp = previousConfig.apps.find(
|
||||
(previousApp) => previousApp.id === app.id
|
||||
);
|
||||
|
||||
const previousProperty = previousApp?.integration?.properties.find(
|
||||
(previousProperty) => previousProperty.field === property.field
|
||||
);
|
||||
|
||||
if (property.value !== undefined && property.value !== null) {
|
||||
Consola.info(
|
||||
'Detected credential change of private secret. Value will be overwritten in configuration'
|
||||
);
|
||||
return {
|
||||
field: property.field,
|
||||
type: property.type,
|
||||
value: property.value,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
field: property.field,
|
||||
type: property.type,
|
||||
value: previousProperty?.value,
|
||||
};
|
||||
}),
|
||||
},
|
||||
})),
|
||||
],
|
||||
};
|
||||
|
||||
newConfig = {
|
||||
...newConfig,
|
||||
widgets: [
|
||||
...newConfig.widgets.map((x) => {
|
||||
if (x.type !== 'rss') {
|
||||
return x;
|
||||
}
|
||||
|
||||
const rssWidget = x as IRssWidget;
|
||||
|
||||
return {
|
||||
...rssWidget,
|
||||
properties: {
|
||||
...rssWidget.properties,
|
||||
rssFeedUrl:
|
||||
typeof rssWidget.properties.rssFeedUrl === 'string'
|
||||
? [rssWidget.properties.rssFeedUrl]
|
||||
: rssWidget.properties.rssFeedUrl,
|
||||
},
|
||||
} as IRssWidget;
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
// Save the body in the /data/config folder with the slug as filename
|
||||
const targetPath = path.join('data/configs', `${input.name}.json`);
|
||||
fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
|
||||
|
||||
Consola.debug(`Config '${input.name}' has been updated and flushed to '${targetPath}'.`);
|
||||
|
||||
return {
|
||||
message: 'Configuration saved with success',
|
||||
};
|
||||
}),
|
||||
});
|
||||
67
src/server/api/routers/dash-dot.ts
Normal file
67
src/server/api/routers/dash-dot.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import axios from 'axios';
|
||||
import { z } from 'zod';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
const dashDotUrlSchema = z.string().url();
|
||||
|
||||
const removeLeadingSlash = (x: string) => (x.endsWith('/') ? x.substring(0, x.length - 1) : x);
|
||||
|
||||
export const dashDotRouter = createTRPCRouter({
|
||||
info: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
url: dashDotUrlSchema.transform(removeLeadingSlash),
|
||||
})
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
storage: z.array(
|
||||
z.object({
|
||||
size: z.number(),
|
||||
})
|
||||
),
|
||||
network: z.object({
|
||||
speedUp: z.number(),
|
||||
speedDown: z.number(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const response = await axios.get(`${input.url}/info`).catch((error) => {
|
||||
if (error.response.status === 404) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Unable to find specified dash-dot instance',
|
||||
});
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
});
|
||||
});
|
||||
return response.data;
|
||||
}),
|
||||
storage: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
url: dashDotUrlSchema.transform(removeLeadingSlash),
|
||||
})
|
||||
)
|
||||
.output(z.array(z.number()))
|
||||
.query(async ({ input }) => {
|
||||
const response = await axios.get(`${input.url}/load/storage`).catch((error) => {
|
||||
if (error.response.status === 404) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Unable to find specified dash-dot',
|
||||
});
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
});
|
||||
});
|
||||
return response.data;
|
||||
}),
|
||||
});
|
||||
150
src/server/api/routers/dns-hole.ts
Normal file
150
src/server/api/routers/dns-hole.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { z } from 'zod';
|
||||
import { findAppProperty } from '~/tools/client/app-properties';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { AdGuard } from '~/tools/server/sdk/adGuard/adGuard';
|
||||
import { PiHoleClient } from '~/tools/server/sdk/pihole/piHole';
|
||||
import { ConfigAppType } from '~/types/app';
|
||||
import { AdStatistics } from '~/widgets/dnshole/type';
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const dnsHoleRouter = createTRPCRouter({
|
||||
control: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
status: z.enum(['enabled', 'disabled']),
|
||||
configName: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
|
||||
const applicableApps = config.apps.filter(
|
||||
(x) => x.integration?.type && ['pihole', 'adGuardHome'].includes(x.integration?.type)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
applicableApps.map(async (app) => {
|
||||
if (app.integration?.type === 'pihole') {
|
||||
await processPiHole(app, input.status === 'disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
await processAdGuard(app, input.status === 'disabled');
|
||||
})
|
||||
);
|
||||
}),
|
||||
summary: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
|
||||
const applicableApps = config.apps.filter(
|
||||
(x) => x.integration?.type && ['pihole', 'adGuardHome'].includes(x.integration?.type)
|
||||
);
|
||||
|
||||
const result = await Promise.all(
|
||||
applicableApps.map(async (app) =>
|
||||
app.integration?.type === 'pihole'
|
||||
? collectPiHoleSummary(app)
|
||||
: collectAdGuardSummary(app)
|
||||
)
|
||||
);
|
||||
|
||||
const data = result.reduce(
|
||||
(prev: AdStatistics, curr) => ({
|
||||
domainsBeingBlocked: prev.domainsBeingBlocked + curr.domainsBeingBlocked,
|
||||
adsBlockedToday: prev.adsBlockedToday + curr.adsBlockedToday,
|
||||
dnsQueriesToday: prev.dnsQueriesToday + curr.dnsQueriesToday,
|
||||
status: [...prev.status, curr.status],
|
||||
adsBlockedTodayPercentage: 0,
|
||||
}),
|
||||
{
|
||||
domainsBeingBlocked: 0,
|
||||
adsBlockedToday: 0,
|
||||
adsBlockedTodayPercentage: 0,
|
||||
dnsQueriesToday: 0,
|
||||
status: [],
|
||||
}
|
||||
);
|
||||
|
||||
//const data: AdStatistics = ;
|
||||
|
||||
data.adsBlockedTodayPercentage = data.adsBlockedToday / data.dnsQueriesToday;
|
||||
if (Number.isNaN(data.adsBlockedTodayPercentage)) {
|
||||
data.adsBlockedTodayPercentage = 0;
|
||||
}
|
||||
return data;
|
||||
}),
|
||||
});
|
||||
|
||||
const processAdGuard = async (app: ConfigAppType, enable: boolean) => {
|
||||
const adGuard = new AdGuard(
|
||||
app.url,
|
||||
findAppProperty(app, 'username'),
|
||||
findAppProperty(app, 'password')
|
||||
);
|
||||
|
||||
if (enable) {
|
||||
await adGuard.disable();
|
||||
return;
|
||||
}
|
||||
|
||||
await adGuard.enable();
|
||||
};
|
||||
|
||||
const processPiHole = async (app: ConfigAppType, enable: boolean) => {
|
||||
const pihole = new PiHoleClient(app.url, findAppProperty(app, 'password'));
|
||||
|
||||
if (enable) {
|
||||
await pihole.enable();
|
||||
return;
|
||||
}
|
||||
|
||||
await pihole.disable();
|
||||
};
|
||||
|
||||
const collectPiHoleSummary = async (app: ConfigAppType) => {
|
||||
const piHole = new PiHoleClient(app.url, findAppProperty(app, 'password'));
|
||||
const summary = await piHole.getSummary();
|
||||
|
||||
return {
|
||||
domainsBeingBlocked: summary.domains_being_blocked,
|
||||
adsBlockedToday: summary.ads_blocked_today,
|
||||
dnsQueriesToday: summary.dns_queries_today,
|
||||
status: {
|
||||
status: summary.status,
|
||||
appId: app.id,
|
||||
},
|
||||
adsBlockedTodayPercentage: summary.ads_percentage_today,
|
||||
};
|
||||
};
|
||||
|
||||
const collectAdGuardSummary = async (app: ConfigAppType) => {
|
||||
const adGuard = new AdGuard(
|
||||
app.url,
|
||||
findAppProperty(app, 'username'),
|
||||
findAppProperty(app, 'password')
|
||||
);
|
||||
|
||||
const stats = await adGuard.getStats();
|
||||
const status = await adGuard.getStatus();
|
||||
const countFilteredDomains = await adGuard.getCountFilteringDomains();
|
||||
|
||||
const blockedQueriesToday = stats.blocked_filtering.reduce((prev, sum) => prev + sum, 0);
|
||||
const queriesToday = stats.dns_queries.reduce((prev, sum) => prev + sum, 0);
|
||||
|
||||
return {
|
||||
domainsBeingBlocked: countFilteredDomains,
|
||||
adsBlockedToday: blockedQueriesToday,
|
||||
dnsQueriesToday: queriesToday,
|
||||
status: {
|
||||
status: status.protection_enabled ? ('enabled' as const) : ('disabled' as const),
|
||||
appId: app.id,
|
||||
},
|
||||
adsBlockedTodayPercentage: (queriesToday / blockedQueriesToday) * 100,
|
||||
};
|
||||
};
|
||||
21
src/server/api/routers/docker/DockerSingleton.ts
Normal file
21
src/server/api/routers/docker/DockerSingleton.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import Docker from 'dockerode';
|
||||
|
||||
export default class DockerSingleton extends Docker {
|
||||
private static dockerInstance: DockerSingleton;
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static getInstance(): DockerSingleton {
|
||||
if (!DockerSingleton.dockerInstance) {
|
||||
DockerSingleton.dockerInstance = new Docker({
|
||||
// If env variable DOCKER_HOST is not set, it will use the default socket
|
||||
...(process.env.DOCKER_HOST && { host: process.env.DOCKER_HOST }),
|
||||
// Same thing for docker port
|
||||
...(process.env.DOCKER_PORT && { port: process.env.DOCKER_PORT }),
|
||||
});
|
||||
}
|
||||
return DockerSingleton.dockerInstance;
|
||||
}
|
||||
}
|
||||
72
src/server/api/routers/docker/router.ts
Normal file
72
src/server/api/routers/docker/router.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import Dockerode from 'dockerode';
|
||||
import { createTRPCRouter, publicProcedure } from '../../trpc';
|
||||
import DockerSingleton from './DockerSingleton';
|
||||
|
||||
const dockerActionSchema = z.enum(['remove', 'start', 'stop', 'restart']);
|
||||
|
||||
export const dockerRouter = createTRPCRouter({
|
||||
containers: publicProcedure.query(async () => {
|
||||
try {
|
||||
const docker = DockerSingleton.getInstance();
|
||||
const containers = await docker.listContainers({ all: true });
|
||||
return containers;
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Unable to get containers',
|
||||
});
|
||||
}
|
||||
}),
|
||||
action: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
action: dockerActionSchema,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const docker = DockerSingleton.getInstance();
|
||||
// Get the container with the ID
|
||||
const container = docker.getContainer(input.id);
|
||||
if (!container) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Container not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Perform the action
|
||||
try {
|
||||
await startAction(container, input.action);
|
||||
return {
|
||||
statusCode: 200,
|
||||
message: `Container ${input.id} ${input.action}ed`,
|
||||
};
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `Unable to ${input.action} container ${input.id}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
const startAction = async (
|
||||
container: Dockerode.Container,
|
||||
action: z.infer<typeof dockerActionSchema>
|
||||
) => {
|
||||
switch (action) {
|
||||
case 'remove':
|
||||
return container.remove();
|
||||
case 'start':
|
||||
return container.start();
|
||||
case 'stop':
|
||||
return container.stop();
|
||||
case 'restart':
|
||||
return container.restart();
|
||||
default:
|
||||
return Promise;
|
||||
}
|
||||
};
|
||||
225
src/server/api/routers/download.ts
Normal file
225
src/server/api/routers/download.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { Deluge } from '@ctrl/deluge';
|
||||
import { QBittorrent } from '@ctrl/qbittorrent';
|
||||
import { AllClientData } from '@ctrl/shared-torrent';
|
||||
import { Transmission } from '@ctrl/transmission';
|
||||
import Consola from 'consola';
|
||||
import dayjs from 'dayjs';
|
||||
import { Client } from 'sabnzbd-api';
|
||||
import { z } from 'zod';
|
||||
import { NzbgetClient } from '~/server/api/routers/usenet/nzbget/nzbget-client';
|
||||
import { NzbgetQueueItem, NzbgetStatus } from '~/server/api/routers/usenet/nzbget/types';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import {
|
||||
NormalizedDownloadAppStat,
|
||||
NormalizedDownloadQueueResponse,
|
||||
} from '~/types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
||||
import { ConfigAppType, IntegrationField } from '~/types/app';
|
||||
import { UsenetQueueItem } from '~/widgets/useNet/types';
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
import { findAppProperty } from '~/tools/client/app-properties';
|
||||
|
||||
export const downloadRouter = createTRPCRouter({
|
||||
get: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
|
||||
const failedClients: string[] = [];
|
||||
|
||||
const clientData: Promise<NormalizedDownloadAppStat>[] = config.apps.map(async (app) => {
|
||||
try {
|
||||
const response = await GetDataFromClient(app);
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
success: false,
|
||||
} as NormalizedDownloadAppStat;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
Consola.error(
|
||||
`Error communicating with your download client '${app.name}' (${app.id}): ${err}`
|
||||
);
|
||||
failedClients.push(app.id);
|
||||
return {
|
||||
success: false,
|
||||
} as NormalizedDownloadAppStat;
|
||||
}
|
||||
});
|
||||
|
||||
const settledPromises = await Promise.allSettled(clientData);
|
||||
|
||||
const data: NormalizedDownloadAppStat[] = settledPromises
|
||||
.filter((x) => x.status === 'fulfilled')
|
||||
.map((promise) => (promise as PromiseFulfilledResult<NormalizedDownloadAppStat>).value)
|
||||
.filter((x) => x !== undefined && x.type !== undefined);
|
||||
|
||||
const responseBody = {
|
||||
apps: data,
|
||||
failedApps: failedClients,
|
||||
} as NormalizedDownloadQueueResponse;
|
||||
|
||||
if (failedClients.length > 0) {
|
||||
Consola.warn(
|
||||
`${failedClients.length} download clients failed. Please check your configuration and the above log`
|
||||
);
|
||||
}
|
||||
|
||||
return responseBody;
|
||||
}),
|
||||
});
|
||||
|
||||
const GetDataFromClient = async (
|
||||
app: ConfigAppType
|
||||
): Promise<NormalizedDownloadAppStat | undefined> => {
|
||||
const reduceTorrent = (data: AllClientData): NormalizedDownloadAppStat => ({
|
||||
type: 'torrent',
|
||||
appId: app.id,
|
||||
success: true,
|
||||
torrents: data.torrents,
|
||||
totalDownload: data.torrents
|
||||
.map((torrent) => torrent.downloadSpeed)
|
||||
.reduce((acc, torrent) => acc + torrent, 0),
|
||||
totalUpload: data.torrents
|
||||
.map((torrent) => torrent.uploadSpeed)
|
||||
.reduce((acc, torrent) => acc + torrent, 0),
|
||||
});
|
||||
|
||||
const findField = (app: ConfigAppType, field: IntegrationField) =>
|
||||
app.integration?.properties.find((x) => x.field === field)?.value ?? undefined;
|
||||
|
||||
switch (app.integration?.type) {
|
||||
case 'deluge': {
|
||||
return reduceTorrent(
|
||||
await new Deluge({
|
||||
baseUrl: app.url,
|
||||
password: findField(app, 'password'),
|
||||
}).getAllData()
|
||||
);
|
||||
}
|
||||
case 'transmission': {
|
||||
return reduceTorrent(
|
||||
await new Transmission({
|
||||
baseUrl: app.url,
|
||||
username: findField(app, 'username'),
|
||||
password: findField(app, 'password'),
|
||||
}).getAllData()
|
||||
);
|
||||
}
|
||||
case 'qBittorrent': {
|
||||
return reduceTorrent(
|
||||
await new QBittorrent({
|
||||
baseUrl: app.url,
|
||||
username: findField(app, 'username'),
|
||||
password: findField(app, 'password'),
|
||||
}).getAllData()
|
||||
);
|
||||
}
|
||||
case 'sabnzbd': {
|
||||
const { origin } = new URL(app.url);
|
||||
const client = new Client(origin, findField(app, 'apiKey') ?? '');
|
||||
const queue = await client.queue();
|
||||
const items: UsenetQueueItem[] = queue.slots.map((slot) => {
|
||||
const [hours, minutes, seconds] = slot.timeleft.split(':');
|
||||
const eta = dayjs.duration({
|
||||
hour: parseInt(hours, 10),
|
||||
minutes: parseInt(minutes, 10),
|
||||
seconds: parseInt(seconds, 10),
|
||||
} as any);
|
||||
|
||||
return {
|
||||
id: slot.nzo_id,
|
||||
eta: eta.asSeconds(),
|
||||
name: slot.filename,
|
||||
progress: parseFloat(slot.percentage),
|
||||
size: parseFloat(slot.mb) * 1000 * 1000,
|
||||
state: slot.status.toLowerCase() as any,
|
||||
};
|
||||
});
|
||||
const killobitsPerSecond = Number(queue.kbpersec);
|
||||
const bytesPerSecond = killobitsPerSecond * 1024; // convert killobytes to bytes
|
||||
return {
|
||||
type: 'usenet',
|
||||
appId: app.id,
|
||||
totalDownload: bytesPerSecond,
|
||||
nzbs: items,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
case 'nzbGet': {
|
||||
const url = new URL(app.url);
|
||||
const options = {
|
||||
host: url.hostname,
|
||||
port: url.port,
|
||||
login: findAppProperty(app, 'username'),
|
||||
hash: findAppProperty(app, 'password'),
|
||||
};
|
||||
|
||||
const nzbGet = NzbgetClient(options);
|
||||
const nzbgetQueue: NzbgetQueueItem[] = await new Promise((resolve, reject) => {
|
||||
nzbGet.listGroups((err: any, result: NzbgetQueueItem[]) => {
|
||||
if (!err) {
|
||||
resolve(result);
|
||||
} else {
|
||||
Consola.error(`Error while listing groups: ${err}`);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (!nzbgetQueue) {
|
||||
throw new Error('Error while getting NZBGet queue');
|
||||
}
|
||||
|
||||
const nzbgetStatus: NzbgetStatus = await new Promise((resolve, reject) => {
|
||||
nzbGet.status((err: any, result: NzbgetStatus) => {
|
||||
if (!err) {
|
||||
resolve(result);
|
||||
} else {
|
||||
Consola.error(`Error while retrieving NZBGet stats: ${err}`);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!nzbgetStatus) {
|
||||
throw new Error('Error while getting NZBGet status');
|
||||
}
|
||||
|
||||
const nzbgetItems: UsenetQueueItem[] = nzbgetQueue.map((item: NzbgetQueueItem) => ({
|
||||
id: item.NZBID.toString(),
|
||||
name: item.NZBName,
|
||||
progress: (item.DownloadedSizeMB / item.FileSizeMB) * 100,
|
||||
eta: (item.RemainingSizeMB * 1000000) / nzbgetStatus.DownloadRate,
|
||||
// Multiple MB to get bytes
|
||||
size: item.FileSizeMB * 1000 * 1000,
|
||||
state: getNzbgetState(item.Status),
|
||||
}));
|
||||
|
||||
return {
|
||||
type: 'usenet',
|
||||
appId: app.id,
|
||||
nzbs: nzbgetItems,
|
||||
success: true,
|
||||
totalDownload: 0,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
function getNzbgetState(status: string) {
|
||||
switch (status) {
|
||||
case 'QUEUED':
|
||||
return 'queued';
|
||||
case 'PAUSED ':
|
||||
return 'paused';
|
||||
default:
|
||||
return 'downloading';
|
||||
}
|
||||
}
|
||||
35
src/server/api/routers/icon.ts
Normal file
35
src/server/api/routers/icon.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { LocalIconsRepository } from '~/tools/server/images/local-icons-repository';
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
import { JsdelivrIconsRepository } from '~/tools/server/images/jsdelivr-icons-repository';
|
||||
import { UnpkgIconsRepository } from '~/tools/server/images/unpkg-icons-repository';
|
||||
|
||||
export const iconRouter = createTRPCRouter({
|
||||
all: publicProcedure.query(async () => {
|
||||
const respositories = [
|
||||
new LocalIconsRepository(),
|
||||
new JsdelivrIconsRepository(
|
||||
JsdelivrIconsRepository.tablerRepository,
|
||||
'Walkxcode Dashboard Icons',
|
||||
'Walkxcode on Github'
|
||||
),
|
||||
new UnpkgIconsRepository(
|
||||
UnpkgIconsRepository.tablerRepository,
|
||||
'Tabler Icons',
|
||||
'Tabler Icons - GitHub (MIT)'
|
||||
),
|
||||
new JsdelivrIconsRepository(
|
||||
JsdelivrIconsRepository.papirusRepository,
|
||||
'Papirus Icons',
|
||||
'Papirus Development Team on GitHub (Apache 2.0)'
|
||||
),
|
||||
new JsdelivrIconsRepository(
|
||||
JsdelivrIconsRepository.homelabSvgAssetsRepository,
|
||||
'Homelab Svg Assets',
|
||||
'loganmarchione on GitHub (MIT)'
|
||||
),
|
||||
];
|
||||
const fetches = respositories.map((rep) => rep.fetch());
|
||||
const data = await Promise.all(fetches);
|
||||
return data;
|
||||
}),
|
||||
});
|
||||
178
src/server/api/routers/media-request.ts
Normal file
178
src/server/api/routers/media-request.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import Consola from 'consola';
|
||||
import { z } from 'zod';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { MediaRequest } from '~/widgets/media-requests/media-request-types';
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
import { MediaRequestListWidget } from '~/widgets/media-requests/MediaRequestListTile';
|
||||
import { checkIntegrationsType } from '~/tools/client/app-properties';
|
||||
|
||||
export const mediaRequestsRouter = createTRPCRouter({
|
||||
all: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
|
||||
const apps = config.apps.filter((app) =>
|
||||
checkIntegrationsType(app.integration, ['overseerr', 'jellyseerr'])
|
||||
);
|
||||
|
||||
Consola.log(`Retrieving media requests from ${apps.length} apps`);
|
||||
|
||||
const promises = apps.map((app): Promise<MediaRequest[]> => {
|
||||
const apiKey =
|
||||
app.integration?.properties.find((prop) => prop.field === 'apiKey')?.value ?? '';
|
||||
const headers: HeadersInit = { 'X-Api-Key': apiKey };
|
||||
return fetch(`${app.url}/api/v1/request?take=25&skip=0&sort=added`, {
|
||||
headers,
|
||||
})
|
||||
.then(async (response) => {
|
||||
const body = (await response.json()) as OverseerrResponse;
|
||||
const mediaWidget = config.widgets.find((x) => x.type === 'media-requests-list') as
|
||||
| MediaRequestListWidget
|
||||
| undefined;
|
||||
if (!mediaWidget) {
|
||||
Consola.log('No media-requests-list found');
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
const appUrl = mediaWidget.properties.replaceLinksWithExternalHost
|
||||
? app.behaviour.externalUrl
|
||||
: app.url;
|
||||
|
||||
const requests = await Promise.all(
|
||||
body.results.map(async (item): Promise<MediaRequest> => {
|
||||
const genericItem = await retrieveDetailsForItem(
|
||||
app.url,
|
||||
item.type,
|
||||
headers,
|
||||
item.media.tmdbId
|
||||
);
|
||||
return {
|
||||
appId: app.id,
|
||||
createdAt: item.createdAt,
|
||||
id: item.id,
|
||||
rootFolder: item.rootFolder,
|
||||
type: item.type,
|
||||
name: genericItem.name,
|
||||
userName: item.requestedBy.displayName,
|
||||
userProfilePicture: constructAvatarUrl(appUrl, item),
|
||||
userLink: `${appUrl}/users/${item.requestedBy.id}`,
|
||||
airDate: genericItem.airDate,
|
||||
status: item.status,
|
||||
backdropPath: `https://image.tmdb.org/t/p/original/${genericItem.backdropPath}`,
|
||||
posterPath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${genericItem.posterPath}`,
|
||||
href: `${appUrl}/movie/${item.media.tmdbId}`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return Promise.resolve(requests);
|
||||
})
|
||||
.catch((err) => {
|
||||
Consola.error(`Failed to request data from Overseerr: ${err}`);
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
});
|
||||
|
||||
const mediaRequests = (await Promise.all(promises)).reduce(
|
||||
(prev, cur) => prev.concat(cur),
|
||||
[]
|
||||
);
|
||||
|
||||
return mediaRequests;
|
||||
}),
|
||||
});
|
||||
|
||||
const constructAvatarUrl = (appUrl: string, item: OverseerrResponseItem) => {
|
||||
const isAbsolute =
|
||||
item.requestedBy.avatar.startsWith('http://') || item.requestedBy.avatar.startsWith('https://');
|
||||
|
||||
if (isAbsolute) {
|
||||
return item.requestedBy.avatar;
|
||||
}
|
||||
|
||||
return `${appUrl}/${item.requestedBy.avatar}`;
|
||||
};
|
||||
|
||||
const retrieveDetailsForItem = async (
|
||||
baseUrl: string,
|
||||
type: OverseerrResponseItem['type'],
|
||||
headers: HeadersInit,
|
||||
id: number
|
||||
): Promise<GenericOverseerrItem> => {
|
||||
if (type === 'tv') {
|
||||
const tvResponse = await fetch(`${baseUrl}/api/v1/tv/${id}`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
const series = (await tvResponse.json()) as OverseerrSeries;
|
||||
|
||||
return {
|
||||
name: series.name,
|
||||
airDate: series.firstAirDate,
|
||||
backdropPath: series.backdropPath,
|
||||
posterPath: series.backdropPath,
|
||||
};
|
||||
}
|
||||
|
||||
const movieResponse = await fetch(`${baseUrl}/api/v1/movie/${id}`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
const movie = (await movieResponse.json()) as OverseerrMovie;
|
||||
|
||||
return {
|
||||
name: movie.originalTitle,
|
||||
airDate: movie.releaseDate,
|
||||
backdropPath: movie.backdropPath,
|
||||
posterPath: movie.posterPath,
|
||||
};
|
||||
};
|
||||
|
||||
type GenericOverseerrItem = {
|
||||
name: string;
|
||||
airDate: string;
|
||||
backdropPath: string;
|
||||
posterPath: string;
|
||||
};
|
||||
|
||||
type OverseerrMovie = {
|
||||
originalTitle: string;
|
||||
releaseDate: string;
|
||||
backdropPath: string;
|
||||
posterPath: string;
|
||||
};
|
||||
|
||||
type OverseerrSeries = {
|
||||
name: string;
|
||||
firstAirDate: string;
|
||||
backdropPath: string;
|
||||
posterPath: string;
|
||||
};
|
||||
|
||||
type OverseerrResponse = {
|
||||
results: OverseerrResponseItem[];
|
||||
};
|
||||
|
||||
type OverseerrResponseItem = {
|
||||
id: number;
|
||||
status: number;
|
||||
createdAt: string;
|
||||
type: 'movie' | 'tv';
|
||||
rootFolder: string;
|
||||
requestedBy: OverseerrResponseItemUser;
|
||||
media: OverseerrResponseItemMedia;
|
||||
};
|
||||
|
||||
type OverseerrResponseItemMedia = {
|
||||
tmdbId: number;
|
||||
};
|
||||
|
||||
type OverseerrResponseItemUser = {
|
||||
id: number;
|
||||
displayName: string;
|
||||
avatar: string;
|
||||
};
|
||||
223
src/server/api/routers/media-server.ts
Normal file
223
src/server/api/routers/media-server.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { Jellyfin } from '@jellyfin/sdk';
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models';
|
||||
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
|
||||
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
|
||||
import Consola from 'consola';
|
||||
import { z } from 'zod';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { PlexClient } from '~/tools/server/sdk/plex/plexClient';
|
||||
import { GenericMediaServer } from '~/types/api/media-server/media-server';
|
||||
import { MediaServersResponseType } from '~/types/api/media-server/response';
|
||||
import { GenericCurrentlyPlaying, GenericSessionInfo } from '~/types/api/media-server/session-info';
|
||||
import { ConfigAppType } from '~/types/app';
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
import { checkIntegrationsType, findAppProperty } from '~/tools/client/app-properties';
|
||||
|
||||
const jellyfin = new Jellyfin({
|
||||
clientInfo: {
|
||||
name: 'Homarr',
|
||||
version: '0.0.1',
|
||||
},
|
||||
deviceInfo: {
|
||||
name: 'Homarr Jellyfin Widget',
|
||||
id: 'homarr-jellyfin-widget',
|
||||
},
|
||||
});
|
||||
|
||||
export const mediaServerRouter = createTRPCRouter({
|
||||
all: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
|
||||
const apps = config.apps.filter((app) =>
|
||||
checkIntegrationsType(app.integration, ['jellyfin', 'plex'])
|
||||
);
|
||||
|
||||
const servers = await Promise.all(
|
||||
apps.map(async (app): Promise<GenericMediaServer | undefined> => {
|
||||
try {
|
||||
return await handleServer(app);
|
||||
} catch (error) {
|
||||
Consola.error(
|
||||
`failed to communicate with media server '${app.name}' (${app.id}): ${error}`
|
||||
);
|
||||
return {
|
||||
serverAddress: app.url,
|
||||
sessions: [],
|
||||
success: false,
|
||||
version: undefined,
|
||||
type: undefined,
|
||||
appId: app.id,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
servers: servers.filter(
|
||||
(server): server is Exclude<typeof server, undefined> => server !== undefined
|
||||
),
|
||||
} satisfies MediaServersResponseType;
|
||||
}),
|
||||
});
|
||||
|
||||
const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | undefined> => {
|
||||
switch (app.integration?.type) {
|
||||
case 'jellyfin': {
|
||||
const username = findAppProperty(app, 'username');
|
||||
|
||||
if (!username) {
|
||||
return {
|
||||
appId: app.id,
|
||||
serverAddress: app.url,
|
||||
sessions: [],
|
||||
type: 'jellyfin',
|
||||
version: undefined,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const password = findAppProperty(app, 'password');
|
||||
|
||||
if (!password) {
|
||||
return {
|
||||
appId: app.id,
|
||||
serverAddress: app.url,
|
||||
sessions: [],
|
||||
type: 'jellyfin',
|
||||
version: undefined,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const api = jellyfin.createApi(app.url);
|
||||
const infoApi = await getSystemApi(api).getPublicSystemInfo();
|
||||
await api.authenticateUserByName(username, password);
|
||||
const sessionApi = await getSessionApi(api);
|
||||
const sessions = await sessionApi.getSessions();
|
||||
return {
|
||||
type: 'jellyfin',
|
||||
appId: app.id,
|
||||
serverAddress: app.url,
|
||||
version: infoApi.data.Version ?? undefined,
|
||||
sessions: sessions.data.map(
|
||||
(session): GenericSessionInfo => ({
|
||||
id: session.Id ?? '?',
|
||||
username: session.UserName ?? undefined,
|
||||
sessionName: `${session.Client} (${session.DeviceName})`,
|
||||
supportsMediaControl: session.SupportsMediaControl ?? false,
|
||||
currentlyPlaying: session.NowPlayingItem
|
||||
? {
|
||||
name: `${session.NowPlayingItem.SeriesName ?? session.NowPlayingItem.Name}`,
|
||||
seasonName: session.NowPlayingItem.SeasonName as string,
|
||||
episodeName: session.NowPlayingItem.Name as string,
|
||||
albumName: session.NowPlayingItem.Album as string,
|
||||
episodeCount: session.NowPlayingItem.EpisodeCount ?? undefined,
|
||||
metadata: {
|
||||
video:
|
||||
session.NowPlayingItem &&
|
||||
session.NowPlayingItem.Width &&
|
||||
session.NowPlayingItem.Height
|
||||
? {
|
||||
videoCodec: undefined,
|
||||
width: session.NowPlayingItem.Width ?? undefined,
|
||||
height: session.NowPlayingItem.Height ?? undefined,
|
||||
bitrate: undefined,
|
||||
videoFrameRate: session.TranscodingInfo?.Framerate
|
||||
? String(session.TranscodingInfo?.Framerate)
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
audio: session.TranscodingInfo
|
||||
? {
|
||||
audioChannels: session.TranscodingInfo.AudioChannels ?? undefined,
|
||||
audioCodec: session.TranscodingInfo.AudioCodec ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
transcoding: session.TranscodingInfo
|
||||
? {
|
||||
audioChannels: session.TranscodingInfo.AudioChannels ?? -1,
|
||||
audioCodec: session.TranscodingInfo.AudioCodec ?? undefined,
|
||||
container: session.TranscodingInfo.Container ?? undefined,
|
||||
width: session.TranscodingInfo.Width ?? undefined,
|
||||
height: session.TranscodingInfo.Height ?? undefined,
|
||||
videoCodec: session.TranscodingInfo?.VideoCodec ?? undefined,
|
||||
audioDecision: undefined,
|
||||
context: undefined,
|
||||
duration: undefined,
|
||||
error: undefined,
|
||||
sourceAudioCodec: undefined,
|
||||
sourceVideoCodec: undefined,
|
||||
timeStamp: undefined,
|
||||
transcodeHwRequested: undefined,
|
||||
videoDecision: undefined,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
type: convertJellyfinType(session.NowPlayingItem.Type),
|
||||
}
|
||||
: undefined,
|
||||
userProfilePicture: undefined,
|
||||
})
|
||||
),
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
case 'plex': {
|
||||
const apiKey = findAppProperty(app, 'apiKey');
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
serverAddress: app.url,
|
||||
sessions: [],
|
||||
type: 'plex',
|
||||
appId: app.id,
|
||||
version: undefined,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const plexClient = new PlexClient(app.url, apiKey);
|
||||
const sessions = await plexClient.getSessions();
|
||||
return {
|
||||
serverAddress: app.url,
|
||||
sessions,
|
||||
type: 'plex',
|
||||
version: undefined,
|
||||
appId: app.id,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
Consola.warn(
|
||||
`media-server api entered a fallback case. This should normally not happen and must be reported. Cause: '${app.name}' (${app.id})`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const convertJellyfinType = (kind: BaseItemKind | undefined): GenericCurrentlyPlaying['type'] => {
|
||||
switch (kind) {
|
||||
case BaseItemKind.Audio:
|
||||
case BaseItemKind.MusicVideo:
|
||||
return 'audio';
|
||||
case BaseItemKind.Episode:
|
||||
case BaseItemKind.Video:
|
||||
return 'video';
|
||||
case BaseItemKind.Movie:
|
||||
return 'movie';
|
||||
case BaseItemKind.TvChannel:
|
||||
case BaseItemKind.TvProgram:
|
||||
case BaseItemKind.LiveTvChannel:
|
||||
case BaseItemKind.LiveTvProgram:
|
||||
return 'tv';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
214
src/server/api/routers/overseerr.ts
Normal file
214
src/server/api/routers/overseerr.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import axios from 'axios';
|
||||
import { z } from 'zod';
|
||||
import Consola from 'consola';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
import { MovieResult } from '~/modules/overseerr/Movie';
|
||||
import { TvShowResult } from '~/modules/overseerr/TvShow';
|
||||
|
||||
export const overseerrRouter = createTRPCRouter({
|
||||
all: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
query: z.string().or(z.undefined()),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
|
||||
const app = config.apps.find(
|
||||
(app) => app.integration?.type === 'overseerr' || app.integration?.type === 'jellyseerr'
|
||||
);
|
||||
|
||||
if (input.query === '' || input.query === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const apiKey = app?.integration?.properties.find((x) => x.field === 'apiKey')?.value;
|
||||
if (!app || !apiKey) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Wrong request',
|
||||
});
|
||||
}
|
||||
const appUrl = new URL(app.url);
|
||||
const data = await axios
|
||||
.get(`${appUrl.origin}/api/v1/search?query=${input.query}`, {
|
||||
headers: {
|
||||
// Set X-Api-Key to the value of the API key
|
||||
'X-Api-Key': apiKey,
|
||||
},
|
||||
})
|
||||
.then((res) => res.data);
|
||||
return data;
|
||||
}),
|
||||
byId: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
id: z.number(),
|
||||
type: z.union([z.literal('movie'), z.literal('tv')]),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
const app = config.apps.find(
|
||||
(app) => app.integration?.type === 'overseerr' || app.integration?.type === 'jellyseerr'
|
||||
);
|
||||
const apiKey = app?.integration?.properties.find((x) => x.field === 'apiKey')?.value;
|
||||
|
||||
if (!apiKey) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No app found',
|
||||
});
|
||||
}
|
||||
|
||||
const appUrl = new URL(app.url);
|
||||
|
||||
if (input.type === 'movie') {
|
||||
const movie = await axios
|
||||
.get(`${appUrl.origin}/api/v1/movie/${input.id}`, {
|
||||
headers: {
|
||||
// Set X-Api-Key to the value of the API key
|
||||
'X-Api-Key': apiKey,
|
||||
},
|
||||
})
|
||||
.then((res) => res.data as MovieResult)
|
||||
.catch((err) => {
|
||||
Consola.error(err);
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Something went wrong',
|
||||
});
|
||||
});
|
||||
return movie;
|
||||
}
|
||||
|
||||
const tv = await axios
|
||||
.get(`${appUrl.origin}/api/v1/tv/${input.id}`, {
|
||||
headers: {
|
||||
// Set X-Api-Key to the value of the API key
|
||||
'X-Api-Key': apiKey,
|
||||
},
|
||||
})
|
||||
.then((res) => res.data as TvShowResult)
|
||||
.catch((err) => {
|
||||
Consola.error(err);
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Something went wrong',
|
||||
});
|
||||
});
|
||||
return tv;
|
||||
}),
|
||||
request: publicProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
configName: z.string(),
|
||||
id: z.number(),
|
||||
})
|
||||
.and(
|
||||
z
|
||||
.object({
|
||||
seasons: z.array(z.number()),
|
||||
type: z.literal('tv'),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
type: z.literal('movie'),
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
const app = config.apps.find(
|
||||
(app) => app.integration?.type === 'overseerr' || app.integration?.type === 'jellyseerr'
|
||||
);
|
||||
const apiKey = app?.integration?.properties.find((x) => x.field === 'apiKey')?.value;
|
||||
if (!apiKey) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No app found',
|
||||
});
|
||||
}
|
||||
|
||||
const appUrl = new URL(app.url);
|
||||
Consola.info('Got an Overseerr request with these arguments', {
|
||||
mediaType: input.type,
|
||||
mediaId: input.id,
|
||||
seasons: input.type === 'tv' ? input.seasons : undefined,
|
||||
});
|
||||
return axios
|
||||
.post(
|
||||
`${appUrl.origin}/api/v1/request`,
|
||||
{
|
||||
mediaType: input.type,
|
||||
mediaId: input.id,
|
||||
seasons: input.type === 'tv' ? input.seasons : undefined,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
// Set X-Api-Key to the value of the API key
|
||||
'X-Api-Key': apiKey,
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((res) => res.data)
|
||||
.catch((err) => {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
}),
|
||||
decide: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
id: z.number(),
|
||||
isApproved: z.boolean(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
Consola.log(
|
||||
`Got a request to ${input.isApproved ? 'approve' : 'decline'} a request`,
|
||||
input.id
|
||||
);
|
||||
const app = config.apps.find(
|
||||
(app) => app.integration?.type === 'overseerr' || app.integration?.type === 'jellyseerr'
|
||||
);
|
||||
|
||||
const apiKey = app?.integration?.properties.find((x) => x.field === 'apiKey')?.value;
|
||||
if (!apiKey) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No app found',
|
||||
});
|
||||
}
|
||||
const appUrl = new URL(app.url);
|
||||
const action = input.isApproved ? 'approve' : 'decline';
|
||||
return axios
|
||||
.post(
|
||||
`${appUrl.origin}/api/v1/request/${input.id}/${action}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'X-Api-Key': apiKey,
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((res) => res.data)
|
||||
.catch((err) => {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
}),
|
||||
});
|
||||
186
src/server/api/routers/rss.ts
Normal file
186
src/server/api/routers/rss.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import Consola from 'consola';
|
||||
import { decode, encode } from 'html-entities';
|
||||
import RssParser from 'rss-parser';
|
||||
import xss from 'xss';
|
||||
import { z } from 'zod';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { Stopwatch } from '~/tools/shared/time/stopwatch.tool';
|
||||
import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
type CustomItem = {
|
||||
'media:content': string;
|
||||
enclosure: {
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
const rssFeedResultObjectSchema = z
|
||||
.object({
|
||||
success: z.literal(false),
|
||||
feed: z.undefined(),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
success: z.literal(true),
|
||||
feed: z.object({
|
||||
title: z.string().or(z.undefined()),
|
||||
items: z.array(
|
||||
z.object({
|
||||
link: z.string(),
|
||||
enclosure: z
|
||||
.object({
|
||||
url: z.string(),
|
||||
})
|
||||
.or(z.undefined()),
|
||||
categories: z.array(z.string()).or(z.undefined()),
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
pubDate: z.string().optional(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
export const rssRouter = createTRPCRouter({
|
||||
all: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
widgetId: z.string().uuid(),
|
||||
feedUrls: z.array(z.string()),
|
||||
configName: z.string(),
|
||||
})
|
||||
)
|
||||
.output(z.array(rssFeedResultObjectSchema))
|
||||
.query(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
|
||||
const rssWidget = config.widgets.find((x) => x.type === 'rss' && x.id === input.widgetId) as
|
||||
| IRssWidget
|
||||
| undefined;
|
||||
|
||||
if (!rssWidget || input.feedUrls.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'required widget does not exist',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await Promise.all(
|
||||
input.feedUrls.map(async (feedUrl) =>
|
||||
getFeedUrl(feedUrl, rssWidget.properties.dangerousAllowSanitizedItemContent)
|
||||
)
|
||||
);
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
|
||||
const getFeedUrl = async (feedUrl: string, dangerousAllowSanitizedItemContent: boolean) => {
|
||||
Consola.info(`Requesting RSS feed at url ${feedUrl}`);
|
||||
const stopWatch = new Stopwatch();
|
||||
const feed = await parser.parseURL(feedUrl);
|
||||
Consola.info(`Retrieved RSS feed after ${stopWatch.getEllapsedMilliseconds()} milliseconds`);
|
||||
|
||||
const orderedFeed = {
|
||||
...feed,
|
||||
items: feed.items
|
||||
.map(
|
||||
(item: {
|
||||
title: string;
|
||||
content: string;
|
||||
'content:encoded': string;
|
||||
categories: string[] | { _: string }[];
|
||||
}) => ({
|
||||
...item,
|
||||
categories: item.categories
|
||||
?.map((category) => (typeof category === 'string' ? category : category._))
|
||||
.filter((category: unknown): category is string => typeof category === 'string'),
|
||||
title: item.title ? decode(item.title) : undefined,
|
||||
content: processItemContent(
|
||||
item['content:encoded'] ?? item.content,
|
||||
dangerousAllowSanitizedItemContent
|
||||
),
|
||||
enclosure: createEnclosure(item),
|
||||
link: createLink(item),
|
||||
})
|
||||
)
|
||||
.sort((a: { pubDate: number }, b: { pubDate: number }) => {
|
||||
if (!a.pubDate || !b.pubDate) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return a.pubDate - b.pubDate;
|
||||
})
|
||||
.slice(0, 20),
|
||||
};
|
||||
|
||||
return {
|
||||
feed: orderedFeed,
|
||||
success: orderedFeed?.items !== undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const processItemContent = (content: string, dangerousAllowSanitizedItemContent: boolean) => {
|
||||
if (dangerousAllowSanitizedItemContent) {
|
||||
return xss(content, {
|
||||
allowList: {
|
||||
p: [],
|
||||
h1: [],
|
||||
h2: [],
|
||||
h3: [],
|
||||
h4: [],
|
||||
h5: [],
|
||||
h6: [],
|
||||
a: ['href'],
|
||||
b: [],
|
||||
strong: [],
|
||||
i: [],
|
||||
em: [],
|
||||
img: ['src', 'width', 'height', 'alt'],
|
||||
br: [],
|
||||
small: [],
|
||||
ul: [],
|
||||
li: [],
|
||||
ol: [],
|
||||
figure: [],
|
||||
svg: [],
|
||||
code: [],
|
||||
mark: [],
|
||||
blockquote: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return encode(content);
|
||||
};
|
||||
|
||||
const createLink = (item: any) => {
|
||||
if (item.link) {
|
||||
return item.link;
|
||||
}
|
||||
|
||||
return item.guid;
|
||||
};
|
||||
|
||||
const createEnclosure = (item: any) => {
|
||||
if (item.enclosure) {
|
||||
return item.enclosure;
|
||||
}
|
||||
|
||||
if (item['media:content']) {
|
||||
return {
|
||||
url: item['media:content'].$.url,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const parser: RssParser<any, CustomItem> = new RssParser({
|
||||
customFields: {
|
||||
item: ['media:content', 'enclosure'],
|
||||
},
|
||||
});
|
||||
393
src/server/api/routers/usenet/router.ts
Normal file
393
src/server/api/routers/usenet/router.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import dayjs from 'dayjs';
|
||||
import { Client } from 'sabnzbd-api';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
NzbgetHistoryItem,
|
||||
NzbgetQueueItem,
|
||||
NzbgetStatus,
|
||||
} from '~/server/api/routers/usenet/nzbget/types';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { UsenetHistoryItem, UsenetQueueItem } from '~/widgets/useNet/types';
|
||||
import { createTRPCRouter, publicProcedure } from '../../trpc';
|
||||
import { NzbgetClient } from './nzbget/nzbget-client';
|
||||
import { checkIntegrationsType, findAppProperty } from '~/tools/client/app-properties';
|
||||
|
||||
export const usenetRouter = createTRPCRouter({
|
||||
info: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
appId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
|
||||
const app = config.apps.find((x) => x.id === input.appId);
|
||||
|
||||
if (!app || !checkIntegrationsType(app.integration, ['nzbGet', 'sabnzbd'])) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `App with ID "${input.appId}" could not be found.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (app.integration.type === 'nzbGet') {
|
||||
const url = new URL(app.url);
|
||||
const options = {
|
||||
host: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
||||
login: findAppProperty(app, 'username'),
|
||||
hash: findAppProperty(app, 'password'),
|
||||
};
|
||||
|
||||
const nzbGet = NzbgetClient(options);
|
||||
|
||||
const nzbgetStatus: NzbgetStatus = await new Promise((resolve, reject) => {
|
||||
nzbGet.status((err: any, result: NzbgetStatus) => {
|
||||
if (!err) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!nzbgetStatus) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Error while getting NZBGet status',
|
||||
});
|
||||
}
|
||||
|
||||
const bytesRemaining = nzbgetStatus.RemainingSizeMB * 1000000;
|
||||
const eta = bytesRemaining / nzbgetStatus.DownloadRate;
|
||||
return {
|
||||
paused: nzbgetStatus.DownloadPaused,
|
||||
sizeLeft: bytesRemaining,
|
||||
speed: nzbgetStatus.DownloadRate,
|
||||
eta,
|
||||
};
|
||||
}
|
||||
|
||||
const apiKey = findAppProperty(app, 'apiKey');
|
||||
if (!apiKey) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `API Key for app "${app.name}" is missing`,
|
||||
});
|
||||
}
|
||||
|
||||
const { origin } = new URL(app.url);
|
||||
|
||||
const queue = await new Client(origin, apiKey).queue(0, -1);
|
||||
|
||||
const [hours, minutes, seconds] = queue.timeleft.split(':');
|
||||
const eta = dayjs.duration({
|
||||
hour: parseInt(hours, 10),
|
||||
minutes: parseInt(minutes, 10),
|
||||
seconds: parseInt(seconds, 10),
|
||||
} as any);
|
||||
|
||||
return {
|
||||
paused: queue.paused,
|
||||
sizeLeft: parseFloat(queue.mbleft) * 1024 * 1024,
|
||||
speed: parseFloat(queue.kbpersec) * 1000,
|
||||
eta: eta.asSeconds(),
|
||||
};
|
||||
}),
|
||||
history: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
appId: z.string(),
|
||||
limit: z.number(),
|
||||
offset: z.number(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
|
||||
const app = config.apps.find((x) => x.id === input.appId);
|
||||
|
||||
if (!app || !checkIntegrationsType(app.integration, ['nzbGet', 'sabnzbd'])) {
|
||||
throw new Error(`App with ID "${input.appId}" could not be found.`);
|
||||
}
|
||||
|
||||
if (app.integration.type === 'nzbGet') {
|
||||
const url = new URL(app.url);
|
||||
const options = {
|
||||
host: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
||||
login: findAppProperty(app, 'username'),
|
||||
hash: findAppProperty(app, 'password'),
|
||||
};
|
||||
|
||||
const nzbGet = NzbgetClient(options);
|
||||
|
||||
const nzbgetHistory: NzbgetHistoryItem[] = await new Promise((resolve, reject) => {
|
||||
nzbGet.history(false, (err: any, result: NzbgetHistoryItem[]) => {
|
||||
if (!err) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!nzbgetHistory) {
|
||||
throw new Error('Error while getting NZBGet history');
|
||||
}
|
||||
|
||||
const nzbgetItems: UsenetHistoryItem[] = nzbgetHistory.map((item: NzbgetHistoryItem) => ({
|
||||
id: item.NZBID.toString(),
|
||||
name: item.Name,
|
||||
// Convert from MB to bytes
|
||||
size: item.DownloadedSizeMB * 1000000,
|
||||
time: item.DownloadTimeSec,
|
||||
}));
|
||||
|
||||
return {
|
||||
items: nzbgetItems,
|
||||
total: nzbgetItems.length,
|
||||
};
|
||||
}
|
||||
|
||||
const { origin } = new URL(app.url);
|
||||
|
||||
const apiKey = findAppProperty(app, 'apiKey');
|
||||
if (!apiKey) {
|
||||
throw new Error(`API Key for app "${app.name}" is missing`);
|
||||
}
|
||||
|
||||
const history = await new Client(origin, apiKey).history(input.offset, input.limit);
|
||||
|
||||
const items: UsenetHistoryItem[] = history.slots.map((slot) => ({
|
||||
id: slot.nzo_id,
|
||||
name: slot.name,
|
||||
size: slot.bytes,
|
||||
time: slot.download_time,
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
total: history.noofslots,
|
||||
};
|
||||
}),
|
||||
pause: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
appId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
const app = config.apps.find((x) => x.id === input.appId);
|
||||
|
||||
if (!app || !checkIntegrationsType(app.integration, ['nzbGet', 'sabnzbd'])) {
|
||||
throw new Error(`App with ID "${input.appId}" could not be found.`);
|
||||
}
|
||||
|
||||
if (app.integration.type === 'nzbGet') {
|
||||
const url = new URL(app.url);
|
||||
const options = {
|
||||
host: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
||||
login: findAppProperty(app, 'username'),
|
||||
hash: findAppProperty(app, 'password'),
|
||||
};
|
||||
|
||||
const nzbGet = NzbgetClient(options);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
nzbGet.pauseDownload(false, (err: any, result: any) => {
|
||||
if (!err) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const apiKey = findAppProperty(app, 'apiKey');
|
||||
if (!apiKey) {
|
||||
throw new Error(`API Key for app "${app.name}" is missing`);
|
||||
}
|
||||
|
||||
const { origin } = new URL(app.url);
|
||||
|
||||
return new Client(origin, apiKey).queuePause();
|
||||
}),
|
||||
resume: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
appId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
|
||||
const app = config.apps.find((x) => x.id === input.appId);
|
||||
|
||||
if (!app || !checkIntegrationsType(app.integration, ['nzbGet', 'sabnzbd'])) {
|
||||
throw new Error(`App with ID "${input.appId}" could not be found.`);
|
||||
}
|
||||
|
||||
if (app.integration.type === 'nzbGet') {
|
||||
const url = new URL(app.url);
|
||||
const options = {
|
||||
host: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
||||
login: findAppProperty(app, 'username'),
|
||||
hash: findAppProperty(app, 'password'),
|
||||
};
|
||||
|
||||
const nzbGet = NzbgetClient(options);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
nzbGet.resumeDownload(false, (err: any, result: any) => {
|
||||
if (!err) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const apiKey = findAppProperty(app, 'apiKey');
|
||||
if (!apiKey) {
|
||||
throw new Error(`API Key for app "${app.name}" is missing`);
|
||||
}
|
||||
|
||||
const { origin } = new URL(app.url);
|
||||
|
||||
return new Client(origin, apiKey).queueResume();
|
||||
}),
|
||||
queue: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
appId: z.string(),
|
||||
limit: z.number(),
|
||||
offset: z.number(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
|
||||
const app = config.apps.find((x) => x.id === input.appId);
|
||||
|
||||
if (!app || !checkIntegrationsType(app.integration, ['nzbGet', 'sabnzbd'])) {
|
||||
throw new Error(`App with ID "${input.appId}" could not be found.`);
|
||||
}
|
||||
|
||||
if (app.integration.type === 'nzbGet') {
|
||||
const url = new URL(app.url);
|
||||
const options = {
|
||||
host: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
||||
login: findAppProperty(app, 'username'),
|
||||
hash: findAppProperty(app, 'password'),
|
||||
};
|
||||
|
||||
const nzbGet = NzbgetClient(options);
|
||||
|
||||
const nzbgetQueue: NzbgetQueueItem[] = await new Promise((resolve, reject) => {
|
||||
nzbGet.listGroups((err: any, result: NzbgetQueueItem[]) => {
|
||||
if (!err) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!nzbgetQueue) {
|
||||
throw new Error('Error while getting NZBGet queue');
|
||||
}
|
||||
|
||||
const nzbgetStatus: NzbgetStatus = await new Promise((resolve, reject) => {
|
||||
nzbGet.status((err: any, result: NzbgetStatus) => {
|
||||
if (!err) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!nzbgetStatus) {
|
||||
throw new Error('Error while getting NZBGet status');
|
||||
}
|
||||
|
||||
const nzbgetItems: UsenetQueueItem[] = nzbgetQueue.map((item: NzbgetQueueItem) => ({
|
||||
id: item.NZBID.toString(),
|
||||
name: item.NZBName,
|
||||
progress: (item.DownloadedSizeMB / item.FileSizeMB) * 100,
|
||||
eta: (item.RemainingSizeMB * 1000000) / nzbgetStatus.DownloadRate,
|
||||
// Multiple MB to get bytes
|
||||
size: item.FileSizeMB * 1000 * 1000,
|
||||
state: getNzbgetState(item.Status),
|
||||
}));
|
||||
|
||||
return {
|
||||
items: nzbgetItems,
|
||||
total: nzbgetItems.length,
|
||||
};
|
||||
}
|
||||
|
||||
const apiKey = findAppProperty(app, 'apiKey');
|
||||
if (!apiKey) {
|
||||
throw new Error(`API Key for app "${app.name}" is missing`);
|
||||
}
|
||||
|
||||
const { origin } = new URL(app.url);
|
||||
const queue = await new Client(origin, apiKey).queue(input.offset, input.limit);
|
||||
|
||||
const items: UsenetQueueItem[] = queue.slots.map((slot) => {
|
||||
const [hours, minutes, seconds] = slot.timeleft.split(':');
|
||||
const eta = dayjs.duration({
|
||||
hour: parseInt(hours, 10),
|
||||
minutes: parseInt(minutes, 10),
|
||||
seconds: parseInt(seconds, 10),
|
||||
} as any);
|
||||
|
||||
return {
|
||||
id: slot.nzo_id,
|
||||
eta: eta.asSeconds(),
|
||||
name: slot.filename,
|
||||
progress: parseFloat(slot.percentage),
|
||||
size: parseFloat(slot.mb) * 1000 * 1000,
|
||||
state: slot.status.toLowerCase() as any,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
total: queue.noofslots,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
function getNzbgetState(status: string) {
|
||||
switch (status) {
|
||||
case 'QUEUED':
|
||||
return 'queued';
|
||||
case 'PAUSED ':
|
||||
return 'paused';
|
||||
default:
|
||||
return 'downloading';
|
||||
}
|
||||
}
|
||||
|
||||
export interface UsenetInfoResponse {
|
||||
paused: boolean;
|
||||
sizeLeft: number;
|
||||
speed: number;
|
||||
eta: number;
|
||||
}
|
||||
93
src/server/api/trpc.ts
Normal file
93
src/server/api/trpc.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
|
||||
* 1. You want to modify request context (see Part 1).
|
||||
* 2. You want to create a new middleware or type of procedure (see Part 3).
|
||||
*
|
||||
* TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
|
||||
* need to use are documented accordingly near the end.
|
||||
*/
|
||||
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import { type CreateNextContextOptions } from '@trpc/server/adapters/next';
|
||||
import superjson from 'superjson';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
/**
|
||||
* 1. CONTEXT
|
||||
*
|
||||
* This section defines the "contexts" that are available in the backend API.
|
||||
*
|
||||
* These allow you to access things when processing a request, like the database, the session, etc.
|
||||
*/
|
||||
|
||||
type CreateContextOptions = Record<never, unknown>;
|
||||
|
||||
/**
|
||||
* This helper generates the "internals" for a tRPC context. If you need to use it, you can export
|
||||
* it from here.
|
||||
*
|
||||
* Examples of things you may need it for:
|
||||
* - testing, so we don't have to mock Next.js' req/res
|
||||
* - tRPC's `createSSGHelpers`, where we don't have req/res
|
||||
*
|
||||
* @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts
|
||||
*/
|
||||
const createInnerTRPCContext = (opts: CreateContextOptions) => ({});
|
||||
|
||||
/**
|
||||
* This is the actual context you will use in your router. It will be used to process every request
|
||||
* that goes through your tRPC endpoint.
|
||||
*
|
||||
* @see https://trpc.io/docs/context
|
||||
*/
|
||||
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
||||
const { req, res } = opts;
|
||||
|
||||
// Get the session from the server using the getServerSession wrapper function
|
||||
|
||||
return createInnerTRPCContext({});
|
||||
};
|
||||
|
||||
/**
|
||||
* 2. INITIALIZATION
|
||||
*
|
||||
* This is where the tRPC API is initialized, connecting the context and transformer. We also parse
|
||||
* ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
|
||||
* errors on the backend.
|
||||
*/
|
||||
|
||||
const t = initTRPC.context<typeof createTRPCContext>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
|
||||
*
|
||||
* These are the pieces you use to build your tRPC API. You should import these a lot in the
|
||||
* "/src/server/api/routers" directory.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This is how you create new routers and sub-routers in your tRPC API.
|
||||
*
|
||||
* @see https://trpc.io/docs/router
|
||||
*/
|
||||
export const createTRPCRouter = t.router;
|
||||
|
||||
/**
|
||||
* Public (unauthenticated) procedure
|
||||
*
|
||||
* This is the base piece you use to build new queries and mutations on your tRPC API. It does not
|
||||
* guarantee that a user querying is authorized, but you can still access user session data if they
|
||||
* are logged in.
|
||||
*/
|
||||
export const publicProcedure = t.procedure;
|
||||
@@ -1,4 +1,27 @@
|
||||
import { ConfigAppType, IntegrationField } from '../../types/app';
|
||||
import { ConfigAppType, IntegrationField, IntegrationType } from '../../types/app';
|
||||
|
||||
export const findAppProperty = (app: ConfigAppType, key: IntegrationField) =>
|
||||
app.integration?.properties.find((prop) => prop.field === key)?.value ?? '';
|
||||
|
||||
/** Checks if the type of an integration is part of the TIntegrations array with propper typing */
|
||||
export const checkIntegrationsType = <
|
||||
TTest extends CheckIntegrationTypeInput,
|
||||
TIntegrations extends readonly IntegrationType[]
|
||||
>(
|
||||
test: TTest | undefined | null,
|
||||
integrations: TIntegrations
|
||||
): test is CheckIntegrationType<TTest, TIntegrations> => {
|
||||
if (!test) return false;
|
||||
return integrations.includes(test.type!);
|
||||
};
|
||||
|
||||
type CheckIntegrationTypeInput = {
|
||||
type: IntegrationType | null;
|
||||
};
|
||||
|
||||
type CheckIntegrationType<
|
||||
TInput extends CheckIntegrationTypeInput,
|
||||
TIntegrations extends readonly IntegrationType[]
|
||||
> = TInput & {
|
||||
type: TIntegrations[number];
|
||||
};
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconCheck, IconX } from '@tabler/icons-react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfigContext } from '../../../config/provider';
|
||||
import { ConfigType } from '../../../types/config';
|
||||
import { queryClient } from '../../server/configurations/tanstack/queryClient.tool';
|
||||
|
||||
export const useCopyConfigMutation = (configName: string) => {
|
||||
const { config } = useConfigContext();
|
||||
const { t } = useTranslation(['settings/general/config-changer']);
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['configs/copy', { configName }],
|
||||
mutationFn: () => fetchCopy(configName, config),
|
||||
onSuccess() {
|
||||
showNotification({
|
||||
title: t('modal.copy.events.configCopied.title'),
|
||||
icon: <IconCheck />,
|
||||
color: 'green',
|
||||
autoClose: 1500,
|
||||
radius: 'md',
|
||||
message: t('modal.copy.events.configCopied.message', { configName }),
|
||||
});
|
||||
// Invalidate a query to fetch new config
|
||||
queryClient.invalidateQueries(['config/get-all']);
|
||||
},
|
||||
onError() {
|
||||
showNotification({
|
||||
title: t('modal.events.configNotCopied.title'),
|
||||
icon: <IconX />,
|
||||
color: 'red',
|
||||
autoClose: 1500,
|
||||
radius: 'md',
|
||||
message: t('modal.events.configNotCopied.message', { configName }),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const fetchCopy = async (configName: string, config: ConfigType | undefined) => {
|
||||
if (!config) {
|
||||
throw new Error('config is not defiend');
|
||||
}
|
||||
|
||||
const copiedConfig = config;
|
||||
copiedConfig.configProperties.name = configName;
|
||||
|
||||
const response = await fetch(`/api/configs/${configName}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
return response.json();
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
export const useDeleteConfigMutation = (configName: string) => {
|
||||
const { t } = useTranslation(['settings/general/config-changer']);
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['configs/delete', { configName }],
|
||||
mutationFn: () => fetchDeletion(configName),
|
||||
onError() {
|
||||
showNotification({
|
||||
title: t('buttons.delete.notifications.deleteFailed.title'),
|
||||
icon: <IconX />,
|
||||
color: 'red',
|
||||
autoClose: 1500,
|
||||
radius: 'md',
|
||||
message: t('buttons.delete.notifications.deleteFailed.message'),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const fetchDeletion = async (configName: string) =>
|
||||
(await fetch(`/api/configs/${configName}`, { method: 'DELETE' })).json();
|
||||
75
src/utils/api.ts
Normal file
75
src/utils/api.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* This is the client-side entrypoint for your tRPC API. It is used to create the `api` object which
|
||||
* contains the Next.js App-wrapper, as well as your type-safe React Query hooks.
|
||||
*
|
||||
* We also create a few inference helpers for input and output types.
|
||||
*/
|
||||
import { createTRPCProxyClient, httpBatchLink, loggerLink } from '@trpc/client';
|
||||
import { createTRPCNext } from '@trpc/next';
|
||||
import { type inferRouterInputs, type inferRouterOutputs } from '@trpc/server';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import { type RootRouter } from '~/server/api/root';
|
||||
|
||||
const getTrpcConfiguration = () => ({
|
||||
/**
|
||||
* Transformer used for data de-serialization from the server.
|
||||
*
|
||||
* @see https://trpc.io/docs/data-transformers
|
||||
*/
|
||||
transformer: superjson,
|
||||
|
||||
/**
|
||||
* Links used to determine request flow from client to server.
|
||||
*
|
||||
* @see https://trpc.io/docs/links
|
||||
*/
|
||||
links: [
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
(opts.direction === 'down' && opts.result instanceof Error),
|
||||
}),
|
||||
httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const getBaseUrl = () => {
|
||||
if (typeof window !== 'undefined') return ''; // browser should use relative url
|
||||
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost
|
||||
};
|
||||
|
||||
/** A set of type-safe react-query hooks for your tRPC API. */
|
||||
export const api = createTRPCNext<RootRouter>({
|
||||
config() {
|
||||
return getTrpcConfiguration();
|
||||
},
|
||||
/**
|
||||
* Whether tRPC should await queries when server rendering pages.
|
||||
*
|
||||
* @see https://trpc.io/docs/nextjs#ssr-boolean-default-false
|
||||
*/
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Inference helper for inputs.
|
||||
*
|
||||
* @example type HelloInput = RouterInputs['example']['hello']
|
||||
*/
|
||||
export type RouterInputs = inferRouterInputs<RootRouter>;
|
||||
|
||||
/**
|
||||
* Inference helper for outputs.
|
||||
*
|
||||
* @example type HelloOutput = RouterOutputs['example']['hello']
|
||||
*/
|
||||
export type RouterOutputs = inferRouterOutputs<RootRouter>;
|
||||
|
||||
/**
|
||||
* A tRPC client that can be used without hooks.
|
||||
*/
|
||||
export const trcpProxyClient = createTRPCProxyClient<RootRouter>(getTrpcConfiguration());
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useMantineTheme } from '@mantine/core';
|
||||
import { Calendar } from '@mantine/dates';
|
||||
import { IconCalendarTime } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { i18n } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
import { api } from '~/utils/api';
|
||||
import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
import { CalendarDay } from './CalendarDay';
|
||||
import { getBgColorByDateAndTheme } from './bg-calculator';
|
||||
import { MediasType } from './type';
|
||||
import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore';
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'calendar',
|
||||
@@ -55,22 +55,18 @@ function CalendarTile({ widget }: CalendarTileProps) {
|
||||
const [month, setMonth] = useState(new Date());
|
||||
const isEditMode = useEditModeStore((x) => x.enabled);
|
||||
|
||||
const { data: medias } = useQuery({
|
||||
queryKey: [
|
||||
'calendar/medias',
|
||||
{ month: month.getMonth(), year: month.getFullYear(), v4: widget.properties.useSonarrv4 },
|
||||
],
|
||||
staleTime: 1000 * 60 * 60 * 5,
|
||||
enabled: isEditMode === false,
|
||||
queryFn: async () =>
|
||||
(await (
|
||||
await fetch(
|
||||
`/api/modules/calendar?year=${month.getFullYear()}&month=${
|
||||
month.getMonth() + 1
|
||||
}&configName=${configName}&widgetId=${widget.id}`
|
||||
)
|
||||
).json()) as MediasType,
|
||||
});
|
||||
const { data: medias } = api.calendar.medias.useQuery(
|
||||
{
|
||||
configName: configName!,
|
||||
month: month.getMonth() + 1,
|
||||
year: month.getFullYear(),
|
||||
options: { useSonarrv4: widget.properties.useSonarrv4 },
|
||||
},
|
||||
{
|
||||
staleTime: 1000 * 60 * 60 * 5,
|
||||
enabled: isEditMode === false,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
|
||||
@@ -2,21 +2,13 @@ import { Group, Stack, Text } from '@mantine/core';
|
||||
import { IconArrowNarrowDown, IconArrowNarrowUp } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { bytes } from '../../tools/bytesHelper';
|
||||
import { RouterOutputs } from '~/utils/api';
|
||||
|
||||
interface DashDotCompactNetworkProps {
|
||||
info: DashDotInfo;
|
||||
}
|
||||
|
||||
export interface DashDotInfo {
|
||||
storage: {
|
||||
size: number;
|
||||
disks: { device: string; brand: string; type: string }[];
|
||||
}[];
|
||||
network: {
|
||||
speedUp: number;
|
||||
speedDown: number;
|
||||
};
|
||||
}
|
||||
export type DashDotInfo = RouterOutputs['dashDot']['info'];
|
||||
|
||||
export const DashDotCompactNetwork = ({ info }: DashDotCompactNetworkProps) => {
|
||||
const { t } = useTranslation('modules/dashdot');
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { Group, Stack, Text } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { api } from '~/utils/api';
|
||||
import { bytes } from '../../tools/bytesHelper';
|
||||
import { percentage } from '../../tools/shared/math/percentage.tool';
|
||||
import { DashDotInfo } from './DashDotCompactNetwork';
|
||||
|
||||
interface DashDotCompactStorageProps {
|
||||
info: DashDotInfo;
|
||||
widgetId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const DashDotCompactStorage = ({ info, widgetId }: DashDotCompactStorageProps) => {
|
||||
export const DashDotCompactStorage = ({ info, url }: DashDotCompactStorageProps) => {
|
||||
const { t } = useTranslation('modules/dashdot');
|
||||
const { data: storageLoad } = useDashDotStorage(widgetId);
|
||||
const { data: storageLoad } = useDashDotStorage(url);
|
||||
|
||||
const totalUsed = calculateTotalLayoutSize({
|
||||
layout: storageLoad ?? [],
|
||||
@@ -55,25 +53,7 @@ interface CalculateTotalLayoutSizeProps<TLayoutItem> {
|
||||
key?: keyof TLayoutItem;
|
||||
}
|
||||
|
||||
const useDashDotStorage = (widgetId: string) => {
|
||||
const { name: configName, config } = useConfigContext();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
'dashdot/storage',
|
||||
{
|
||||
configName,
|
||||
url: config?.widgets.find((x) => x.type === 'dashdot')?.properties.url,
|
||||
widgetId,
|
||||
},
|
||||
],
|
||||
queryFn: () => fetchDashDotStorageLoad(configName, widgetId),
|
||||
const useDashDotStorage = (url: string) =>
|
||||
api.dashDot.storage.useQuery({
|
||||
url,
|
||||
});
|
||||
};
|
||||
|
||||
async function fetchDashDotStorageLoad(configName: string | undefined, widgetId: string) {
|
||||
if (!configName) throw new Error('configName is undefined');
|
||||
return (await (
|
||||
await axios.get('/api/modules/dashdot/storage', { params: { configName, widgetId } })
|
||||
).data) as number[];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createStyles, Title, useMantineTheme, getStylesRef } from '@mantine/core';
|
||||
import { Title, createStyles, getStylesRef, useMantineTheme } from '@mantine/core';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { DashDotCompactNetwork, DashDotInfo } from './DashDotCompactNetwork';
|
||||
import { DashDotCompactStorage } from './DashDotCompactStorage';
|
||||
@@ -11,7 +11,6 @@ interface DashDotGraphProps {
|
||||
dashDotUrl: string;
|
||||
usePercentages: boolean;
|
||||
info: DashDotInfo;
|
||||
widgetId: string;
|
||||
}
|
||||
|
||||
export const DashDotGraph = ({
|
||||
@@ -22,13 +21,12 @@ export const DashDotGraph = ({
|
||||
dashDotUrl,
|
||||
usePercentages,
|
||||
info,
|
||||
widgetId,
|
||||
}: DashDotGraphProps) => {
|
||||
const { t } = useTranslation('modules/dashdot');
|
||||
const { classes } = useStyles();
|
||||
|
||||
if (graph === 'storage' && isCompact) {
|
||||
return <DashDotCompactStorage info={info} widgetId={widgetId} />;
|
||||
return <DashDotCompactStorage info={info} url={dashDotUrl} />;
|
||||
}
|
||||
|
||||
if (graph === 'network' && isCompact) {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { Center, createStyles, Grid, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconUnlink } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { api } from '~/utils/api';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
import { DashDotInfo } from './DashDotCompactNetwork';
|
||||
import { DashDotGraph } from './DashDotGraph';
|
||||
|
||||
const definition = defineWidget({
|
||||
@@ -161,10 +158,9 @@ function DashDotTile({ widget }: DashDotTileProps) {
|
||||
const detectedProtocolDowngrade =
|
||||
locationProtocol === 'https:' && dashDotUrl.toLowerCase().startsWith('http:');
|
||||
|
||||
const { data: info } = useDashDotInfo({
|
||||
const { data: info } = useDashDotInfoQuery({
|
||||
dashDotUrl,
|
||||
enabled: !detectedProtocolDowngrade,
|
||||
widgetId: widget.id,
|
||||
});
|
||||
|
||||
if (detectedProtocolDowngrade) {
|
||||
@@ -202,7 +198,6 @@ function DashDotTile({ widget }: DashDotTileProps) {
|
||||
isCompact={g.subValues.compactView ?? false}
|
||||
multiView={g.subValues.multiView ?? false}
|
||||
usePercentages={usePercentages}
|
||||
widgetId={widget.id}
|
||||
/>
|
||||
</Grid.Col>
|
||||
))}
|
||||
@@ -212,37 +207,16 @@ function DashDotTile({ widget }: DashDotTileProps) {
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const useDashDotInfo = ({
|
||||
dashDotUrl,
|
||||
enabled,
|
||||
widgetId,
|
||||
}: {
|
||||
dashDotUrl: string;
|
||||
enabled: boolean;
|
||||
widgetId: string;
|
||||
}) => {
|
||||
const { name: configName } = useConfigContext();
|
||||
return useQuery({
|
||||
refetchInterval: 50000,
|
||||
queryKey: [
|
||||
'dashdot/info',
|
||||
{
|
||||
configName,
|
||||
dashDotUrl,
|
||||
},
|
||||
],
|
||||
queryFn: () => fetchDashDotInfo(configName, widgetId),
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchDashDotInfo = async (configName: string | undefined, widgetId: string) => {
|
||||
if (!configName) return {} as DashDotInfo;
|
||||
return (await (
|
||||
await axios.get('/api/modules/dashdot/info', { params: { configName, widgetId } })
|
||||
).data) as DashDotInfo;
|
||||
};
|
||||
const useDashDotInfoQuery = ({ dashDotUrl, enabled }: { dashDotUrl: string; enabled: boolean }) =>
|
||||
api.dashDot.info.useQuery(
|
||||
{
|
||||
url: dashDotUrl,
|
||||
},
|
||||
{
|
||||
refetchInterval: 50000,
|
||||
enabled,
|
||||
}
|
||||
);
|
||||
|
||||
export const useDashDotTileStyles = createStyles((theme) => ({
|
||||
graphsContainer: {
|
||||
|
||||
@@ -5,9 +5,10 @@ import { useConfigContext } from '../../config/provider';
|
||||
import { defineWidget } from '../helper';
|
||||
import { WidgetLoading } from '../loading';
|
||||
import { IWidget } from '../widgets';
|
||||
import { useDnsHoleControlMutation, useDnsHoleSummeryQuery } from './query';
|
||||
import { PiholeApiSummaryType } from './type';
|
||||
import { queryClient } from '../../tools/server/configurations/tanstack/queryClient.tool';
|
||||
import { api } from '~/utils/api';
|
||||
import { useDnsHoleSummeryQuery } from './DnsHoleSummary';
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'dns-hole-controls',
|
||||
@@ -29,13 +30,13 @@ interface DnsHoleControlsWidgetProps {
|
||||
}
|
||||
|
||||
function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
|
||||
const { isInitialLoading, data, refetch } = useDnsHoleSummeryQuery();
|
||||
const { isInitialLoading, data } = useDnsHoleSummeryQuery();
|
||||
const { mutateAsync } = useDnsHoleControlMutation();
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const { config } = useConfigContext();
|
||||
const { name: configName, config } = useConfigContext();
|
||||
|
||||
if (isInitialLoading || !data) {
|
||||
if (isInitialLoading || !data || !configName) {
|
||||
return <WidgetLoading />;
|
||||
}
|
||||
|
||||
@@ -44,7 +45,10 @@ function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
|
||||
<Group grow>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await mutateAsync('enabled');
|
||||
await mutateAsync({
|
||||
status: 'enabled',
|
||||
configName,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['dns-hole-summary'] });
|
||||
}}
|
||||
leftIcon={<IconPlayerPlay size={20} />}
|
||||
@@ -55,7 +59,10 @@ function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await mutateAsync('disabled');
|
||||
await mutateAsync({
|
||||
status: 'disabled',
|
||||
configName,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['dns-hole-summary'] });
|
||||
}}
|
||||
leftIcon={<IconPlayerStop size={20} />}
|
||||
@@ -117,4 +124,6 @@ const StatusBadge = ({ status }: { status: PiholeApiSummaryType['status'] }) =>
|
||||
);
|
||||
};
|
||||
|
||||
const useDnsHoleControlMutation = () => api.dnsHole.control.useMutation();
|
||||
|
||||
export default definition;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { Card, Center, Container, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
IconAd,
|
||||
@@ -7,11 +6,13 @@ import {
|
||||
IconSearch,
|
||||
IconWorldWww,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { api } from '~/utils/api';
|
||||
import { formatNumber } from '../../tools/client/math';
|
||||
import { defineWidget } from '../helper';
|
||||
import { WidgetLoading } from '../loading';
|
||||
import { IWidget } from '../widgets';
|
||||
import { formatNumber } from '../../tools/client/math';
|
||||
import { useDnsHoleSummeryQuery } from './query';
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'dns-hole-summary',
|
||||
@@ -179,4 +180,17 @@ function DnsHoleSummaryWidgetTile({ widget }: DnsHoleSummaryWidgetProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export const useDnsHoleSummeryQuery = () => {
|
||||
const { name: configName } = useConfigContext();
|
||||
|
||||
return api.dnsHole.summary.useQuery(
|
||||
{
|
||||
configName: configName!,
|
||||
},
|
||||
{
|
||||
refetchInterval: 3 * 60 * 1000,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default definition;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { AdStatistics, PiholeApiSummaryType } from './type';
|
||||
|
||||
export const useDnsHoleSummeryQuery = () =>
|
||||
useQuery({
|
||||
queryKey: ['dns-hole-summary'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/modules/dns-hole/summary');
|
||||
return (await response.json()) as AdStatistics;
|
||||
},
|
||||
refetchInterval: 3 * 60 * 1000,
|
||||
});
|
||||
|
||||
export const useDnsHoleControlMutation = () =>
|
||||
useMutation({
|
||||
mutationKey: ['dns-hole-control'],
|
||||
mutationFn: async (status: PiholeApiSummaryType['status']) => {
|
||||
const response = await fetch(`/api/modules/dns-hole/control?status=${status}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
@@ -10,16 +10,16 @@ import {
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { IconCheck, IconGitPullRequest, IconThumbDown, IconThumbUp } from '@tabler/icons-react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck, IconGitPullRequest, IconThumbDown, IconThumbUp } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { api } from '~/utils/api';
|
||||
import { defineWidget } from '../helper';
|
||||
import { WidgetLoading } from '../loading';
|
||||
import { IWidget } from '../widgets';
|
||||
import { useMediaRequestQuery } from './media-request-query';
|
||||
import { MediaRequest, MediaRequestStatus } from './media-request-types';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'media-requests-list',
|
||||
@@ -45,21 +45,55 @@ interface MediaRequestListWidgetProps {
|
||||
widget: MediaRequestListWidget;
|
||||
}
|
||||
|
||||
function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
|
||||
const { t } = useTranslation('modules/media-requests-list');
|
||||
const { data, refetch, isLoading } = useMediaRequestQuery();
|
||||
// Use mutation to approve or deny a pending request
|
||||
const mutate = useMutation({
|
||||
mutationFn: async (e: { request: MediaRequest; action: string }) => {
|
||||
const data = await axios.put(`/api/modules/overseerr/${e.request.id}?action=${e.action}`);
|
||||
notifications.show({
|
||||
title: t('requestUpdated'),
|
||||
message: t('requestUpdatedMessage', { title: e.request.name }),
|
||||
color: 'blue',
|
||||
});
|
||||
refetch();
|
||||
type MediaRequestDecisionVariables = {
|
||||
request: MediaRequest;
|
||||
isApproved: boolean;
|
||||
};
|
||||
const useMediaRequestDecisionMutation = () => {
|
||||
const { name: configName } = useConfigContext();
|
||||
const utils = api.useContext();
|
||||
const { mutateAsync } = api.overseerr.decide.useMutation({
|
||||
onSuccess() {
|
||||
utils.mediaRequest.all.invalidate();
|
||||
},
|
||||
});
|
||||
return async (variables: MediaRequestDecisionVariables) => {
|
||||
const action = variables.isApproved ? 'Approving' : 'Declining';
|
||||
notifications.show({
|
||||
id: `decide-${variables.request.id}`,
|
||||
color: 'yellow',
|
||||
title: `${action} request...`,
|
||||
message: undefined,
|
||||
loading: true,
|
||||
});
|
||||
await mutateAsync(
|
||||
{
|
||||
configName: configName!,
|
||||
id: variables.request.id,
|
||||
isApproved: variables.isApproved,
|
||||
},
|
||||
{
|
||||
onSuccess(_data, variables) {
|
||||
const title = variables.isApproved ? 'Request was approved!' : 'Request was declined!';
|
||||
notifications.update({
|
||||
id: `decide-${variables.id}`,
|
||||
color: 'teal',
|
||||
title,
|
||||
message: undefined,
|
||||
icon: <IconCheck size="1rem" />,
|
||||
autoClose: 2000,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
|
||||
const { t } = useTranslation('modules/media-requests-list');
|
||||
const { data, isLoading } = useMediaRequestQuery();
|
||||
// Use mutation to approve or deny a pending request
|
||||
const decideAsync = useMediaRequestDecisionMutation();
|
||||
|
||||
if (!data || isLoading) {
|
||||
return <WidgetLoading />;
|
||||
@@ -157,18 +191,10 @@ function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
|
||||
loading: true,
|
||||
});
|
||||
|
||||
await mutate.mutateAsync({ request: item, action: 'approve' }).then(() =>
|
||||
notifications.update({
|
||||
id: `approve ${item.id}`,
|
||||
color: 'teal',
|
||||
title: 'Request was approved!',
|
||||
message: undefined,
|
||||
icon: <IconCheck size="1rem" />,
|
||||
autoClose: 2000,
|
||||
})
|
||||
);
|
||||
|
||||
await refetch();
|
||||
await decideAsync({
|
||||
request: item,
|
||||
isApproved: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconThumbUp />
|
||||
@@ -179,8 +205,10 @@ function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={async () => {
|
||||
await mutate.mutateAsync({ request: item, action: 'decline' });
|
||||
await refetch();
|
||||
await decideAsync({
|
||||
request: item,
|
||||
isApproved: false,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconThumbDown />
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { MediaRequest } from './media-request-types';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
export const useMediaRequestQuery = () => useQuery({
|
||||
queryKey: ['media-requests'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/modules/media-requests');
|
||||
return (await response.json()) as MediaRequest[];
|
||||
},
|
||||
refetchInterval: 3 * 60 * 1000,
|
||||
});
|
||||
export const useMediaRequestQuery = () => {
|
||||
const { name: configName } = useConfigContext();
|
||||
return api.mediaRequest.all.useQuery(
|
||||
{ configName: configName! },
|
||||
{
|
||||
refetchInterval: 3 * 60 * 1000,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,11 +15,12 @@ import {
|
||||
createStyles,
|
||||
} from '@mantine/core';
|
||||
import { IconClock, IconRefresh, IconRss } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { api } from '~/utils/api';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
|
||||
@@ -65,27 +66,11 @@ interface RssTileProps {
|
||||
widget: IRssWidget;
|
||||
}
|
||||
|
||||
export const useGetRssFeeds = (feedUrls: string[], refreshInterval: number, widgetId: string) =>
|
||||
useQuery({
|
||||
queryKey: ['rss-feeds', feedUrls],
|
||||
// Cache the results for 24 hours
|
||||
cacheTime: 1000 * 60 * 60 * 24,
|
||||
staleTime: 1000 * 60 * refreshInterval,
|
||||
queryFn: async () => {
|
||||
const responses = await Promise.all(
|
||||
feedUrls.map((feedUrl) =>
|
||||
fetch(
|
||||
`/api/modules/rss?widgetId=${widgetId}&feedUrl=${encodeURIComponent(feedUrl)}`
|
||||
).then((response) => response.json())
|
||||
)
|
||||
);
|
||||
return responses;
|
||||
},
|
||||
});
|
||||
|
||||
function RssTile({ widget }: RssTileProps) {
|
||||
const { t } = useTranslation('modules/rss');
|
||||
const { name: configName } = useConfigContext();
|
||||
const { data, isLoading, isFetching, isError, refetch } = useGetRssFeeds(
|
||||
configName,
|
||||
widget.properties.rssFeedUrl,
|
||||
widget.properties.refreshInterval,
|
||||
widget.id
|
||||
@@ -122,6 +107,7 @@ function RssTile({ widget }: RssTileProps) {
|
||||
<Title order={6}>{t('descriptor.card.errors.general.title')}</Title>
|
||||
<Text align="center">{t('descriptor.card.errors.general.text')}</Text>
|
||||
</Stack>
|
||||
<RefetchButton refetch={refetch} isFetching={isFetching} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -192,25 +178,54 @@ function RssTile({ widget }: RssTileProps) {
|
||||
))}
|
||||
</ScrollArea>
|
||||
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
radius="xl"
|
||||
pos="absolute"
|
||||
right={10}
|
||||
onClick={() => refetch()}
|
||||
bottom={10}
|
||||
styles={{
|
||||
root: {
|
||||
borderColor: 'red',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isFetching ? <Loader /> : <IconRefresh />}
|
||||
</ActionIcon>
|
||||
<RefetchButton refetch={refetch} isFetching={isFetching} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export const useGetRssFeeds = (
|
||||
configName: string | undefined,
|
||||
feedUrls: string[],
|
||||
refreshInterval: number,
|
||||
widgetId: string
|
||||
) =>
|
||||
api.rss.all.useQuery(
|
||||
{
|
||||
configName: configName ?? '',
|
||||
feedUrls,
|
||||
widgetId,
|
||||
},
|
||||
{
|
||||
// Cache the results for 24 hours
|
||||
cacheTime: 1000 * 60 * 60 * 24,
|
||||
staleTime: 1000 * 60 * refreshInterval,
|
||||
enabled: !!configName,
|
||||
}
|
||||
);
|
||||
|
||||
interface RefetchButtonProps {
|
||||
refetch: () => void;
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
const RefetchButton = ({ isFetching, refetch }: RefetchButtonProps) => (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
radius="xl"
|
||||
pos="absolute"
|
||||
right={10}
|
||||
onClick={() => refetch()}
|
||||
bottom={10}
|
||||
styles={{
|
||||
root: {
|
||||
borderColor: 'red',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isFetching ? <Loader /> : <IconRefresh />}
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
const InfoDisplay = ({ date, title }: { date: string; title: string | undefined }) => (
|
||||
<Group mt="auto" spacing="xs">
|
||||
<IconClock size={14} />
|
||||
|
||||
@@ -10,8 +10,8 @@ import { useConfigContext } from '../../config/provider';
|
||||
import { MIN_WIDTH_MOBILE } from '../../constants/constants';
|
||||
import {
|
||||
useGetUsenetInfo,
|
||||
usePauseUsenetQueue,
|
||||
useResumeUsenetQueue,
|
||||
usePauseUsenetQueueMutation,
|
||||
useResumeUsenetQueueMutation,
|
||||
} from '../../hooks/widgets/dashDot/api';
|
||||
import { humanFileSize } from '../../tools/humanFileSize';
|
||||
import { AppIntegrationType } from '../../types/app';
|
||||
@@ -60,8 +60,8 @@ function UseNetTile({ widget }: UseNetTileProps) {
|
||||
}
|
||||
}, [downloadApps, selectedAppId]);
|
||||
|
||||
const { mutate: pause } = usePauseUsenetQueue({ appId: selectedAppId! });
|
||||
const { mutate: resume } = useResumeUsenetQueue({ appId: selectedAppId! });
|
||||
const pauseAsync = usePauseUsenetQueueMutation({ appId: selectedAppId! });
|
||||
const resumeAsync = useResumeUsenetQueueMutation({ appId: selectedAppId! });
|
||||
|
||||
if (downloadApps.length === 0) {
|
||||
return (
|
||||
@@ -107,11 +107,25 @@ function UseNetTile({ widget }: UseNetTileProps) {
|
||||
<Tabs.Panel value="queue">
|
||||
<UsenetQueueList appId={selectedAppId} />
|
||||
{!data ? null : data.paused ? (
|
||||
<Button uppercase onClick={() => resume()} radius="xl" size="xs" fullWidth mt="sm">
|
||||
<Button
|
||||
uppercase
|
||||
onClick={async () => resumeAsync({ appId: selectedAppId })}
|
||||
radius="xl"
|
||||
size="xs"
|
||||
fullWidth
|
||||
mt="sm"
|
||||
>
|
||||
<IconPlayerPlay size={12} style={{ marginRight: 5 }} /> {t('info.paused')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button uppercase onClick={() => pause()} radius="xl" size="xs" fullWidth mt="sm">
|
||||
<Button
|
||||
uppercase
|
||||
onClick={async () => pauseAsync({ appId: selectedAppId })}
|
||||
radius="xl"
|
||||
size="xs"
|
||||
fullWidth
|
||||
mt="sm"
|
||||
>
|
||||
<IconPlayerPause size={12} style={{ marginRight: 5 }} />{' '}
|
||||
{dayjs.duration(data.eta, 's').format('HH:mm')}
|
||||
</Button>
|
||||
|
||||
@@ -13,14 +13,13 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import { IconAlertCircle } from '@tabler/icons-react';
|
||||
import { AxiosError } from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useGetUsenetHistory } from '../../hooks/widgets/dashDot/api';
|
||||
import { humanFileSize } from '../../tools/humanFileSize';
|
||||
import { parseDuration } from '../../tools/client/parseDuration';
|
||||
import { humanFileSize } from '../../tools/humanFileSize';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
@@ -65,7 +64,7 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ a
|
||||
>
|
||||
{t('modules/usenet:history.error.message')}
|
||||
<Code mt="sm" block>
|
||||
{(error as AxiosError)?.response?.data as string}
|
||||
{error.message}
|
||||
</Code>
|
||||
</Alert>
|
||||
</Group>
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import { IconAlertCircle, IconPlayerPause, IconPlayerPlay } from '@tabler/icons-react';
|
||||
import { AxiosError } from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
@@ -70,7 +69,7 @@ export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ appId
|
||||
>
|
||||
{t('queue.error.message')}
|
||||
<Code mt="sm" block>
|
||||
{(error as AxiosError)?.response?.data as string}
|
||||
{error.data}
|
||||
</Code>
|
||||
</Alert>
|
||||
</Group>
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
],
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
},
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
import { configDefaults, defineConfig } from 'vitest/config';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tsconfigPaths()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
coverage: {
|
||||
@@ -14,9 +15,6 @@ export default defineConfig({
|
||||
exclude: ['.next/', '.yarn/', 'data/'],
|
||||
},
|
||||
setupFiles: ['./tests/setupVitest.ts'],
|
||||
exclude: [
|
||||
...configDefaults.exclude,
|
||||
'.next',
|
||||
],
|
||||
exclude: [...configDefaults.exclude, '.next'],
|
||||
},
|
||||
});
|
||||
|
||||
97
yarn.lock
97
yarn.lock
@@ -2011,6 +2011,52 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@trpc/client@npm:^10.29.1":
|
||||
version: 10.29.1
|
||||
resolution: "@trpc/client@npm:10.29.1"
|
||||
peerDependencies:
|
||||
"@trpc/server": 10.29.1
|
||||
checksum: b617edb56e9ec7fa0665703761c666244e58b8a9da5a2fffcdef880cdde7ec8c92c20d1362ac1c836904518d94db247ec286cc17e01fc4eea41b7cafbf3479fe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@trpc/next@npm:^10.29.1":
|
||||
version: 10.29.1
|
||||
resolution: "@trpc/next@npm:10.29.1"
|
||||
dependencies:
|
||||
react-ssr-prepass: ^1.5.0
|
||||
peerDependencies:
|
||||
"@tanstack/react-query": ^4.18.0
|
||||
"@trpc/client": 10.29.1
|
||||
"@trpc/react-query": 10.29.1
|
||||
"@trpc/server": 10.29.1
|
||||
next: "*"
|
||||
react: ">=16.8.0"
|
||||
react-dom: ">=16.8.0"
|
||||
checksum: 6afd6d7b1702eda5a26e7e8d2d381729fceb180952a2382314f08dd945b6e3204716c532b0ac6ddef10135816879c61dbe4b27aa88bcd98100890ede8d42fb25
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@trpc/react-query@npm:^10.29.1":
|
||||
version: 10.29.1
|
||||
resolution: "@trpc/react-query@npm:10.29.1"
|
||||
peerDependencies:
|
||||
"@tanstack/react-query": ^4.18.0
|
||||
"@trpc/client": 10.29.1
|
||||
"@trpc/server": 10.29.1
|
||||
react: ">=16.8.0"
|
||||
react-dom: ">=16.8.0"
|
||||
checksum: 3e52195eb894c0110b7fd99b0ab4fde38ade0b28d76c788d602c97789b76c66c91f678460fd68f3dc67c9b454340c7fe08363dfec69d4127982f9329bb42f503
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@trpc/server@npm:^10.29.1":
|
||||
version: 10.29.1
|
||||
resolution: "@trpc/server@npm:10.29.1"
|
||||
checksum: d31ac9921764aea774ef14ae3d9d4c7cb245582dde7f75facd355732043eb9d697a8584c19377dce9acdf9b21f473917f77842fda29eb77fd604484e23b42ded
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsconfig/node10@npm:^1.0.7":
|
||||
version: 1.0.9
|
||||
resolution: "@tsconfig/node10@npm:1.0.9"
|
||||
@@ -5617,6 +5663,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"globrex@npm:^0.1.2":
|
||||
version: 0.1.2
|
||||
resolution: "globrex@npm:0.1.2"
|
||||
checksum: adca162494a176ce9ecf4dd232f7b802956bb1966b37f60c15e49d2e7d961b66c60826366dc2649093cad5a0d69970cfa8875bd1695b5a1a2f33dcd2aa88da3c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gopd@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "gopd@npm:1.0.1"
|
||||
@@ -5828,6 +5881,10 @@ __metadata:
|
||||
"@tanstack/react-query-persist-client": ^4.28.0
|
||||
"@testing-library/jest-dom": ^5.16.5
|
||||
"@testing-library/react": ^14.0.0
|
||||
"@trpc/client": ^10.29.1
|
||||
"@trpc/next": ^10.29.1
|
||||
"@trpc/react-query": ^10.29.1
|
||||
"@trpc/server": ^10.29.1
|
||||
"@types/dockerode": ^3.3.9
|
||||
"@types/node": 18.16.17
|
||||
"@types/prismjs": ^1.26.0
|
||||
@@ -5880,6 +5937,7 @@ __metadata:
|
||||
typescript: ^5.0.4
|
||||
uuid: ^9.0.0
|
||||
video.js: ^8.0.3
|
||||
vite-tsconfig-paths: ^4.2.0
|
||||
vitest: ^0.32.0
|
||||
vitest-fetch-mock: ^0.2.2
|
||||
xml-js: ^1.6.11
|
||||
@@ -8414,6 +8472,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-ssr-prepass@npm:^1.5.0":
|
||||
version: 1.5.0
|
||||
resolution: "react-ssr-prepass@npm:1.5.0"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: efe89b9f8b2053474613f56dfdbeb41d8ee2e572bf819a39377d95d3e4a9acf8a4a16e28d8d8034cb9ac2b316d11dc9e62217743e4322046d08175eb3b4fed3e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-style-singleton@npm:^2.2.1":
|
||||
version: 2.2.1
|
||||
resolution: "react-style-singleton@npm:2.2.1"
|
||||
@@ -9507,6 +9574,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tsconfck@npm:^2.1.0":
|
||||
version: 2.1.1
|
||||
resolution: "tsconfck@npm:2.1.1"
|
||||
peerDependencies:
|
||||
typescript: ^4.3.5 || ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
bin:
|
||||
tsconfck: bin/tsconfck.js
|
||||
checksum: c531525f39763cbbd7e6dbf5e29f12a7ae67eb8712816c14d06a9db6cbdc9dda9ac3cd6db07ef645f8a4cdea906447ab44e2c8679e320871cf9dd598756e8c83
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tsconfig-paths@npm:^3.14.1":
|
||||
version: 3.14.2
|
||||
resolution: "tsconfig-paths@npm:3.14.2"
|
||||
@@ -9981,6 +10062,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vite-tsconfig-paths@npm:^4.2.0":
|
||||
version: 4.2.0
|
||||
resolution: "vite-tsconfig-paths@npm:4.2.0"
|
||||
dependencies:
|
||||
debug: ^4.1.1
|
||||
globrex: ^0.1.2
|
||||
tsconfck: ^2.1.0
|
||||
peerDependencies:
|
||||
vite: "*"
|
||||
peerDependenciesMeta:
|
||||
vite:
|
||||
optional: true
|
||||
checksum: 73a8467de72d7ac502328454fd00c19571cd4bad2dd5982643b24718bb95e449a3f4153cfc2d58a358bfc8f37e592fb442fc10884b59ae82138c1329160cd952
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vite@npm:^3.0.0 || ^4.0.0":
|
||||
version: 4.3.9
|
||||
resolution: "vite@npm:4.3.9"
|
||||
|
||||
Reference in New Issue
Block a user