Merge pull request #1032 from ajnart/trpc

🏗️ Migrate api endpoints to tRPC
This commit is contained in:
Meier Lukas
2023-06-10 21:06:56 +02:00
committed by GitHub
72 changed files with 3142 additions and 868 deletions

View File

@@ -48,6 +48,10 @@
"@tanstack/react-query": "^4.2.1",
"@tanstack/react-query-devtools": "^4.24.4",
"@tanstack/react-query-persist-client": "^4.28.0",
"@trpc/client": "^10.29.1",
"@trpc/next": "^10.29.1",
"@trpc/react-query": "^10.29.1",
"@trpc/server": "^10.29.1",
"@vitejs/plugin-react": "^4.0.0",
"axios": "^1.0.0",
"consola": "^3.0.0",
@@ -111,6 +115,7 @@
"turbo": "latest",
"typescript": "^5.0.4",
"video.js": "^8.0.3",
"vite-tsconfig-paths": "^4.2.0",
"vitest": "^0.32.0",
"vitest-fetch-mock": "^0.2.2"
},

View File

@@ -1,12 +1,12 @@
import { Center, Dialog, Loader, Notification, Select, Tooltip } from '@mantine/core';
import { useToggle } from '@mantine/hooks';
import { useQuery } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import { setCookie } from 'cookies-next';
import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import { api } from '~/utils/api';
import { useConfigContext } from '../../config/provider';
export default function ConfigChanger() {
@@ -95,10 +95,4 @@ export default function ConfigChanger() {
);
}
const useConfigsQuery = () =>
useQuery({
queryKey: ['config/get-all'],
queryFn: fetchConfigs,
});
const fetchConfigs = async () => (await (await fetch('/api/configs')).json()) as string[];
const useConfigsQuery = () => api.config.all.useQuery();

View File

@@ -4,18 +4,20 @@ import { showNotification } from '@mantine/notifications';
import { IconCheck as Check, IconPhoto, IconUpload, IconX, IconX as X } from '@tabler/icons-react';
import { setCookie } from 'cookies-next';
import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router';
import { useConfigStore } from '../../config/store';
import { ConfigType } from '../../types/config';
import { api } from '~/utils/api';
export const LoadConfigComponent = () => {
const { addConfig } = useConfigStore();
const theme = useMantineTheme();
const { t } = useTranslation('settings/general/config-changer');
const { mutateAsync: loadAsync } = useLoadConfig();
return (
<Dropzone.FullScreen
onDrop={async (files) => {
const fileName = files[0].name.replaceAll('.json', '');
const configName = files[0].name.replaceAll('.json', '');
const fileText = await files[0].text();
try {
@@ -32,26 +34,7 @@ export const LoadConfigComponent = () => {
}
const newConfig: ConfigType = JSON.parse(fileText);
await addConfig(fileName, newConfig, true);
showNotification({
autoClose: 5000,
radius: 'md',
title: (
<Text>
{t('dropzone.notifications.loadedSuccessfully.title', {
configName: fileName,
})}
</Text>
),
color: 'green',
icon: <Check />,
message: undefined,
});
setCookie('config-name', fileName, {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'strict',
});
await loadAsync({ name: configName, config: newConfig });
}}
accept={['application/json']}
>
@@ -89,3 +72,34 @@ export const LoadConfigComponent = () => {
</Dropzone.FullScreen>
);
};
const useLoadConfig = () => {
const { t } = useTranslation('settings/general/config-changer');
const { addConfig } = useConfigStore();
const router = useRouter();
return api.config.save.useMutation({
async onSuccess(_data, variables) {
await addConfig(variables.name, variables.config);
showNotification({
autoClose: 5000,
radius: 'md',
title: (
<Text>
{t('dropzone.notifications.loadedSuccessfully.title', {
configName: variables.name,
})}
</Text>
),
color: 'green',
icon: <Check />,
message: undefined,
});
setCookie('config-name', variables.name, {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'strict',
});
router.push(`/${variables.name}`);
},
});
};

View File

@@ -1,10 +1,10 @@
import { Indicator, Tooltip } from '@mantine/core';
import Consola from 'consola';
import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../../config/provider';
import { AppType } from '../../../../types/app';
import { api } from '~/utils/api';
interface AppPingProps {
app: AppType;
@@ -16,18 +16,7 @@ export const AppPing = ({ app }: AppPingProps) => {
const active =
(config?.settings.customization.layout.enabledPing && app.network.enabledStatusChecker) ??
false;
const { data, isLoading } = useQuery({
queryKey: ['ping', { id: app.id, name: app.name }],
queryFn: async () => {
const response = await fetch(`/api/modules/ping?url=${encodeURI(app.url)}`);
const isOk = getIsOk(app, response.status);
return {
status: response.status,
state: isOk ? 'online' : 'down',
};
},
enabled: active,
});
const { data, isLoading, error } = usePingQuery(app, active);
const isOnline = data?.state === 'online';
@@ -49,7 +38,7 @@ export const AppPing = ({ app }: AppPingProps) => {
? t('states.loading')
: isOnline
? t('states.online', { response: data.status })
: t('states.offline', { response: data?.status })
: t('states.offline', { response: data?.status ?? error?.data?.httpStatus })
}
>
<Indicator
@@ -62,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) => {
if (app.network.okStatus === undefined || app.network.statusCodes.length >= 1) {
Consola.log('Using new status codes');

View File

@@ -15,9 +15,9 @@ import {
} from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { useGetDashboardIcons } from '../../hooks/icons/useGetDashboardIcons';
import { humanFileSize } from '../../tools/humanFileSize';
import { DebouncedImage } from './DebouncedImage';
import { api } from '~/utils/api';
export const IconSelector = forwardRef(
(
@@ -175,3 +175,12 @@ interface ItemProps extends SelectItemProps {
size: number;
copyright: string | undefined;
}
const useGetDashboardIcons = () =>
api.icon.all.useQuery(undefined, {
refetchOnMount: false,
// Cache for infinity, refetch every so often.
cacheTime: Infinity,
staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false,
});

View File

@@ -10,23 +10,28 @@ import {
import { useDisclosure } from '@mantine/hooks';
import { openConfirmModal } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { IconAlertTriangle, IconCheck, IconCopy, IconDownload, IconTrash } from '@tabler/icons-react';
import {
IconAlertTriangle,
IconCheck,
IconCopy,
IconDownload,
IconTrash,
IconX,
} from '@tabler/icons-react';
import fileDownload from 'js-file-download';
import { Trans, useTranslation } from 'next-i18next';
import { useRouter } from 'next/router';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { useDeleteConfigMutation } from '../../../../tools/config/mutations/useDeleteConfigMutation';
import Tip from '../../../layout/Tip';
import { CreateConfigCopyModal } from './CreateCopyModal';
import { api } from '~/utils/api';
export default function ConfigActions() {
const router = useRouter();
const { t } = useTranslation(['settings/general/config-changer', 'settings/common', 'common']);
const [createCopyModalOpened, createCopyModal] = useDisclosure(false);
const { config } = useConfigContext();
const { removeConfig } = useConfigStore();
const { mutateAsync } = useDeleteConfigMutation(config?.configProperties.name ?? 'default');
const { mutateAsync } = useDeleteConfigMutation();
if (!config) return null;
@@ -61,28 +66,9 @@ export default function ConfigActions() {
},
zIndex: 201,
onConfirm: async () => {
const response = await mutateAsync();
if (response.error) {
showNotification({
title: t('buttons.delete.notifications.deleteFailedDefaultConfig.title'),
message: t('buttons.delete.notifications.deleteFailedDefaultConfig.message'),
const response = await mutateAsync({
name: config?.configProperties.name ?? 'default',
});
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(() => ({
actionIcon: {
width: 'auto',

View File

@@ -1,8 +1,11 @@
import { Button, Group, Modal, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useTranslation } from 'next-i18next';
import { IconCheck, IconX } from '@tabler/icons-react';
import { showNotification } from '@mantine/notifications';
import { useConfigStore } from '../../../../config/store';
import { useCopyConfigMutation } from '../../../../tools/config/mutations/useCopyConfigMutation';
import { useConfigContext } from '~/config/provider';
import { api } from '~/utils/api';
interface CreateConfigCopyModalProps {
opened: boolean;
@@ -16,6 +19,7 @@ export const CreateConfigCopyModal = ({
initialConfigName,
}: CreateConfigCopyModalProps) => {
const { configs } = useConfigStore();
const { config } = useConfigContext();
const { t } = useTranslation(['settings/general/config-changer']);
const form = useForm({
@@ -40,7 +44,7 @@ export const CreateConfigCopyModal = ({
validateInputOnBlur: true,
});
const { mutateAsync } = useCopyConfigMutation(form.values.configName);
const { mutateAsync } = useCopyConfigMutation();
const handleClose = () => {
form.setFieldValue('configName', initialConfigName);
@@ -50,7 +54,17 @@ export const CreateConfigCopyModal = ({
const handleSubmit = async (values: typeof form.values) => {
if (!form.isValid) return;
await mutateAsync();
if (!config) {
throw new Error('config is not defiend');
}
const copiedConfig = config;
copiedConfig.configProperties.name = form.values.configName;
await mutateAsync({
name: form.values.configName,
config: copiedConfig,
});
closeModal();
};
@@ -76,3 +90,33 @@ export const CreateConfigCopyModal = ({
</Modal>
);
};
const useCopyConfigMutation = () => {
const { t } = useTranslation(['settings/general/config-changer']);
const utils = api.useContext();
return api.config.save.useMutation({
onSuccess(_data, variables) {
showNotification({
title: t('modal.copy.events.configCopied.title'),
icon: <IconCheck />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: t('modal.copy.events.configCopied.message', { configName: variables.name }),
});
// Invalidate a query to fetch new config
utils.config.all.invalidate();
},
onError(_error, variables) {
showNotification({
title: t('modal.events.configNotCopied.title'),
icon: <IconX />,
color: 'red',
autoClose: 1500,
radius: 'md',
message: t('modal.events.configNotCopied.message', { configName: variables.name }),
});
},
});
};

View File

@@ -2,13 +2,13 @@ import { ActionIcon, Button, Group, Text, Title, Tooltip } from '@mantine/core';
import { useHotkeys, useWindowEvent } from '@mantine/hooks';
import { hideNotification, showNotification } from '@mantine/notifications';
import { IconEditCircle, IconEditCircleOff } from '@tabler/icons-react';
import axios from 'axios';
import Consola from 'consola';
import { getCookie } from 'cookies-next';
import { Trans, useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../../../config/provider';
import { useScreenSmallerThan } from '../../../../../hooks/useScreenSmallerThan';
import { api } from '~/utils/api';
import { useEditModeStore } from '../../../../Dashboard/Views/useEditModeStore';
import { useNamedWrapperColumnCount } from '../../../../Dashboard/Wrappers/gridstack/store';
import { useCardStyles } from '../../../useCardStyles';
@@ -28,6 +28,7 @@ export const ToggleEditModeAction = () => {
const smallerThanSm = useScreenSmallerThan('sm');
const { config } = useConfigContext();
const { classes } = useCardStyles(true);
const { mutateAsync: saveConfig } = api.config.save.useMutation();
useHotkeys([['mod+E', toggleEditMode]]);
@@ -41,11 +42,12 @@ export const ToggleEditModeAction = () => {
return undefined;
});
const toggleButtonClicked = () => {
const toggleButtonClicked = async () => {
toggleEditMode();
if (enabled || config === undefined || config?.schemaVersion === undefined) {
if (config === undefined || config?.schemaVersion === undefined) return;
if (enabled) {
const configName = getCookie('config-name')?.toString() ?? 'default';
axios.put(`/api/configs/${configName}`, { ...config });
await saveConfig({ name: configName, config });
Consola.log('Saved config to server', configName);
hideNotification('toggle-edit-mode');
} else if (!enabled) {

View File

@@ -13,10 +13,9 @@ import {
import { useDebouncedValue, useHotkeys } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { IconBrandYoutube, IconDownload, IconMovie, IconSearch } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { useTranslation } from 'next-i18next';
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import { api } from '~/utils/api';
import { useConfigContext } from '../../../config/provider';
import { OverseerrMediaDisplay } from '../../../modules/common';
import { IModule } from '../../../modules/ModuleTypes';
@@ -141,26 +140,12 @@ export function Search() {
const openTarget = getOpenTarget(config);
const [opened, setOpened] = useState(false);
const {
data: OverseerrResults,
isLoading,
error,
} = useQuery(
['overseerr', debounced],
async () => {
const res = await axios.get(`/api/modules/overseerr?query=${debounced}`);
return res.data.results ?? [];
},
{
enabled:
const isOverseerrSearchEnabled =
isOverseerrEnabled === true &&
selectedSearchEngine.value === 'overseerr' &&
debounced.length > 3,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchInterval: false,
}
);
debounced.length > 3;
const { data: overseerrResults } = useOverseerrSearchQuery(debounced, isOverseerrSearchEnabled);
const isModuleEnabled = config?.settings.customization.layout.enabledSearchbar;
if (!isModuleEnabled) {
@@ -173,7 +158,7 @@ export function Search() {
<Box style={{ width: '100%', maxWidth: 400 }}>
<Popover
opened={
(OverseerrResults && OverseerrResults.length > 0 && opened && searchQuery.length > 3) ??
(overseerrResults && overseerrResults.length > 0 && opened && searchQuery.length > 3) ??
false
}
position="bottom"
@@ -224,11 +209,11 @@ export function Search() {
</Popover.Target>
<Popover.Dropdown>
<ScrollArea style={{ height: '80vh', maxWidth: '90vw' }} offsetScrollbars>
{OverseerrResults &&
OverseerrResults.slice(0, 4).map((result: any, index: number) => (
{overseerrResults &&
overseerrResults.slice(0, 4).map((result: any, index: number) => (
<React.Fragment key={index}>
<OverseerrMediaDisplay key={result.id} media={result} />
{index < OverseerrResults.length - 1 && index < 3 && (
{index < overseerrResults.length - 1 && index < 3 && (
<Divider variant="dashed" my="xs" />
)}
</React.Fragment>
@@ -312,3 +297,19 @@ const getOpenTarget = (config: ConfigType | undefined): '_blank' | '_self' => {
return config.settings.common.searchEngine.properties.openInNewTab ? '_blank' : '_self';
};
const useOverseerrSearchQuery = (query: string, isEnabled: boolean) => {
const { name: configName } = useConfigContext();
return api.overseerr.all.useQuery(
{
query,
configName: configName!,
},
{
enabled: isEnabled,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchInterval: false,
}
);
};

View File

@@ -1,5 +1,5 @@
import axios from 'axios';
import { create } from 'zustand';
import { trcpProxyClient } from '~/utils/api';
import { ConfigType } from '../types/config';
export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
@@ -13,7 +13,7 @@ export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
],
}));
},
addConfig: async (name: string, config: ConfigType, shouldSaveConfigToFileSystem = true) => {
addConfig: async (name: string, config: ConfigType) => {
set((old) => ({
...old,
configs: [
@@ -21,11 +21,6 @@ export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
{ value: config, increaseVersion: () => {} },
],
}));
if (!shouldSaveConfigToFileSystem) {
return;
}
axios.put(`/api/configs/${name}`, { ...config });
},
removeConfig: (name: string) => {
set((old) => ({
@@ -66,7 +61,10 @@ export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
}
if (shouldSaveConfigToFileSystem) {
axios.put(`/api/configs/${name}`, { ...updatedConfig });
trcpProxyClient.config.save.mutate({
name,
config: updatedConfig,
});
}
},
}));
@@ -74,11 +72,7 @@ export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
interface UseConfigStoreType {
configs: { increaseVersion: () => void; value: ConfigType }[];
initConfig: (name: string, config: ConfigType, increaseVersion: () => void) => void;
addConfig: (
name: string,
config: ConfigType,
shouldSaveConfigToFileSystem: boolean
) => Promise<void>;
addConfig: (name: string, config: ConfigType) => Promise<void>;
removeConfig: (name: string) => void;
updateConfig: (
name: string,

View File

@@ -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,
});

View File

@@ -1,154 +1,94 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { Results } from 'sabnzbd-api';
import type {
UsenetQueueRequestParams,
UsenetQueueResponse,
} from '../../../pages/api/modules/usenet/queue';
import type {
UsenetHistoryRequestParams,
UsenetHistoryResponse,
} from '../../../pages/api/modules/usenet/history';
import { UsenetInfoRequestParams, UsenetInfoResponse } from '../../../pages/api/modules/usenet';
import { useConfigContext } from '~/config/provider';
import { RouterInputs, api } from '~/utils/api';
import { UsenetInfoRequestParams } from '../../../pages/api/modules/usenet';
import type { UsenetHistoryRequestParams } from '../../../pages/api/modules/usenet/history';
import { UsenetPauseRequestParams } from '../../../pages/api/modules/usenet/pause';
import { queryClient } from '../../../tools/server/configurations/tanstack/queryClient.tool';
import type { UsenetQueueRequestParams } from '../../../pages/api/modules/usenet/queue';
import { UsenetResumeRequestParams } from '../../../pages/api/modules/usenet/resume';
const POLLING_INTERVAL = 2000;
export const useGetUsenetInfo = (params: UsenetInfoRequestParams) =>
useQuery(
['usenetInfo', params.appId],
async () =>
(
await axios.get<UsenetInfoResponse>('/api/modules/usenet', {
params,
})
).data,
{
refetchInterval: POLLING_INTERVAL,
keepPreviousData: true,
retry: 2,
enabled: Boolean(params.appId),
}
);
export const useGetUsenetInfo = ({ appId }: UsenetInfoRequestParams) => {
const { name: configName } = useConfigContext();
export const useGetUsenetDownloads = (params: UsenetQueueRequestParams) =>
useQuery(
['usenetDownloads', ...Object.values(params)],
async () =>
(
await axios.get<UsenetQueueResponse>('/api/modules/usenet/queue', {
params,
})
).data,
return api.usenet.info.useQuery(
{
refetchInterval: POLLING_INTERVAL,
keepPreviousData: true,
retry: 2,
}
);
export const useGetUsenetHistory = (params: UsenetHistoryRequestParams) =>
useQuery(
['usenetHistory', ...Object.values(params)],
async () =>
(
await axios.get<UsenetHistoryResponse>('/api/modules/usenet/history', {
params,
})
).data,
{
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 };
appId,
configName: configName!,
},
onError(err, _, context) {
if (context?.previousInfo) {
queryClient.setQueryData<UsenetInfoResponse>(
['usenetInfo', params.appId],
context.previousInfo
);
{
refetchInterval: POLLING_INTERVAL,
keepPreviousData: true,
retry: 2,
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() {
queryClient.invalidateQueries(['usenetInfo', params.appId]);
utils.usenet.info.invalidate({ appId: params.appId });
},
}
);
};
};
export const useResumeUsenetQueue = (params: UsenetResumeRequestParams) =>
useMutation(
['usenetResume', ...Object.values(params)],
async () =>
(
await axios.post<Results>(
'/api/modules/usenet/resume',
{},
export const useResumeUsenetQueueMutation = (params: UsenetResumeRequestParams) => {
const { name: configName } = useConfigContext();
const { mutateAsync } = api.usenet.resume.useMutation();
const utils = api.useContext();
return async (variables: Omit<RouterInputs['usenet']['resume'], 'configName'>) => {
await mutateAsync(
{
params,
}
)
).data,
configName: configName!,
...variables,
},
{
async onMutate() {
await queryClient.cancelQueries(['usenetInfo', params.appId]);
const previousInfo = queryClient.getQueryData<UsenetInfoResponse>([
'usenetInfo',
params.appId,
]);
if (previousInfo) {
queryClient.setQueryData<UsenetInfoResponse>(['usenetInfo', params.appId], {
...previousInfo,
paused: false,
});
}
return { previousInfo };
},
onError(err, _, context) {
if (context?.previousInfo) {
queryClient.setQueryData<UsenetInfoResponse>(
['usenetInfo', params.appId],
context.previousInfo
);
}
},
onSettled() {
queryClient.invalidateQueries(['usenetInfo', params.appId]);
utils.usenet.info.invalidate({ appId: params.appId });
},
}
);
};
};

View File

@@ -1,12 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { NormalizedDownloadQueueResponse } from '../../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
import { useConfigContext } from '~/config/provider';
import { api } from '~/utils/api';
export const useGetDownloadClientsQueue = () =>
useQuery({
queryKey: ['network-speed'],
queryFn: async (): Promise<NormalizedDownloadQueueResponse> => {
const response = await fetch('/api/modules/downloads');
return response.json();
export const useGetDownloadClientsQueue = () => {
const { name: configName } = useConfigContext();
return api.download.get.useQuery(
{
configName: configName!,
},
{
refetchInterval: 3000,
});
}
);
};

View File

@@ -1,17 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { MediaServersResponseType } from '../../../types/api/media-server/response';
import { useConfigContext } from '~/config/provider';
import { api } from '~/utils/api';
interface GetMediaServersParams {
enabled: boolean;
}
export const useGetMediaServers = ({ enabled }: GetMediaServersParams) =>
useQuery({
queryKey: ['media-servers'],
queryFn: async (): Promise<MediaServersResponseType> => {
const response = await fetch('/api/modules/media-server');
return response.json();
export const useGetMediaServers = ({ enabled }: GetMediaServersParams) => {
const { name: configName } = useConfigContext();
return api.mediaServer.all.useQuery(
{
configName: configName!,
},
{
enabled,
refetchInterval: 10 * 1000,
});
}
);
};

View File

@@ -10,56 +10,16 @@ import {
IconRotateClockwise,
IconTrash,
} from '@tabler/icons-react';
import axios from 'axios';
import Dockerode from 'dockerode';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { RouterInputs, api } from '~/utils/api';
import { useConfigContext } from '../../config/provider';
import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions';
import { MatchingImages, ServiceType, tryMatchPort } from '../../tools/types';
import { AppType } from '../../types/app';
function sendDockerCommand(
action: string,
containerId: string,
containerName: string,
reload: () => void,
t: (key: string) => string,
) {
notifications.show({
id: containerId,
loading: true,
title: `${t(`actions.${action}.start`)} ${containerName}`,
message: undefined,
autoClose: false,
withCloseButton: false,
});
axios
.get(`/api/docker/container/${containerId}?action=${action}`)
.then((res) => {
notifications.show({
id: containerId,
title: containerName,
message: `${t(`actions.${action}.end`)} ${containerName}`,
icon: <IconCheck />,
autoClose: 2000,
});
})
.catch((err) => {
notifications.update({
id: containerId,
color: 'red',
title: t('errors.unknownError.title'),
message: err.response.data.reason,
autoClose: 2000,
});
})
.finally(() => {
reload();
});
}
export interface ContainerActionBarProps {
selected: Dockerode.ContainerInfo[];
reload: () => void;
@@ -68,8 +28,9 @@ export interface ContainerActionBarProps {
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
const { t } = useTranslation('modules/docker');
const [isLoading, setisLoading] = useState(false);
const { name: configName, config } = useConfigContext();
const { config } = useConfigContext();
const getLowestWrapper = () => config?.wrappers.sort((a, b) => a.position - b.position)[0];
const sendDockerCommand = useDockerActionMutation();
if (process.env.DISABLE_EDIT_MODE === 'true') {
return null;
@@ -96,11 +57,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
<Button
leftIcon={<IconRotateClockwise />}
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('restart', container.Id, container.Names[0].substring(1), reload, t)
)
)
Promise.all(selected.map((container) => sendDockerCommand(container, 'restart')))
}
variant="light"
color="orange"
@@ -112,11 +69,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
<Button
leftIcon={<IconPlayerStop />}
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('stop', container.Id, container.Names[0].substring(1), reload, t)
)
)
Promise.all(selected.map((container) => sendDockerCommand(container, 'stop')))
}
variant="light"
color="red"
@@ -128,11 +81,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
<Button
leftIcon={<IconPlayerPlay />}
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('start', container.Id, container.Names[0].substring(1), reload, t)
)
)
Promise.all(selected.map((container) => sendDockerCommand(container, 'start')))
}
variant="light"
color="green"
@@ -147,11 +96,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
variant="light"
radius="md"
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('remove', container.Id, container.Names[0].substring(1), reload, t)
)
)
Promise.all(selected.map((container) => sendDockerCommand(container, 'remove')))
}
disabled={selected.length === 0}
>
@@ -210,6 +155,55 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
);
}
const useDockerActionMutation = () => {
const { t } = useTranslation('modules/docker');
const utils = api.useContext();
const mutation = api.docker.action.useMutation();
return async (
container: Dockerode.ContainerInfo,
action: RouterInputs['docker']['action']['action']
) => {
const containerName = container.Names[0].substring(1);
notifications.show({
id: container.Id,
loading: true,
title: `${t(`actions.${action}.start`)} ${containerName}`,
message: undefined,
autoClose: false,
withCloseButton: false,
});
await mutation.mutateAsync(
{ action, id: container.Id },
{
onSuccess: () => {
notifications.show({
id: container.Id,
title: containerName,
message: `${t(`actions.${action}.end`)} ${containerName}`,
icon: <IconCheck />,
autoClose: 2000,
});
},
onError: (err) => {
notifications.update({
id: container.Id,
color: 'red',
title: t('errors.unknownError.title'),
message: err.message,
autoClose: 2000,
});
},
onSettled: () => {
utils.docker.containers.invalidate();
},
}
);
};
};
/**
* @deprecated legacy code
*/

View File

@@ -1,14 +1,13 @@
import { ActionIcon, Drawer, Tooltip } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';
import { IconBrandDocker } from '@tabler/icons-react';
import axios from 'axios';
import Docker from 'dockerode';
import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useCardStyles } from '../../components/layout/useCardStyles';
import { useConfigContext } from '../../config/provider';
import { api } from '~/utils/api';
import ContainerActionBar from './ContainerActionBar';
import DockerTable from './DockerTable';
@@ -20,22 +19,13 @@ export default function DockerMenuButton(props: any) {
const dockerEnabled = config?.settings.customization.layout.enabledDocker || false;
const { data, isLoading, refetch } = useQuery({
queryKey: ['containers'],
queryFn: async () => {
const containers = await axios.get('/api/docker/containers');
return containers.data;
},
const { data, refetch } = api.docker.containers.useQuery(undefined, {
enabled: dockerEnabled,
});
useHotkeys([['mod+B', () => setOpened(!opened)]]);
const { t } = useTranslation('modules/docker');
useEffect(() => {
refetch();
}, [config?.settings]);
const reload = () => {
refetch();
setSelection([]);

View File

@@ -1,2 +1 @@
export * from './ping';
export * from './overseerr';

View File

@@ -1,14 +1,15 @@
import { Alert, Button, Checkbox, createStyles, Group, Modal, Stack, Table } from '@mantine/core';
import { showNotification, updateNotification } from '@mantine/notifications';
import { IconAlertCircle, IconCheck, IconDownload } from '@tabler/icons-react';
import axios from 'axios';
import Consola from 'consola';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useConfigContext } from '~/config/provider';
import { api } from '~/utils/api';
import { useColorTheme } from '../../tools/color';
import { MovieResult } from './Movie.d';
import { MediaType, Result } from './SearchResult.d';
import { Result } from './SearchResult.d';
import { TvShowResult, TvShowResultSeason } from './TvShow.d';
interface RequestModalProps {
@@ -27,20 +28,22 @@ const useStyles = createStyles((theme) => ({
}));
export function RequestModal({ base, opened, setOpened }: RequestModalProps) {
const [result, setResult] = useState<MovieResult | TvShowResult>();
const { secondaryColor } = useColorTheme();
const { name: configName } = useConfigContext();
const { data: result } = api.overseerr.byId.useQuery(
{
id: base.id,
type: base.mediaType,
configName: configName!,
},
{
enabled: opened,
}
);
function getResults(base: Result) {
axios.get(`/api/modules/overseerr/${base.id}?type=${base.mediaType}`).then((res) => {
setResult(res.data);
});
}
if (opened && !result) {
getResults(base);
}
if (!result || !opened) {
return null;
}
return base.mediaType === 'movie' ? (
<MovieRequestModal result={result as MovieResult} opened={opened} setOpened={setOpened} />
) : (
@@ -58,6 +61,7 @@ export function MovieRequestModal({
setOpened: (opened: boolean) => void;
}) {
const { secondaryColor } = useColorTheme();
const requestMediaAsync = useMediaRequestMutation();
const { t } = useTranslation('modules/overseerr');
return (
@@ -93,7 +97,7 @@ export function MovieRequestModal({
<Button
variant="outline"
onClick={() => {
askForMedia(MediaType.Movie, result.id, result.title, []);
requestMediaAsync('movie', result.id, result.title);
}}
>
{t('popup.item.buttons.request')}
@@ -116,6 +120,7 @@ export function TvRequestModal({
const [selection, setSelection] = useState<TvShowResultSeason[]>(result.seasons);
const { classes, cx } = useStyles();
const { t } = useTranslation('modules/overseerr');
const requestMediaAsync = useMediaRequestMutation();
const toggleRow = (container: TvShowResultSeason) =>
setSelection((current: TvShowResultSeason[]) =>
@@ -196,8 +201,8 @@ export function TvRequestModal({
variant="outline"
disabled={selection.length === 0}
onClick={() => {
askForMedia(
MediaType.Tv,
requestMediaAsync(
'tv',
result.id,
result.name,
selection.map((s) => s.seasonNumber)
@@ -212,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}`);
showNotification({
title: 'Request',
@@ -224,9 +233,16 @@ function askForMedia(type: MediaType, id: number, name: string, seasons?: number
withCloseButton: false,
icon: <IconAlertCircle />,
});
axios
.post(`/api/modules/overseerr/${id}`, { type, seasons })
.then(() => {
await mutateAsync(
{
configName: configName!,
id,
type,
seasons: seasons ?? [],
},
{
onSuccess: () => {
updateNotification({
id: id.toString(),
title: '',
@@ -235,8 +251,8 @@ function askForMedia(type: MediaType, id: number, name: string, seasons?: number
icon: <IconCheck />,
autoClose: 2000,
});
})
.catch((err) => {
},
onError: (err) => {
updateNotification({
id: id.toString(),
color: 'red',
@@ -244,5 +260,8 @@ function askForMedia(type: MediaType, id: number, name: string, seasons?: number
message: err.message,
autoClose: 2000,
});
});
},
}
);
};
};

View File

@@ -56,10 +56,7 @@ export interface MediaInfo {
mediaUrl?: string;
}
export enum MediaType {
Movie = 'movie',
Tv = 'tv',
}
export type MediaType = 'movie' | 'tv';
export enum OriginalLanguage {
En = 'en',

View File

@@ -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>
);
}

View File

@@ -1 +0,0 @@
export { PingModule } from './PingModule';

View File

@@ -33,15 +33,17 @@ import { theme } from '../tools/server/theme/theme';
import { useEditModeInformationStore } from '../hooks/useEditModeInformation';
import '../styles/global.scss';
import nextI18nextConfig from '../../next-i18next.config';
import { api } from '~/utils/api';
function App(
this: any,
props: AppProps & {
props: AppProps<{
colorScheme: ColorScheme;
packageAttributes: ServerSidePackageAttributesType;
editModeEnabled: boolean;
defaultColorScheme: ColorScheme;
}
}>
) {
const { Component, pageProps } = props;
const [primaryColor, setPrimaryColor] = useState<MantineTheme['primaryColor']>('red');
@@ -58,7 +60,7 @@ function App(
// hook will return either 'dark' or 'light' on client
// and always 'light' during ssr as window.matchMedia is not available
const preferredColorScheme = useColorScheme(props.defaultColorScheme);
const preferredColorScheme = useColorScheme(props.pageProps.defaultColorScheme);
const [colorScheme, setColorScheme] = useLocalStorage<ColorScheme>({
key: 'mantine-color-scheme',
defaultValue: preferredColorScheme,
@@ -69,9 +71,9 @@ function App(
const { setDisabled } = useEditModeInformationStore();
useEffect(() => {
setInitialPackageAttributes(props.packageAttributes);
setInitialPackageAttributes(props.pageProps.packageAttributes);
if (!props.editModeEnabled) {
if (!props.pageProps.editModeEnabled) {
setDisabled();
}
}, []);
@@ -161,11 +163,13 @@ App.getInitialProps = ({ ctx }: { ctx: GetServerSidePropsContext }) => {
const colorScheme: ColorScheme = (process.env.DEFAULT_COLOR_SCHEME as ColorScheme) ?? 'light';
return {
pageProps: {
colorScheme: getCookie('color-scheme', ctx) || 'light',
packageAttributes: getServiceSidePackageAttributes(),
editModeEnabled: !disableEditMode,
defaultColorScheme: colorScheme,
},
};
};
export default appWithTranslation(App);
export default appWithTranslation<any>(api.withTRPC(App), nextI18nextConfig as any);

View File

@@ -5,8 +5,9 @@ import Consola from 'consola';
import { NextApiRequest, NextApiResponse } from 'next';
import { z } from 'zod';
import { AppIntegrationType } from '../../../types/app';
import { AppIntegrationType, IntegrationType } from '../../../types/app';
import { getConfig } from '../../../tools/config/getConfig';
import { checkIntegrationsType } from '~/tools/client/app-properties';
export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the reuqest is a POST or a GET
@@ -51,14 +52,14 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
const calendar = config.widgets.find((w) => w.type === 'calendar' && w.id === widgetId);
const useSonarrv4 = calendar?.properties.useSonarrv4 ?? false;
const mediaAppIntegrationTypes: AppIntegrationType['type'][] = [
const mediaAppIntegrationTypes = [
'sonarr',
'radarr',
'readarr',
'lidarr',
];
const mediaApps = config.apps.filter(
(app) => app.integration && mediaAppIntegrationTypes.includes(app.integration.type)
] as const satisfies readonly IntegrationType[];
const mediaApps = config.apps.filter((app) =>
checkIntegrationsType(app.integration, mediaAppIntegrationTypes)
);
const IntegrationTypeEndpointMap = new Map<AppIntegrationType['type'], string>([

View File

@@ -13,8 +13,8 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { Client } from 'sabnzbd-api';
import { NzbgetClient } from '../usenet/nzbget/nzbget-client';
import { NzbgetQueueItem, NzbgetStatus } from '../usenet/nzbget/types';
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
import { NzbgetQueueItem, NzbgetStatus } from '../../../../server/api/routers/usenet/nzbget/types';
import { ConfigAppType, IntegrationField } from '../../../../types/app';
import { getConfig } from '../../../../tools/config/getConfig';
import { UsenetQueueItem } from '../../../../widgets/useNet/types';
@@ -22,6 +22,7 @@ import {
NormalizedDownloadAppStat,
NormalizedDownloadQueueResponse,
} from '../../../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
import { findAppProperty } from '~/tools/client/app-properties';
const Get = async (request: NextApiRequest, response: NextApiResponse) => {
const configName = getCookie('config-name', { req: request });
@@ -151,8 +152,8 @@ const GetDataFromClient = async (
const options = {
host: url.hostname,
port: url.port,
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
login: findAppProperty(app, 'username'),
hash: findAppProperty(app, 'password'),
};
const nzbGet = NzbgetClient(options);

View File

@@ -5,13 +5,14 @@ import { getConfig } from '../../../../tools/config/getConfig';
import { MediaRequest } from '../../../../widgets/media-requests/media-request-types';
import { MediaRequestListWidget } from '../../../../widgets/media-requests/MediaRequestListTile';
import { checkIntegrationsType } from '~/tools/client/app-properties';
const Get = async (request: NextApiRequest, response: NextApiResponse) => {
const configName = getCookie('config-name', { req: request });
const config = getConfig(configName?.toString() ?? 'default');
const apps = config.apps.filter((app) =>
['overseerr', 'jellyseerr'].includes(app.integration?.type ?? '')
checkIntegrationsType(app.integration, ['overseerr', 'jellyseerr'])
);
Consola.log(`Retrieving media requests from ${apps.length} apps`);
@@ -24,8 +25,9 @@ const Get = async (request: NextApiRequest, response: NextApiResponse) => {
})
.then(async (response) => {
const body = (await response.json()) as OverseerrResponse;
const mediaWidget = config.widgets.find(
(x) => x.type === 'media-requests-list') as MediaRequestListWidget | undefined;
const mediaWidget = config.widgets.find((x) => x.type === 'media-requests-list') as
| MediaRequestListWidget
| undefined;
if (!mediaWidget) {
Consola.log('No media-requests-list found');
return Promise.resolve([]);

View File

@@ -18,6 +18,7 @@ import {
GenericSessionInfo,
} from '../../../../types/api/media-server/session-info';
import { PlexClient } from '../../../../tools/server/sdk/plex/plexClient';
import { checkIntegrationsType, findAppProperty } from '~/tools/client/app-properties';
const jellyfin = new Jellyfin({
clientInfo: {
@@ -35,7 +36,7 @@ const Get = async (request: NextApiRequest, response: NextApiResponse) => {
const config = getConfig(configName?.toString() ?? 'default');
const apps = config.apps.filter((app) =>
['jellyfin', 'plex'].includes(app.integration?.type ?? '')
checkIntegrationsType(app.integration, ['jellyfin', 'plex'])
);
const servers = await Promise.all(
@@ -66,9 +67,9 @@ const Get = async (request: NextApiRequest, response: NextApiResponse) => {
const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | undefined> => {
switch (app.integration?.type) {
case 'jellyfin': {
const username = app.integration.properties.find((x) => x.field === 'username');
const username = findAppProperty(app, 'username');
if (!username || !username.value) {
if (!username) {
return {
appId: app.id,
serverAddress: app.url,
@@ -79,9 +80,9 @@ const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | un
};
}
const password = app.integration.properties.find((x) => x.field === 'password');
const password = findAppProperty(app, 'password');
if (!password || !password.value) {
if (!password) {
return {
appId: app.id,
serverAddress: app.url,
@@ -94,7 +95,7 @@ const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | un
const api = jellyfin.createApi(app.url);
const infoApi = await getSystemApi(api).getPublicSystemInfo();
await api.authenticateUserByName(username.value, password.value);
await api.authenticateUserByName(username, password);
const sessionApi = await getSessionApi(api);
const sessions = await sessionApi.getSessions();
return {
@@ -166,9 +167,9 @@ const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | un
};
}
case 'plex': {
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey');
const apiKey = findAppProperty(app, 'apiKey');
if (!apiKey || !apiKey.value) {
if (!apiKey) {
return {
serverAddress: app.url,
sessions: [],
@@ -179,7 +180,7 @@ const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | un
};
}
const plexClient = new PlexClient(app.url, apiKey.value);
const plexClient = new PlexClient(app.url, apiKey);
const sessions = await plexClient.getSessions();
return {
serverAddress: app.url,

View File

@@ -3,10 +3,11 @@ import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { NextApiRequest, NextApiResponse } from 'next';
import { Client } from 'sabnzbd-api';
import { NzbgetHistoryItem } from './nzbget/types';
import { NzbgetClient } from './nzbget/nzbget-client';
import { NzbgetHistoryItem } from '../../../../server/api/routers/usenet/nzbget/types';
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
import { getConfig } from '../../../../tools/config/getConfig';
import { UsenetHistoryItem } from '../../../../widgets/useNet/types';
import { findAppProperty } from '~/tools/client/app-properties';
dayjs.extend(duration);
@@ -40,8 +41,8 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
const options = {
host: url.hostname,
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
login: findAppProperty(app, 'username'),
hash: findAppProperty(app, 'password'),
};
const nzbGet = NzbgetClient(options);
@@ -77,7 +78,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
case 'sabnzbd': {
const { origin } = new URL(app.url);
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
const apiKey = findAppProperty(app, 'apiKey');
if (!apiKey) {
throw new Error(`API Key for app "${app.name}" is missing`);
}

View File

@@ -4,8 +4,9 @@ import duration from 'dayjs/plugin/duration';
import { NextApiRequest, NextApiResponse } from 'next';
import { Client } from 'sabnzbd-api';
import { getConfig } from '../../../../tools/config/getConfig';
import { NzbgetClient } from './nzbget/nzbget-client';
import { NzbgetStatus } from './nzbget/types';
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
import { NzbgetStatus } from '../../../../server/api/routers/usenet/nzbget/types';
import { findAppProperty } from '~/tools/client/app-properties';
dayjs.extend(duration);
@@ -39,8 +40,8 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
const options = {
host: url.hostname,
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
login: findAppProperty(app, 'username'),
hash: findAppProperty(app, 'password'),
};
const nzbGet = NzbgetClient(options);
@@ -70,7 +71,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
break;
}
case 'sabnzbd': {
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
const apiKey = findAppProperty(app, 'apiKey');
if (!apiKey) {
throw new Error(`API Key for app "${app.name}" is missing`);
}

View File

@@ -4,7 +4,8 @@ import duration from 'dayjs/plugin/duration';
import { NextApiRequest, NextApiResponse } from 'next';
import { Client } from 'sabnzbd-api';
import { getConfig } from '../../../../tools/config/getConfig';
import { NzbgetClient } from './nzbget/nzbget-client';
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
import { findAppProperty } from '~/tools/client/app-properties';
dayjs.extend(duration);
@@ -31,8 +32,8 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
const options = {
host: url.hostname,
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
login: findAppProperty(app, 'username'),
hash: findAppProperty(app, 'password'),
};
const nzbGet = NzbgetClient(options);
@@ -49,7 +50,7 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
break;
}
case 'sabnzbd': {
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
const apiKey = findAppProperty(app, 'apiKey');
if (!apiKey) {
throw new Error(`API Key for app "${app.name}" is missing`);
}

View File

@@ -5,8 +5,9 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { Client } from 'sabnzbd-api';
import { getConfig } from '../../../../tools/config/getConfig';
import { UsenetQueueItem } from '../../../../widgets/useNet/types';
import { NzbgetClient } from './nzbget/nzbget-client';
import { NzbgetQueueItem, NzbgetStatus } from './nzbget/types';
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
import { NzbgetQueueItem, NzbgetStatus } from '../../../../server/api/routers/usenet/nzbget/types';
import { findAppProperty } from '~/tools/client/app-properties';
dayjs.extend(duration);
@@ -40,8 +41,8 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
const options = {
host: url.hostname,
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
login: findAppProperty(app, 'username'),
hash: findAppProperty(app, 'password'),
};
const nzbGet = NzbgetClient(options);
@@ -91,7 +92,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
break;
}
case 'sabnzbd': {
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
const apiKey = findAppProperty(app, 'apiKey');
if (!apiKey) {
throw new Error(`API Key for app "${app.name}" is missing`);
}

View File

@@ -4,7 +4,8 @@ import duration from 'dayjs/plugin/duration';
import { NextApiRequest, NextApiResponse } from 'next';
import { Client } from 'sabnzbd-api';
import { getConfig } from '../../../../tools/config/getConfig';
import { NzbgetClient } from './nzbget/nzbget-client';
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
import { findAppProperty } from '~/tools/client/app-properties';
dayjs.extend(duration);
@@ -32,8 +33,8 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
const options = {
host: url.hostname,
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
login: findAppProperty(app, 'username'),
hash: findAppProperty(app, 'password'),
};
const nzbGet = NzbgetClient(options);
@@ -50,7 +51,7 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
break;
}
case 'sabnzbd': {
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
const apiKey = findAppProperty(app, 'apiKey');
if (!apiKey) {
throw new Error(`API Key for app "${app.name}" is missing`);
}

View 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
View 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;

View 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;
}),
});

View 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),
};
}),
});

View 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',
};
}),
});

View 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;
}),
});

View 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,
};
};

View 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;
}
}

View 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;
}
};

View 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';
}
}

View 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;
}),
});

View 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;
};

View 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;
}
};

View 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,
});
});
}),
});

View 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'],
},
});

View 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
View 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;

View File

@@ -1,4 +1,27 @@
import { ConfigAppType, IntegrationField } from '../../types/app';
import { ConfigAppType, IntegrationField, IntegrationType } from '../../types/app';
export const findAppProperty = (app: ConfigAppType, key: IntegrationField) =>
app.integration?.properties.find((prop) => prop.field === key)?.value ?? '';
/** Checks if the type of an integration is part of the TIntegrations array with propper typing */
export const checkIntegrationsType = <
TTest extends CheckIntegrationTypeInput,
TIntegrations extends readonly IntegrationType[]
>(
test: TTest | undefined | null,
integrations: TIntegrations
): test is CheckIntegrationType<TTest, TIntegrations> => {
if (!test) return false;
return integrations.includes(test.type!);
};
type CheckIntegrationTypeInput = {
type: IntegrationType | null;
};
type CheckIntegrationType<
TInput extends CheckIntegrationTypeInput,
TIntegrations extends readonly IntegrationType[]
> = TInput & {
type: TIntegrations[number];
};

View File

@@ -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();
};

View File

@@ -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
View 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());

View File

@@ -1,16 +1,16 @@
import { useMantineTheme } from '@mantine/core';
import { Calendar } from '@mantine/dates';
import { IconCalendarTime } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { i18n } from 'next-i18next';
import { useState } from 'react';
import { api } from '~/utils/api';
import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore';
import { useConfigContext } from '../../config/provider';
import { defineWidget } from '../helper';
import { IWidget } from '../widgets';
import { CalendarDay } from './CalendarDay';
import { getBgColorByDateAndTheme } from './bg-calculator';
import { MediasType } from './type';
import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore';
const definition = defineWidget({
id: 'calendar',
@@ -55,22 +55,18 @@ function CalendarTile({ widget }: CalendarTileProps) {
const [month, setMonth] = useState(new Date());
const isEditMode = useEditModeStore((x) => x.enabled);
const { data: medias } = useQuery({
queryKey: [
'calendar/medias',
{ month: month.getMonth(), year: month.getFullYear(), v4: widget.properties.useSonarrv4 },
],
const { data: medias } = api.calendar.medias.useQuery(
{
configName: configName!,
month: month.getMonth() + 1,
year: month.getFullYear(),
options: { useSonarrv4: widget.properties.useSonarrv4 },
},
{
staleTime: 1000 * 60 * 60 * 5,
enabled: isEditMode === false,
queryFn: async () =>
(await (
await fetch(
`/api/modules/calendar?year=${month.getFullYear()}&month=${
month.getMonth() + 1
}&configName=${configName}&widgetId=${widget.id}`
)
).json()) as MediasType,
});
}
);
return (
<Calendar

View File

@@ -2,21 +2,13 @@ import { Group, Stack, Text } from '@mantine/core';
import { IconArrowNarrowDown, IconArrowNarrowUp } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { bytes } from '../../tools/bytesHelper';
import { RouterOutputs } from '~/utils/api';
interface DashDotCompactNetworkProps {
info: DashDotInfo;
}
export interface DashDotInfo {
storage: {
size: number;
disks: { device: string; brand: string; type: string }[];
}[];
network: {
speedUp: number;
speedDown: number;
};
}
export type DashDotInfo = RouterOutputs['dashDot']['info'];
export const DashDotCompactNetwork = ({ info }: DashDotCompactNetworkProps) => {
const { t } = useTranslation('modules/dashdot');

View File

@@ -1,20 +1,18 @@
import { Group, Stack, Text } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../config/provider';
import { api } from '~/utils/api';
import { bytes } from '../../tools/bytesHelper';
import { percentage } from '../../tools/shared/math/percentage.tool';
import { DashDotInfo } from './DashDotCompactNetwork';
interface DashDotCompactStorageProps {
info: DashDotInfo;
widgetId: string;
url: string;
}
export const DashDotCompactStorage = ({ info, widgetId }: DashDotCompactStorageProps) => {
export const DashDotCompactStorage = ({ info, url }: DashDotCompactStorageProps) => {
const { t } = useTranslation('modules/dashdot');
const { data: storageLoad } = useDashDotStorage(widgetId);
const { data: storageLoad } = useDashDotStorage(url);
const totalUsed = calculateTotalLayoutSize({
layout: storageLoad ?? [],
@@ -55,25 +53,7 @@ interface CalculateTotalLayoutSizeProps<TLayoutItem> {
key?: keyof TLayoutItem;
}
const useDashDotStorage = (widgetId: string) => {
const { name: configName, config } = useConfigContext();
return useQuery({
queryKey: [
'dashdot/storage',
{
configName,
url: config?.widgets.find((x) => x.type === 'dashdot')?.properties.url,
widgetId,
},
],
queryFn: () => fetchDashDotStorageLoad(configName, widgetId),
const useDashDotStorage = (url: string) =>
api.dashDot.storage.useQuery({
url,
});
};
async function fetchDashDotStorageLoad(configName: string | undefined, widgetId: string) {
if (!configName) throw new Error('configName is undefined');
return (await (
await axios.get('/api/modules/dashdot/storage', { params: { configName, widgetId } })
).data) as number[];
}

View File

@@ -1,4 +1,4 @@
import { createStyles, Title, useMantineTheme, getStylesRef } from '@mantine/core';
import { Title, createStyles, getStylesRef, useMantineTheme } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { DashDotCompactNetwork, DashDotInfo } from './DashDotCompactNetwork';
import { DashDotCompactStorage } from './DashDotCompactStorage';
@@ -11,7 +11,6 @@ interface DashDotGraphProps {
dashDotUrl: string;
usePercentages: boolean;
info: DashDotInfo;
widgetId: string;
}
export const DashDotGraph = ({
@@ -22,13 +21,12 @@ export const DashDotGraph = ({
dashDotUrl,
usePercentages,
info,
widgetId,
}: DashDotGraphProps) => {
const { t } = useTranslation('modules/dashdot');
const { classes } = useStyles();
if (graph === 'storage' && isCompact) {
return <DashDotCompactStorage info={info} widgetId={widgetId} />;
return <DashDotCompactStorage info={info} url={dashDotUrl} />;
}
if (graph === 'network' && isCompact) {

View File

@@ -1,12 +1,9 @@
import { Center, createStyles, Grid, Stack, Text, Title } from '@mantine/core';
import { IconUnlink } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../config/provider';
import { api } from '~/utils/api';
import { defineWidget } from '../helper';
import { IWidget } from '../widgets';
import { DashDotInfo } from './DashDotCompactNetwork';
import { DashDotGraph } from './DashDotGraph';
const definition = defineWidget({
@@ -161,10 +158,9 @@ function DashDotTile({ widget }: DashDotTileProps) {
const detectedProtocolDowngrade =
locationProtocol === 'https:' && dashDotUrl.toLowerCase().startsWith('http:');
const { data: info } = useDashDotInfo({
const { data: info } = useDashDotInfoQuery({
dashDotUrl,
enabled: !detectedProtocolDowngrade,
widgetId: widget.id,
});
if (detectedProtocolDowngrade) {
@@ -202,7 +198,6 @@ function DashDotTile({ widget }: DashDotTileProps) {
isCompact={g.subValues.compactView ?? false}
multiView={g.subValues.multiView ?? false}
usePercentages={usePercentages}
widgetId={widget.id}
/>
</Grid.Col>
))}
@@ -212,37 +207,16 @@ function DashDotTile({ widget }: DashDotTileProps) {
</Stack>
);
}
const useDashDotInfo = ({
dashDotUrl,
enabled,
widgetId,
}: {
dashDotUrl: string;
enabled: boolean;
widgetId: string;
}) => {
const { name: configName } = useConfigContext();
return useQuery({
refetchInterval: 50000,
queryKey: [
'dashdot/info',
const useDashDotInfoQuery = ({ dashDotUrl, enabled }: { dashDotUrl: string; enabled: boolean }) =>
api.dashDot.info.useQuery(
{
configName,
dashDotUrl,
url: dashDotUrl,
},
],
queryFn: () => fetchDashDotInfo(configName, widgetId),
{
refetchInterval: 50000,
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) => ({
graphsContainer: {

View File

@@ -5,9 +5,10 @@ import { useConfigContext } from '../../config/provider';
import { defineWidget } from '../helper';
import { WidgetLoading } from '../loading';
import { IWidget } from '../widgets';
import { useDnsHoleControlMutation, useDnsHoleSummeryQuery } from './query';
import { PiholeApiSummaryType } from './type';
import { queryClient } from '../../tools/server/configurations/tanstack/queryClient.tool';
import { api } from '~/utils/api';
import { useDnsHoleSummeryQuery } from './DnsHoleSummary';
const definition = defineWidget({
id: 'dns-hole-controls',
@@ -29,13 +30,13 @@ interface DnsHoleControlsWidgetProps {
}
function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
const { isInitialLoading, data, refetch } = useDnsHoleSummeryQuery();
const { isInitialLoading, data } = useDnsHoleSummeryQuery();
const { mutateAsync } = useDnsHoleControlMutation();
const { t } = useTranslation('common');
const { config } = useConfigContext();
const { name: configName, config } = useConfigContext();
if (isInitialLoading || !data) {
if (isInitialLoading || !data || !configName) {
return <WidgetLoading />;
}
@@ -44,7 +45,10 @@ function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
<Group grow>
<Button
onClick={async () => {
await mutateAsync('enabled');
await mutateAsync({
status: 'enabled',
configName,
});
await queryClient.invalidateQueries({ queryKey: ['dns-hole-summary'] });
}}
leftIcon={<IconPlayerPlay size={20} />}
@@ -55,7 +59,10 @@ function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
</Button>
<Button
onClick={async () => {
await mutateAsync('disabled');
await mutateAsync({
status: 'disabled',
configName,
});
await queryClient.invalidateQueries({ queryKey: ['dns-hole-summary'] });
}}
leftIcon={<IconPlayerStop size={20} />}
@@ -117,4 +124,6 @@ const StatusBadge = ({ status }: { status: PiholeApiSummaryType['status'] }) =>
);
};
const useDnsHoleControlMutation = () => api.dnsHole.control.useMutation();
export default definition;

View File

@@ -1,4 +1,3 @@
import { useTranslation } from 'next-i18next';
import { Card, Center, Container, Stack, Text } from '@mantine/core';
import {
IconAd,
@@ -7,11 +6,13 @@ import {
IconSearch,
IconWorldWww,
} from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '~/config/provider';
import { api } from '~/utils/api';
import { formatNumber } from '../../tools/client/math';
import { defineWidget } from '../helper';
import { WidgetLoading } from '../loading';
import { IWidget } from '../widgets';
import { formatNumber } from '../../tools/client/math';
import { useDnsHoleSummeryQuery } from './query';
const definition = defineWidget({
id: 'dns-hole-summary',
@@ -179,4 +180,17 @@ function DnsHoleSummaryWidgetTile({ widget }: DnsHoleSummaryWidgetProps) {
);
}
export const useDnsHoleSummeryQuery = () => {
const { name: configName } = useConfigContext();
return api.dnsHole.summary.useQuery(
{
configName: configName!,
},
{
refetchInterval: 3 * 60 * 1000,
}
);
};
export default definition;

View File

@@ -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();
},
});

View File

@@ -10,16 +10,16 @@ import {
Text,
Tooltip,
} from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { IconCheck, IconGitPullRequest, IconThumbDown, IconThumbUp } from '@tabler/icons-react';
import { useMutation } from '@tanstack/react-query';
import axios from 'axios';
import { notifications } from '@mantine/notifications';
import { IconCheck, IconGitPullRequest, IconThumbDown, IconThumbUp } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { api } from '~/utils/api';
import { defineWidget } from '../helper';
import { WidgetLoading } from '../loading';
import { IWidget } from '../widgets';
import { useMediaRequestQuery } from './media-request-query';
import { MediaRequest, MediaRequestStatus } from './media-request-types';
import { useConfigContext } from '~/config/provider';
const definition = defineWidget({
id: 'media-requests-list',
@@ -45,21 +45,55 @@ interface MediaRequestListWidgetProps {
widget: MediaRequestListWidget;
}
function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
const { t } = useTranslation('modules/media-requests-list');
const { data, refetch, isLoading } = useMediaRequestQuery();
// Use mutation to approve or deny a pending request
const mutate = useMutation({
mutationFn: async (e: { request: MediaRequest; action: string }) => {
const data = await axios.put(`/api/modules/overseerr/${e.request.id}?action=${e.action}`);
notifications.show({
title: t('requestUpdated'),
message: t('requestUpdatedMessage', { title: e.request.name }),
color: 'blue',
});
refetch();
type MediaRequestDecisionVariables = {
request: MediaRequest;
isApproved: boolean;
};
const useMediaRequestDecisionMutation = () => {
const { name: configName } = useConfigContext();
const utils = api.useContext();
const { mutateAsync } = api.overseerr.decide.useMutation({
onSuccess() {
utils.mediaRequest.all.invalidate();
},
});
return async (variables: MediaRequestDecisionVariables) => {
const action = variables.isApproved ? 'Approving' : 'Declining';
notifications.show({
id: `decide-${variables.request.id}`,
color: 'yellow',
title: `${action} request...`,
message: undefined,
loading: true,
});
await mutateAsync(
{
configName: configName!,
id: variables.request.id,
isApproved: variables.isApproved,
},
{
onSuccess(_data, variables) {
const title = variables.isApproved ? 'Request was approved!' : 'Request was declined!';
notifications.update({
id: `decide-${variables.id}`,
color: 'teal',
title,
message: undefined,
icon: <IconCheck size="1rem" />,
autoClose: 2000,
});
},
}
);
};
};
function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
const { t } = useTranslation('modules/media-requests-list');
const { data, isLoading } = useMediaRequestQuery();
// Use mutation to approve or deny a pending request
const decideAsync = useMediaRequestDecisionMutation();
if (!data || isLoading) {
return <WidgetLoading />;
@@ -157,18 +191,10 @@ function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
loading: true,
});
await mutate.mutateAsync({ request: item, action: 'approve' }).then(() =>
notifications.update({
id: `approve ${item.id}`,
color: 'teal',
title: 'Request was approved!',
message: undefined,
icon: <IconCheck size="1rem" />,
autoClose: 2000,
})
);
await refetch();
await decideAsync({
request: item,
isApproved: true,
});
}}
>
<IconThumbUp />
@@ -179,8 +205,10 @@ function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
variant="light"
color="red"
onClick={async () => {
await mutate.mutateAsync({ request: item, action: 'decline' });
await refetch();
await decideAsync({
request: item,
isApproved: false,
});
}}
>
<IconThumbDown />

View File

@@ -1,11 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { MediaRequest } from './media-request-types';
import { useConfigContext } from '~/config/provider';
import { api } from '~/utils/api';
export const useMediaRequestQuery = () => useQuery({
queryKey: ['media-requests'],
queryFn: async () => {
const response = await fetch('/api/modules/media-requests');
return (await response.json()) as MediaRequest[];
},
export const useMediaRequestQuery = () => {
const { name: configName } = useConfigContext();
return api.mediaRequest.all.useQuery(
{ configName: configName! },
{
refetchInterval: 3 * 60 * 1000,
});
}
);
};

View File

@@ -15,11 +15,12 @@ import {
createStyles,
} from '@mantine/core';
import { IconClock, IconRefresh, IconRss } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import Link from 'next/link';
import { useConfigContext } from '~/config/provider';
import { api } from '~/utils/api';
import { defineWidget } from '../helper';
import { IWidget } from '../widgets';
@@ -65,27 +66,11 @@ interface RssTileProps {
widget: IRssWidget;
}
export const useGetRssFeeds = (feedUrls: string[], refreshInterval: number, widgetId: string) =>
useQuery({
queryKey: ['rss-feeds', feedUrls],
// Cache the results for 24 hours
cacheTime: 1000 * 60 * 60 * 24,
staleTime: 1000 * 60 * refreshInterval,
queryFn: async () => {
const responses = await Promise.all(
feedUrls.map((feedUrl) =>
fetch(
`/api/modules/rss?widgetId=${widgetId}&feedUrl=${encodeURIComponent(feedUrl)}`
).then((response) => response.json())
)
);
return responses;
},
});
function RssTile({ widget }: RssTileProps) {
const { t } = useTranslation('modules/rss');
const { name: configName } = useConfigContext();
const { data, isLoading, isFetching, isError, refetch } = useGetRssFeeds(
configName,
widget.properties.rssFeedUrl,
widget.properties.refreshInterval,
widget.id
@@ -122,6 +107,7 @@ function RssTile({ widget }: RssTileProps) {
<Title order={6}>{t('descriptor.card.errors.general.title')}</Title>
<Text align="center">{t('descriptor.card.errors.general.text')}</Text>
</Stack>
<RefetchButton refetch={refetch} isFetching={isFetching} />
</Center>
);
}
@@ -192,6 +178,37 @@ function RssTile({ widget }: RssTileProps) {
))}
</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
size="sm"
radius="xl"
@@ -207,9 +224,7 @@ function RssTile({ widget }: RssTileProps) {
>
{isFetching ? <Loader /> : <IconRefresh />}
</ActionIcon>
</Stack>
);
}
const InfoDisplay = ({ date, title }: { date: string; title: string | undefined }) => (
<Group mt="auto" spacing="xs">

View File

@@ -10,8 +10,8 @@ import { useConfigContext } from '../../config/provider';
import { MIN_WIDTH_MOBILE } from '../../constants/constants';
import {
useGetUsenetInfo,
usePauseUsenetQueue,
useResumeUsenetQueue,
usePauseUsenetQueueMutation,
useResumeUsenetQueueMutation,
} from '../../hooks/widgets/dashDot/api';
import { humanFileSize } from '../../tools/humanFileSize';
import { AppIntegrationType } from '../../types/app';
@@ -60,8 +60,8 @@ function UseNetTile({ widget }: UseNetTileProps) {
}
}, [downloadApps, selectedAppId]);
const { mutate: pause } = usePauseUsenetQueue({ appId: selectedAppId! });
const { mutate: resume } = useResumeUsenetQueue({ appId: selectedAppId! });
const pauseAsync = usePauseUsenetQueueMutation({ appId: selectedAppId! });
const resumeAsync = useResumeUsenetQueueMutation({ appId: selectedAppId! });
if (downloadApps.length === 0) {
return (
@@ -107,11 +107,25 @@ function UseNetTile({ widget }: UseNetTileProps) {
<Tabs.Panel value="queue">
<UsenetQueueList appId={selectedAppId} />
{!data ? null : data.paused ? (
<Button uppercase onClick={() => resume()} radius="xl" size="xs" fullWidth mt="sm">
<Button
uppercase
onClick={async () => resumeAsync({ appId: selectedAppId })}
radius="xl"
size="xs"
fullWidth
mt="sm"
>
<IconPlayerPlay size={12} style={{ marginRight: 5 }} /> {t('info.paused')}
</Button>
) : (
<Button uppercase onClick={() => pause()} radius="xl" size="xs" fullWidth mt="sm">
<Button
uppercase
onClick={async () => pauseAsync({ appId: selectedAppId })}
radius="xl"
size="xs"
fullWidth
mt="sm"
>
<IconPlayerPause size={12} style={{ marginRight: 5 }} />{' '}
{dayjs.duration(data.eta, 's').format('HH:mm')}
</Button>

View File

@@ -13,14 +13,13 @@ import {
} from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { IconAlertCircle } from '@tabler/icons-react';
import { AxiosError } from 'axios';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { useTranslation } from 'next-i18next';
import { FunctionComponent, useState } from 'react';
import { useGetUsenetHistory } from '../../hooks/widgets/dashDot/api';
import { humanFileSize } from '../../tools/humanFileSize';
import { parseDuration } from '../../tools/client/parseDuration';
import { humanFileSize } from '../../tools/humanFileSize';
dayjs.extend(duration);
@@ -65,7 +64,7 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ a
>
{t('modules/usenet:history.error.message')}
<Code mt="sm" block>
{(error as AxiosError)?.response?.data as string}
{error.message}
</Code>
</Alert>
</Group>

View File

@@ -16,7 +16,6 @@ import {
} from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { IconAlertCircle, IconPlayerPause, IconPlayerPlay } from '@tabler/icons-react';
import { AxiosError } from 'axios';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { useTranslation } from 'next-i18next';
@@ -70,7 +69,7 @@ export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ appId
>
{t('queue.error.message')}
<Code mt="sm" block>
{(error as AxiosError)?.response?.data as string}
{error.data}
</Code>
</Alert>
</Group>

View File

@@ -22,7 +22,10 @@
{
"name": "next"
}
]
],
"paths": {
"~/*": ["./src/*"]
},
},
"include": [
"next-env.d.ts",

View File

@@ -1,10 +1,11 @@
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import { configDefaults, defineConfig } from 'vitest/config';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [react(), tsconfigPaths()],
test: {
environment: 'happy-dom',
coverage: {
@@ -14,9 +15,6 @@ export default defineConfig({
exclude: ['.next/', '.yarn/', 'data/'],
},
setupFiles: ['./tests/setupVitest.ts'],
exclude: [
...configDefaults.exclude,
'.next',
],
exclude: [...configDefaults.exclude, '.next'],
},
});

View File

@@ -2011,6 +2011,52 @@ __metadata:
languageName: node
linkType: hard
"@trpc/client@npm:^10.29.1":
version: 10.29.1
resolution: "@trpc/client@npm:10.29.1"
peerDependencies:
"@trpc/server": 10.29.1
checksum: b617edb56e9ec7fa0665703761c666244e58b8a9da5a2fffcdef880cdde7ec8c92c20d1362ac1c836904518d94db247ec286cc17e01fc4eea41b7cafbf3479fe
languageName: node
linkType: hard
"@trpc/next@npm:^10.29.1":
version: 10.29.1
resolution: "@trpc/next@npm:10.29.1"
dependencies:
react-ssr-prepass: ^1.5.0
peerDependencies:
"@tanstack/react-query": ^4.18.0
"@trpc/client": 10.29.1
"@trpc/react-query": 10.29.1
"@trpc/server": 10.29.1
next: "*"
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: 6afd6d7b1702eda5a26e7e8d2d381729fceb180952a2382314f08dd945b6e3204716c532b0ac6ddef10135816879c61dbe4b27aa88bcd98100890ede8d42fb25
languageName: node
linkType: hard
"@trpc/react-query@npm:^10.29.1":
version: 10.29.1
resolution: "@trpc/react-query@npm:10.29.1"
peerDependencies:
"@tanstack/react-query": ^4.18.0
"@trpc/client": 10.29.1
"@trpc/server": 10.29.1
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: 3e52195eb894c0110b7fd99b0ab4fde38ade0b28d76c788d602c97789b76c66c91f678460fd68f3dc67c9b454340c7fe08363dfec69d4127982f9329bb42f503
languageName: node
linkType: hard
"@trpc/server@npm:^10.29.1":
version: 10.29.1
resolution: "@trpc/server@npm:10.29.1"
checksum: d31ac9921764aea774ef14ae3d9d4c7cb245582dde7f75facd355732043eb9d697a8584c19377dce9acdf9b21f473917f77842fda29eb77fd604484e23b42ded
languageName: node
linkType: hard
"@tsconfig/node10@npm:^1.0.7":
version: 1.0.9
resolution: "@tsconfig/node10@npm:1.0.9"
@@ -5617,6 +5663,13 @@ __metadata:
languageName: node
linkType: hard
"globrex@npm:^0.1.2":
version: 0.1.2
resolution: "globrex@npm:0.1.2"
checksum: adca162494a176ce9ecf4dd232f7b802956bb1966b37f60c15e49d2e7d961b66c60826366dc2649093cad5a0d69970cfa8875bd1695b5a1a2f33dcd2aa88da3c
languageName: node
linkType: hard
"gopd@npm:^1.0.1":
version: 1.0.1
resolution: "gopd@npm:1.0.1"
@@ -5828,6 +5881,10 @@ __metadata:
"@tanstack/react-query-persist-client": ^4.28.0
"@testing-library/jest-dom": ^5.16.5
"@testing-library/react": ^14.0.0
"@trpc/client": ^10.29.1
"@trpc/next": ^10.29.1
"@trpc/react-query": ^10.29.1
"@trpc/server": ^10.29.1
"@types/dockerode": ^3.3.9
"@types/node": 18.16.17
"@types/prismjs": ^1.26.0
@@ -5880,6 +5937,7 @@ __metadata:
typescript: ^5.0.4
uuid: ^9.0.0
video.js: ^8.0.3
vite-tsconfig-paths: ^4.2.0
vitest: ^0.32.0
vitest-fetch-mock: ^0.2.2
xml-js: ^1.6.11
@@ -8414,6 +8472,15 @@ __metadata:
languageName: node
linkType: hard
"react-ssr-prepass@npm:^1.5.0":
version: 1.5.0
resolution: "react-ssr-prepass@npm:1.5.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: efe89b9f8b2053474613f56dfdbeb41d8ee2e572bf819a39377d95d3e4a9acf8a4a16e28d8d8034cb9ac2b316d11dc9e62217743e4322046d08175eb3b4fed3e
languageName: node
linkType: hard
"react-style-singleton@npm:^2.2.1":
version: 2.2.1
resolution: "react-style-singleton@npm:2.2.1"
@@ -9507,6 +9574,20 @@ __metadata:
languageName: node
linkType: hard
"tsconfck@npm:^2.1.0":
version: 2.1.1
resolution: "tsconfck@npm:2.1.1"
peerDependencies:
typescript: ^4.3.5 || ^5.0.0
peerDependenciesMeta:
typescript:
optional: true
bin:
tsconfck: bin/tsconfck.js
checksum: c531525f39763cbbd7e6dbf5e29f12a7ae67eb8712816c14d06a9db6cbdc9dda9ac3cd6db07ef645f8a4cdea906447ab44e2c8679e320871cf9dd598756e8c83
languageName: node
linkType: hard
"tsconfig-paths@npm:^3.14.1":
version: 3.14.2
resolution: "tsconfig-paths@npm:3.14.2"
@@ -9981,6 +10062,22 @@ __metadata:
languageName: node
linkType: hard
"vite-tsconfig-paths@npm:^4.2.0":
version: 4.2.0
resolution: "vite-tsconfig-paths@npm:4.2.0"
dependencies:
debug: ^4.1.1
globrex: ^0.1.2
tsconfck: ^2.1.0
peerDependencies:
vite: "*"
peerDependenciesMeta:
vite:
optional: true
checksum: 73a8467de72d7ac502328454fd00c19571cd4bad2dd5982643b24718bb95e449a3f4153cfc2d58a358bfc8f37e592fb442fc10884b59ae82138c1329160cd952
languageName: node
linkType: hard
"vite@npm:^3.0.0 || ^4.0.0":
version: 4.3.9
resolution: "vite@npm:4.3.9"