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": "^4.2.1",
|
||||||
"@tanstack/react-query-devtools": "^4.24.4",
|
"@tanstack/react-query-devtools": "^4.24.4",
|
||||||
"@tanstack/react-query-persist-client": "^4.28.0",
|
"@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",
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
"axios": "^1.0.0",
|
"axios": "^1.0.0",
|
||||||
"consola": "^3.0.0",
|
"consola": "^3.0.0",
|
||||||
@@ -111,6 +115,7 @@
|
|||||||
"turbo": "latest",
|
"turbo": "latest",
|
||||||
"typescript": "^5.0.4",
|
"typescript": "^5.0.4",
|
||||||
"video.js": "^8.0.3",
|
"video.js": "^8.0.3",
|
||||||
|
"vite-tsconfig-paths": "^4.2.0",
|
||||||
"vitest": "^0.32.0",
|
"vitest": "^0.32.0",
|
||||||
"vitest-fetch-mock": "^0.2.2"
|
"vitest-fetch-mock": "^0.2.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Center, Dialog, Loader, Notification, Select, Tooltip } from '@mantine/core';
|
import { Center, Dialog, Loader, Notification, Select, Tooltip } from '@mantine/core';
|
||||||
import { useToggle } from '@mantine/hooks';
|
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 { setCookie } from 'cookies-next';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { api } from '~/utils/api';
|
||||||
import { IconCheck } from '@tabler/icons-react';
|
|
||||||
import { useConfigContext } from '../../config/provider';
|
import { useConfigContext } from '../../config/provider';
|
||||||
|
|
||||||
export default function ConfigChanger() {
|
export default function ConfigChanger() {
|
||||||
@@ -95,10 +95,4 @@ export default function ConfigChanger() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const useConfigsQuery = () =>
|
const useConfigsQuery = () => api.config.all.useQuery();
|
||||||
useQuery({
|
|
||||||
queryKey: ['config/get-all'],
|
|
||||||
queryFn: fetchConfigs,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchConfigs = async () => (await (await fetch('/api/configs')).json()) as string[];
|
|
||||||
|
|||||||
@@ -4,18 +4,20 @@ import { showNotification } from '@mantine/notifications';
|
|||||||
import { IconCheck as Check, IconPhoto, IconUpload, IconX, IconX as X } from '@tabler/icons-react';
|
import { IconCheck as Check, IconPhoto, IconUpload, IconX, IconX as X } from '@tabler/icons-react';
|
||||||
import { setCookie } from 'cookies-next';
|
import { setCookie } from 'cookies-next';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import { useConfigStore } from '../../config/store';
|
import { useConfigStore } from '../../config/store';
|
||||||
import { ConfigType } from '../../types/config';
|
import { ConfigType } from '../../types/config';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
export const LoadConfigComponent = () => {
|
export const LoadConfigComponent = () => {
|
||||||
const { addConfig } = useConfigStore();
|
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
const { t } = useTranslation('settings/general/config-changer');
|
const { t } = useTranslation('settings/general/config-changer');
|
||||||
|
const { mutateAsync: loadAsync } = useLoadConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropzone.FullScreen
|
<Dropzone.FullScreen
|
||||||
onDrop={async (files) => {
|
onDrop={async (files) => {
|
||||||
const fileName = files[0].name.replaceAll('.json', '');
|
const configName = files[0].name.replaceAll('.json', '');
|
||||||
const fileText = await files[0].text();
|
const fileText = await files[0].text();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -32,26 +34,7 @@ export const LoadConfigComponent = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newConfig: ConfigType = JSON.parse(fileText);
|
const newConfig: ConfigType = JSON.parse(fileText);
|
||||||
|
await loadAsync({ name: configName, config: newConfig });
|
||||||
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',
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
accept={['application/json']}
|
accept={['application/json']}
|
||||||
>
|
>
|
||||||
@@ -89,3 +72,34 @@ export const LoadConfigComponent = () => {
|
|||||||
</Dropzone.FullScreen>
|
</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 { Indicator, Tooltip } from '@mantine/core';
|
||||||
import Consola from 'consola';
|
import Consola from 'consola';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { useConfigContext } from '../../../../config/provider';
|
import { useConfigContext } from '../../../../config/provider';
|
||||||
import { AppType } from '../../../../types/app';
|
import { AppType } from '../../../../types/app';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
interface AppPingProps {
|
interface AppPingProps {
|
||||||
app: AppType;
|
app: AppType;
|
||||||
@@ -16,18 +16,7 @@ export const AppPing = ({ app }: AppPingProps) => {
|
|||||||
const active =
|
const active =
|
||||||
(config?.settings.customization.layout.enabledPing && app.network.enabledStatusChecker) ??
|
(config?.settings.customization.layout.enabledPing && app.network.enabledStatusChecker) ??
|
||||||
false;
|
false;
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading, error } = usePingQuery(app, active);
|
||||||
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 isOnline = data?.state === 'online';
|
const isOnline = data?.state === 'online';
|
||||||
|
|
||||||
@@ -49,7 +38,7 @@ export const AppPing = ({ app }: AppPingProps) => {
|
|||||||
? t('states.loading')
|
? t('states.loading')
|
||||||
: isOnline
|
: isOnline
|
||||||
? t('states.online', { response: data.status })
|
? t('states.online', { response: data.status })
|
||||||
: t('states.offline', { response: data?.status })
|
: t('states.offline', { response: data?.status ?? error?.data?.httpStatus })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Indicator
|
<Indicator
|
||||||
@@ -62,6 +51,24 @@ 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) => {
|
const getIsOk = (app: AppType, status: number) => {
|
||||||
if (app.network.okStatus === undefined || app.network.statusCodes.length >= 1) {
|
if (app.network.okStatus === undefined || app.network.statusCodes.length >= 1) {
|
||||||
Consola.log('Using new status codes');
|
Consola.log('Using new status codes');
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconSearch } from '@tabler/icons-react';
|
import { IconSearch } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { useGetDashboardIcons } from '../../hooks/icons/useGetDashboardIcons';
|
|
||||||
import { humanFileSize } from '../../tools/humanFileSize';
|
import { humanFileSize } from '../../tools/humanFileSize';
|
||||||
import { DebouncedImage } from './DebouncedImage';
|
import { DebouncedImage } from './DebouncedImage';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
export const IconSelector = forwardRef(
|
export const IconSelector = forwardRef(
|
||||||
(
|
(
|
||||||
@@ -175,3 +175,12 @@ interface ItemProps extends SelectItemProps {
|
|||||||
size: number;
|
size: number;
|
||||||
copyright: string | undefined;
|
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 { useDisclosure } from '@mantine/hooks';
|
||||||
import { openConfirmModal } from '@mantine/modals';
|
import { openConfirmModal } from '@mantine/modals';
|
||||||
import { showNotification } from '@mantine/notifications';
|
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 fileDownload from 'js-file-download';
|
||||||
import { Trans, useTranslation } from 'next-i18next';
|
import { Trans, useTranslation } from 'next-i18next';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useConfigContext } from '../../../../config/provider';
|
import { useConfigContext } from '../../../../config/provider';
|
||||||
import { useConfigStore } from '../../../../config/store';
|
import { useConfigStore } from '../../../../config/store';
|
||||||
import { useDeleteConfigMutation } from '../../../../tools/config/mutations/useDeleteConfigMutation';
|
|
||||||
import Tip from '../../../layout/Tip';
|
import Tip from '../../../layout/Tip';
|
||||||
import { CreateConfigCopyModal } from './CreateCopyModal';
|
import { CreateConfigCopyModal } from './CreateCopyModal';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
export default function ConfigActions() {
|
export default function ConfigActions() {
|
||||||
const router = useRouter();
|
|
||||||
const { t } = useTranslation(['settings/general/config-changer', 'settings/common', 'common']);
|
const { t } = useTranslation(['settings/general/config-changer', 'settings/common', 'common']);
|
||||||
const [createCopyModalOpened, createCopyModal] = useDisclosure(false);
|
const [createCopyModalOpened, createCopyModal] = useDisclosure(false);
|
||||||
const { config } = useConfigContext();
|
const { config } = useConfigContext();
|
||||||
const { removeConfig } = useConfigStore();
|
const { mutateAsync } = useDeleteConfigMutation();
|
||||||
const { mutateAsync } = useDeleteConfigMutation(config?.configProperties.name ?? 'default');
|
|
||||||
|
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
|
|
||||||
@@ -61,28 +66,9 @@ export default function ConfigActions() {
|
|||||||
},
|
},
|
||||||
zIndex: 201,
|
zIndex: 201,
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
const response = await mutateAsync();
|
const response = await mutateAsync({
|
||||||
|
name: config?.configProperties.name ?? 'default',
|
||||||
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'),
|
|
||||||
});
|
|
||||||
|
|
||||||
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(() => ({
|
const useStyles = createStyles(() => ({
|
||||||
actionIcon: {
|
actionIcon: {
|
||||||
width: 'auto',
|
width: 'auto',
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Button, Group, Modal, TextInput, Title } from '@mantine/core';
|
import { Button, Group, Modal, TextInput, Title } from '@mantine/core';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
import { IconCheck, IconX } from '@tabler/icons-react';
|
||||||
|
import { showNotification } from '@mantine/notifications';
|
||||||
import { useConfigStore } from '../../../../config/store';
|
import { useConfigStore } from '../../../../config/store';
|
||||||
import { useCopyConfigMutation } from '../../../../tools/config/mutations/useCopyConfigMutation';
|
import { useConfigContext } from '~/config/provider';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
interface CreateConfigCopyModalProps {
|
interface CreateConfigCopyModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
@@ -16,6 +19,7 @@ export const CreateConfigCopyModal = ({
|
|||||||
initialConfigName,
|
initialConfigName,
|
||||||
}: CreateConfigCopyModalProps) => {
|
}: CreateConfigCopyModalProps) => {
|
||||||
const { configs } = useConfigStore();
|
const { configs } = useConfigStore();
|
||||||
|
const { config } = useConfigContext();
|
||||||
const { t } = useTranslation(['settings/general/config-changer']);
|
const { t } = useTranslation(['settings/general/config-changer']);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
@@ -40,7 +44,7 @@ export const CreateConfigCopyModal = ({
|
|||||||
validateInputOnBlur: true,
|
validateInputOnBlur: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync } = useCopyConfigMutation(form.values.configName);
|
const { mutateAsync } = useCopyConfigMutation();
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
form.setFieldValue('configName', initialConfigName);
|
form.setFieldValue('configName', initialConfigName);
|
||||||
@@ -50,7 +54,17 @@ export const CreateConfigCopyModal = ({
|
|||||||
const handleSubmit = async (values: typeof form.values) => {
|
const handleSubmit = async (values: typeof form.values) => {
|
||||||
if (!form.isValid) return;
|
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();
|
closeModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,3 +90,33 @@ export const CreateConfigCopyModal = ({
|
|||||||
</Modal>
|
</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 { useHotkeys, useWindowEvent } from '@mantine/hooks';
|
||||||
import { hideNotification, showNotification } from '@mantine/notifications';
|
import { hideNotification, showNotification } from '@mantine/notifications';
|
||||||
import { IconEditCircle, IconEditCircleOff } from '@tabler/icons-react';
|
import { IconEditCircle, IconEditCircleOff } from '@tabler/icons-react';
|
||||||
import axios from 'axios';
|
|
||||||
import Consola from 'consola';
|
import Consola from 'consola';
|
||||||
import { getCookie } from 'cookies-next';
|
import { getCookie } from 'cookies-next';
|
||||||
import { Trans, useTranslation } from 'next-i18next';
|
import { Trans, useTranslation } from 'next-i18next';
|
||||||
import { useConfigContext } from '../../../../../config/provider';
|
import { useConfigContext } from '../../../../../config/provider';
|
||||||
import { useScreenSmallerThan } from '../../../../../hooks/useScreenSmallerThan';
|
import { useScreenSmallerThan } from '../../../../../hooks/useScreenSmallerThan';
|
||||||
|
|
||||||
|
import { api } from '~/utils/api';
|
||||||
import { useEditModeStore } from '../../../../Dashboard/Views/useEditModeStore';
|
import { useEditModeStore } from '../../../../Dashboard/Views/useEditModeStore';
|
||||||
import { useNamedWrapperColumnCount } from '../../../../Dashboard/Wrappers/gridstack/store';
|
import { useNamedWrapperColumnCount } from '../../../../Dashboard/Wrappers/gridstack/store';
|
||||||
import { useCardStyles } from '../../../useCardStyles';
|
import { useCardStyles } from '../../../useCardStyles';
|
||||||
@@ -28,6 +28,7 @@ export const ToggleEditModeAction = () => {
|
|||||||
const smallerThanSm = useScreenSmallerThan('sm');
|
const smallerThanSm = useScreenSmallerThan('sm');
|
||||||
const { config } = useConfigContext();
|
const { config } = useConfigContext();
|
||||||
const { classes } = useCardStyles(true);
|
const { classes } = useCardStyles(true);
|
||||||
|
const { mutateAsync: saveConfig } = api.config.save.useMutation();
|
||||||
|
|
||||||
useHotkeys([['mod+E', toggleEditMode]]);
|
useHotkeys([['mod+E', toggleEditMode]]);
|
||||||
|
|
||||||
@@ -41,11 +42,12 @@ export const ToggleEditModeAction = () => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleButtonClicked = () => {
|
const toggleButtonClicked = async () => {
|
||||||
toggleEditMode();
|
toggleEditMode();
|
||||||
if (enabled || config === undefined || config?.schemaVersion === undefined) {
|
if (config === undefined || config?.schemaVersion === undefined) return;
|
||||||
|
if (enabled) {
|
||||||
const configName = getCookie('config-name')?.toString() ?? 'default';
|
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);
|
Consola.log('Saved config to server', configName);
|
||||||
hideNotification('toggle-edit-mode');
|
hideNotification('toggle-edit-mode');
|
||||||
} else if (!enabled) {
|
} else if (!enabled) {
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ import {
|
|||||||
import { useDebouncedValue, useHotkeys } from '@mantine/hooks';
|
import { useDebouncedValue, useHotkeys } from '@mantine/hooks';
|
||||||
import { showNotification } from '@mantine/notifications';
|
import { showNotification } from '@mantine/notifications';
|
||||||
import { IconBrandYoutube, IconDownload, IconMovie, IconSearch } from '@tabler/icons-react';
|
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 { useTranslation } from 'next-i18next';
|
||||||
import React, { forwardRef, useEffect, useRef, useState } from 'react';
|
import React, { forwardRef, useEffect, useRef, useState } from 'react';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
import { useConfigContext } from '../../../config/provider';
|
import { useConfigContext } from '../../../config/provider';
|
||||||
import { OverseerrMediaDisplay } from '../../../modules/common';
|
import { OverseerrMediaDisplay } from '../../../modules/common';
|
||||||
import { IModule } from '../../../modules/ModuleTypes';
|
import { IModule } from '../../../modules/ModuleTypes';
|
||||||
@@ -141,26 +140,12 @@ export function Search() {
|
|||||||
const openTarget = getOpenTarget(config);
|
const openTarget = getOpenTarget(config);
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
|
|
||||||
const {
|
const isOverseerrSearchEnabled =
|
||||||
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 &&
|
isOverseerrEnabled === true &&
|
||||||
selectedSearchEngine.value === 'overseerr' &&
|
selectedSearchEngine.value === 'overseerr' &&
|
||||||
debounced.length > 3,
|
debounced.length > 3;
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnMount: false,
|
const { data: overseerrResults } = useOverseerrSearchQuery(debounced, isOverseerrSearchEnabled);
|
||||||
refetchInterval: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const isModuleEnabled = config?.settings.customization.layout.enabledSearchbar;
|
const isModuleEnabled = config?.settings.customization.layout.enabledSearchbar;
|
||||||
if (!isModuleEnabled) {
|
if (!isModuleEnabled) {
|
||||||
@@ -173,7 +158,7 @@ export function Search() {
|
|||||||
<Box style={{ width: '100%', maxWidth: 400 }}>
|
<Box style={{ width: '100%', maxWidth: 400 }}>
|
||||||
<Popover
|
<Popover
|
||||||
opened={
|
opened={
|
||||||
(OverseerrResults && OverseerrResults.length > 0 && opened && searchQuery.length > 3) ??
|
(overseerrResults && overseerrResults.length > 0 && opened && searchQuery.length > 3) ??
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
@@ -224,11 +209,11 @@ export function Search() {
|
|||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<ScrollArea style={{ height: '80vh', maxWidth: '90vw' }} offsetScrollbars>
|
<ScrollArea style={{ height: '80vh', maxWidth: '90vw' }} offsetScrollbars>
|
||||||
{OverseerrResults &&
|
{overseerrResults &&
|
||||||
OverseerrResults.slice(0, 4).map((result: any, index: number) => (
|
overseerrResults.slice(0, 4).map((result: any, index: number) => (
|
||||||
<React.Fragment key={index}>
|
<React.Fragment key={index}>
|
||||||
<OverseerrMediaDisplay key={result.id} media={result} />
|
<OverseerrMediaDisplay key={result.id} media={result} />
|
||||||
{index < OverseerrResults.length - 1 && index < 3 && (
|
{index < overseerrResults.length - 1 && index < 3 && (
|
||||||
<Divider variant="dashed" my="xs" />
|
<Divider variant="dashed" my="xs" />
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
@@ -312,3 +297,19 @@ const getOpenTarget = (config: ConfigType | undefined): '_blank' | '_self' => {
|
|||||||
|
|
||||||
return config.settings.common.searchEngine.properties.openInNewTab ? '_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 { create } from 'zustand';
|
||||||
|
import { trcpProxyClient } from '~/utils/api';
|
||||||
import { ConfigType } from '../types/config';
|
import { ConfigType } from '../types/config';
|
||||||
|
|
||||||
export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
|
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) => ({
|
set((old) => ({
|
||||||
...old,
|
...old,
|
||||||
configs: [
|
configs: [
|
||||||
@@ -21,11 +21,6 @@ export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
|
|||||||
{ value: config, increaseVersion: () => {} },
|
{ value: config, increaseVersion: () => {} },
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (!shouldSaveConfigToFileSystem) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
axios.put(`/api/configs/${name}`, { ...config });
|
|
||||||
},
|
},
|
||||||
removeConfig: (name: string) => {
|
removeConfig: (name: string) => {
|
||||||
set((old) => ({
|
set((old) => ({
|
||||||
@@ -66,7 +61,10 @@ export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (shouldSaveConfigToFileSystem) {
|
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 {
|
interface UseConfigStoreType {
|
||||||
configs: { increaseVersion: () => void; value: ConfigType }[];
|
configs: { increaseVersion: () => void; value: ConfigType }[];
|
||||||
initConfig: (name: string, config: ConfigType, increaseVersion: () => void) => void;
|
initConfig: (name: string, config: ConfigType, increaseVersion: () => void) => void;
|
||||||
addConfig: (
|
addConfig: (name: string, config: ConfigType) => Promise<void>;
|
||||||
name: string,
|
|
||||||
config: ConfigType,
|
|
||||||
shouldSaveConfigToFileSystem: boolean
|
|
||||||
) => Promise<void>;
|
|
||||||
removeConfig: (name: string) => void;
|
removeConfig: (name: string) => void;
|
||||||
updateConfig: (
|
updateConfig: (
|
||||||
name: string,
|
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 { useConfigContext } from '~/config/provider';
|
||||||
import axios from 'axios';
|
import { RouterInputs, api } from '~/utils/api';
|
||||||
import { Results } from 'sabnzbd-api';
|
import { UsenetInfoRequestParams } from '../../../pages/api/modules/usenet';
|
||||||
import type {
|
import type { UsenetHistoryRequestParams } from '../../../pages/api/modules/usenet/history';
|
||||||
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 { UsenetPauseRequestParams } from '../../../pages/api/modules/usenet/pause';
|
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';
|
import { UsenetResumeRequestParams } from '../../../pages/api/modules/usenet/resume';
|
||||||
|
|
||||||
const POLLING_INTERVAL = 2000;
|
const POLLING_INTERVAL = 2000;
|
||||||
|
|
||||||
export const useGetUsenetInfo = (params: UsenetInfoRequestParams) =>
|
export const useGetUsenetInfo = ({ appId }: UsenetInfoRequestParams) => {
|
||||||
useQuery(
|
const { name: configName } = useConfigContext();
|
||||||
['usenetInfo', params.appId],
|
|
||||||
async () =>
|
|
||||||
(
|
|
||||||
await axios.get<UsenetInfoResponse>('/api/modules/usenet', {
|
|
||||||
params,
|
|
||||||
})
|
|
||||||
).data,
|
|
||||||
{
|
|
||||||
refetchInterval: POLLING_INTERVAL,
|
|
||||||
keepPreviousData: true,
|
|
||||||
retry: 2,
|
|
||||||
enabled: Boolean(params.appId),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const useGetUsenetDownloads = (params: UsenetQueueRequestParams) =>
|
return api.usenet.info.useQuery(
|
||||||
useQuery(
|
|
||||||
['usenetDownloads', ...Object.values(params)],
|
|
||||||
async () =>
|
|
||||||
(
|
|
||||||
await axios.get<UsenetQueueResponse>('/api/modules/usenet/queue', {
|
|
||||||
params,
|
|
||||||
})
|
|
||||||
).data,
|
|
||||||
{
|
{
|
||||||
refetchInterval: POLLING_INTERVAL,
|
appId,
|
||||||
keepPreviousData: true,
|
configName: configName!,
|
||||||
retry: 2,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const useGetUsenetHistory = (params: UsenetHistoryRequestParams) =>
|
|
||||||
useQuery(
|
|
||||||
['usenetHistory', ...Object.values(params)],
|
|
||||||
async () =>
|
|
||||||
(
|
|
||||||
await axios.get<UsenetHistoryResponse>('/api/modules/usenet/history', {
|
|
||||||
params,
|
|
||||||
})
|
|
||||||
).data,
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (previousInfo) {
|
|
||||||
queryClient.setQueryData<UsenetInfoResponse>(['usenetInfo', params.appId], {
|
|
||||||
...previousInfo,
|
|
||||||
paused: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { previousInfo };
|
|
||||||
},
|
},
|
||||||
onError(err, _, context) {
|
{
|
||||||
if (context?.previousInfo) {
|
refetchInterval: POLLING_INTERVAL,
|
||||||
queryClient.setQueryData<UsenetInfoResponse>(
|
keepPreviousData: true,
|
||||||
['usenetInfo', params.appId],
|
retry: 2,
|
||||||
context.previousInfo
|
enabled: !!appId,
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const { name: configName } = useConfigContext();
|
||||||
|
return api.usenet.history.useQuery(
|
||||||
|
{
|
||||||
|
configName: configName!,
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchInterval: POLLING_INTERVAL,
|
||||||
|
keepPreviousData: true,
|
||||||
|
retry: 2,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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() {
|
onSettled() {
|
||||||
queryClient.invalidateQueries(['usenetInfo', params.appId]);
|
utils.usenet.info.invalidate({ appId: params.appId });
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const useResumeUsenetQueue = (params: UsenetResumeRequestParams) =>
|
export const useResumeUsenetQueueMutation = (params: UsenetResumeRequestParams) => {
|
||||||
useMutation(
|
const { name: configName } = useConfigContext();
|
||||||
['usenetResume', ...Object.values(params)],
|
const { mutateAsync } = api.usenet.resume.useMutation();
|
||||||
async () =>
|
const utils = api.useContext();
|
||||||
(
|
return async (variables: Omit<RouterInputs['usenet']['resume'], 'configName'>) => {
|
||||||
await axios.post<Results>(
|
await mutateAsync(
|
||||||
'/api/modules/usenet/resume',
|
|
||||||
{},
|
|
||||||
{
|
{
|
||||||
params,
|
configName: configName!,
|
||||||
}
|
...variables,
|
||||||
)
|
},
|
||||||
).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() {
|
onSettled() {
|
||||||
queryClient.invalidateQueries(['usenetInfo', params.appId]);
|
utils.usenet.info.invalidate({ appId: params.appId });
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { NormalizedDownloadQueueResponse } from '../../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
export const useGetDownloadClientsQueue = () =>
|
export const useGetDownloadClientsQueue = () => {
|
||||||
useQuery({
|
const { name: configName } = useConfigContext();
|
||||||
queryKey: ['network-speed'],
|
return api.download.get.useQuery(
|
||||||
queryFn: async (): Promise<NormalizedDownloadQueueResponse> => {
|
{
|
||||||
const response = await fetch('/api/modules/downloads');
|
configName: configName!,
|
||||||
return response.json();
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
refetchInterval: 3000,
|
refetchInterval: 3000,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { MediaServersResponseType } from '../../../types/api/media-server/response';
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
interface GetMediaServersParams {
|
interface GetMediaServersParams {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGetMediaServers = ({ enabled }: GetMediaServersParams) =>
|
export const useGetMediaServers = ({ enabled }: GetMediaServersParams) => {
|
||||||
useQuery({
|
const { name: configName } = useConfigContext();
|
||||||
queryKey: ['media-servers'],
|
|
||||||
queryFn: async (): Promise<MediaServersResponseType> => {
|
return api.mediaServer.all.useQuery(
|
||||||
const response = await fetch('/api/modules/media-server');
|
{
|
||||||
return response.json();
|
configName: configName!,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
enabled,
|
enabled,
|
||||||
refetchInterval: 10 * 1000,
|
refetchInterval: 10 * 1000,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,56 +10,16 @@ import {
|
|||||||
IconRotateClockwise,
|
IconRotateClockwise,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import axios from 'axios';
|
|
||||||
import Dockerode from 'dockerode';
|
import Dockerode from 'dockerode';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { RouterInputs, api } from '~/utils/api';
|
||||||
import { useConfigContext } from '../../config/provider';
|
import { useConfigContext } from '../../config/provider';
|
||||||
import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions';
|
import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions';
|
||||||
import { MatchingImages, ServiceType, tryMatchPort } from '../../tools/types';
|
import { MatchingImages, ServiceType, tryMatchPort } from '../../tools/types';
|
||||||
import { AppType } from '../../types/app';
|
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 {
|
export interface ContainerActionBarProps {
|
||||||
selected: Dockerode.ContainerInfo[];
|
selected: Dockerode.ContainerInfo[];
|
||||||
reload: () => void;
|
reload: () => void;
|
||||||
@@ -68,8 +28,9 @@ export interface ContainerActionBarProps {
|
|||||||
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
|
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
|
||||||
const { t } = useTranslation('modules/docker');
|
const { t } = useTranslation('modules/docker');
|
||||||
const [isLoading, setisLoading] = useState(false);
|
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 getLowestWrapper = () => config?.wrappers.sort((a, b) => a.position - b.position)[0];
|
||||||
|
const sendDockerCommand = useDockerActionMutation();
|
||||||
|
|
||||||
if (process.env.DISABLE_EDIT_MODE === 'true') {
|
if (process.env.DISABLE_EDIT_MODE === 'true') {
|
||||||
return null;
|
return null;
|
||||||
@@ -96,11 +57,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
|||||||
<Button
|
<Button
|
||||||
leftIcon={<IconRotateClockwise />}
|
leftIcon={<IconRotateClockwise />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
Promise.all(
|
Promise.all(selected.map((container) => sendDockerCommand(container, 'restart')))
|
||||||
selected.map((container) =>
|
|
||||||
sendDockerCommand('restart', container.Id, container.Names[0].substring(1), reload, t)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
variant="light"
|
variant="light"
|
||||||
color="orange"
|
color="orange"
|
||||||
@@ -112,11 +69,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
|||||||
<Button
|
<Button
|
||||||
leftIcon={<IconPlayerStop />}
|
leftIcon={<IconPlayerStop />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
Promise.all(
|
Promise.all(selected.map((container) => sendDockerCommand(container, 'stop')))
|
||||||
selected.map((container) =>
|
|
||||||
sendDockerCommand('stop', container.Id, container.Names[0].substring(1), reload, t)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
variant="light"
|
variant="light"
|
||||||
color="red"
|
color="red"
|
||||||
@@ -128,11 +81,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
|||||||
<Button
|
<Button
|
||||||
leftIcon={<IconPlayerPlay />}
|
leftIcon={<IconPlayerPlay />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
Promise.all(
|
Promise.all(selected.map((container) => sendDockerCommand(container, 'start')))
|
||||||
selected.map((container) =>
|
|
||||||
sendDockerCommand('start', container.Id, container.Names[0].substring(1), reload, t)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
variant="light"
|
variant="light"
|
||||||
color="green"
|
color="green"
|
||||||
@@ -147,11 +96,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
|||||||
variant="light"
|
variant="light"
|
||||||
radius="md"
|
radius="md"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
Promise.all(
|
Promise.all(selected.map((container) => sendDockerCommand(container, 'remove')))
|
||||||
selected.map((container) =>
|
|
||||||
sendDockerCommand('remove', container.Id, container.Names[0].substring(1), reload, t)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
disabled={selected.length === 0}
|
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
|
* @deprecated legacy code
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { ActionIcon, Drawer, Tooltip } from '@mantine/core';
|
import { ActionIcon, Drawer, Tooltip } from '@mantine/core';
|
||||||
import { useHotkeys } from '@mantine/hooks';
|
import { useHotkeys } from '@mantine/hooks';
|
||||||
import { IconBrandDocker } from '@tabler/icons-react';
|
import { IconBrandDocker } from '@tabler/icons-react';
|
||||||
import axios from 'axios';
|
|
||||||
import Docker from 'dockerode';
|
import Docker from 'dockerode';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useCardStyles } from '../../components/layout/useCardStyles';
|
import { useCardStyles } from '../../components/layout/useCardStyles';
|
||||||
import { useConfigContext } from '../../config/provider';
|
import { useConfigContext } from '../../config/provider';
|
||||||
|
|
||||||
|
import { api } from '~/utils/api';
|
||||||
import ContainerActionBar from './ContainerActionBar';
|
import ContainerActionBar from './ContainerActionBar';
|
||||||
import DockerTable from './DockerTable';
|
import DockerTable from './DockerTable';
|
||||||
|
|
||||||
@@ -20,22 +19,13 @@ export default function DockerMenuButton(props: any) {
|
|||||||
|
|
||||||
const dockerEnabled = config?.settings.customization.layout.enabledDocker || false;
|
const dockerEnabled = config?.settings.customization.layout.enabledDocker || false;
|
||||||
|
|
||||||
const { data, isLoading, refetch } = useQuery({
|
const { data, refetch } = api.docker.containers.useQuery(undefined, {
|
||||||
queryKey: ['containers'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const containers = await axios.get('/api/docker/containers');
|
|
||||||
return containers.data;
|
|
||||||
},
|
|
||||||
enabled: dockerEnabled,
|
enabled: dockerEnabled,
|
||||||
});
|
});
|
||||||
useHotkeys([['mod+B', () => setOpened(!opened)]]);
|
useHotkeys([['mod+B', () => setOpened(!opened)]]);
|
||||||
|
|
||||||
const { t } = useTranslation('modules/docker');
|
const { t } = useTranslation('modules/docker');
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refetch();
|
|
||||||
}, [config?.settings]);
|
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
refetch();
|
refetch();
|
||||||
setSelection([]);
|
setSelection([]);
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export * from './ping';
|
|
||||||
export * from './overseerr';
|
export * from './overseerr';
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Alert, Button, Checkbox, createStyles, Group, Modal, Stack, Table } from '@mantine/core';
|
import { Alert, Button, Checkbox, createStyles, Group, Modal, Stack, Table } from '@mantine/core';
|
||||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||||
import { IconAlertCircle, IconCheck, IconDownload } from '@tabler/icons-react';
|
import { IconAlertCircle, IconCheck, IconDownload } from '@tabler/icons-react';
|
||||||
import axios from 'axios';
|
|
||||||
import Consola from 'consola';
|
import Consola from 'consola';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useConfigContext } from '~/config/provider';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
import { useColorTheme } from '../../tools/color';
|
import { useColorTheme } from '../../tools/color';
|
||||||
import { MovieResult } from './Movie.d';
|
import { MovieResult } from './Movie.d';
|
||||||
import { MediaType, Result } from './SearchResult.d';
|
import { Result } from './SearchResult.d';
|
||||||
import { TvShowResult, TvShowResultSeason } from './TvShow.d';
|
import { TvShowResult, TvShowResultSeason } from './TvShow.d';
|
||||||
|
|
||||||
interface RequestModalProps {
|
interface RequestModalProps {
|
||||||
@@ -27,20 +28,22 @@ const useStyles = createStyles((theme) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export function RequestModal({ base, opened, setOpened }: RequestModalProps) {
|
export function RequestModal({ base, opened, setOpened }: RequestModalProps) {
|
||||||
const [result, setResult] = useState<MovieResult | TvShowResult>();
|
const { name: configName } = useConfigContext();
|
||||||
const { secondaryColor } = useColorTheme();
|
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) {
|
if (!result || !opened) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return base.mediaType === 'movie' ? (
|
return base.mediaType === 'movie' ? (
|
||||||
<MovieRequestModal result={result as MovieResult} opened={opened} setOpened={setOpened} />
|
<MovieRequestModal result={result as MovieResult} opened={opened} setOpened={setOpened} />
|
||||||
) : (
|
) : (
|
||||||
@@ -58,6 +61,7 @@ export function MovieRequestModal({
|
|||||||
setOpened: (opened: boolean) => void;
|
setOpened: (opened: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { secondaryColor } = useColorTheme();
|
const { secondaryColor } = useColorTheme();
|
||||||
|
const requestMediaAsync = useMediaRequestMutation();
|
||||||
const { t } = useTranslation('modules/overseerr');
|
const { t } = useTranslation('modules/overseerr');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,7 +97,7 @@ export function MovieRequestModal({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
askForMedia(MediaType.Movie, result.id, result.title, []);
|
requestMediaAsync('movie', result.id, result.title);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('popup.item.buttons.request')}
|
{t('popup.item.buttons.request')}
|
||||||
@@ -116,6 +120,7 @@ export function TvRequestModal({
|
|||||||
const [selection, setSelection] = useState<TvShowResultSeason[]>(result.seasons);
|
const [selection, setSelection] = useState<TvShowResultSeason[]>(result.seasons);
|
||||||
const { classes, cx } = useStyles();
|
const { classes, cx } = useStyles();
|
||||||
const { t } = useTranslation('modules/overseerr');
|
const { t } = useTranslation('modules/overseerr');
|
||||||
|
const requestMediaAsync = useMediaRequestMutation();
|
||||||
|
|
||||||
const toggleRow = (container: TvShowResultSeason) =>
|
const toggleRow = (container: TvShowResultSeason) =>
|
||||||
setSelection((current: TvShowResultSeason[]) =>
|
setSelection((current: TvShowResultSeason[]) =>
|
||||||
@@ -196,8 +201,8 @@ export function TvRequestModal({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={selection.length === 0}
|
disabled={selection.length === 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
askForMedia(
|
requestMediaAsync(
|
||||||
MediaType.Tv,
|
'tv',
|
||||||
result.id,
|
result.id,
|
||||||
result.name,
|
result.name,
|
||||||
selection.map((s) => s.seasonNumber)
|
selection.map((s) => s.seasonNumber)
|
||||||
@@ -212,7 +217,11 @@ export function TvRequestModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function askForMedia(type: MediaType, id: number, name: string, seasons?: number[]) {
|
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}`);
|
Consola.info(`Requesting ${type} ${id} ${name}`);
|
||||||
showNotification({
|
showNotification({
|
||||||
title: 'Request',
|
title: 'Request',
|
||||||
@@ -224,9 +233,16 @@ function askForMedia(type: MediaType, id: number, name: string, seasons?: number
|
|||||||
withCloseButton: false,
|
withCloseButton: false,
|
||||||
icon: <IconAlertCircle />,
|
icon: <IconAlertCircle />,
|
||||||
});
|
});
|
||||||
axios
|
|
||||||
.post(`/api/modules/overseerr/${id}`, { type, seasons })
|
await mutateAsync(
|
||||||
.then(() => {
|
{
|
||||||
|
configName: configName!,
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
seasons: seasons ?? [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
updateNotification({
|
updateNotification({
|
||||||
id: id.toString(),
|
id: id.toString(),
|
||||||
title: '',
|
title: '',
|
||||||
@@ -235,8 +251,8 @@ function askForMedia(type: MediaType, id: number, name: string, seasons?: number
|
|||||||
icon: <IconCheck />,
|
icon: <IconCheck />,
|
||||||
autoClose: 2000,
|
autoClose: 2000,
|
||||||
});
|
});
|
||||||
})
|
},
|
||||||
.catch((err) => {
|
onError: (err) => {
|
||||||
updateNotification({
|
updateNotification({
|
||||||
id: id.toString(),
|
id: id.toString(),
|
||||||
color: 'red',
|
color: 'red',
|
||||||
@@ -244,5 +260,8 @@ function askForMedia(type: MediaType, id: number, name: string, seasons?: number
|
|||||||
message: err.message,
|
message: err.message,
|
||||||
autoClose: 2000,
|
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;
|
mediaUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MediaType {
|
export type MediaType = 'movie' | 'tv';
|
||||||
Movie = 'movie',
|
|
||||||
Tv = 'tv',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum OriginalLanguage {
|
export enum OriginalLanguage {
|
||||||
En = 'en',
|
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 { useEditModeInformationStore } from '../hooks/useEditModeInformation';
|
||||||
import '../styles/global.scss';
|
import '../styles/global.scss';
|
||||||
|
import nextI18nextConfig from '../../next-i18next.config';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
function App(
|
function App(
|
||||||
this: any,
|
this: any,
|
||||||
props: AppProps & {
|
props: AppProps<{
|
||||||
colorScheme: ColorScheme;
|
colorScheme: ColorScheme;
|
||||||
packageAttributes: ServerSidePackageAttributesType;
|
packageAttributes: ServerSidePackageAttributesType;
|
||||||
editModeEnabled: boolean;
|
editModeEnabled: boolean;
|
||||||
defaultColorScheme: ColorScheme;
|
defaultColorScheme: ColorScheme;
|
||||||
}
|
}>
|
||||||
) {
|
) {
|
||||||
const { Component, pageProps } = props;
|
const { Component, pageProps } = props;
|
||||||
const [primaryColor, setPrimaryColor] = useState<MantineTheme['primaryColor']>('red');
|
const [primaryColor, setPrimaryColor] = useState<MantineTheme['primaryColor']>('red');
|
||||||
@@ -58,7 +60,7 @@ function App(
|
|||||||
|
|
||||||
// hook will return either 'dark' or 'light' on client
|
// hook will return either 'dark' or 'light' on client
|
||||||
// and always 'light' during ssr as window.matchMedia is not available
|
// 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>({
|
const [colorScheme, setColorScheme] = useLocalStorage<ColorScheme>({
|
||||||
key: 'mantine-color-scheme',
|
key: 'mantine-color-scheme',
|
||||||
defaultValue: preferredColorScheme,
|
defaultValue: preferredColorScheme,
|
||||||
@@ -69,9 +71,9 @@ function App(
|
|||||||
const { setDisabled } = useEditModeInformationStore();
|
const { setDisabled } = useEditModeInformationStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInitialPackageAttributes(props.packageAttributes);
|
setInitialPackageAttributes(props.pageProps.packageAttributes);
|
||||||
|
|
||||||
if (!props.editModeEnabled) {
|
if (!props.pageProps.editModeEnabled) {
|
||||||
setDisabled();
|
setDisabled();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -161,11 +163,13 @@ App.getInitialProps = ({ ctx }: { ctx: GetServerSidePropsContext }) => {
|
|||||||
const colorScheme: ColorScheme = (process.env.DEFAULT_COLOR_SCHEME as ColorScheme) ?? 'light';
|
const colorScheme: ColorScheme = (process.env.DEFAULT_COLOR_SCHEME as ColorScheme) ?? 'light';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
pageProps: {
|
||||||
colorScheme: getCookie('color-scheme', ctx) || 'light',
|
colorScheme: getCookie('color-scheme', ctx) || 'light',
|
||||||
packageAttributes: getServiceSidePackageAttributes(),
|
packageAttributes: getServiceSidePackageAttributes(),
|
||||||
editModeEnabled: !disableEditMode,
|
editModeEnabled: !disableEditMode,
|
||||||
defaultColorScheme: colorScheme,
|
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 { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { AppIntegrationType } from '../../../types/app';
|
import { AppIntegrationType, IntegrationType } from '../../../types/app';
|
||||||
import { getConfig } from '../../../tools/config/getConfig';
|
import { getConfig } from '../../../tools/config/getConfig';
|
||||||
|
import { checkIntegrationsType } from '~/tools/client/app-properties';
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
// Filter out if the reuqest is a POST or a GET
|
// 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 calendar = config.widgets.find((w) => w.type === 'calendar' && w.id === widgetId);
|
||||||
const useSonarrv4 = calendar?.properties.useSonarrv4 ?? false;
|
const useSonarrv4 = calendar?.properties.useSonarrv4 ?? false;
|
||||||
|
|
||||||
const mediaAppIntegrationTypes: AppIntegrationType['type'][] = [
|
const mediaAppIntegrationTypes = [
|
||||||
'sonarr',
|
'sonarr',
|
||||||
'radarr',
|
'radarr',
|
||||||
'readarr',
|
'readarr',
|
||||||
'lidarr',
|
'lidarr',
|
||||||
];
|
] as const satisfies readonly IntegrationType[];
|
||||||
const mediaApps = config.apps.filter(
|
const mediaApps = config.apps.filter((app) =>
|
||||||
(app) => app.integration && mediaAppIntegrationTypes.includes(app.integration.type)
|
checkIntegrationsType(app.integration, mediaAppIntegrationTypes)
|
||||||
);
|
);
|
||||||
|
|
||||||
const IntegrationTypeEndpointMap = new Map<AppIntegrationType['type'], string>([
|
const IntegrationTypeEndpointMap = new Map<AppIntegrationType['type'], string>([
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import { NextApiRequest, NextApiResponse } from 'next';
|
|||||||
|
|
||||||
import { Client } from 'sabnzbd-api';
|
import { Client } from 'sabnzbd-api';
|
||||||
|
|
||||||
import { NzbgetClient } from '../usenet/nzbget/nzbget-client';
|
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
|
||||||
import { NzbgetQueueItem, NzbgetStatus } from '../usenet/nzbget/types';
|
import { NzbgetQueueItem, NzbgetStatus } from '../../../../server/api/routers/usenet/nzbget/types';
|
||||||
import { ConfigAppType, IntegrationField } from '../../../../types/app';
|
import { ConfigAppType, IntegrationField } from '../../../../types/app';
|
||||||
import { getConfig } from '../../../../tools/config/getConfig';
|
import { getConfig } from '../../../../tools/config/getConfig';
|
||||||
import { UsenetQueueItem } from '../../../../widgets/useNet/types';
|
import { UsenetQueueItem } from '../../../../widgets/useNet/types';
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
NormalizedDownloadAppStat,
|
NormalizedDownloadAppStat,
|
||||||
NormalizedDownloadQueueResponse,
|
NormalizedDownloadQueueResponse,
|
||||||
} from '../../../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
} from '../../../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
||||||
|
import { findAppProperty } from '~/tools/client/app-properties';
|
||||||
|
|
||||||
const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
||||||
const configName = getCookie('config-name', { req: request });
|
const configName = getCookie('config-name', { req: request });
|
||||||
@@ -151,8 +152,8 @@ const GetDataFromClient = async (
|
|||||||
const options = {
|
const options = {
|
||||||
host: url.hostname,
|
host: url.hostname,
|
||||||
port: url.port,
|
port: url.port,
|
||||||
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
|
login: findAppProperty(app, 'username'),
|
||||||
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
|
hash: findAppProperty(app, 'password'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const nzbGet = NzbgetClient(options);
|
const nzbGet = NzbgetClient(options);
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { getConfig } from '../../../../tools/config/getConfig';
|
|||||||
|
|
||||||
import { MediaRequest } from '../../../../widgets/media-requests/media-request-types';
|
import { MediaRequest } from '../../../../widgets/media-requests/media-request-types';
|
||||||
import { MediaRequestListWidget } from '../../../../widgets/media-requests/MediaRequestListTile';
|
import { MediaRequestListWidget } from '../../../../widgets/media-requests/MediaRequestListTile';
|
||||||
|
import { checkIntegrationsType } from '~/tools/client/app-properties';
|
||||||
|
|
||||||
const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
||||||
const configName = getCookie('config-name', { req: request });
|
const configName = getCookie('config-name', { req: request });
|
||||||
const config = getConfig(configName?.toString() ?? 'default');
|
const config = getConfig(configName?.toString() ?? 'default');
|
||||||
|
|
||||||
const apps = config.apps.filter((app) =>
|
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`);
|
Consola.log(`Retrieving media requests from ${apps.length} apps`);
|
||||||
@@ -24,8 +25,9 @@ const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
|||||||
})
|
})
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
const body = (await response.json()) as OverseerrResponse;
|
const body = (await response.json()) as OverseerrResponse;
|
||||||
const mediaWidget = config.widgets.find(
|
const mediaWidget = config.widgets.find((x) => x.type === 'media-requests-list') as
|
||||||
(x) => x.type === 'media-requests-list') as MediaRequestListWidget | undefined;
|
| MediaRequestListWidget
|
||||||
|
| undefined;
|
||||||
if (!mediaWidget) {
|
if (!mediaWidget) {
|
||||||
Consola.log('No media-requests-list found');
|
Consola.log('No media-requests-list found');
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
GenericSessionInfo,
|
GenericSessionInfo,
|
||||||
} from '../../../../types/api/media-server/session-info';
|
} from '../../../../types/api/media-server/session-info';
|
||||||
import { PlexClient } from '../../../../tools/server/sdk/plex/plexClient';
|
import { PlexClient } from '../../../../tools/server/sdk/plex/plexClient';
|
||||||
|
import { checkIntegrationsType, findAppProperty } from '~/tools/client/app-properties';
|
||||||
|
|
||||||
const jellyfin = new Jellyfin({
|
const jellyfin = new Jellyfin({
|
||||||
clientInfo: {
|
clientInfo: {
|
||||||
@@ -35,7 +36,7 @@ const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
|||||||
const config = getConfig(configName?.toString() ?? 'default');
|
const config = getConfig(configName?.toString() ?? 'default');
|
||||||
|
|
||||||
const apps = config.apps.filter((app) =>
|
const apps = config.apps.filter((app) =>
|
||||||
['jellyfin', 'plex'].includes(app.integration?.type ?? '')
|
checkIntegrationsType(app.integration, ['jellyfin', 'plex'])
|
||||||
);
|
);
|
||||||
|
|
||||||
const servers = await Promise.all(
|
const servers = await Promise.all(
|
||||||
@@ -66,9 +67,9 @@ const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
|||||||
const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | undefined> => {
|
const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | undefined> => {
|
||||||
switch (app.integration?.type) {
|
switch (app.integration?.type) {
|
||||||
case 'jellyfin': {
|
case 'jellyfin': {
|
||||||
const username = app.integration.properties.find((x) => x.field === 'username');
|
const username = findAppProperty(app, 'username');
|
||||||
|
|
||||||
if (!username || !username.value) {
|
if (!username) {
|
||||||
return {
|
return {
|
||||||
appId: app.id,
|
appId: app.id,
|
||||||
serverAddress: app.url,
|
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 {
|
return {
|
||||||
appId: app.id,
|
appId: app.id,
|
||||||
serverAddress: app.url,
|
serverAddress: app.url,
|
||||||
@@ -94,7 +95,7 @@ const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | un
|
|||||||
|
|
||||||
const api = jellyfin.createApi(app.url);
|
const api = jellyfin.createApi(app.url);
|
||||||
const infoApi = await getSystemApi(api).getPublicSystemInfo();
|
const infoApi = await getSystemApi(api).getPublicSystemInfo();
|
||||||
await api.authenticateUserByName(username.value, password.value);
|
await api.authenticateUserByName(username, password);
|
||||||
const sessionApi = await getSessionApi(api);
|
const sessionApi = await getSessionApi(api);
|
||||||
const sessions = await sessionApi.getSessions();
|
const sessions = await sessionApi.getSessions();
|
||||||
return {
|
return {
|
||||||
@@ -166,9 +167,9 @@ const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | un
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'plex': {
|
case 'plex': {
|
||||||
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey');
|
const apiKey = findAppProperty(app, 'apiKey');
|
||||||
|
|
||||||
if (!apiKey || !apiKey.value) {
|
if (!apiKey) {
|
||||||
return {
|
return {
|
||||||
serverAddress: app.url,
|
serverAddress: app.url,
|
||||||
sessions: [],
|
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();
|
const sessions = await plexClient.getSessions();
|
||||||
return {
|
return {
|
||||||
serverAddress: app.url,
|
serverAddress: app.url,
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import dayjs from 'dayjs';
|
|||||||
import duration from 'dayjs/plugin/duration';
|
import duration from 'dayjs/plugin/duration';
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
import { Client } from 'sabnzbd-api';
|
import { Client } from 'sabnzbd-api';
|
||||||
import { NzbgetHistoryItem } from './nzbget/types';
|
import { NzbgetHistoryItem } from '../../../../server/api/routers/usenet/nzbget/types';
|
||||||
import { NzbgetClient } from './nzbget/nzbget-client';
|
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
|
||||||
import { getConfig } from '../../../../tools/config/getConfig';
|
import { getConfig } from '../../../../tools/config/getConfig';
|
||||||
import { UsenetHistoryItem } from '../../../../widgets/useNet/types';
|
import { UsenetHistoryItem } from '../../../../widgets/useNet/types';
|
||||||
|
import { findAppProperty } from '~/tools/client/app-properties';
|
||||||
|
|
||||||
dayjs.extend(duration);
|
dayjs.extend(duration);
|
||||||
|
|
||||||
@@ -40,8 +41,8 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const options = {
|
const options = {
|
||||||
host: url.hostname,
|
host: url.hostname,
|
||||||
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
||||||
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
|
login: findAppProperty(app, 'username'),
|
||||||
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
|
hash: findAppProperty(app, 'password'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const nzbGet = NzbgetClient(options);
|
const nzbGet = NzbgetClient(options);
|
||||||
@@ -77,7 +78,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
case 'sabnzbd': {
|
case 'sabnzbd': {
|
||||||
const { origin } = new URL(app.url);
|
const { origin } = new URL(app.url);
|
||||||
|
|
||||||
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
|
const apiKey = findAppProperty(app, 'apiKey');
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error(`API Key for app "${app.name}" is missing`);
|
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 { NextApiRequest, NextApiResponse } from 'next';
|
||||||
import { Client } from 'sabnzbd-api';
|
import { Client } from 'sabnzbd-api';
|
||||||
import { getConfig } from '../../../../tools/config/getConfig';
|
import { getConfig } from '../../../../tools/config/getConfig';
|
||||||
import { NzbgetClient } from './nzbget/nzbget-client';
|
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
|
||||||
import { NzbgetStatus } from './nzbget/types';
|
import { NzbgetStatus } from '../../../../server/api/routers/usenet/nzbget/types';
|
||||||
|
import { findAppProperty } from '~/tools/client/app-properties';
|
||||||
|
|
||||||
dayjs.extend(duration);
|
dayjs.extend(duration);
|
||||||
|
|
||||||
@@ -39,8 +40,8 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const options = {
|
const options = {
|
||||||
host: url.hostname,
|
host: url.hostname,
|
||||||
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
||||||
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
|
login: findAppProperty(app, 'username'),
|
||||||
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
|
hash: findAppProperty(app, 'password'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const nzbGet = NzbgetClient(options);
|
const nzbGet = NzbgetClient(options);
|
||||||
@@ -70,7 +71,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'sabnzbd': {
|
case 'sabnzbd': {
|
||||||
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
|
const apiKey = findAppProperty(app, 'apiKey');
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error(`API Key for app "${app.name}" is missing`);
|
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 { NextApiRequest, NextApiResponse } from 'next';
|
||||||
import { Client } from 'sabnzbd-api';
|
import { Client } from 'sabnzbd-api';
|
||||||
import { getConfig } from '../../../../tools/config/getConfig';
|
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);
|
dayjs.extend(duration);
|
||||||
|
|
||||||
@@ -31,8 +32,8 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const options = {
|
const options = {
|
||||||
host: url.hostname,
|
host: url.hostname,
|
||||||
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
||||||
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
|
login: findAppProperty(app, 'username'),
|
||||||
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
|
hash: findAppProperty(app, 'password'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const nzbGet = NzbgetClient(options);
|
const nzbGet = NzbgetClient(options);
|
||||||
@@ -49,7 +50,7 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'sabnzbd': {
|
case 'sabnzbd': {
|
||||||
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
|
const apiKey = findAppProperty(app, 'apiKey');
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error(`API Key for app "${app.name}" is missing`);
|
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 { Client } from 'sabnzbd-api';
|
||||||
import { getConfig } from '../../../../tools/config/getConfig';
|
import { getConfig } from '../../../../tools/config/getConfig';
|
||||||
import { UsenetQueueItem } from '../../../../widgets/useNet/types';
|
import { UsenetQueueItem } from '../../../../widgets/useNet/types';
|
||||||
import { NzbgetClient } from './nzbget/nzbget-client';
|
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
|
||||||
import { NzbgetQueueItem, NzbgetStatus } from './nzbget/types';
|
import { NzbgetQueueItem, NzbgetStatus } from '../../../../server/api/routers/usenet/nzbget/types';
|
||||||
|
import { findAppProperty } from '~/tools/client/app-properties';
|
||||||
|
|
||||||
dayjs.extend(duration);
|
dayjs.extend(duration);
|
||||||
|
|
||||||
@@ -40,8 +41,8 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const options = {
|
const options = {
|
||||||
host: url.hostname,
|
host: url.hostname,
|
||||||
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
||||||
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
|
login: findAppProperty(app, 'username'),
|
||||||
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
|
hash: findAppProperty(app, 'password'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const nzbGet = NzbgetClient(options);
|
const nzbGet = NzbgetClient(options);
|
||||||
@@ -91,7 +92,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'sabnzbd': {
|
case 'sabnzbd': {
|
||||||
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
|
const apiKey = findAppProperty(app, 'apiKey');
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error(`API Key for app "${app.name}" is missing`);
|
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 { NextApiRequest, NextApiResponse } from 'next';
|
||||||
import { Client } from 'sabnzbd-api';
|
import { Client } from 'sabnzbd-api';
|
||||||
import { getConfig } from '../../../../tools/config/getConfig';
|
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);
|
dayjs.extend(duration);
|
||||||
|
|
||||||
@@ -32,8 +33,8 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const options = {
|
const options = {
|
||||||
host: url.hostname,
|
host: url.hostname,
|
||||||
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
||||||
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
|
login: findAppProperty(app, 'username'),
|
||||||
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
|
hash: findAppProperty(app, 'password'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const nzbGet = NzbgetClient(options);
|
const nzbGet = NzbgetClient(options);
|
||||||
@@ -50,7 +51,7 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'sabnzbd': {
|
case 'sabnzbd': {
|
||||||
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
|
const apiKey = findAppProperty(app, 'apiKey');
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error(`API Key for app "${app.name}" is missing`);
|
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) =>
|
export const findAppProperty = (app: ConfigAppType, key: IntegrationField) =>
|
||||||
app.integration?.properties.find((prop) => prop.field === key)?.value ?? '';
|
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 { useMantineTheme } from '@mantine/core';
|
||||||
import { Calendar } from '@mantine/dates';
|
import { Calendar } from '@mantine/dates';
|
||||||
import { IconCalendarTime } from '@tabler/icons-react';
|
import { IconCalendarTime } from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { i18n } from 'next-i18next';
|
import { i18n } from 'next-i18next';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore';
|
||||||
import { useConfigContext } from '../../config/provider';
|
import { useConfigContext } from '../../config/provider';
|
||||||
import { defineWidget } from '../helper';
|
import { defineWidget } from '../helper';
|
||||||
import { IWidget } from '../widgets';
|
import { IWidget } from '../widgets';
|
||||||
import { CalendarDay } from './CalendarDay';
|
import { CalendarDay } from './CalendarDay';
|
||||||
import { getBgColorByDateAndTheme } from './bg-calculator';
|
import { getBgColorByDateAndTheme } from './bg-calculator';
|
||||||
import { MediasType } from './type';
|
import { MediasType } from './type';
|
||||||
import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore';
|
|
||||||
|
|
||||||
const definition = defineWidget({
|
const definition = defineWidget({
|
||||||
id: 'calendar',
|
id: 'calendar',
|
||||||
@@ -55,22 +55,18 @@ function CalendarTile({ widget }: CalendarTileProps) {
|
|||||||
const [month, setMonth] = useState(new Date());
|
const [month, setMonth] = useState(new Date());
|
||||||
const isEditMode = useEditModeStore((x) => x.enabled);
|
const isEditMode = useEditModeStore((x) => x.enabled);
|
||||||
|
|
||||||
const { data: medias } = useQuery({
|
const { data: medias } = api.calendar.medias.useQuery(
|
||||||
queryKey: [
|
{
|
||||||
'calendar/medias',
|
configName: configName!,
|
||||||
{ month: month.getMonth(), year: month.getFullYear(), v4: widget.properties.useSonarrv4 },
|
month: month.getMonth() + 1,
|
||||||
],
|
year: month.getFullYear(),
|
||||||
|
options: { useSonarrv4: widget.properties.useSonarrv4 },
|
||||||
|
},
|
||||||
|
{
|
||||||
staleTime: 1000 * 60 * 60 * 5,
|
staleTime: 1000 * 60 * 60 * 5,
|
||||||
enabled: isEditMode === false,
|
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Calendar
|
<Calendar
|
||||||
|
|||||||
@@ -2,21 +2,13 @@ import { Group, Stack, Text } from '@mantine/core';
|
|||||||
import { IconArrowNarrowDown, IconArrowNarrowUp } from '@tabler/icons-react';
|
import { IconArrowNarrowDown, IconArrowNarrowUp } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { bytes } from '../../tools/bytesHelper';
|
import { bytes } from '../../tools/bytesHelper';
|
||||||
|
import { RouterOutputs } from '~/utils/api';
|
||||||
|
|
||||||
interface DashDotCompactNetworkProps {
|
interface DashDotCompactNetworkProps {
|
||||||
info: DashDotInfo;
|
info: DashDotInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashDotInfo {
|
export type DashDotInfo = RouterOutputs['dashDot']['info'];
|
||||||
storage: {
|
|
||||||
size: number;
|
|
||||||
disks: { device: string; brand: string; type: string }[];
|
|
||||||
}[];
|
|
||||||
network: {
|
|
||||||
speedUp: number;
|
|
||||||
speedDown: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DashDotCompactNetwork = ({ info }: DashDotCompactNetworkProps) => {
|
export const DashDotCompactNetwork = ({ info }: DashDotCompactNetworkProps) => {
|
||||||
const { t } = useTranslation('modules/dashdot');
|
const { t } = useTranslation('modules/dashdot');
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { Group, Stack, Text } from '@mantine/core';
|
import { Group, Stack, Text } from '@mantine/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { useConfigContext } from '../../config/provider';
|
import { api } from '~/utils/api';
|
||||||
import { bytes } from '../../tools/bytesHelper';
|
import { bytes } from '../../tools/bytesHelper';
|
||||||
import { percentage } from '../../tools/shared/math/percentage.tool';
|
import { percentage } from '../../tools/shared/math/percentage.tool';
|
||||||
import { DashDotInfo } from './DashDotCompactNetwork';
|
import { DashDotInfo } from './DashDotCompactNetwork';
|
||||||
|
|
||||||
interface DashDotCompactStorageProps {
|
interface DashDotCompactStorageProps {
|
||||||
info: DashDotInfo;
|
info: DashDotInfo;
|
||||||
widgetId: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DashDotCompactStorage = ({ info, widgetId }: DashDotCompactStorageProps) => {
|
export const DashDotCompactStorage = ({ info, url }: DashDotCompactStorageProps) => {
|
||||||
const { t } = useTranslation('modules/dashdot');
|
const { t } = useTranslation('modules/dashdot');
|
||||||
const { data: storageLoad } = useDashDotStorage(widgetId);
|
const { data: storageLoad } = useDashDotStorage(url);
|
||||||
|
|
||||||
const totalUsed = calculateTotalLayoutSize({
|
const totalUsed = calculateTotalLayoutSize({
|
||||||
layout: storageLoad ?? [],
|
layout: storageLoad ?? [],
|
||||||
@@ -55,25 +53,7 @@ interface CalculateTotalLayoutSizeProps<TLayoutItem> {
|
|||||||
key?: keyof TLayoutItem;
|
key?: keyof TLayoutItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useDashDotStorage = (widgetId: string) => {
|
const useDashDotStorage = (url: string) =>
|
||||||
const { name: configName, config } = useConfigContext();
|
api.dashDot.storage.useQuery({
|
||||||
|
url,
|
||||||
return useQuery({
|
|
||||||
queryKey: [
|
|
||||||
'dashdot/storage',
|
|
||||||
{
|
|
||||||
configName,
|
|
||||||
url: config?.widgets.find((x) => x.type === 'dashdot')?.properties.url,
|
|
||||||
widgetId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
queryFn: () => fetchDashDotStorageLoad(configName, widgetId),
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
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 { useTranslation } from 'next-i18next';
|
||||||
import { DashDotCompactNetwork, DashDotInfo } from './DashDotCompactNetwork';
|
import { DashDotCompactNetwork, DashDotInfo } from './DashDotCompactNetwork';
|
||||||
import { DashDotCompactStorage } from './DashDotCompactStorage';
|
import { DashDotCompactStorage } from './DashDotCompactStorage';
|
||||||
@@ -11,7 +11,6 @@ interface DashDotGraphProps {
|
|||||||
dashDotUrl: string;
|
dashDotUrl: string;
|
||||||
usePercentages: boolean;
|
usePercentages: boolean;
|
||||||
info: DashDotInfo;
|
info: DashDotInfo;
|
||||||
widgetId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DashDotGraph = ({
|
export const DashDotGraph = ({
|
||||||
@@ -22,13 +21,12 @@ export const DashDotGraph = ({
|
|||||||
dashDotUrl,
|
dashDotUrl,
|
||||||
usePercentages,
|
usePercentages,
|
||||||
info,
|
info,
|
||||||
widgetId,
|
|
||||||
}: DashDotGraphProps) => {
|
}: DashDotGraphProps) => {
|
||||||
const { t } = useTranslation('modules/dashdot');
|
const { t } = useTranslation('modules/dashdot');
|
||||||
const { classes } = useStyles();
|
const { classes } = useStyles();
|
||||||
|
|
||||||
if (graph === 'storage' && isCompact) {
|
if (graph === 'storage' && isCompact) {
|
||||||
return <DashDotCompactStorage info={info} widgetId={widgetId} />;
|
return <DashDotCompactStorage info={info} url={dashDotUrl} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (graph === 'network' && isCompact) {
|
if (graph === 'network' && isCompact) {
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { Center, createStyles, Grid, Stack, Text, Title } from '@mantine/core';
|
import { Center, createStyles, Grid, Stack, Text, Title } from '@mantine/core';
|
||||||
import { IconUnlink } from '@tabler/icons-react';
|
import { IconUnlink } from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { useConfigContext } from '../../config/provider';
|
import { api } from '~/utils/api';
|
||||||
import { defineWidget } from '../helper';
|
import { defineWidget } from '../helper';
|
||||||
import { IWidget } from '../widgets';
|
import { IWidget } from '../widgets';
|
||||||
import { DashDotInfo } from './DashDotCompactNetwork';
|
|
||||||
import { DashDotGraph } from './DashDotGraph';
|
import { DashDotGraph } from './DashDotGraph';
|
||||||
|
|
||||||
const definition = defineWidget({
|
const definition = defineWidget({
|
||||||
@@ -161,10 +158,9 @@ function DashDotTile({ widget }: DashDotTileProps) {
|
|||||||
const detectedProtocolDowngrade =
|
const detectedProtocolDowngrade =
|
||||||
locationProtocol === 'https:' && dashDotUrl.toLowerCase().startsWith('http:');
|
locationProtocol === 'https:' && dashDotUrl.toLowerCase().startsWith('http:');
|
||||||
|
|
||||||
const { data: info } = useDashDotInfo({
|
const { data: info } = useDashDotInfoQuery({
|
||||||
dashDotUrl,
|
dashDotUrl,
|
||||||
enabled: !detectedProtocolDowngrade,
|
enabled: !detectedProtocolDowngrade,
|
||||||
widgetId: widget.id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (detectedProtocolDowngrade) {
|
if (detectedProtocolDowngrade) {
|
||||||
@@ -202,7 +198,6 @@ function DashDotTile({ widget }: DashDotTileProps) {
|
|||||||
isCompact={g.subValues.compactView ?? false}
|
isCompact={g.subValues.compactView ?? false}
|
||||||
multiView={g.subValues.multiView ?? false}
|
multiView={g.subValues.multiView ?? false}
|
||||||
usePercentages={usePercentages}
|
usePercentages={usePercentages}
|
||||||
widgetId={widget.id}
|
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
))}
|
))}
|
||||||
@@ -212,37 +207,16 @@ function DashDotTile({ widget }: DashDotTileProps) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const useDashDotInfoQuery = ({ dashDotUrl, enabled }: { dashDotUrl: string; enabled: boolean }) =>
|
||||||
const useDashDotInfo = ({
|
api.dashDot.info.useQuery(
|
||||||
dashDotUrl,
|
|
||||||
enabled,
|
|
||||||
widgetId,
|
|
||||||
}: {
|
|
||||||
dashDotUrl: string;
|
|
||||||
enabled: boolean;
|
|
||||||
widgetId: string;
|
|
||||||
}) => {
|
|
||||||
const { name: configName } = useConfigContext();
|
|
||||||
return useQuery({
|
|
||||||
refetchInterval: 50000,
|
|
||||||
queryKey: [
|
|
||||||
'dashdot/info',
|
|
||||||
{
|
{
|
||||||
configName,
|
url: dashDotUrl,
|
||||||
dashDotUrl,
|
|
||||||
},
|
},
|
||||||
],
|
{
|
||||||
queryFn: () => fetchDashDotInfo(configName, widgetId),
|
refetchInterval: 50000,
|
||||||
enabled,
|
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDashDotTileStyles = createStyles((theme) => ({
|
export const useDashDotTileStyles = createStyles((theme) => ({
|
||||||
graphsContainer: {
|
graphsContainer: {
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { useConfigContext } from '../../config/provider';
|
|||||||
import { defineWidget } from '../helper';
|
import { defineWidget } from '../helper';
|
||||||
import { WidgetLoading } from '../loading';
|
import { WidgetLoading } from '../loading';
|
||||||
import { IWidget } from '../widgets';
|
import { IWidget } from '../widgets';
|
||||||
import { useDnsHoleControlMutation, useDnsHoleSummeryQuery } from './query';
|
|
||||||
import { PiholeApiSummaryType } from './type';
|
import { PiholeApiSummaryType } from './type';
|
||||||
import { queryClient } from '../../tools/server/configurations/tanstack/queryClient.tool';
|
import { queryClient } from '../../tools/server/configurations/tanstack/queryClient.tool';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
import { useDnsHoleSummeryQuery } from './DnsHoleSummary';
|
||||||
|
|
||||||
const definition = defineWidget({
|
const definition = defineWidget({
|
||||||
id: 'dns-hole-controls',
|
id: 'dns-hole-controls',
|
||||||
@@ -29,13 +30,13 @@ interface DnsHoleControlsWidgetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
|
function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
|
||||||
const { isInitialLoading, data, refetch } = useDnsHoleSummeryQuery();
|
const { isInitialLoading, data } = useDnsHoleSummeryQuery();
|
||||||
const { mutateAsync } = useDnsHoleControlMutation();
|
const { mutateAsync } = useDnsHoleControlMutation();
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
|
|
||||||
const { config } = useConfigContext();
|
const { name: configName, config } = useConfigContext();
|
||||||
|
|
||||||
if (isInitialLoading || !data) {
|
if (isInitialLoading || !data || !configName) {
|
||||||
return <WidgetLoading />;
|
return <WidgetLoading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +45,10 @@ function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
|
|||||||
<Group grow>
|
<Group grow>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutateAsync('enabled');
|
await mutateAsync({
|
||||||
|
status: 'enabled',
|
||||||
|
configName,
|
||||||
|
});
|
||||||
await queryClient.invalidateQueries({ queryKey: ['dns-hole-summary'] });
|
await queryClient.invalidateQueries({ queryKey: ['dns-hole-summary'] });
|
||||||
}}
|
}}
|
||||||
leftIcon={<IconPlayerPlay size={20} />}
|
leftIcon={<IconPlayerPlay size={20} />}
|
||||||
@@ -55,7 +59,10 @@ function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutateAsync('disabled');
|
await mutateAsync({
|
||||||
|
status: 'disabled',
|
||||||
|
configName,
|
||||||
|
});
|
||||||
await queryClient.invalidateQueries({ queryKey: ['dns-hole-summary'] });
|
await queryClient.invalidateQueries({ queryKey: ['dns-hole-summary'] });
|
||||||
}}
|
}}
|
||||||
leftIcon={<IconPlayerStop size={20} />}
|
leftIcon={<IconPlayerStop size={20} />}
|
||||||
@@ -117,4 +124,6 @@ const StatusBadge = ({ status }: { status: PiholeApiSummaryType['status'] }) =>
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const useDnsHoleControlMutation = () => api.dnsHole.control.useMutation();
|
||||||
|
|
||||||
export default definition;
|
export default definition;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useTranslation } from 'next-i18next';
|
|
||||||
import { Card, Center, Container, Stack, Text } from '@mantine/core';
|
import { Card, Center, Container, Stack, Text } from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconAd,
|
IconAd,
|
||||||
@@ -7,11 +6,13 @@ import {
|
|||||||
IconSearch,
|
IconSearch,
|
||||||
IconWorldWww,
|
IconWorldWww,
|
||||||
} from '@tabler/icons-react';
|
} 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 { defineWidget } from '../helper';
|
||||||
import { WidgetLoading } from '../loading';
|
import { WidgetLoading } from '../loading';
|
||||||
import { IWidget } from '../widgets';
|
import { IWidget } from '../widgets';
|
||||||
import { formatNumber } from '../../tools/client/math';
|
|
||||||
import { useDnsHoleSummeryQuery } from './query';
|
|
||||||
|
|
||||||
const definition = defineWidget({
|
const definition = defineWidget({
|
||||||
id: 'dns-hole-summary',
|
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;
|
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,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} 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 { 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 { defineWidget } from '../helper';
|
||||||
import { WidgetLoading } from '../loading';
|
import { WidgetLoading } from '../loading';
|
||||||
import { IWidget } from '../widgets';
|
import { IWidget } from '../widgets';
|
||||||
import { useMediaRequestQuery } from './media-request-query';
|
import { useMediaRequestQuery } from './media-request-query';
|
||||||
import { MediaRequest, MediaRequestStatus } from './media-request-types';
|
import { MediaRequest, MediaRequestStatus } from './media-request-types';
|
||||||
|
import { useConfigContext } from '~/config/provider';
|
||||||
|
|
||||||
const definition = defineWidget({
|
const definition = defineWidget({
|
||||||
id: 'media-requests-list',
|
id: 'media-requests-list',
|
||||||
@@ -45,21 +45,55 @@ interface MediaRequestListWidgetProps {
|
|||||||
widget: MediaRequestListWidget;
|
widget: MediaRequestListWidget;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
|
type MediaRequestDecisionVariables = {
|
||||||
const { t } = useTranslation('modules/media-requests-list');
|
request: MediaRequest;
|
||||||
const { data, refetch, isLoading } = useMediaRequestQuery();
|
isApproved: boolean;
|
||||||
// Use mutation to approve or deny a pending request
|
};
|
||||||
const mutate = useMutation({
|
const useMediaRequestDecisionMutation = () => {
|
||||||
mutationFn: async (e: { request: MediaRequest; action: string }) => {
|
const { name: configName } = useConfigContext();
|
||||||
const data = await axios.put(`/api/modules/overseerr/${e.request.id}?action=${e.action}`);
|
const utils = api.useContext();
|
||||||
notifications.show({
|
const { mutateAsync } = api.overseerr.decide.useMutation({
|
||||||
title: t('requestUpdated'),
|
onSuccess() {
|
||||||
message: t('requestUpdatedMessage', { title: e.request.name }),
|
utils.mediaRequest.all.invalidate();
|
||||||
color: 'blue',
|
|
||||||
});
|
|
||||||
refetch();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
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) {
|
if (!data || isLoading) {
|
||||||
return <WidgetLoading />;
|
return <WidgetLoading />;
|
||||||
@@ -157,18 +191,10 @@ function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
|
|||||||
loading: true,
|
loading: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await mutate.mutateAsync({ request: item, action: 'approve' }).then(() =>
|
await decideAsync({
|
||||||
notifications.update({
|
request: item,
|
||||||
id: `approve ${item.id}`,
|
isApproved: true,
|
||||||
color: 'teal',
|
});
|
||||||
title: 'Request was approved!',
|
|
||||||
message: undefined,
|
|
||||||
icon: <IconCheck size="1rem" />,
|
|
||||||
autoClose: 2000,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await refetch();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconThumbUp />
|
<IconThumbUp />
|
||||||
@@ -179,8 +205,10 @@ function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
|
|||||||
variant="light"
|
variant="light"
|
||||||
color="red"
|
color="red"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutate.mutateAsync({ request: item, action: 'decline' });
|
await decideAsync({
|
||||||
await refetch();
|
request: item,
|
||||||
|
isApproved: false,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconThumbDown />
|
<IconThumbDown />
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { MediaRequest } from './media-request-types';
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
export const useMediaRequestQuery = () => useQuery({
|
export const useMediaRequestQuery = () => {
|
||||||
queryKey: ['media-requests'],
|
const { name: configName } = useConfigContext();
|
||||||
queryFn: async () => {
|
return api.mediaRequest.all.useQuery(
|
||||||
const response = await fetch('/api/modules/media-requests');
|
{ configName: configName! },
|
||||||
return (await response.json()) as MediaRequest[];
|
{
|
||||||
},
|
|
||||||
refetchInterval: 3 * 60 * 1000,
|
refetchInterval: 3 * 60 * 1000,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ import {
|
|||||||
createStyles,
|
createStyles,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconClock, IconRefresh, IconRss } from '@tabler/icons-react';
|
import { IconClock, IconRefresh, IconRss } from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { useConfigContext } from '~/config/provider';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
import { defineWidget } from '../helper';
|
import { defineWidget } from '../helper';
|
||||||
import { IWidget } from '../widgets';
|
import { IWidget } from '../widgets';
|
||||||
|
|
||||||
@@ -65,27 +66,11 @@ interface RssTileProps {
|
|||||||
widget: IRssWidget;
|
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) {
|
function RssTile({ widget }: RssTileProps) {
|
||||||
const { t } = useTranslation('modules/rss');
|
const { t } = useTranslation('modules/rss');
|
||||||
|
const { name: configName } = useConfigContext();
|
||||||
const { data, isLoading, isFetching, isError, refetch } = useGetRssFeeds(
|
const { data, isLoading, isFetching, isError, refetch } = useGetRssFeeds(
|
||||||
|
configName,
|
||||||
widget.properties.rssFeedUrl,
|
widget.properties.rssFeedUrl,
|
||||||
widget.properties.refreshInterval,
|
widget.properties.refreshInterval,
|
||||||
widget.id
|
widget.id
|
||||||
@@ -122,6 +107,7 @@ function RssTile({ widget }: RssTileProps) {
|
|||||||
<Title order={6}>{t('descriptor.card.errors.general.title')}</Title>
|
<Title order={6}>{t('descriptor.card.errors.general.title')}</Title>
|
||||||
<Text align="center">{t('descriptor.card.errors.general.text')}</Text>
|
<Text align="center">{t('descriptor.card.errors.general.text')}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<RefetchButton refetch={refetch} isFetching={isFetching} />
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -192,6 +178,37 @@ function RssTile({ widget }: RssTileProps) {
|
|||||||
))}
|
))}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
|
<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
|
<ActionIcon
|
||||||
size="sm"
|
size="sm"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
@@ -207,9 +224,7 @@ function RssTile({ widget }: RssTileProps) {
|
|||||||
>
|
>
|
||||||
{isFetching ? <Loader /> : <IconRefresh />}
|
{isFetching ? <Loader /> : <IconRefresh />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Stack>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const InfoDisplay = ({ date, title }: { date: string; title: string | undefined }) => (
|
const InfoDisplay = ({ date, title }: { date: string; title: string | undefined }) => (
|
||||||
<Group mt="auto" spacing="xs">
|
<Group mt="auto" spacing="xs">
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { useConfigContext } from '../../config/provider';
|
|||||||
import { MIN_WIDTH_MOBILE } from '../../constants/constants';
|
import { MIN_WIDTH_MOBILE } from '../../constants/constants';
|
||||||
import {
|
import {
|
||||||
useGetUsenetInfo,
|
useGetUsenetInfo,
|
||||||
usePauseUsenetQueue,
|
usePauseUsenetQueueMutation,
|
||||||
useResumeUsenetQueue,
|
useResumeUsenetQueueMutation,
|
||||||
} from '../../hooks/widgets/dashDot/api';
|
} from '../../hooks/widgets/dashDot/api';
|
||||||
import { humanFileSize } from '../../tools/humanFileSize';
|
import { humanFileSize } from '../../tools/humanFileSize';
|
||||||
import { AppIntegrationType } from '../../types/app';
|
import { AppIntegrationType } from '../../types/app';
|
||||||
@@ -60,8 +60,8 @@ function UseNetTile({ widget }: UseNetTileProps) {
|
|||||||
}
|
}
|
||||||
}, [downloadApps, selectedAppId]);
|
}, [downloadApps, selectedAppId]);
|
||||||
|
|
||||||
const { mutate: pause } = usePauseUsenetQueue({ appId: selectedAppId! });
|
const pauseAsync = usePauseUsenetQueueMutation({ appId: selectedAppId! });
|
||||||
const { mutate: resume } = useResumeUsenetQueue({ appId: selectedAppId! });
|
const resumeAsync = useResumeUsenetQueueMutation({ appId: selectedAppId! });
|
||||||
|
|
||||||
if (downloadApps.length === 0) {
|
if (downloadApps.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -107,11 +107,25 @@ function UseNetTile({ widget }: UseNetTileProps) {
|
|||||||
<Tabs.Panel value="queue">
|
<Tabs.Panel value="queue">
|
||||||
<UsenetQueueList appId={selectedAppId} />
|
<UsenetQueueList appId={selectedAppId} />
|
||||||
{!data ? null : data.paused ? (
|
{!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')}
|
<IconPlayerPlay size={12} style={{ marginRight: 5 }} /> {t('info.paused')}
|
||||||
</Button>
|
</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 }} />{' '}
|
<IconPlayerPause size={12} style={{ marginRight: 5 }} />{' '}
|
||||||
{dayjs.duration(data.eta, 's').format('HH:mm')}
|
{dayjs.duration(data.eta, 's').format('HH:mm')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useElementSize } from '@mantine/hooks';
|
import { useElementSize } from '@mantine/hooks';
|
||||||
import { IconAlertCircle } from '@tabler/icons-react';
|
import { IconAlertCircle } from '@tabler/icons-react';
|
||||||
import { AxiosError } from 'axios';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import duration from 'dayjs/plugin/duration';
|
import duration from 'dayjs/plugin/duration';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { FunctionComponent, useState } from 'react';
|
import { FunctionComponent, useState } from 'react';
|
||||||
import { useGetUsenetHistory } from '../../hooks/widgets/dashDot/api';
|
import { useGetUsenetHistory } from '../../hooks/widgets/dashDot/api';
|
||||||
import { humanFileSize } from '../../tools/humanFileSize';
|
|
||||||
import { parseDuration } from '../../tools/client/parseDuration';
|
import { parseDuration } from '../../tools/client/parseDuration';
|
||||||
|
import { humanFileSize } from '../../tools/humanFileSize';
|
||||||
|
|
||||||
dayjs.extend(duration);
|
dayjs.extend(duration);
|
||||||
|
|
||||||
@@ -65,7 +64,7 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ a
|
|||||||
>
|
>
|
||||||
{t('modules/usenet:history.error.message')}
|
{t('modules/usenet:history.error.message')}
|
||||||
<Code mt="sm" block>
|
<Code mt="sm" block>
|
||||||
{(error as AxiosError)?.response?.data as string}
|
{error.message}
|
||||||
</Code>
|
</Code>
|
||||||
</Alert>
|
</Alert>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useElementSize } from '@mantine/hooks';
|
import { useElementSize } from '@mantine/hooks';
|
||||||
import { IconAlertCircle, IconPlayerPause, IconPlayerPlay } from '@tabler/icons-react';
|
import { IconAlertCircle, IconPlayerPause, IconPlayerPlay } from '@tabler/icons-react';
|
||||||
import { AxiosError } from 'axios';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import duration from 'dayjs/plugin/duration';
|
import duration from 'dayjs/plugin/duration';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
@@ -70,7 +69,7 @@ export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ appId
|
|||||||
>
|
>
|
||||||
{t('queue.error.message')}
|
{t('queue.error.message')}
|
||||||
<Code mt="sm" block>
|
<Code mt="sm" block>
|
||||||
{(error as AxiosError)?.response?.data as string}
|
{error.data}
|
||||||
</Code>
|
</Code>
|
||||||
</Alert>
|
</Alert>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -22,7 +22,10 @@
|
|||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["./src/*"]
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
import { configDefaults, defineConfig } from 'vitest/config';
|
import { configDefaults, defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), tsconfigPaths()],
|
||||||
test: {
|
test: {
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
coverage: {
|
coverage: {
|
||||||
@@ -14,9 +15,6 @@ export default defineConfig({
|
|||||||
exclude: ['.next/', '.yarn/', 'data/'],
|
exclude: ['.next/', '.yarn/', 'data/'],
|
||||||
},
|
},
|
||||||
setupFiles: ['./tests/setupVitest.ts'],
|
setupFiles: ['./tests/setupVitest.ts'],
|
||||||
exclude: [
|
exclude: [...configDefaults.exclude, '.next'],
|
||||||
...configDefaults.exclude,
|
|
||||||
'.next',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
97
yarn.lock
97
yarn.lock
@@ -2011,6 +2011,52 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@tsconfig/node10@npm:^1.0.7":
|
||||||
version: 1.0.9
|
version: 1.0.9
|
||||||
resolution: "@tsconfig/node10@npm:1.0.9"
|
resolution: "@tsconfig/node10@npm:1.0.9"
|
||||||
@@ -5617,6 +5663,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"gopd@npm:^1.0.1":
|
||||||
version: 1.0.1
|
version: 1.0.1
|
||||||
resolution: "gopd@npm:1.0.1"
|
resolution: "gopd@npm:1.0.1"
|
||||||
@@ -5828,6 +5881,10 @@ __metadata:
|
|||||||
"@tanstack/react-query-persist-client": ^4.28.0
|
"@tanstack/react-query-persist-client": ^4.28.0
|
||||||
"@testing-library/jest-dom": ^5.16.5
|
"@testing-library/jest-dom": ^5.16.5
|
||||||
"@testing-library/react": ^14.0.0
|
"@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/dockerode": ^3.3.9
|
||||||
"@types/node": 18.16.17
|
"@types/node": 18.16.17
|
||||||
"@types/prismjs": ^1.26.0
|
"@types/prismjs": ^1.26.0
|
||||||
@@ -5880,6 +5937,7 @@ __metadata:
|
|||||||
typescript: ^5.0.4
|
typescript: ^5.0.4
|
||||||
uuid: ^9.0.0
|
uuid: ^9.0.0
|
||||||
video.js: ^8.0.3
|
video.js: ^8.0.3
|
||||||
|
vite-tsconfig-paths: ^4.2.0
|
||||||
vitest: ^0.32.0
|
vitest: ^0.32.0
|
||||||
vitest-fetch-mock: ^0.2.2
|
vitest-fetch-mock: ^0.2.2
|
||||||
xml-js: ^1.6.11
|
xml-js: ^1.6.11
|
||||||
@@ -8414,6 +8472,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"react-style-singleton@npm:^2.2.1":
|
||||||
version: 2.2.1
|
version: 2.2.1
|
||||||
resolution: "react-style-singleton@npm:2.2.1"
|
resolution: "react-style-singleton@npm:2.2.1"
|
||||||
@@ -9507,6 +9574,20 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"tsconfig-paths@npm:^3.14.1":
|
||||||
version: 3.14.2
|
version: 3.14.2
|
||||||
resolution: "tsconfig-paths@npm:3.14.2"
|
resolution: "tsconfig-paths@npm:3.14.2"
|
||||||
@@ -9981,6 +10062,22 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"vite@npm:^3.0.0 || ^4.0.0":
|
||||||
version: 4.3.9
|
version: 4.3.9
|
||||||
resolution: "vite@npm:4.3.9"
|
resolution: "vite@npm:4.3.9"
|
||||||
|
|||||||
Reference in New Issue
Block a user