mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-09 15:05:48 +01:00
🗑️ Remove deprecated code (#1225)
This commit is contained in:
@@ -71,7 +71,7 @@ export const EditAppModal = ({
|
|||||||
behaviour: {
|
behaviour: {
|
||||||
externalUrl: (url: string) => {
|
externalUrl: (url: string) => {
|
||||||
if (url === undefined || url.length < 1) {
|
if (url === undefined || url.length < 1) {
|
||||||
return null;
|
return 'External URI is required';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!url.match(appUrlWithAnyProtocolRegex)) {
|
if (!url.match(appUrlWithAnyProtocolRegex)) {
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export const AvailableElementTypes = ({
|
|||||||
},
|
},
|
||||||
behaviour: {
|
behaviour: {
|
||||||
isOpeningNewTab: true,
|
isOpeningNewTab: true,
|
||||||
externalUrl: '',
|
externalUrl: 'https://homarr.dev',
|
||||||
},
|
},
|
||||||
|
|
||||||
area: {
|
area: {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
||||||
import { Button, Group } from '@mantine/core';
|
import { Button, Group } from '@mantine/core';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +17,6 @@ import { RouterInputs, api } from '~/utils/api';
|
|||||||
|
|
||||||
import { useConfigContext } from '../../config/provider';
|
import { useConfigContext } from '../../config/provider';
|
||||||
import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions';
|
import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions';
|
||||||
import { MatchingImages, ServiceType, tryMatchPort } from '../../tools/types';
|
|
||||||
import { AppType } from '../../types/app';
|
import { AppType } from '../../types/app';
|
||||||
|
|
||||||
export interface ContainerActionBarProps {
|
export interface ContainerActionBarProps {
|
||||||
@@ -28,10 +26,15 @@ export interface ContainerActionBarProps {
|
|||||||
|
|
||||||
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
|
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
|
||||||
const { t } = useTranslation('modules/docker');
|
const { t } = useTranslation('modules/docker');
|
||||||
const [isLoading, setisLoading] = useState(false);
|
const [isLoading, setLoading] = useState(false);
|
||||||
const { config } = useConfigContext();
|
const { config } = useConfigContext();
|
||||||
const getLowestWrapper = () => config?.wrappers.sort((a, b) => a.position - b.position)[0];
|
|
||||||
const sendDockerCommand = useDockerActionMutation();
|
const sendDockerCommand = useDockerActionMutation();
|
||||||
|
if (!config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const getLowestWrapper = () =>
|
||||||
|
config.wrappers.sort((wrapper1, wrapper2) => wrapper1.position - wrapper2.position)[0];
|
||||||
|
|
||||||
if (process.env.DISABLE_EDIT_MODE === 'true') {
|
if (process.env.DISABLE_EDIT_MODE === 'true') {
|
||||||
return null;
|
return null;
|
||||||
@@ -42,10 +45,10 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
|||||||
<Button
|
<Button
|
||||||
leftIcon={<IconRefresh />}
|
leftIcon={<IconRefresh />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setisLoading(true);
|
setLoading(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
reload();
|
reload();
|
||||||
setisLoading(false);
|
setLoading(false);
|
||||||
}, 750);
|
}, 750);
|
||||||
}}
|
}}
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -57,8 +60,8 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
leftIcon={<IconRotateClockwise />}
|
leftIcon={<IconRotateClockwise />}
|
||||||
onClick={() =>
|
onClick={async () =>
|
||||||
Promise.all(selected.map((container) => sendDockerCommand(container, 'restart')))
|
await Promise.all(selected.map((container) => sendDockerCommand(container, 'restart')))
|
||||||
}
|
}
|
||||||
variant="light"
|
variant="light"
|
||||||
color="orange"
|
color="orange"
|
||||||
@@ -69,8 +72,8 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
leftIcon={<IconPlayerStop />}
|
leftIcon={<IconPlayerStop />}
|
||||||
onClick={() =>
|
onClick={async () =>
|
||||||
Promise.all(selected.map((container) => sendDockerCommand(container, 'stop')))
|
await Promise.all(selected.map((container) => sendDockerCommand(container, 'stop')))
|
||||||
}
|
}
|
||||||
variant="light"
|
variant="light"
|
||||||
color="red"
|
color="red"
|
||||||
@@ -81,8 +84,8 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
leftIcon={<IconPlayerPlay />}
|
leftIcon={<IconPlayerPlay />}
|
||||||
onClick={() =>
|
onClick={async () =>
|
||||||
Promise.all(selected.map((container) => sendDockerCommand(container, 'start')))
|
await Promise.all(selected.map((container) => sendDockerCommand(container, 'start')))
|
||||||
}
|
}
|
||||||
variant="light"
|
variant="light"
|
||||||
color="green"
|
color="green"
|
||||||
@@ -96,8 +99,8 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
|||||||
color="red"
|
color="red"
|
||||||
variant="light"
|
variant="light"
|
||||||
radius="md"
|
radius="md"
|
||||||
onClick={() =>
|
onClick={async () =>
|
||||||
Promise.all(selected.map((container) => sendDockerCommand(container, 'remove')))
|
await Promise.all(selected.map((container) => sendDockerCommand(container, 'remove')))
|
||||||
}
|
}
|
||||||
disabled={selected.length === 0}
|
disabled={selected.length === 0}
|
||||||
>
|
>
|
||||||
@@ -108,29 +111,32 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
|||||||
color="indigo"
|
color="indigo"
|
||||||
variant="light"
|
variant="light"
|
||||||
radius="md"
|
radius="md"
|
||||||
disabled={selected.length === 0 || selected.length > 1}
|
disabled={selected.length !== 1}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const app = tryMatchService(selected.at(0)!);
|
const containerInfo = selected[0];
|
||||||
const containerUrl = `http://localhost:${selected[0].Ports[0]?.PublicPort ?? 0}`;
|
|
||||||
|
const port = containerInfo.Ports.at(0)?.PublicPort;
|
||||||
|
const address = port ? `http://localhost:${port}` : `http://localhost`;
|
||||||
|
const name = containerInfo.Names.at(0) ?? 'App';
|
||||||
|
|
||||||
openContextModalGeneric<{ app: AppType; allowAppNamePropagation: boolean }>({
|
openContextModalGeneric<{ app: AppType; allowAppNamePropagation: boolean }>({
|
||||||
modal: 'editApp',
|
modal: 'editApp',
|
||||||
zIndex: 202,
|
zIndex: 202,
|
||||||
innerProps: {
|
innerProps: {
|
||||||
app: {
|
app: {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
name: app.name ? app.name : selected[0].Names[0].substring(1),
|
name: name,
|
||||||
url: containerUrl,
|
url: address,
|
||||||
appearance: {
|
appearance: {
|
||||||
iconUrl: app.icon ? app.icon : '/imgs/logo/logo.png',
|
iconUrl: '/imgs/logo/logo.png',
|
||||||
},
|
},
|
||||||
network: {
|
network: {
|
||||||
enabledStatusChecker: true,
|
enabledStatusChecker: true,
|
||||||
statusCodes: ['200'],
|
statusCodes: ['200', '301', '302']
|
||||||
okStatus: [200],
|
|
||||||
},
|
},
|
||||||
behaviour: {
|
behaviour: {
|
||||||
isOpeningNewTab: true,
|
isOpeningNewTab: true,
|
||||||
externalUrl: '',
|
externalUrl: address
|
||||||
},
|
},
|
||||||
area: {
|
area: {
|
||||||
type: 'wrapper',
|
type: 'wrapper',
|
||||||
@@ -204,36 +210,3 @@ const useDockerActionMutation = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated legacy code
|
|
||||||
*/
|
|
||||||
function tryMatchType(imageName: string): ServiceType {
|
|
||||||
const match = MatchingImages.find(({ image }) => imageName.includes(image));
|
|
||||||
if (match) {
|
|
||||||
return match.type;
|
|
||||||
}
|
|
||||||
// TODO: Remove this legacy code
|
|
||||||
return 'Other';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
* @param container the container to match
|
|
||||||
* @returns a new service
|
|
||||||
*/
|
|
||||||
const tryMatchService = (container: Dockerode.ContainerInfo | undefined) => {
|
|
||||||
if (container === undefined) return {};
|
|
||||||
const name = container.Names[0].substring(1);
|
|
||||||
const type = tryMatchType(container.Image);
|
|
||||||
const port = tryMatchPort(type.toLowerCase())?.value ?? container.Ports[0]?.PublicPort;
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
id: container.Id,
|
|
||||||
type: tryMatchType(container.Image),
|
|
||||||
url: `localhost${port ? `:${port}` : ''}`,
|
|
||||||
icon: `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${name
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.toLowerCase()}.png`,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
**Each module has a set of rules:**
|
|
||||||
- Exported Typed IModule element (Unique Name, description, component, ...)
|
|
||||||
- Needs to be in a new folder
|
|
||||||
- Needs to be exported in the modules/newmodule/index.tsx of the new folder
|
|
||||||
- Needs to be imported in the modules/index.tsx file
|
|
||||||
- Needs to look good when wrapped with the modules/ModuleWrapper component
|
|
||||||
- Needs to be put somewhere fitting in the app (While waiting for the big AppStore overhall)
|
|
||||||
- Any API Calls need to be safe and done on the widget itself (via useEffect or similar)
|
|
||||||
- You can't add a package (unless there is a very specific need for it. Contact [@Ajnart](ajnart@pm.me) or make a [Discussion](https://github.com/ajnart/homarr/discussions/new).
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
|
|
||||||
import DockerSingleton from '../DockerSingleton';
|
|
||||||
|
|
||||||
const docker = DockerSingleton.getInstance();
|
|
||||||
|
|
||||||
async function Get(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
// Get the slug of the request
|
|
||||||
const { id } = req.query as { id: string };
|
|
||||||
const { action } = req.query;
|
|
||||||
// Get the action on the request (start, stop, restart)
|
|
||||||
if (action !== 'start' && action !== 'stop' && action !== 'restart' && action !== 'remove') {
|
|
||||||
return res.status(400).json({
|
|
||||||
statusCode: 400,
|
|
||||||
message: 'Invalid action',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!id) {
|
|
||||||
return res.status(400).json({
|
|
||||||
message: 'Missing ID',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Get the container with the ID
|
|
||||||
const container = docker.getContainer(id);
|
|
||||||
const startAction = async () => {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await startAction();
|
|
||||||
return res.status(200).json({
|
|
||||||
statusCode: 200,
|
|
||||||
message: `Container ${id} ${action}ed`,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(500).json(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
// Filter out if the reuqest is a Put or a GET
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
return Get(req, res);
|
|
||||||
}
|
|
||||||
return res.status(405).json({
|
|
||||||
statusCode: 405,
|
|
||||||
message: 'Method not allowed',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
|
|
||||||
import DockerSingleton from './DockerSingleton';
|
|
||||||
|
|
||||||
async function Get(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
try {
|
|
||||||
const docker = DockerSingleton.getInstance();
|
|
||||||
const containers = await docker.listContainers({ all: true });
|
|
||||||
res.status(200).json(containers);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ err });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
// Filter out if the reuqest is a POST or a GET
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
return Get(req, res);
|
|
||||||
}
|
|
||||||
return res.status(405).json({
|
|
||||||
statusCode: 405,
|
|
||||||
message: 'Method not allowed',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
|
|
||||||
import { JsdelivrIconsRepository } from '../../../tools/server/images/jsdelivr-icons-repository';
|
|
||||||
import { LocalIconsRepository } from '../../../tools/server/images/local-icons-repository';
|
|
||||||
import { UnpkgIconsRepository } from '../../../tools/server/images/unpkg-icons-repository';
|
|
||||||
|
|
||||||
const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
|
||||||
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 response.status(200).json(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async (request: NextApiRequest, response: NextApiResponse) => {
|
|
||||||
if (request.method === 'GET') {
|
|
||||||
return Get(request, response);
|
|
||||||
}
|
|
||||||
return response.status(405).json({
|
|
||||||
statusCode: 405,
|
|
||||||
message: 'Method not allowed',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
|
|
||||||
import { backendMigrateConfig } from '../../tools/config/backendMigrateConfig';
|
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
// Gets all the config files
|
|
||||||
const configs = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json'));
|
|
||||||
// If there is no config, redirect to the index
|
|
||||||
configs.every((config) => {
|
|
||||||
const configData = JSON.parse(fs.readFileSync(`./data/configs/${config}`, 'utf8'));
|
|
||||||
if (!configData.schemaVersion) {
|
|
||||||
// Migrate the config
|
|
||||||
backendMigrateConfig(configData, config.replace('.json', ''));
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
return res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
message: 'Configs migrated',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import Consola from 'consola';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { checkIntegrationsType } from '~/tools/client/app-properties';
|
|
||||||
|
|
||||||
import { getConfig } from '../../../tools/config/getConfig';
|
|
||||||
import { AppIntegrationType, IntegrationType } from '../../../types/app';
|
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
// Filter out if the reuqest is a POST or a GET
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
return Get(req, res);
|
|
||||||
}
|
|
||||||
return res.status(405).json({
|
|
||||||
statusCode: 405,
|
|
||||||
message: 'Method not allowed',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getQuerySchema = z.object({
|
|
||||||
month: z
|
|
||||||
.string()
|
|
||||||
.regex(/^\d+$/)
|
|
||||||
.transform((x) => parseInt(x, 10)),
|
|
||||||
year: z
|
|
||||||
.string()
|
|
||||||
.regex(/^\d+$/)
|
|
||||||
.transform((x) => parseInt(x, 10)),
|
|
||||||
widgetId: z.string().uuid(),
|
|
||||||
configName: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
async function Get(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
const parseResult = getQuerySchema.safeParse(req.query);
|
|
||||||
|
|
||||||
if (!parseResult.success) {
|
|
||||||
return res.status(400).json({
|
|
||||||
statusCode: 400,
|
|
||||||
message: 'Invalid query parameters, please specify the widgetId, month, year and configName',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse req.body as a AppItem
|
|
||||||
const { month, year, widgetId, configName } = parseResult.data;
|
|
||||||
|
|
||||||
const config = getConfig(configName);
|
|
||||||
|
|
||||||
// Find the calendar widget in the config
|
|
||||||
const calendar = config.widgets.find((w) => w.type === 'calendar' && w.id === widgetId);
|
|
||||||
const useSonarrv4 = calendar?.properties.useSonarrv4 ?? false;
|
|
||||||
|
|
||||||
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', useSonarrv4 ? '/api/v3/calendar' : '/api/calendar'],
|
|
||||||
['radarr', '/api/v3/calendar'],
|
|
||||||
['lidarr', '/api/v1/calendar'],
|
|
||||||
['readarr', '/api/v1/calendar'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const medias = await Promise.all(
|
|
||||||
await 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 results = await Promise.all(medias);
|
|
||||||
const countFailed = results.filter((x) => !x.success).length;
|
|
||||||
if (countFailed > 0) {
|
|
||||||
Consola.warn(`A total of ${countFailed} apps for the calendar widget failed`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
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),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
Consola.error(`Error while requesting media from your app. Check your configuration. ${error}`);
|
|
||||||
|
|
||||||
return res.status(500).json({
|
|
||||||
tvShows: [],
|
|
||||||
movies: [],
|
|
||||||
books: [],
|
|
||||||
musics: [],
|
|
||||||
totalCount: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { getConfig } from '../../../../tools/config/getConfig';
|
|
||||||
import { IDashDotTile } from '../../../../widgets/dashDot/DashDotTile';
|
|
||||||
|
|
||||||
const getQuerySchema = z.object({
|
|
||||||
configName: z.string(),
|
|
||||||
widgetId: z.string().uuid(),
|
|
||||||
});
|
|
||||||
|
|
||||||
async function Get(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
const parseResult = getQuerySchema.safeParse(req.query);
|
|
||||||
|
|
||||||
if (!parseResult.success) {
|
|
||||||
return res.status(400).json({
|
|
||||||
statusCode: 400,
|
|
||||||
message: 'Invalid query parameters, please specify the widgetId and configName',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { configName, widgetId } = parseResult.data;
|
|
||||||
|
|
||||||
const config = getConfig(configName);
|
|
||||||
|
|
||||||
const dashDotWidget = config.widgets.find((x) => x.type === 'dashdot' && x.id === widgetId);
|
|
||||||
|
|
||||||
if (!dashDotWidget) {
|
|
||||||
return res.status(400).json({
|
|
||||||
message: 'There is no dashdot widget defined',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const dashDotUrl = (dashDotWidget as IDashDotTile).properties.url;
|
|
||||||
|
|
||||||
if (!dashDotUrl) {
|
|
||||||
return res.status(400).json({
|
|
||||||
message: 'Dashdot url must be defined in config',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the origin URL
|
|
||||||
const url = dashDotUrl.endsWith('/')
|
|
||||||
? dashDotUrl.substring(0, dashDotUrl.length - 1)
|
|
||||||
: dashDotUrl;
|
|
||||||
const response = await axios.get(`${url}/info`);
|
|
||||||
|
|
||||||
// Return the response
|
|
||||||
return res.status(200).json(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
// Filter out if the reuqest is a POST or a GET
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
return Get(req, res);
|
|
||||||
}
|
|
||||||
return res.status(405).json({
|
|
||||||
statusCode: 405,
|
|
||||||
message: 'Method not allowed',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { getConfig } from '../../../../tools/config/getConfig';
|
|
||||||
import { IDashDotTile } from '../../../../widgets/dashDot/DashDotTile';
|
|
||||||
|
|
||||||
const getQuerySchema = z.object({
|
|
||||||
configName: z.string(),
|
|
||||||
widgetId: z.string().uuid(),
|
|
||||||
});
|
|
||||||
|
|
||||||
async function Get(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
const parseResult = getQuerySchema.safeParse(req.query);
|
|
||||||
|
|
||||||
if (!parseResult.success) {
|
|
||||||
return res.status(400).json({
|
|
||||||
statusCode: 400,
|
|
||||||
message: 'Invalid query parameters, please specify the widgetId and configName',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { configName, widgetId } = parseResult.data;
|
|
||||||
|
|
||||||
const config = getConfig(configName);
|
|
||||||
const dashDotWidget = config.widgets.find((x) => x.type === 'dashdot' && x.id === widgetId);
|
|
||||||
|
|
||||||
if (!dashDotWidget) {
|
|
||||||
return res.status(400).json({
|
|
||||||
message: 'There is no dashdot widget defined',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const dashDotUrl = (dashDotWidget as IDashDotTile).properties.url;
|
|
||||||
|
|
||||||
if (!dashDotUrl) {
|
|
||||||
return res.status(400).json({
|
|
||||||
message: 'Dashdot url must be defined in config',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the origin URL
|
|
||||||
const url = dashDotUrl.endsWith('/')
|
|
||||||
? dashDotUrl.substring(0, dashDotUrl.length - 1)
|
|
||||||
: dashDotUrl;
|
|
||||||
const response = await axios.get(`${url}/load/storage`);
|
|
||||||
// Return the response
|
|
||||||
return res.status(200).json(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
// Filter out if the reuqest is a POST or a GET
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
return Get(req, res);
|
|
||||||
}
|
|
||||||
return res.status(405).json({
|
|
||||||
statusCode: 405,
|
|
||||||
message: 'Method not allowed',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
/* eslint-disable no-await-in-loop */
|
|
||||||
import { getCookie } from 'cookies-next';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
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';
|
|
||||||
|
|
||||||
const getQuerySchema = z.object({
|
|
||||||
action: z.enum(['enable', 'disable']),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Post = async (request: NextApiRequest, response: NextApiResponse) => {
|
|
||||||
const configName = getCookie('config-name', { req: request });
|
|
||||||
const config = getConfig(configName?.toString() ?? 'default');
|
|
||||||
|
|
||||||
const parseResult = getQuerySchema.safeParse(request.query);
|
|
||||||
|
|
||||||
if (!parseResult.success) {
|
|
||||||
response.status(400).json({ message: 'invalid query parameters, please specify the status' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const applicableApps = config.apps.filter(
|
|
||||||
(x) => x.integration?.type && ['pihole', 'adGuardHome'].includes(x.integration?.type)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let i = 0; i < applicableApps.length; i += 1) {
|
|
||||||
const app = applicableApps[i];
|
|
||||||
|
|
||||||
if (app.integration?.type === 'pihole') {
|
|
||||||
await processPiHole(app, parseResult.data.action === 'enable');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await processAdGuard(app, parseResult.data.action === 'disable');
|
|
||||||
}
|
|
||||||
|
|
||||||
response.status(200).json({});
|
|
||||||
};
|
|
||||||
|
|
||||||
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, 'apiKey'));
|
|
||||||
|
|
||||||
if (enable) {
|
|
||||||
await pihole.enable();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await pihole.disable();
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async (request: NextApiRequest, response: NextApiResponse) => {
|
|
||||||
if (request.method === 'POST') {
|
|
||||||
return Post(request, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.status(405).json({});
|
|
||||||
};
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import Consola from 'consola';
|
|
||||||
import { createMocks } from 'node-mocks-http';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { ConfigType } from '../../../../types/config';
|
|
||||||
import GetSummary from './summary';
|
|
||||||
|
|
||||||
const mockedGetConfig = vi.fn();
|
|
||||||
|
|
||||||
describe('DNS hole', () => {
|
|
||||||
it('combine and return aggregated data', async () => {
|
|
||||||
// arrange
|
|
||||||
const { req, res } = createMocks({
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./../../../../tools/config/getConfig.ts', () => ({
|
|
||||||
get getConfig() {
|
|
||||||
return mockedGetConfig;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
mockedGetConfig.mockReturnValue({
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
url: 'http://pi.hole',
|
|
||||||
integration: {
|
|
||||||
type: 'pihole',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'apiKey',
|
|
||||||
type: 'private',
|
|
||||||
value: 'hf3829fj238g8',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as ConfigType);
|
|
||||||
const errorLogSpy = vi.spyOn(Consola, 'error');
|
|
||||||
const warningLogSpy = vi.spyOn(Consola, 'warn');
|
|
||||||
|
|
||||||
fetchMock.mockResponse((request) => {
|
|
||||||
if (request.url === 'http://pi.hole/admin/api.php?summaryRaw&auth=hf3829fj238g8') {
|
|
||||||
return JSON.stringify({
|
|
||||||
domains_being_blocked: 780348,
|
|
||||||
dns_queries_today: 36910,
|
|
||||||
ads_blocked_today: 9700,
|
|
||||||
ads_percentage_today: 26.280142,
|
|
||||||
unique_domains: 6217,
|
|
||||||
queries_forwarded: 12943,
|
|
||||||
queries_cached: 13573,
|
|
||||||
clients_ever_seen: 20,
|
|
||||||
unique_clients: 17,
|
|
||||||
dns_queries_all_types: 36910,
|
|
||||||
reply_UNKNOWN: 947,
|
|
||||||
reply_NODATA: 3313,
|
|
||||||
reply_NXDOMAIN: 1244,
|
|
||||||
reply_CNAME: 5265,
|
|
||||||
reply_IP: 25635,
|
|
||||||
reply_DOMAIN: 97,
|
|
||||||
reply_RRNAME: 4,
|
|
||||||
reply_SERVFAIL: 28,
|
|
||||||
reply_REFUSED: 0,
|
|
||||||
reply_NOTIMP: 0,
|
|
||||||
reply_OTHER: 0,
|
|
||||||
reply_DNSSEC: 0,
|
|
||||||
reply_NONE: 0,
|
|
||||||
reply_BLOB: 377,
|
|
||||||
dns_queries_all_replies: 36910,
|
|
||||||
privacy_level: 0,
|
|
||||||
status: 'enabled',
|
|
||||||
gravity_last_updated: {
|
|
||||||
file_exists: true,
|
|
||||||
absolute: 1682216493,
|
|
||||||
relative: {
|
|
||||||
days: 5,
|
|
||||||
hours: 17,
|
|
||||||
minutes: 52,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(new Error(`Bad url: ${request.url}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await GetSummary(req, res);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(res._getStatusCode()).toBe(200);
|
|
||||||
expect(res.finished).toBe(true);
|
|
||||||
expect(JSON.parse(res._getData())).toEqual({
|
|
||||||
adsBlockedToday: 9700,
|
|
||||||
adsBlockedTodayPercentage: 0.26280140883229475,
|
|
||||||
dnsQueriesToday: 36910,
|
|
||||||
domainsBeingBlocked: 780348,
|
|
||||||
status: [
|
|
||||||
{
|
|
||||||
status: 'enabled',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(errorLogSpy).not.toHaveBeenCalled();
|
|
||||||
expect(warningLogSpy).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
errorLogSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('combine and return aggregated data when multiple instances', async () => {
|
|
||||||
// arrange
|
|
||||||
const { req, res } = createMocks({
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./../../../../tools/config/getConfig.ts', () => ({
|
|
||||||
get getConfig() {
|
|
||||||
return mockedGetConfig;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
mockedGetConfig.mockReturnValue({
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
id: 'app1',
|
|
||||||
url: 'http://pi.hole',
|
|
||||||
integration: {
|
|
||||||
type: 'pihole',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'apiKey',
|
|
||||||
type: 'private',
|
|
||||||
value: 'hf3829fj238g8',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'app2',
|
|
||||||
url: 'http://pi2.hole',
|
|
||||||
integration: {
|
|
||||||
type: 'pihole',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'apiKey',
|
|
||||||
type: 'private',
|
|
||||||
value: 'ayaka',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as ConfigType);
|
|
||||||
const errorLogSpy = vi.spyOn(Consola, 'error');
|
|
||||||
const warningLogSpy = vi.spyOn(Consola, 'warn');
|
|
||||||
|
|
||||||
fetchMock.mockResponse((request) => {
|
|
||||||
if (request.url === 'http://pi.hole/admin/api.php?summaryRaw&auth=hf3829fj238g8') {
|
|
||||||
return JSON.stringify({
|
|
||||||
domains_being_blocked: 3,
|
|
||||||
dns_queries_today: 8,
|
|
||||||
ads_blocked_today: 5,
|
|
||||||
ads_percentage_today: 26,
|
|
||||||
unique_domains: 4,
|
|
||||||
queries_forwarded: 2,
|
|
||||||
queries_cached: 2,
|
|
||||||
clients_ever_seen: 2,
|
|
||||||
unique_clients: 3,
|
|
||||||
dns_queries_all_types: 3,
|
|
||||||
reply_UNKNOWN: 2,
|
|
||||||
reply_NODATA: 3,
|
|
||||||
reply_NXDOMAIN: 5,
|
|
||||||
reply_CNAME: 6,
|
|
||||||
reply_IP: 5,
|
|
||||||
reply_DOMAIN: 3,
|
|
||||||
reply_RRNAME: 2,
|
|
||||||
reply_SERVFAIL: 2,
|
|
||||||
reply_REFUSED: 0,
|
|
||||||
reply_NOTIMP: 0,
|
|
||||||
reply_OTHER: 0,
|
|
||||||
reply_DNSSEC: 0,
|
|
||||||
reply_NONE: 0,
|
|
||||||
reply_BLOB: 1,
|
|
||||||
dns_queries_all_replies: 36910,
|
|
||||||
privacy_level: 0,
|
|
||||||
status: 'enabled',
|
|
||||||
gravity_last_updated: {
|
|
||||||
file_exists: true,
|
|
||||||
absolute: 1682216493,
|
|
||||||
relative: {
|
|
||||||
days: 5,
|
|
||||||
hours: 17,
|
|
||||||
minutes: 52,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.url === 'http://pi2.hole/admin/api.php?summaryRaw&auth=ayaka') {
|
|
||||||
return JSON.stringify({
|
|
||||||
domains_being_blocked: 1,
|
|
||||||
dns_queries_today: 3,
|
|
||||||
ads_blocked_today: 2,
|
|
||||||
ads_percentage_today: 47,
|
|
||||||
unique_domains: 4,
|
|
||||||
queries_forwarded: 4,
|
|
||||||
queries_cached: 2,
|
|
||||||
clients_ever_seen: 2,
|
|
||||||
unique_clients: 2,
|
|
||||||
dns_queries_all_types: 1,
|
|
||||||
reply_UNKNOWN: 3,
|
|
||||||
reply_NODATA: 2,
|
|
||||||
reply_NXDOMAIN: 1,
|
|
||||||
reply_CNAME: 3,
|
|
||||||
reply_IP: 2,
|
|
||||||
reply_DOMAIN: 97,
|
|
||||||
reply_RRNAME: 4,
|
|
||||||
reply_SERVFAIL: 28,
|
|
||||||
reply_REFUSED: 0,
|
|
||||||
reply_NOTIMP: 0,
|
|
||||||
reply_OTHER: 0,
|
|
||||||
reply_DNSSEC: 0,
|
|
||||||
reply_NONE: 0,
|
|
||||||
reply_BLOB: 2,
|
|
||||||
dns_queries_all_replies: 4,
|
|
||||||
privacy_level: 0,
|
|
||||||
status: 'disabled',
|
|
||||||
gravity_last_updated: {
|
|
||||||
file_exists: true,
|
|
||||||
absolute: 1682216493,
|
|
||||||
relative: {
|
|
||||||
days: 5,
|
|
||||||
hours: 17,
|
|
||||||
minutes: 52,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(new Error(`Bad url: ${request.url}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await GetSummary(req, res);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(res._getStatusCode()).toBe(200);
|
|
||||||
expect(res.finished).toBe(true);
|
|
||||||
expect(JSON.parse(res._getData())).toStrictEqual({
|
|
||||||
adsBlockedToday: 7,
|
|
||||||
adsBlockedTodayPercentage: 0.6363636363636364,
|
|
||||||
dnsQueriesToday: 11,
|
|
||||||
domainsBeingBlocked: 4,
|
|
||||||
status: [
|
|
||||||
{
|
|
||||||
appId: 'app1',
|
|
||||||
status: 'enabled',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
appId: 'app2',
|
|
||||||
status: 'disabled',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(errorLogSpy).not.toHaveBeenCalled();
|
|
||||||
expect(warningLogSpy).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
errorLogSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
/* eslint-disable no-await-in-loop */
|
|
||||||
import Consola from 'consola';
|
|
||||||
import { getCookie } from 'cookies-next';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
|
|
||||||
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 { AdStatistics } from '../../../../widgets/dnshole/type';
|
|
||||||
|
|
||||||
export const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
|
||||||
const configName = getCookie('config-name', { req: request });
|
|
||||||
const config = getConfig(configName?.toString() ?? 'default');
|
|
||||||
|
|
||||||
const applicableApps = config.apps.filter(
|
|
||||||
(x) => x.integration?.type && ['pihole', 'adGuardHome'].includes(x.integration?.type)
|
|
||||||
);
|
|
||||||
|
|
||||||
const data: AdStatistics = {
|
|
||||||
domainsBeingBlocked: 0,
|
|
||||||
adsBlockedToday: 0,
|
|
||||||
adsBlockedTodayPercentage: 0,
|
|
||||||
dnsQueriesToday: 0,
|
|
||||||
status: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const adsBlockedTodayPercentageArr: number[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < applicableApps.length; i += 1) {
|
|
||||||
const app = applicableApps[i];
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (app.integration?.type) {
|
|
||||||
case 'pihole': {
|
|
||||||
const piHole = new PiHoleClient(app.url, findAppProperty(app, 'apiKey'));
|
|
||||||
const summary = await piHole.getSummary();
|
|
||||||
|
|
||||||
data.domainsBeingBlocked += summary.domains_being_blocked;
|
|
||||||
data.adsBlockedToday += summary.ads_blocked_today;
|
|
||||||
data.dnsQueriesToday += summary.dns_queries_today;
|
|
||||||
data.status.push({
|
|
||||||
status: summary.status,
|
|
||||||
appId: app.id,
|
|
||||||
});
|
|
||||||
adsBlockedTodayPercentageArr.push(summary.ads_percentage_today);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'adGuardHome': {
|
|
||||||
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);
|
|
||||||
data.adsBlockedToday = blockedQueriesToday;
|
|
||||||
data.domainsBeingBlocked += countFilteredDomains;
|
|
||||||
data.dnsQueriesToday += queriesToday;
|
|
||||||
data.status.push({
|
|
||||||
status: status.protection_enabled ? 'enabled' : 'disabled',
|
|
||||||
appId: app.id,
|
|
||||||
});
|
|
||||||
adsBlockedTodayPercentageArr.push((queriesToday / blockedQueriesToday) * 100);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
Consola.error(`Integration communication for app ${app.id} failed: unknown type`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
Consola.error(`Failed to communicate with DNS hole at ${app.url}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data.adsBlockedTodayPercentage = data.adsBlockedToday / data.dnsQueriesToday;
|
|
||||||
if (Number.isNaN(data.adsBlockedTodayPercentage)) {
|
|
||||||
data.adsBlockedTodayPercentage = 0;
|
|
||||||
}
|
|
||||||
return response.status(200).json(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async (request: NextApiRequest, response: NextApiResponse) => {
|
|
||||||
if (request.method === 'GET') {
|
|
||||||
return Get(request, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.status(405);
|
|
||||||
};
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
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 { getCookie } from 'cookies-next';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
import { Client } from 'sabnzbd-api';
|
|
||||||
import { findAppProperty } from '~/tools/client/app-properties';
|
|
||||||
|
|
||||||
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';
|
|
||||||
|
|
||||||
const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
|
||||||
const configName = getCookie('config-name', { req: request });
|
|
||||||
const config = getConfig(configName?.toString() ?? 'default');
|
|
||||||
|
|
||||||
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 response.status(200).json(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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async (request: NextApiRequest, response: NextApiResponse) => {
|
|
||||||
if (request.method === 'GET') {
|
|
||||||
return Get(request, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.status(405);
|
|
||||||
};
|
|
||||||
|
|
||||||
function getNzbgetState(status: string) {
|
|
||||||
switch (status) {
|
|
||||||
case 'QUEUED':
|
|
||||||
return 'queued';
|
|
||||||
case 'PAUSED ':
|
|
||||||
return 'paused';
|
|
||||||
default:
|
|
||||||
return 'downloading';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,534 +0,0 @@
|
|||||||
import Consola from 'consola';
|
|
||||||
import { createMocks } from 'node-mocks-http';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import 'vitest-fetch-mock';
|
|
||||||
|
|
||||||
import { ConfigType } from '../../../../types/config';
|
|
||||||
import MediaRequestsRoute from './index';
|
|
||||||
|
|
||||||
const mockedGetConfig = vi.fn();
|
|
||||||
|
|
||||||
describe('media-requests api', () => {
|
|
||||||
it('reduce when empty list of requests', async () => {
|
|
||||||
// Arrange
|
|
||||||
const { req, res } = createMocks();
|
|
||||||
|
|
||||||
vi.mock('./../../../../tools/config/getConfig.ts', () => ({
|
|
||||||
get getConfig() {
|
|
||||||
return mockedGetConfig;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
mockedGetConfig.mockReturnValue({
|
|
||||||
apps: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await MediaRequestsRoute(req, res);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(res._getStatusCode()).toBe(200);
|
|
||||||
expect(res.finished).toBe(true);
|
|
||||||
expect(JSON.parse(res._getData())).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('log error when fetch was not successful', async () => {
|
|
||||||
// Arrange
|
|
||||||
const { req, res } = createMocks();
|
|
||||||
|
|
||||||
vi.mock('./../../../../tools/config/getConfig.ts', () => ({
|
|
||||||
get getConfig() {
|
|
||||||
return mockedGetConfig;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
mockedGetConfig.mockReturnValue({
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
integration: {
|
|
||||||
type: 'overseerr',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'apiKey',
|
|
||||||
type: 'private',
|
|
||||||
value: 'abc',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as ConfigType);
|
|
||||||
const logSpy = vi.spyOn(Consola, 'error');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await MediaRequestsRoute(req, res);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(res._getStatusCode()).toBe(200);
|
|
||||||
expect(res.finished).toBe(true);
|
|
||||||
expect(JSON.parse(res._getData())).toEqual([]);
|
|
||||||
|
|
||||||
expect(logSpy).toHaveBeenCalledOnce();
|
|
||||||
expect(logSpy.mock.lastCall).toEqual([
|
|
||||||
'Failed to request data from Overseerr: FetchError: invalid json response body at reason: Unexpected end of JSON input',
|
|
||||||
]);
|
|
||||||
|
|
||||||
logSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetch and return requests in response with external url', async () => {
|
|
||||||
// Arrange
|
|
||||||
const { req, res } = createMocks({
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./../../../../tools/config/getConfig.ts', () => ({
|
|
||||||
get getConfig() {
|
|
||||||
return mockedGetConfig;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
mockedGetConfig.mockReturnValue({
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
url: 'http://my-overseerr.local',
|
|
||||||
behaviour: {
|
|
||||||
externalUrl: 'http://my-overseerr.external',
|
|
||||||
},
|
|
||||||
integration: {
|
|
||||||
type: 'overseerr',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'apiKey',
|
|
||||||
type: 'private',
|
|
||||||
value: 'abc',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
widgets: [
|
|
||||||
{
|
|
||||||
id: 'hjeruijgrig',
|
|
||||||
type: 'media-requests-list',
|
|
||||||
properties: {
|
|
||||||
replaceLinksWithExternalHost: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as unknown as ConfigType);
|
|
||||||
const logSpy = vi.spyOn(Consola, 'error');
|
|
||||||
|
|
||||||
fetchMock.mockResponse((request) => {
|
|
||||||
if (request.url === 'http://my-overseerr.local/api/v1/request?take=25&skip=0&sort=added') {
|
|
||||||
return JSON.stringify({
|
|
||||||
pageInfo: { pages: 3, pageSize: 20, results: 42, page: 1 },
|
|
||||||
results: [
|
|
||||||
{
|
|
||||||
id: 44,
|
|
||||||
status: 2,
|
|
||||||
createdAt: '2023-04-06T19:38:45.000Z',
|
|
||||||
updatedAt: '2023-04-06T19:38:45.000Z',
|
|
||||||
type: 'movie',
|
|
||||||
is4k: false,
|
|
||||||
serverId: 0,
|
|
||||||
profileId: 4,
|
|
||||||
tags: [],
|
|
||||||
isAutoRequest: false,
|
|
||||||
media: {
|
|
||||||
downloadStatus: [],
|
|
||||||
downloadStatus4k: [],
|
|
||||||
id: 999,
|
|
||||||
mediaType: 'movie',
|
|
||||||
tmdbId: 99999999,
|
|
||||||
tvdbId: null,
|
|
||||||
imdbId: null,
|
|
||||||
status: 5,
|
|
||||||
status4k: 1,
|
|
||||||
createdAt: '2023-02-06T19:38:45.000Z',
|
|
||||||
updatedAt: '2023-02-06T20:00:04.000Z',
|
|
||||||
lastSeasonChange: '2023-08-06T19:38:45.000Z',
|
|
||||||
mediaAddedAt: '2023-05-14T06:30:34.000Z',
|
|
||||||
serviceId: 0,
|
|
||||||
serviceId4k: null,
|
|
||||||
externalServiceId: 32,
|
|
||||||
externalServiceId4k: null,
|
|
||||||
externalServiceSlug: '000000000000',
|
|
||||||
externalServiceSlug4k: null,
|
|
||||||
ratingKey: null,
|
|
||||||
ratingKey4k: null,
|
|
||||||
jellyfinMediaId: '0000',
|
|
||||||
jellyfinMediaId4k: null,
|
|
||||||
mediaUrl:
|
|
||||||
'http://your-jellyfin.local/web/index.html#!/details?id=mn8q2j4gq038g&context=home&serverId=jf83fj34gm340g',
|
|
||||||
serviceUrl: 'http://your-jellyfin.local/movie/0000',
|
|
||||||
},
|
|
||||||
seasons: [],
|
|
||||||
modifiedBy: {
|
|
||||||
permissions: 2,
|
|
||||||
warnings: [],
|
|
||||||
id: 1,
|
|
||||||
email: 'example-user@homarr.dev',
|
|
||||||
plexUsername: null,
|
|
||||||
jellyfinUsername: 'example-user',
|
|
||||||
username: null,
|
|
||||||
recoveryLinkExpirationDate: null,
|
|
||||||
userType: 3,
|
|
||||||
plexId: null,
|
|
||||||
jellyfinUserId: '00000000000000000',
|
|
||||||
jellyfinDeviceId: '111111111111111111',
|
|
||||||
jellyfinAuthToken: '2222222222222222222',
|
|
||||||
plexToken: null,
|
|
||||||
avatar: '/os_logo_square.png',
|
|
||||||
movieQuotaLimit: null,
|
|
||||||
movieQuotaDays: null,
|
|
||||||
tvQuotaLimit: null,
|
|
||||||
tvQuotaDays: null,
|
|
||||||
createdAt: '2022-07-03T19:53:08.000Z',
|
|
||||||
updatedAt: '2022-07-03T19:53:08.000Z',
|
|
||||||
requestCount: 34,
|
|
||||||
displayName: 'Example User',
|
|
||||||
},
|
|
||||||
requestedBy: {
|
|
||||||
permissions: 2,
|
|
||||||
warnings: [],
|
|
||||||
id: 1,
|
|
||||||
email: 'example-user@homarr.dev',
|
|
||||||
plexUsername: null,
|
|
||||||
jellyfinUsername: 'example-user',
|
|
||||||
username: null,
|
|
||||||
recoveryLinkExpirationDate: null,
|
|
||||||
userType: 3,
|
|
||||||
plexId: null,
|
|
||||||
jellyfinUserId: '00000000000000000',
|
|
||||||
jellyfinDeviceId: '111111111111111111',
|
|
||||||
jellyfinAuthToken: '2222222222222222222',
|
|
||||||
plexToken: null,
|
|
||||||
avatar: '/os_logo_square.png',
|
|
||||||
movieQuotaLimit: null,
|
|
||||||
movieQuotaDays: null,
|
|
||||||
tvQuotaLimit: null,
|
|
||||||
tvQuotaDays: null,
|
|
||||||
createdAt: '2022-07-03T19:53:08.000Z',
|
|
||||||
updatedAt: '2022-07-03T19:53:08.000Z',
|
|
||||||
requestCount: 34,
|
|
||||||
displayName: 'Example User',
|
|
||||||
},
|
|
||||||
seasonCount: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.url === 'http://my-overseerr.local/api/v1/movie/99999999') {
|
|
||||||
return JSON.stringify({
|
|
||||||
id: 0,
|
|
||||||
adult: false,
|
|
||||||
budget: 0,
|
|
||||||
genres: [
|
|
||||||
{
|
|
||||||
id: 18,
|
|
||||||
name: 'Dashboards',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
relatedVideos: [],
|
|
||||||
originalLanguage: 'jp',
|
|
||||||
originalTitle: 'Homarrrr Movie',
|
|
||||||
popularity: 9.352,
|
|
||||||
productionCompanies: [],
|
|
||||||
productionCountries: [],
|
|
||||||
releaseDate: '2023-12-08',
|
|
||||||
releases: {
|
|
||||||
results: [],
|
|
||||||
},
|
|
||||||
revenue: 0,
|
|
||||||
spokenLanguages: [
|
|
||||||
{
|
|
||||||
english_name: 'Japanese',
|
|
||||||
iso_639_1: 'jp',
|
|
||||||
name: '日本語',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
status: 'Released',
|
|
||||||
title: 'Homarr Movie',
|
|
||||||
video: false,
|
|
||||||
voteAverage: 9.999,
|
|
||||||
voteCount: 0,
|
|
||||||
backdropPath: '/mhjq8jr0qgrjnghnh.jpg',
|
|
||||||
homepage: '',
|
|
||||||
imdbId: 'tt0000000',
|
|
||||||
overview: 'A very cool movie',
|
|
||||||
posterPath: '/hf4j0928gq543njgh8935nqh8.jpg',
|
|
||||||
runtime: 97,
|
|
||||||
tagline: '',
|
|
||||||
credits: {},
|
|
||||||
collection: null,
|
|
||||||
externalIds: {
|
|
||||||
facebookId: null,
|
|
||||||
imdbId: null,
|
|
||||||
instagramId: null,
|
|
||||||
twitterId: null,
|
|
||||||
},
|
|
||||||
watchProviders: [],
|
|
||||||
keywords: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(new Error(`Bad url: ${request.url}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await MediaRequestsRoute(req, res);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(res._getStatusCode()).toBe(200);
|
|
||||||
expect(res.finished).toBe(true);
|
|
||||||
expect(JSON.parse(res._getData())).toEqual([
|
|
||||||
{
|
|
||||||
airDate: '2023-12-08',
|
|
||||||
backdropPath: 'https://image.tmdb.org/t/p/original//mhjq8jr0qgrjnghnh.jpg',
|
|
||||||
createdAt: '2023-04-06T19:38:45.000Z',
|
|
||||||
href: 'http://my-overseerr.external/movie/99999999',
|
|
||||||
id: 44,
|
|
||||||
name: 'Homarrrr Movie',
|
|
||||||
posterPath:
|
|
||||||
'https://image.tmdb.org/t/p/w600_and_h900_bestv2//hf4j0928gq543njgh8935nqh8.jpg',
|
|
||||||
status: 2,
|
|
||||||
type: 'movie',
|
|
||||||
userLink: 'http://my-overseerr.external/users/1',
|
|
||||||
userName: 'Example User',
|
|
||||||
userProfilePicture: 'http://my-overseerr.external//os_logo_square.png',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(logSpy).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
logSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetch and return requests in response with internal url', async () => {
|
|
||||||
// Arrange
|
|
||||||
const { req, res } = createMocks({
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./../../../../tools/config/getConfig.ts', () => ({
|
|
||||||
get getConfig() {
|
|
||||||
return mockedGetConfig;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
mockedGetConfig.mockReturnValue({
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
url: 'http://my-overseerr.local',
|
|
||||||
behaviour: {
|
|
||||||
externalUrl: 'http://my-overseerr.external',
|
|
||||||
},
|
|
||||||
integration: {
|
|
||||||
type: 'overseerr',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'apiKey',
|
|
||||||
type: 'private',
|
|
||||||
value: 'abc',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
widgets: [
|
|
||||||
{
|
|
||||||
id: 'hjeruijgrig',
|
|
||||||
type: 'media-requests-list',
|
|
||||||
properties: {
|
|
||||||
replaceLinksWithExternalHost: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as unknown as ConfigType);
|
|
||||||
const logSpy = vi.spyOn(Consola, 'error');
|
|
||||||
|
|
||||||
fetchMock.mockResponse((request) => {
|
|
||||||
if (request.url === 'http://my-overseerr.local/api/v1/request?take=25&skip=0&sort=added') {
|
|
||||||
return JSON.stringify({
|
|
||||||
pageInfo: { pages: 3, pageSize: 20, results: 42, page: 1 },
|
|
||||||
results: [
|
|
||||||
{
|
|
||||||
id: 44,
|
|
||||||
status: 2,
|
|
||||||
createdAt: '2023-04-06T19:38:45.000Z',
|
|
||||||
updatedAt: '2023-04-06T19:38:45.000Z',
|
|
||||||
type: 'movie',
|
|
||||||
is4k: false,
|
|
||||||
serverId: 0,
|
|
||||||
profileId: 4,
|
|
||||||
tags: [],
|
|
||||||
isAutoRequest: false,
|
|
||||||
media: {
|
|
||||||
downloadStatus: [],
|
|
||||||
downloadStatus4k: [],
|
|
||||||
id: 999,
|
|
||||||
mediaType: 'movie',
|
|
||||||
tmdbId: 99999999,
|
|
||||||
tvdbId: null,
|
|
||||||
imdbId: null,
|
|
||||||
status: 5,
|
|
||||||
status4k: 1,
|
|
||||||
createdAt: '2023-02-06T19:38:45.000Z',
|
|
||||||
updatedAt: '2023-02-06T20:00:04.000Z',
|
|
||||||
lastSeasonChange: '2023-08-06T19:38:45.000Z',
|
|
||||||
mediaAddedAt: '2023-05-14T06:30:34.000Z',
|
|
||||||
serviceId: 0,
|
|
||||||
serviceId4k: null,
|
|
||||||
externalServiceId: 32,
|
|
||||||
externalServiceId4k: null,
|
|
||||||
externalServiceSlug: '000000000000',
|
|
||||||
externalServiceSlug4k: null,
|
|
||||||
ratingKey: null,
|
|
||||||
ratingKey4k: null,
|
|
||||||
jellyfinMediaId: '0000',
|
|
||||||
jellyfinMediaId4k: null,
|
|
||||||
mediaUrl:
|
|
||||||
'http://your-jellyfin.local/web/index.html#!/details?id=mn8q2j4gq038g&context=home&serverId=jf83fj34gm340g',
|
|
||||||
serviceUrl: 'http://your-jellyfin.local/movie/0000',
|
|
||||||
},
|
|
||||||
seasons: [],
|
|
||||||
modifiedBy: {
|
|
||||||
permissions: 2,
|
|
||||||
warnings: [],
|
|
||||||
id: 1,
|
|
||||||
email: 'example-user@homarr.dev',
|
|
||||||
plexUsername: null,
|
|
||||||
jellyfinUsername: 'example-user',
|
|
||||||
username: null,
|
|
||||||
recoveryLinkExpirationDate: null,
|
|
||||||
userType: 3,
|
|
||||||
plexId: null,
|
|
||||||
jellyfinUserId: '00000000000000000',
|
|
||||||
jellyfinDeviceId: '111111111111111111',
|
|
||||||
jellyfinAuthToken: '2222222222222222222',
|
|
||||||
plexToken: null,
|
|
||||||
avatar: '/os_logo_square.png',
|
|
||||||
movieQuotaLimit: null,
|
|
||||||
movieQuotaDays: null,
|
|
||||||
tvQuotaLimit: null,
|
|
||||||
tvQuotaDays: null,
|
|
||||||
createdAt: '2022-07-03T19:53:08.000Z',
|
|
||||||
updatedAt: '2022-07-03T19:53:08.000Z',
|
|
||||||
requestCount: 34,
|
|
||||||
displayName: 'Example User',
|
|
||||||
},
|
|
||||||
requestedBy: {
|
|
||||||
permissions: 2,
|
|
||||||
warnings: [],
|
|
||||||
id: 1,
|
|
||||||
email: 'example-user@homarr.dev',
|
|
||||||
plexUsername: null,
|
|
||||||
jellyfinUsername: 'example-user',
|
|
||||||
username: null,
|
|
||||||
recoveryLinkExpirationDate: null,
|
|
||||||
userType: 3,
|
|
||||||
plexId: null,
|
|
||||||
jellyfinUserId: '00000000000000000',
|
|
||||||
jellyfinDeviceId: '111111111111111111',
|
|
||||||
jellyfinAuthToken: '2222222222222222222',
|
|
||||||
plexToken: null,
|
|
||||||
avatar: '/os_logo_square.png',
|
|
||||||
movieQuotaLimit: null,
|
|
||||||
movieQuotaDays: null,
|
|
||||||
tvQuotaLimit: null,
|
|
||||||
tvQuotaDays: null,
|
|
||||||
createdAt: '2022-07-03T19:53:08.000Z',
|
|
||||||
updatedAt: '2022-07-03T19:53:08.000Z',
|
|
||||||
requestCount: 34,
|
|
||||||
displayName: 'Example User',
|
|
||||||
},
|
|
||||||
seasonCount: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.url === 'http://my-overseerr.local/api/v1/movie/99999999') {
|
|
||||||
return JSON.stringify({
|
|
||||||
id: 0,
|
|
||||||
adult: false,
|
|
||||||
budget: 0,
|
|
||||||
genres: [
|
|
||||||
{
|
|
||||||
id: 18,
|
|
||||||
name: 'Dashboards',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
relatedVideos: [],
|
|
||||||
originalLanguage: 'jp',
|
|
||||||
originalTitle: 'Homarrrr Movie',
|
|
||||||
popularity: 9.352,
|
|
||||||
productionCompanies: [],
|
|
||||||
productionCountries: [],
|
|
||||||
releaseDate: '2023-12-08',
|
|
||||||
releases: {
|
|
||||||
results: [],
|
|
||||||
},
|
|
||||||
revenue: 0,
|
|
||||||
spokenLanguages: [
|
|
||||||
{
|
|
||||||
english_name: 'Japanese',
|
|
||||||
iso_639_1: 'jp',
|
|
||||||
name: '日本語',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
status: 'Released',
|
|
||||||
title: 'Homarr Movie',
|
|
||||||
video: false,
|
|
||||||
voteAverage: 9.999,
|
|
||||||
voteCount: 0,
|
|
||||||
backdropPath: '/mhjq8jr0qgrjnghnh.jpg',
|
|
||||||
homepage: '',
|
|
||||||
imdbId: 'tt0000000',
|
|
||||||
overview: 'A very cool movie',
|
|
||||||
posterPath: '/hf4j0928gq543njgh8935nqh8.jpg',
|
|
||||||
runtime: 97,
|
|
||||||
tagline: '',
|
|
||||||
credits: {},
|
|
||||||
collection: null,
|
|
||||||
externalIds: {
|
|
||||||
facebookId: null,
|
|
||||||
imdbId: null,
|
|
||||||
instagramId: null,
|
|
||||||
twitterId: null,
|
|
||||||
},
|
|
||||||
watchProviders: [],
|
|
||||||
keywords: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(new Error(`Bad url: ${request.url}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await MediaRequestsRoute(req, res);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(res._getStatusCode()).toBe(200);
|
|
||||||
expect(res.finished).toBe(true);
|
|
||||||
expect(JSON.parse(res._getData())).toEqual([
|
|
||||||
{
|
|
||||||
airDate: '2023-12-08',
|
|
||||||
backdropPath: 'https://image.tmdb.org/t/p/original//mhjq8jr0qgrjnghnh.jpg',
|
|
||||||
createdAt: '2023-04-06T19:38:45.000Z',
|
|
||||||
href: 'http://my-overseerr.local/movie/99999999',
|
|
||||||
id: 44,
|
|
||||||
name: 'Homarrrr Movie',
|
|
||||||
posterPath:
|
|
||||||
'https://image.tmdb.org/t/p/w600_and_h900_bestv2//hf4j0928gq543njgh8935nqh8.jpg',
|
|
||||||
status: 2,
|
|
||||||
type: 'movie',
|
|
||||||
userLink: 'http://my-overseerr.local/users/1',
|
|
||||||
userName: 'Example User',
|
|
||||||
userProfilePicture: 'http://my-overseerr.local//os_logo_square.png',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(logSpy).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
logSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import Consola from 'consola';
|
|
||||||
import { getCookie } from 'cookies-next';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
import { checkIntegrationsType } from '~/tools/client/app-properties';
|
|
||||||
|
|
||||||
import { getConfig } from '../../../../tools/config/getConfig';
|
|
||||||
import { MediaRequestListWidget } from '../../../../widgets/media-requests/MediaRequestListTile';
|
|
||||||
import { MediaRequest } from '../../../../widgets/media-requests/media-request-types';
|
|
||||||
|
|
||||||
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) =>
|
|
||||||
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 response.status(200).json(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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async (request: NextApiRequest, response: NextApiResponse) => {
|
|
||||||
if (request.method === 'GET') {
|
|
||||||
return Get(request, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.status(405);
|
|
||||||
};
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
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 { getCookie } from 'cookies-next';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
import { checkIntegrationsType, findAppProperty } from '~/tools/client/app-properties';
|
|
||||||
|
|
||||||
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';
|
|
||||||
|
|
||||||
const jellyfin = new Jellyfin({
|
|
||||||
clientInfo: {
|
|
||||||
name: 'Homarr',
|
|
||||||
version: '0.0.1',
|
|
||||||
},
|
|
||||||
deviceInfo: {
|
|
||||||
name: 'Homarr Jellyfin Widget',
|
|
||||||
id: 'homarr-jellyfin-widget',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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) =>
|
|
||||||
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 response.status(200).json({
|
|
||||||
servers: servers.filter((server) => server !== undefined),
|
|
||||||
} as 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async (request: NextApiRequest, response: NextApiResponse) => {
|
|
||||||
if (request.method === 'GET') {
|
|
||||||
return Get(request, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.status(405);
|
|
||||||
};
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import Consola from 'consola';
|
|
||||||
import { getCookie } from 'cookies-next';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
|
|
||||||
import type { MediaType } from '../../../../modules/overseerr/SearchResult';
|
|
||||||
import { getConfig } from '../../../../tools/config/getConfig';
|
|
||||||
|
|
||||||
async function Get(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
// Get the slug of the request
|
|
||||||
const { id, type } = req.query as { id: string; type: string };
|
|
||||||
const configName = getCookie('config-name', { req });
|
|
||||||
const config = getConfig(configName?.toString() ?? 'default');
|
|
||||||
const app = config.apps.find(
|
|
||||||
(app) => app.integration?.type === 'overseerr' || app.integration?.type === 'jellyseerr'
|
|
||||||
);
|
|
||||||
if (!id) {
|
|
||||||
return res.status(400).json({ error: 'No id provided' });
|
|
||||||
}
|
|
||||||
if (!type) {
|
|
||||||
return res.status(400).json({ error: 'No type provided' });
|
|
||||||
}
|
|
||||||
const apiKey = app?.integration?.properties.find((x) => x.field === 'apiKey')?.value;
|
|
||||||
if (!apiKey) {
|
|
||||||
return res.status(400).json({ error: 'No apps found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const appUrl = new URL(app.url);
|
|
||||||
switch (type) {
|
|
||||||
case 'movie':
|
|
||||||
return axios
|
|
||||||
.get(`${appUrl.origin}/api/v1/movie/${id}`, {
|
|
||||||
headers: {
|
|
||||||
// Set X-Api-Key to the value of the API key
|
|
||||||
'X-Api-Key': apiKey,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((axiosres) => res.status(200).json(axiosres.data))
|
|
||||||
|
|
||||||
.catch((err) => {
|
|
||||||
Consola.error(err);
|
|
||||||
return res.status(500).json({
|
|
||||||
message: 'Something went wrong',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
case 'tv':
|
|
||||||
// Make request to the tv api
|
|
||||||
return axios
|
|
||||||
.get(`${appUrl.origin}/api/v1/tv/${id}`, {
|
|
||||||
headers: {
|
|
||||||
// Set X-Api-Key to the value of the API key
|
|
||||||
'X-Api-Key': apiKey,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((axiosres) => res.status(200).json(axiosres.data))
|
|
||||||
.catch((err) => {
|
|
||||||
Consola.error(err);
|
|
||||||
return res.status(500).json({
|
|
||||||
message: 'Something went wrong',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
default:
|
|
||||||
return res.status(400).json({
|
|
||||||
message: 'Wrong request, type should be movie or tv',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function Post(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
// Get the slug of the request
|
|
||||||
const { id } = req.query as { id: string };
|
|
||||||
const { seasons, type } = req.body as { seasons?: number[]; type: MediaType };
|
|
||||||
const configName = getCookie('config-name', { req });
|
|
||||||
const config = getConfig(configName?.toString() ?? 'default');
|
|
||||||
const app = config.apps.find(
|
|
||||||
(app) => app.integration?.type === 'overseerr' || app.integration?.type === 'jellyseerr'
|
|
||||||
);
|
|
||||||
if (!id) {
|
|
||||||
return res.status(400).json({ error: 'No id provided' });
|
|
||||||
}
|
|
||||||
if (!type) {
|
|
||||||
return res.status(400).json({ error: 'No type provided' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = app?.integration?.properties.find((x) => x.field === 'apiKey')?.value;
|
|
||||||
if (!apiKey) {
|
|
||||||
return res.status(400).json({ error: 'No app found' });
|
|
||||||
}
|
|
||||||
if (type === 'movie' && !seasons) {
|
|
||||||
return res.status(400).json({ error: 'No seasons provided' });
|
|
||||||
}
|
|
||||||
const appUrl = new URL(app.url);
|
|
||||||
Consola.info('Got an Overseerr request with these arguments', {
|
|
||||||
mediaType: type,
|
|
||||||
mediaId: id,
|
|
||||||
seasons,
|
|
||||||
});
|
|
||||||
return axios
|
|
||||||
.post(
|
|
||||||
`${appUrl.origin}/api/v1/request`,
|
|
||||||
{
|
|
||||||
mediaType: type,
|
|
||||||
mediaId: Number(id),
|
|
||||||
seasons,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
// Set X-Api-Key to the value of the API key
|
|
||||||
'X-Api-Key': apiKey,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then((axiosres) => res.status(200).json(axiosres.data))
|
|
||||||
.catch((err) =>
|
|
||||||
res.status(500).json({
|
|
||||||
message: err.message,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function Put(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
// Get the slug of the request
|
|
||||||
const { id, action } = req.query as { id: string; action: string };
|
|
||||||
const configName = getCookie('config-name', { req });
|
|
||||||
const config = getConfig(configName?.toString() ?? 'default');
|
|
||||||
Consola.log('Got a request to approve or decline a request', id, action);
|
|
||||||
const app = config.apps.find(
|
|
||||||
(app) => app.integration?.type === 'overseerr' || app.integration?.type === 'jellyseerr'
|
|
||||||
);
|
|
||||||
if (!id) {
|
|
||||||
return res.status(400).json({ error: 'No id provided' });
|
|
||||||
}
|
|
||||||
if (action !== 'approve' && action !== 'decline') {
|
|
||||||
return res.status(400).json({ error: 'Action type undefined' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = app?.integration?.properties.find((x) => x.field === 'apiKey')?.value;
|
|
||||||
if (!apiKey) {
|
|
||||||
return res.status(400).json({ error: 'No app found' });
|
|
||||||
}
|
|
||||||
const appUrl = new URL(app.url);
|
|
||||||
return axios
|
|
||||||
.post(
|
|
||||||
`${appUrl.origin}/api/v1/request/${id}/${action}`,
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'X-Api-Key': apiKey,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then((axiosres) => res.status(200).json(axiosres.data))
|
|
||||||
.catch((err) =>
|
|
||||||
res.status(500).json({
|
|
||||||
message: err.message,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
if (req.method === 'POST') {
|
|
||||||
return Post(req, res);
|
|
||||||
}
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
return Get(req, res);
|
|
||||||
}
|
|
||||||
if (req.method === 'PUT') {
|
|
||||||
return Put(req, res);
|
|
||||||
}
|
|
||||||
return res.status(405).json({
|
|
||||||
statusCode: 405,
|
|
||||||
message: 'Method not allowed',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import { getCookie } from 'cookies-next';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
|
|
||||||
import { getConfig } from '../../../../tools/config/getConfig';
|
|
||||||
|
|
||||||
async function Get(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
const configName = getCookie('config-name', { req });
|
|
||||||
const config = getConfig(configName?.toString() ?? 'default');
|
|
||||||
const { query } = req.query;
|
|
||||||
const app = config.apps.find(
|
|
||||||
(app) => app.integration?.type === 'overseerr' || app.integration?.type === 'jellyseerr'
|
|
||||||
);
|
|
||||||
// If query is an empty string, return an empty array
|
|
||||||
if (query === '' || query === undefined) {
|
|
||||||
return res.status(200).json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = app?.integration?.properties.find((x) => x.field === 'apiKey')?.value;
|
|
||||||
if (!app || !query || !apiKey) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Wrong request',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const appUrl = new URL(app.url);
|
|
||||||
const data = await axios
|
|
||||||
.get(`${appUrl.origin}/api/v1/search?query=${query}`, {
|
|
||||||
headers: {
|
|
||||||
// Set X-Api-Key to the value of the API key
|
|
||||||
'X-Api-Key': apiKey,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((res) => res.data);
|
|
||||||
// Get login, password and url from the body
|
|
||||||
res.status(200).json(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
// Filter out if the reuqest is a POST or a GET
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
return Get(req, res);
|
|
||||||
}
|
|
||||||
return res.status(405).json({
|
|
||||||
statusCode: 405,
|
|
||||||
message: 'Method not allowed',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import Consola from 'consola';
|
|
||||||
import { getCookie } from 'cookies-next';
|
|
||||||
import { decode, encode } from 'html-entities';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
import Parser 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';
|
|
||||||
|
|
||||||
type CustomItem = {
|
|
||||||
'media:content': string;
|
|
||||||
enclosure: {
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const parser: Parser<any, CustomItem> = new Parser({
|
|
||||||
customFields: {
|
|
||||||
item: ['media:content', 'enclosure'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const getQuerySchema = z.object({
|
|
||||||
widgetId: z.string().uuid(),
|
|
||||||
feedUrl: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
|
||||||
const configName = getCookie('config-name', { req: request });
|
|
||||||
const config = getConfig(configName?.toString() ?? 'default');
|
|
||||||
|
|
||||||
const parseResult = getQuerySchema.safeParse(request.query);
|
|
||||||
|
|
||||||
if (!parseResult.success) {
|
|
||||||
response.status(400).json({ message: 'invalid query parameters, please specify the widgetId' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rssWidget = config.widgets.find(
|
|
||||||
(x) => x.type === 'rss' && x.id === parseResult.data.widgetId
|
|
||||||
) as IRssWidget | undefined;
|
|
||||||
if (
|
|
||||||
!rssWidget ||
|
|
||||||
!rssWidget.properties.rssFeedUrl ||
|
|
||||||
rssWidget.properties.rssFeedUrl.length < 1
|
|
||||||
) {
|
|
||||||
response.status(400).json({ message: 'required widget does not exist' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Consola.info(`Requesting RSS feed at url ${parseResult.data.feedUrl}`);
|
|
||||||
const stopWatch = new Stopwatch();
|
|
||||||
const feed = await parser.parseURL(parseResult.data.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 }) => ({
|
|
||||||
...item,
|
|
||||||
title: item.title ? decode(item.title) : undefined,
|
|
||||||
content: processItemContent(
|
|
||||||
item['content:encoded'] ?? item.content,
|
|
||||||
rssWidget.properties.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),
|
|
||||||
};
|
|
||||||
|
|
||||||
response.status(200).json({
|
|
||||||
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'],
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async (request: NextApiRequest, response: NextApiResponse) => {
|
|
||||||
if (request.method === 'GET') {
|
|
||||||
return Get(request, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.status(405);
|
|
||||||
};
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import { getCookie } from 'cookies-next';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import duration from 'dayjs/plugin/duration';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
import { Client } from 'sabnzbd-api';
|
|
||||||
import { findAppProperty } from '~/tools/client/app-properties';
|
|
||||||
|
|
||||||
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
|
|
||||||
import { NzbgetHistoryItem } from '../../../../server/api/routers/usenet/nzbget/types';
|
|
||||||
import { getConfig } from '../../../../tools/config/getConfig';
|
|
||||||
import { UsenetHistoryItem } from '../../../../widgets/useNet/types';
|
|
||||||
|
|
||||||
dayjs.extend(duration);
|
|
||||||
|
|
||||||
export interface UsenetHistoryRequestParams {
|
|
||||||
appId: string;
|
|
||||||
offset: number;
|
|
||||||
limit: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsenetHistoryResponse {
|
|
||||||
items: UsenetHistoryItem[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function Get(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
try {
|
|
||||||
const configName = getCookie('config-name', { req });
|
|
||||||
const config = getConfig(configName?.toString() ?? 'default');
|
|
||||||
const { limit, offset, appId } = req.query as any as UsenetHistoryRequestParams;
|
|
||||||
|
|
||||||
const app = config.apps.find((x) => x.id === appId);
|
|
||||||
|
|
||||||
if (!app) {
|
|
||||||
throw new Error(`App with ID "${req.query.appId}" could not be found.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: UsenetHistoryResponse;
|
|
||||||
switch (app.integration?.type) {
|
|
||||||
case '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,
|
|
||||||
}));
|
|
||||||
|
|
||||||
response = {
|
|
||||||
items: nzbgetItems,
|
|
||||||
total: nzbgetItems.length,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'sabnzbd': {
|
|
||||||
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(offset, limit);
|
|
||||||
|
|
||||||
const items: UsenetHistoryItem[] = history.slots.map((slot) => ({
|
|
||||||
id: slot.nzo_id,
|
|
||||||
name: slot.name,
|
|
||||||
size: slot.bytes,
|
|
||||||
time: slot.download_time,
|
|
||||||
}));
|
|
||||||
|
|
||||||
response = {
|
|
||||||
items,
|
|
||||||
total: history.noofslots,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new Error(`App type "${app.integration?.type}" unrecognized.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json(response);
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(500).send((err as any).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
// Filter out if the reuqest is a POST or a GET
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
return Get(req, res);
|
|
||||||
}
|
|
||||||
return res.status(405).json({
|
|
||||||
statusCode: 405,
|
|
||||||
message: 'Method not allowed',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { getCookie } from 'cookies-next';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import duration from 'dayjs/plugin/duration';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
import { Client } from 'sabnzbd-api';
|
|
||||||
import { findAppProperty } from '~/tools/client/app-properties';
|
|
||||||
|
|
||||||
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
|
|
||||||
import { NzbgetStatus } from '../../../../server/api/routers/usenet/nzbget/types';
|
|
||||||
import { getConfig } from '../../../../tools/config/getConfig';
|
|
||||||
|
|
||||||
dayjs.extend(duration);
|
|
||||||
|
|
||||||
export interface UsenetInfoRequestParams {
|
|
||||||
appId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsenetInfoResponse {
|
|
||||||
paused: boolean;
|
|
||||||
sizeLeft: number;
|
|
||||||
speed: number;
|
|
||||||
eta: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function Get(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
try {
|
|
||||||
const configName = getCookie('config-name', { req });
|
|
||||||
const config = getConfig(configName?.toString() ?? 'default');
|
|
||||||
const { appId } = req.query as any as UsenetInfoRequestParams;
|
|
||||||
|
|
||||||
const app = config.apps.find((x) => x.id === appId);
|
|
||||||
|
|
||||||
if (!app) {
|
|
||||||
throw new Error(`App with ID "${req.query.appId}" could not be found.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: UsenetInfoResponse;
|
|
||||||
switch (app.integration?.type) {
|
|
||||||
case '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 Error('Error while getting NZBGet status');
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytesRemaining = nzbgetStatus.RemainingSizeMB * 1000000;
|
|
||||||
const eta = bytesRemaining / nzbgetStatus.DownloadRate;
|
|
||||||
response = {
|
|
||||||
paused: nzbgetStatus.DownloadPaused,
|
|
||||||
sizeLeft: bytesRemaining,
|
|
||||||
speed: nzbgetStatus.DownloadRate,
|
|
||||||
eta,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'sabnzbd': {
|
|
||||||
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(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);
|
|
||||||
|
|
||||||
response = {
|
|
||||||
paused: queue.paused,
|
|
||||||
sizeLeft: parseFloat(queue.mbleft) * 1024 * 1024,
|
|
||||||
speed: parseFloat(queue.kbpersec) * 1000,
|
|
||||||
eta: eta.asSeconds(),
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new Error(`App type "${app.integration?.type}" unrecognized.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json(response);
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(500).send((err as any).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
// Filter out if the reuqest is a POST or a GET
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
return Get(req, res);
|
|
||||||
}
|
|
||||||
return res.status(405).json({
|
|
||||||
statusCode: 405,
|
|
||||||
message: 'Method not allowed',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { getCookie } from 'cookies-next';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import duration from 'dayjs/plugin/duration';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
import { Client } from 'sabnzbd-api';
|
|
||||||
import { findAppProperty } from '~/tools/client/app-properties';
|
|
||||||
|
|
||||||
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
|
|
||||||
import { getConfig } from '../../../../tools/config/getConfig';
|
|
||||||
|
|
||||||
dayjs.extend(duration);
|
|
||||||
|
|
||||||
export interface UsenetPauseRequestParams {
|
|
||||||
appId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function Post(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
try {
|
|
||||||
const configName = getCookie('config-name', { req });
|
|
||||||
const config = getConfig(configName?.toString() ?? 'default');
|
|
||||||
const { appId } = req.query as any as UsenetPauseRequestParams;
|
|
||||||
|
|
||||||
const app = config.apps.find((x) => x.id === appId);
|
|
||||||
|
|
||||||
if (!app) {
|
|
||||||
throw new Error(`App with ID "${req.query.appId}" could not be found.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let result;
|
|
||||||
switch (app.integration?.type) {
|
|
||||||
case '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);
|
|
||||||
|
|
||||||
result = await new Promise((resolve, reject) => {
|
|
||||||
nzbGet.pauseDownload(false, (err: any, result: any) => {
|
|
||||||
if (!err) {
|
|
||||||
resolve(result);
|
|
||||||
} else {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'sabnzbd': {
|
|
||||||
const apiKey = findAppProperty(app, 'apiKey');
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error(`API Key for app "${app.name}" is missing`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { origin } = new URL(app.url);
|
|
||||||
|
|
||||||
result = await new Client(origin, apiKey).queuePause();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new Error(`App type "${app.integration?.type}" unrecognized.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json(result);
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(500).send((err as any).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
// Filter out if the reuqest is a POST or a GET
|
|
||||||
if (req.method === 'POST') {
|
|
||||||
return Post(req, res);
|
|
||||||
}
|
|
||||||
return res.status(405).json({
|
|
||||||
statusCode: 405,
|
|
||||||
message: 'Method not allowed',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import { getCookie } from 'cookies-next';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import duration from 'dayjs/plugin/duration';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
import { Client } from 'sabnzbd-api';
|
|
||||||
import { findAppProperty } from '~/tools/client/app-properties';
|
|
||||||
|
|
||||||
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 { UsenetQueueItem } from '../../../../widgets/useNet/types';
|
|
||||||
|
|
||||||
dayjs.extend(duration);
|
|
||||||
|
|
||||||
export interface UsenetQueueRequestParams {
|
|
||||||
appId: string;
|
|
||||||
offset: number;
|
|
||||||
limit: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsenetQueueResponse {
|
|
||||||
items: UsenetQueueItem[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function Get(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
try {
|
|
||||||
const configName = getCookie('config-name', { req });
|
|
||||||
const config = getConfig(configName?.toString() ?? 'default');
|
|
||||||
const { limit, offset, appId } = req.query as any as UsenetQueueRequestParams;
|
|
||||||
|
|
||||||
const app = config.apps.find((x) => x.id === appId);
|
|
||||||
|
|
||||||
if (!app) {
|
|
||||||
throw new Error(`App with ID "${req.query.appId}" could not be found.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: UsenetQueueResponse;
|
|
||||||
switch (app.integration?.type) {
|
|
||||||
case '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),
|
|
||||||
}));
|
|
||||||
|
|
||||||
response = {
|
|
||||||
items: nzbgetItems,
|
|
||||||
total: nzbgetItems.length,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'sabnzbd': {
|
|
||||||
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(offset, 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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
response = {
|
|
||||||
items,
|
|
||||||
total: queue.noofslots,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new Error(`App type "${app.integration?.type}" unrecognized.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json(response);
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(500).send((err as any).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNzbgetState(status: string) {
|
|
||||||
switch (status) {
|
|
||||||
case 'QUEUED':
|
|
||||||
return 'queued';
|
|
||||||
case 'PAUSED ':
|
|
||||||
return 'paused';
|
|
||||||
default:
|
|
||||||
return 'downloading';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
// Filter out if the reuqest is a POST or a GET
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
return Get(req, res);
|
|
||||||
}
|
|
||||||
return res.status(405).json({
|
|
||||||
statusCode: 405,
|
|
||||||
message: 'Method not allowed',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { getCookie } from 'cookies-next';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import duration from 'dayjs/plugin/duration';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
import { Client } from 'sabnzbd-api';
|
|
||||||
import { findAppProperty } from '~/tools/client/app-properties';
|
|
||||||
|
|
||||||
import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client';
|
|
||||||
import { getConfig } from '../../../../tools/config/getConfig';
|
|
||||||
|
|
||||||
dayjs.extend(duration);
|
|
||||||
|
|
||||||
export interface UsenetResumeRequestParams {
|
|
||||||
appId: string;
|
|
||||||
nzbId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function Post(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
try {
|
|
||||||
const configName = getCookie('config-name', { req });
|
|
||||||
const config = getConfig(configName?.toString() ?? 'default');
|
|
||||||
const { appId } = req.query as any as UsenetResumeRequestParams;
|
|
||||||
|
|
||||||
const app = config.apps.find((x) => x.id === appId);
|
|
||||||
|
|
||||||
if (!app) {
|
|
||||||
throw new Error(`App with ID "${req.query.appId}" could not be found.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let result;
|
|
||||||
switch (app.integration?.type) {
|
|
||||||
case '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);
|
|
||||||
|
|
||||||
result = await new Promise((resolve, reject) => {
|
|
||||||
nzbGet.resumeDownload(false, (err: any, result: any) => {
|
|
||||||
if (!err) {
|
|
||||||
resolve(result);
|
|
||||||
} else {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'sabnzbd': {
|
|
||||||
const apiKey = findAppProperty(app, 'apiKey');
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error(`API Key for app "${app.name}" is missing`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { origin } = new URL(app.url);
|
|
||||||
|
|
||||||
result = await new Client(origin, apiKey).queueResume();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new Error(`App type "${app.integration?.type}" unrecognized.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json(result);
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(500).send((err as any).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
// Filter out if the reuqest is a POST or a GET
|
|
||||||
if (req.method === 'POST') {
|
|
||||||
return Post(req, res);
|
|
||||||
}
|
|
||||||
return res.status(405).json({
|
|
||||||
statusCode: 405,
|
|
||||||
message: 'Method not allowed',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
import {
|
|
||||||
Alert,
|
|
||||||
Anchor,
|
|
||||||
AppShell,
|
|
||||||
Badge,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Container,
|
|
||||||
Group,
|
|
||||||
Header,
|
|
||||||
List,
|
|
||||||
Loader,
|
|
||||||
Paper,
|
|
||||||
Progress,
|
|
||||||
Space,
|
|
||||||
Stack,
|
|
||||||
Stepper,
|
|
||||||
Switch,
|
|
||||||
Text,
|
|
||||||
ThemeIcon,
|
|
||||||
Title,
|
|
||||||
createStyles,
|
|
||||||
useMantineColorScheme,
|
|
||||||
useMantineTheme,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import {
|
|
||||||
IconAlertCircle,
|
|
||||||
IconBrandDiscord,
|
|
||||||
IconCheck,
|
|
||||||
IconCircleCheck,
|
|
||||||
IconMoonStars,
|
|
||||||
IconSun,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import fs from 'fs';
|
|
||||||
import { GetServerSidePropsContext } from 'next';
|
|
||||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { Logo } from '../components/layout/Logo';
|
|
||||||
import { usePrimaryGradient } from '../components/layout/useGradient';
|
|
||||||
|
|
||||||
const useStyles = createStyles((theme) => ({
|
|
||||||
root: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '100%',
|
|
||||||
backgroundColor: theme.fn.variant({ variant: 'light', color: theme.primaryColor }).background,
|
|
||||||
},
|
|
||||||
|
|
||||||
label: {
|
|
||||||
textAlign: 'center',
|
|
||||||
color: theme.colors[theme.primaryColor][8],
|
|
||||||
fontWeight: 900,
|
|
||||||
fontSize: 110,
|
|
||||||
lineHeight: 1,
|
|
||||||
marginBottom: `calc(${theme.spacing.xl} * 1.5)`,
|
|
||||||
|
|
||||||
[theme.fn.smallerThan('sm')]: {
|
|
||||||
fontSize: 60,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
title: {
|
|
||||||
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
|
||||||
textAlign: 'center',
|
|
||||||
fontWeight: 900,
|
|
||||||
fontSize: 38,
|
|
||||||
|
|
||||||
[theme.fn.smallerThan('sm')]: {
|
|
||||||
fontSize: 32,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
card: {
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'visible',
|
|
||||||
padding: theme.spacing.xl,
|
|
||||||
},
|
|
||||||
|
|
||||||
icon: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: -ICON_SIZE / 3,
|
|
||||||
left: `calc(50% - ${ICON_SIZE / 2}px)`,
|
|
||||||
},
|
|
||||||
|
|
||||||
description: {
|
|
||||||
maxWidth: 700,
|
|
||||||
margin: 'auto',
|
|
||||||
marginTop: theme.spacing.xl,
|
|
||||||
marginBottom: `calc(${theme.spacing.xl} * 1.5)`,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
export default function ServerError({ configs }: { configs: any }) {
|
|
||||||
const { classes } = useStyles();
|
|
||||||
const [active, setActive] = React.useState(0);
|
|
||||||
const gradient = usePrimaryGradient();
|
|
||||||
const [progress, setProgress] = React.useState(0);
|
|
||||||
const [isUpgrading, setIsUpgrading] = React.useState(false);
|
|
||||||
const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current));
|
|
||||||
const prevStep = () => setActive((current) => (current > 0 ? current - 1 : current));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell
|
|
||||||
padding={0}
|
|
||||||
header={
|
|
||||||
<Header
|
|
||||||
height={60}
|
|
||||||
px="xs"
|
|
||||||
styles={(theme) => ({
|
|
||||||
main: {
|
|
||||||
backgroundColor:
|
|
||||||
theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0],
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Group position="apart" align="start">
|
|
||||||
<Box mt="xs">
|
|
||||||
<Logo />
|
|
||||||
</Box>
|
|
||||||
<SwitchToggle />
|
|
||||||
</Group>
|
|
||||||
{/* Header content */}
|
|
||||||
</Header>
|
|
||||||
}
|
|
||||||
styles={(theme) => ({
|
|
||||||
main: {
|
|
||||||
backgroundColor: theme.fn.variant({ variant: 'light', color: theme.primaryColor })
|
|
||||||
.background,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className={classes.root}>
|
|
||||||
<Container>
|
|
||||||
<Group noWrap>
|
|
||||||
<motion.div
|
|
||||||
animate={{ scale: [1, 1.2, 1] }}
|
|
||||||
// Spring animation
|
|
||||||
transition={{ duration: 1.5, ease: 'easeInOut' }}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
<Title variant="gradient" gradient={gradient} className={classes.label}>
|
|
||||||
Homarr v0.11
|
|
||||||
</Title>
|
|
||||||
</motion.div>
|
|
||||||
</Group>
|
|
||||||
<Title mb="md" className={classes.title}>
|
|
||||||
{active === 0 && "Good to see you back! Let's get started"}
|
|
||||||
{active === 1 && progress !== 100 && 'Migrating your configs'}
|
|
||||||
{active === 1 && progress === 100 && 'Migration complete!'}
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
<Stepper active={active}>
|
|
||||||
<Stepper.Step label="Step 1" description="Welcome back">
|
|
||||||
<Text size="lg" align="center" className={classes.description}>
|
|
||||||
<b>A few things have changed since the last time you used Homarr.</b> We'll
|
|
||||||
help you migrate your old configuration to the new format. This process is automatic
|
|
||||||
and should take less than a minute. Then, you'll be able to use the new
|
|
||||||
features of Homarr!
|
|
||||||
</Text>
|
|
||||||
<Alert
|
|
||||||
style={{ maxWidth: 700, margin: 'auto' }}
|
|
||||||
icon={<IconAlertCircle size={16} />}
|
|
||||||
title="Please make a backup of your configs!"
|
|
||||||
color="red"
|
|
||||||
radius="md"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
Please make sure to have a backup of your configs in case something goes wrong.{' '}
|
|
||||||
<b>Not all settings can be migrated</b>, so you'll have to re-do some
|
|
||||||
configuration yourself.
|
|
||||||
</Alert>
|
|
||||||
</Stepper.Step>
|
|
||||||
<Stepper.Step
|
|
||||||
loading={progress < 100 && active === 1}
|
|
||||||
icon={progress === 100 && <IconCheck />}
|
|
||||||
label="Step 2"
|
|
||||||
description="Migrating your configs"
|
|
||||||
>
|
|
||||||
<StatsCard configs={configs} progress={progress} setProgress={setProgress} />
|
|
||||||
</Stepper.Step>
|
|
||||||
<Stepper.Step label="Step 3" description="New features">
|
|
||||||
<Text size="lg" align="center" className={classes.description}>
|
|
||||||
<b>Homarr v0.11</b> brings a lot of new features, if you are interested in learning
|
|
||||||
about them, please check out the{' '}
|
|
||||||
<Anchor target="_blank" href="https://homarr.dev/">
|
|
||||||
documentation page
|
|
||||||
</Anchor>
|
|
||||||
</Text>
|
|
||||||
</Stepper.Step>
|
|
||||||
<Stepper.Completed>
|
|
||||||
<Text size="lg" align="center" className={classes.description}>
|
|
||||||
That's it ! We hope you enjoy the new flexibility v0.11 brings. If you spot any
|
|
||||||
bugs make sure to report them as a{' '}
|
|
||||||
<Anchor
|
|
||||||
target="_blank"
|
|
||||||
href="
|
|
||||||
https://github.com/ajnart/homarr/issues/new?assignees=&labels=%F0%9F%90%9B+Bug&template=bug.yml&title=New%20bug"
|
|
||||||
>
|
|
||||||
<b>github issue</b>
|
|
||||||
</Anchor>{' '}
|
|
||||||
or directly on the
|
|
||||||
<Anchor target="_blank" href="https://discord.gg/aCsmEV5RgA">
|
|
||||||
<b>
|
|
||||||
<IconBrandDiscord size={20} />
|
|
||||||
discord !
|
|
||||||
</b>
|
|
||||||
</Anchor>
|
|
||||||
</Text>
|
|
||||||
</Stepper.Completed>
|
|
||||||
</Stepper>
|
|
||||||
<Group position="center" mt="xl">
|
|
||||||
<Button
|
|
||||||
leftIcon={active === 3 && <IconCheck size={20} stroke={1.5} />}
|
|
||||||
onClick={active === 3 ? () => window.location.reload() : nextStep}
|
|
||||||
variant="filled"
|
|
||||||
disabled={active === 1 && progress < 100}
|
|
||||||
>
|
|
||||||
{active === 3 ? 'Finish' : 'Next'}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
</AppShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SwitchToggle() {
|
|
||||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
|
||||||
const theme = useMantineTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Switch
|
|
||||||
checked={colorScheme === 'dark'}
|
|
||||||
onChange={() => toggleColorScheme()}
|
|
||||||
size="lg"
|
|
||||||
onLabel={<IconSun color={theme.white} size={20} stroke={1.5} />}
|
|
||||||
offLabel={<IconMoonStars color={theme.colors.gray[6]} size={20} stroke={1.5} />}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getServerSideProps({ req, res, locale }: GetServerSidePropsContext) {
|
|
||||||
// Get all the configs in the /data/configs folder
|
|
||||||
// All the files that end in ".json"
|
|
||||||
const configs = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json'));
|
|
||||||
|
|
||||||
if (configs.length === 0) {
|
|
||||||
res.writeHead(302, {
|
|
||||||
Location: '/',
|
|
||||||
});
|
|
||||||
res.end();
|
|
||||||
return { props: {} };
|
|
||||||
}
|
|
||||||
// If all the configs are migrated (contains a schemaVersion), redirect to the index
|
|
||||||
if (
|
|
||||||
configs.every(
|
|
||||||
(config) => JSON.parse(fs.readFileSync(`./data/configs/${config}`, 'utf8')).schemaVersion
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
res.writeHead(302, {
|
|
||||||
Location: '/',
|
|
||||||
});
|
|
||||||
res.end();
|
|
||||||
return {
|
|
||||||
processed: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
configs: configs.map(
|
|
||||||
// Get all the file names in ./data/configs
|
|
||||||
(config) => config.replace('.json', '')
|
|
||||||
),
|
|
||||||
...(await serverSideTranslations(locale!, [])),
|
|
||||||
// Will be passed to the page component as props
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ICON_SIZE = 60;
|
|
||||||
|
|
||||||
export function StatsCard({
|
|
||||||
configs,
|
|
||||||
progress,
|
|
||||||
setProgress,
|
|
||||||
}: {
|
|
||||||
configs: string[];
|
|
||||||
progress: number;
|
|
||||||
setProgress: (progress: number) => void;
|
|
||||||
}) {
|
|
||||||
const { classes } = useStyles();
|
|
||||||
const numberOfConfigs = configs.length;
|
|
||||||
// Update the progress every 100ms
|
|
||||||
const [treatedConfigs, setTreatedConfigs] = useState<string[]>([]);
|
|
||||||
// Stop the progress at 100%
|
|
||||||
useEffect(() => {
|
|
||||||
const data = axios.post('/api/migrate').then((response) => {
|
|
||||||
setProgress(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (configs.length === 0) {
|
|
||||||
clearInterval(interval);
|
|
||||||
setProgress(100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Add last element of configs to the treatedConfigs array
|
|
||||||
setTreatedConfigs((treatedConfigs) => [...treatedConfigs, configs[configs.length - 1]]);
|
|
||||||
// Remove last element of configs
|
|
||||||
configs.pop();
|
|
||||||
}, 500);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [configs]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper radius="md" className={classes.card} mt={ICON_SIZE / 3}>
|
|
||||||
<Group position="apart">
|
|
||||||
<Text size="sm" color="dimmed">
|
|
||||||
Progress
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" color="dimmed">
|
|
||||||
{(100 / (numberOfConfigs + 1)).toFixed(1)}%
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Stack>
|
|
||||||
<Progress animate={progress < 100} value={100 / (numberOfConfigs + 1)} mt={5} />
|
|
||||||
<List
|
|
||||||
spacing="xs"
|
|
||||||
size="sm"
|
|
||||||
center
|
|
||||||
icon={
|
|
||||||
<ThemeIcon color="teal" size={24} radius="xl">
|
|
||||||
<IconCircleCheck size={16} />
|
|
||||||
</ThemeIcon>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{configs.map((config, index) => (
|
|
||||||
<List.Item key={index + 10} icon={<Loader size={24} color="orange" />}>
|
|
||||||
{config ?? 'Unknown'}
|
|
||||||
</List.Item>
|
|
||||||
))}
|
|
||||||
{treatedConfigs.map((config, index) => (
|
|
||||||
<List.Item key={index}>{config ?? 'Unknown'}</List.Item>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Group position="apart" mt="md">
|
|
||||||
<Space />
|
|
||||||
<Badge size="sm">{configs.length} configs left</Badge>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
import { BackendConfigType } from '../../types/config';
|
|
||||||
import { Config } from '../types';
|
|
||||||
import { migrateConfig } from './migrateConfig';
|
|
||||||
|
|
||||||
export function backendMigrateConfig(config: Config, name: string): BackendConfigType {
|
|
||||||
const migratedConfig = migrateConfig(config);
|
|
||||||
|
|
||||||
// Make a backup of the old file ./data/configs/${name}.json
|
|
||||||
// New name is ./data/configs/${name}.bak
|
|
||||||
fs.copyFileSync(`./data/configs/${name}.json`, `./data/configs/${name}.json.bak`);
|
|
||||||
|
|
||||||
// Overrite the file ./data/configs/${name}.json
|
|
||||||
// with the new config format
|
|
||||||
fs.writeFileSync(`./data/configs/${name}.json`, JSON.stringify(migratedConfig, null, 2));
|
|
||||||
|
|
||||||
return migratedConfig;
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import Consola from 'consola';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { BackendConfigType, ConfigType } from '../../types/config';
|
import { BackendConfigType, ConfigType } from '../../types/config';
|
||||||
import { backendMigrateConfig } from './backendMigrateConfig';
|
|
||||||
import { configExists } from './configExists';
|
import { configExists } from './configExists';
|
||||||
import { getFallbackConfig } from './getFallbackConfig';
|
import { getFallbackConfig } from './getFallbackConfig';
|
||||||
import { readConfig } from './readConfig';
|
import { readConfig } from './readConfig';
|
||||||
@@ -16,10 +15,6 @@ export const getConfig = (name: string): BackendConfigType => {
|
|||||||
// then it is an old config file and we should try to migrate it
|
// then it is an old config file and we should try to migrate it
|
||||||
// to the new format.
|
// to the new format.
|
||||||
const config = readConfig(name);
|
const config = readConfig(name);
|
||||||
if (config.schemaVersion === undefined) {
|
|
||||||
Consola.log('Migrating config file...', config.name);
|
|
||||||
return backendMigrateConfig(config, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
let backendConfig = config as BackendConfigType;
|
let backendConfig = config as BackendConfigType;
|
||||||
|
|
||||||
|
|||||||
@@ -1,490 +0,0 @@
|
|||||||
import Consola from 'consola';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
import { ConfigAppIntegrationType, ConfigAppType, IntegrationType } from '../../types/app';
|
|
||||||
import { AreaType } from '../../types/area';
|
|
||||||
import { CategoryType } from '../../types/category';
|
|
||||||
import { BackendConfigType } from '../../types/config';
|
|
||||||
import { SearchEngineCommonSettingsType } from '../../types/settings';
|
|
||||||
import { ICalendarWidget } from '../../widgets/calendar/CalendarTile';
|
|
||||||
import { IDashDotTile } from '../../widgets/dashDot/DashDotTile';
|
|
||||||
import { IDateWidget } from '../../widgets/date/DateTile';
|
|
||||||
import { ITorrentNetworkTraffic } from '../../widgets/download-speed/TorrentNetworkTrafficTile';
|
|
||||||
import { ITorrent } from '../../widgets/torrent/TorrentTile';
|
|
||||||
import { IUsenetWidget } from '../../widgets/useNet/UseNetTile';
|
|
||||||
import { IWeatherWidget } from '../../widgets/weather/WeatherTile';
|
|
||||||
import { IWidget } from '../../widgets/widgets';
|
|
||||||
import { Config, serviceItem } from '../types';
|
|
||||||
|
|
||||||
export function migrateConfig(config: Config): BackendConfigType {
|
|
||||||
const newConfig: BackendConfigType = {
|
|
||||||
schemaVersion: 1,
|
|
||||||
configProperties: {
|
|
||||||
name: config.name ?? 'default',
|
|
||||||
},
|
|
||||||
categories: [],
|
|
||||||
widgets: migrateModules(config).filter((widget) => widget !== null),
|
|
||||||
apps: [],
|
|
||||||
settings: {
|
|
||||||
common: {
|
|
||||||
searchEngine: migrateSearchEngine(config),
|
|
||||||
defaultConfig: 'default',
|
|
||||||
},
|
|
||||||
customization: {
|
|
||||||
colors: {
|
|
||||||
primary: config.settings.primaryColor ?? 'red',
|
|
||||||
secondary: config.settings.secondaryColor ?? 'orange',
|
|
||||||
shade: config.settings.primaryShade ?? 7,
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
enabledDocker: config.modules.docker?.enabled ?? false,
|
|
||||||
enabledLeftSidebar: false,
|
|
||||||
enabledPing: config.modules.ping?.enabled ?? false,
|
|
||||||
enabledRightSidebar: false,
|
|
||||||
enabledSearchbar: config.modules.search?.enabled ?? true,
|
|
||||||
},
|
|
||||||
accessibility: {
|
|
||||||
disablePingPulse: false,
|
|
||||||
replacePingDotsWithIcons: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
wrappers: [
|
|
||||||
{
|
|
||||||
id: 'default',
|
|
||||||
position: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
config.services.forEach((service) => {
|
|
||||||
const { category: categoryName } = service;
|
|
||||||
|
|
||||||
if (!categoryName) {
|
|
||||||
newConfig.apps.push(
|
|
||||||
migrateService(service, {
|
|
||||||
type: 'wrapper',
|
|
||||||
properties: {
|
|
||||||
id: 'default',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const category = getConfigAndCreateIfNotExsists(newConfig, categoryName);
|
|
||||||
|
|
||||||
if (!category) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
newConfig.apps.push(
|
|
||||||
migrateService(service, { type: 'category', properties: { id: category.id } })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Consola.info('Migrator converted a configuration with the old schema to the new schema');
|
|
||||||
|
|
||||||
return newConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
const migrateSearchEngine = (config: Config): SearchEngineCommonSettingsType => {
|
|
||||||
switch (config.settings.searchUrl) {
|
|
||||||
case 'https://bing.com/search?q=':
|
|
||||||
return {
|
|
||||||
type: 'bing',
|
|
||||||
properties: {
|
|
||||||
enabled: true,
|
|
||||||
openInNewTab: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case 'https://google.com/search?q=':
|
|
||||||
return {
|
|
||||||
type: 'google',
|
|
||||||
properties: {
|
|
||||||
enabled: true,
|
|
||||||
openInNewTab: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case 'https://duckduckgo.com/?q=':
|
|
||||||
return {
|
|
||||||
type: 'duckDuckGo',
|
|
||||||
properties: {
|
|
||||||
enabled: true,
|
|
||||||
openInNewTab: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
type: 'custom',
|
|
||||||
properties: {
|
|
||||||
enabled: true,
|
|
||||||
openInNewTab: true,
|
|
||||||
template: config.settings.searchUrl,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getConfigAndCreateIfNotExsists = (
|
|
||||||
config: BackendConfigType,
|
|
||||||
categoryName: string
|
|
||||||
): CategoryType | null => {
|
|
||||||
const foundCategory = config.categories.find((c) => c.name === categoryName);
|
|
||||||
if (foundCategory) {
|
|
||||||
return foundCategory;
|
|
||||||
}
|
|
||||||
|
|
||||||
const category: CategoryType = {
|
|
||||||
id: uuidv4(),
|
|
||||||
name: categoryName,
|
|
||||||
position: config.categories.length + 1, // sync up with index of categories
|
|
||||||
};
|
|
||||||
|
|
||||||
config.categories.push(category);
|
|
||||||
|
|
||||||
// sync up with categories
|
|
||||||
if (config.wrappers.length < config.categories.length) {
|
|
||||||
config.wrappers.push({
|
|
||||||
id: uuidv4(),
|
|
||||||
position: config.wrappers.length + 1, // sync up with index of categories
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return category;
|
|
||||||
};
|
|
||||||
|
|
||||||
const migrateService = (oldService: serviceItem, areaType: AreaType): ConfigAppType => ({
|
|
||||||
id: uuidv4(),
|
|
||||||
name: oldService.name,
|
|
||||||
url: oldService.url,
|
|
||||||
behaviour: {
|
|
||||||
isOpeningNewTab: oldService.newTab ?? true,
|
|
||||||
externalUrl: oldService.openedUrl ?? '',
|
|
||||||
},
|
|
||||||
network: {
|
|
||||||
enabledStatusChecker: oldService.ping ?? true,
|
|
||||||
statusCodes: oldService.status ?? ['200'],
|
|
||||||
},
|
|
||||||
appearance: {
|
|
||||||
iconUrl: migrateIcon(oldService.icon),
|
|
||||||
},
|
|
||||||
integration: migrateIntegration(oldService),
|
|
||||||
area: areaType,
|
|
||||||
shape: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const migrateModules = (config: Config): IWidget<string, any>[] => {
|
|
||||||
const moduleKeys = Object.keys(config.modules);
|
|
||||||
return moduleKeys
|
|
||||||
.map((moduleKey): IWidget<string, any> | null => {
|
|
||||||
const oldModule = config.modules[moduleKey];
|
|
||||||
|
|
||||||
if (!oldModule.enabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (moduleKey.toLowerCase()) {
|
|
||||||
case 'torrent-status':
|
|
||||||
case 'Torrent':
|
|
||||||
return {
|
|
||||||
id: uuidv4(),
|
|
||||||
type: 'torrents-status',
|
|
||||||
properties: {
|
|
||||||
refreshInterval: 10,
|
|
||||||
displayCompletedTorrents: oldModule.options?.hideComplete?.value ?? false,
|
|
||||||
displayStaleTorrents: true,
|
|
||||||
labelFilter: [],
|
|
||||||
labelFilterIsWhitelist: true,
|
|
||||||
},
|
|
||||||
area: {
|
|
||||||
type: 'wrapper',
|
|
||||||
properties: {
|
|
||||||
id: 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shape: {},
|
|
||||||
} as ITorrent;
|
|
||||||
case 'weather':
|
|
||||||
return {
|
|
||||||
id: uuidv4(),
|
|
||||||
type: 'weather',
|
|
||||||
properties: {
|
|
||||||
displayInFahrenheit: oldModule.options?.freedomunit?.value ?? false,
|
|
||||||
location: {
|
|
||||||
name: oldModule.options?.location?.value ?? '',
|
|
||||||
latitude: 0,
|
|
||||||
longitude: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
area: {
|
|
||||||
type: 'wrapper',
|
|
||||||
properties: {
|
|
||||||
id: 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shape: {},
|
|
||||||
} as IWeatherWidget;
|
|
||||||
case 'dashdot':
|
|
||||||
case 'Dash.': {
|
|
||||||
const oldDashDotService = config.services.find((service) => service.type === 'Dash.');
|
|
||||||
return {
|
|
||||||
id: uuidv4(),
|
|
||||||
type: 'dashdot',
|
|
||||||
properties: {
|
|
||||||
url: oldModule.options?.url?.value ?? oldDashDotService?.url ?? '',
|
|
||||||
cpuMultiView: oldModule.options?.cpuMultiView?.value ?? false,
|
|
||||||
storageMultiView: oldModule.options?.storageMultiView?.value ?? false,
|
|
||||||
useCompactView: oldModule.options?.useCompactView?.value ?? false,
|
|
||||||
graphs: oldModule.options?.graphs?.value ?? ['cpu', 'ram'],
|
|
||||||
},
|
|
||||||
area: {
|
|
||||||
type: 'wrapper',
|
|
||||||
properties: {
|
|
||||||
id: 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shape: {},
|
|
||||||
} as unknown as IDashDotTile;
|
|
||||||
}
|
|
||||||
case 'date':
|
|
||||||
return {
|
|
||||||
id: uuidv4(),
|
|
||||||
type: 'date',
|
|
||||||
properties: {
|
|
||||||
display24HourFormat: oldModule.options?.full?.value ?? true,
|
|
||||||
},
|
|
||||||
area: {
|
|
||||||
type: 'wrapper',
|
|
||||||
properties: {
|
|
||||||
id: 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shape: {},
|
|
||||||
} as IDateWidget;
|
|
||||||
case 'Download Speed' || 'dlspeed':
|
|
||||||
return {
|
|
||||||
id: uuidv4(),
|
|
||||||
type: 'dlspeed',
|
|
||||||
properties: {},
|
|
||||||
area: {
|
|
||||||
type: 'wrapper',
|
|
||||||
properties: {
|
|
||||||
id: 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shape: {},
|
|
||||||
} as ITorrentNetworkTraffic;
|
|
||||||
case 'calendar':
|
|
||||||
return {
|
|
||||||
id: uuidv4(),
|
|
||||||
type: 'calendar',
|
|
||||||
properties: {},
|
|
||||||
area: {
|
|
||||||
type: 'wrapper',
|
|
||||||
properties: {
|
|
||||||
id: 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shape: {},
|
|
||||||
} as ICalendarWidget;
|
|
||||||
case 'usenet':
|
|
||||||
return {
|
|
||||||
id: uuidv4(),
|
|
||||||
type: 'usenet',
|
|
||||||
properties: {},
|
|
||||||
area: {
|
|
||||||
type: 'wrapper',
|
|
||||||
properties: {
|
|
||||||
id: 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shape: {},
|
|
||||||
} as IUsenetWidget;
|
|
||||||
default:
|
|
||||||
Consola.error(`Failed to map unknown module type ${moduleKey} to new type definitions.`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((x) => x !== null) as IWidget<string, any>[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const migrateIcon = (iconUrl: string) => {
|
|
||||||
if (iconUrl.startsWith('https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/')) {
|
|
||||||
const icon = iconUrl.split('/').at(-1);
|
|
||||||
Consola.warn(
|
|
||||||
`Detected legacy icon repository. Upgrading to replacement repository: ${iconUrl} -> ${icon}`
|
|
||||||
);
|
|
||||||
return `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${icon}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return iconUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
const migrateIntegration = (oldService: serviceItem): ConfigAppIntegrationType => {
|
|
||||||
const logInformation = (newType: IntegrationType) => {
|
|
||||||
Consola.info(`Migrated integration ${oldService.type} to the new type ${newType}`);
|
|
||||||
};
|
|
||||||
switch (oldService.type) {
|
|
||||||
case 'Deluge':
|
|
||||||
logInformation('deluge');
|
|
||||||
return {
|
|
||||||
type: 'deluge',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'password',
|
|
||||||
type: 'private',
|
|
||||||
value: oldService.password,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
case 'Jellyseerr':
|
|
||||||
logInformation('jellyseerr');
|
|
||||||
return {
|
|
||||||
type: 'jellyseerr',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'apiKey',
|
|
||||||
type: 'private',
|
|
||||||
value: oldService.apiKey,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
case 'Overseerr':
|
|
||||||
logInformation('overseerr');
|
|
||||||
return {
|
|
||||||
type: 'overseerr',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'apiKey',
|
|
||||||
type: 'private',
|
|
||||||
value: oldService.apiKey,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
case 'Lidarr':
|
|
||||||
logInformation('lidarr');
|
|
||||||
return {
|
|
||||||
type: 'lidarr',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'apiKey',
|
|
||||||
type: 'private',
|
|
||||||
value: oldService.apiKey,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
case 'Radarr':
|
|
||||||
logInformation('radarr');
|
|
||||||
return {
|
|
||||||
type: 'radarr',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'apiKey',
|
|
||||||
type: 'private',
|
|
||||||
value: oldService.apiKey,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
case 'Readarr':
|
|
||||||
logInformation('readarr');
|
|
||||||
return {
|
|
||||||
type: 'readarr',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'apiKey',
|
|
||||||
type: 'private',
|
|
||||||
value: oldService.apiKey,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
case 'Sabnzbd':
|
|
||||||
logInformation('sabnzbd');
|
|
||||||
return {
|
|
||||||
type: 'sabnzbd',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'apiKey',
|
|
||||||
type: 'private',
|
|
||||||
value: oldService.apiKey,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
case 'Sonarr':
|
|
||||||
logInformation('sonarr');
|
|
||||||
return {
|
|
||||||
type: 'sonarr',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'apiKey',
|
|
||||||
type: 'private',
|
|
||||||
value: oldService.apiKey,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
case 'NZBGet':
|
|
||||||
logInformation('nzbGet');
|
|
||||||
return {
|
|
||||||
type: 'nzbGet',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'username',
|
|
||||||
type: 'private',
|
|
||||||
value: oldService.username,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'password',
|
|
||||||
type: 'private',
|
|
||||||
value: oldService.password,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
case 'qBittorrent':
|
|
||||||
logInformation('qBittorrent');
|
|
||||||
return {
|
|
||||||
type: 'qBittorrent',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'username',
|
|
||||||
type: 'private',
|
|
||||||
value: oldService.username,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'password',
|
|
||||||
type: 'private',
|
|
||||||
value: oldService.password,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
case 'Transmission':
|
|
||||||
logInformation('transmission');
|
|
||||||
return {
|
|
||||||
type: 'transmission',
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
field: 'username',
|
|
||||||
type: 'private',
|
|
||||||
value: oldService.username,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'password',
|
|
||||||
type: 'private',
|
|
||||||
value: oldService.password,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
case 'Other':
|
|
||||||
return {
|
|
||||||
type: null,
|
|
||||||
properties: [],
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
Consola.warn(
|
|
||||||
`Integration type of service ${oldService.name} could not be mapped to new integration type definition`
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
type: null,
|
|
||||||
properties: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import { ConfigType } from '../types/config';
|
|
||||||
import { getFallbackConfig } from './config/getFallbackConfig';
|
|
||||||
|
|
||||||
export function getConfig(name: string, props: any = undefined) {
|
|
||||||
// Check if the config file exists
|
|
||||||
const configPath = path.join(process.cwd(), 'data/configs', `${name}.json`);
|
|
||||||
if (!fs.existsSync(configPath)) {
|
|
||||||
return getFallbackConfig() as unknown as ConfigType;
|
|
||||||
}
|
|
||||||
const config = fs.readFileSync(configPath, 'utf8');
|
|
||||||
// Print loaded config
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
configName: name,
|
|
||||||
config: JSON.parse(config),
|
|
||||||
...props,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { MantineTheme } from '@mantine/core';
|
|
||||||
|
|
||||||
import { OptionValues } from '../modules/ModuleTypes';
|
|
||||||
|
|
||||||
export interface Settings {
|
|
||||||
searchUrl: string;
|
|
||||||
searchNewTab?: boolean;
|
|
||||||
title?: string;
|
|
||||||
logo?: string;
|
|
||||||
favicon?: string;
|
|
||||||
primaryColor?: MantineTheme['primaryColor'];
|
|
||||||
secondaryColor?: MantineTheme['primaryColor'];
|
|
||||||
primaryShade?: MantineTheme['primaryShade'];
|
|
||||||
background?: string;
|
|
||||||
customCSS?: string;
|
|
||||||
appOpacity?: number;
|
|
||||||
widgetPosition?: string;
|
|
||||||
grow?: boolean;
|
|
||||||
appCardWidth?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Config {
|
|
||||||
name: string;
|
|
||||||
services: serviceItem[];
|
|
||||||
settings: Settings;
|
|
||||||
modules: {
|
|
||||||
[key: string]: ConfigModule;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfigModule {
|
|
||||||
title: string;
|
|
||||||
enabled: boolean;
|
|
||||||
options: {
|
|
||||||
[key: string]: OptionValues;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Targets = [
|
|
||||||
{ value: '_blank', label: 'New Tab' },
|
|
||||||
{ value: '_top', label: 'Same Window' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ServiceTypeList = [
|
|
||||||
'Other',
|
|
||||||
'Dash.',
|
|
||||||
'Deluge',
|
|
||||||
'Emby',
|
|
||||||
'Lidarr',
|
|
||||||
'Plex',
|
|
||||||
'qBittorrent',
|
|
||||||
'Radarr',
|
|
||||||
'Readarr',
|
|
||||||
'Sonarr',
|
|
||||||
'Transmission',
|
|
||||||
'Overseerr',
|
|
||||||
'Jellyseerr',
|
|
||||||
'Sabnzbd',
|
|
||||||
'NZBGet',
|
|
||||||
];
|
|
||||||
export type ServiceType =
|
|
||||||
| 'Other'
|
|
||||||
| 'Dash.'
|
|
||||||
| 'Deluge'
|
|
||||||
| 'Emby'
|
|
||||||
| 'Lidarr'
|
|
||||||
| 'Plex'
|
|
||||||
| 'qBittorrent'
|
|
||||||
| 'Radarr'
|
|
||||||
| 'Readarr'
|
|
||||||
| 'Sonarr'
|
|
||||||
| 'Overseerr'
|
|
||||||
| 'Jellyseerr'
|
|
||||||
| 'Transmission'
|
|
||||||
| 'Sabnzbd'
|
|
||||||
| 'NZBGet';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
* @param name the name to match
|
|
||||||
* @param form the form
|
|
||||||
* @returns the port from the map
|
|
||||||
*/
|
|
||||||
export function tryMatchPort(name: string | undefined, form?: any) {
|
|
||||||
if (!name) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
// Match name with portmap key
|
|
||||||
const port = portmap.find((p) => p.name === name.toLowerCase());
|
|
||||||
if (form && port) {
|
|
||||||
form.setFieldValue('url', `http://localhost:${port.value}`);
|
|
||||||
}
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const portmap = [
|
|
||||||
{ name: 'qbittorrent', value: '8080' },
|
|
||||||
{ name: 'sonarr', value: '8989' },
|
|
||||||
{ name: 'radarr', value: '7878' },
|
|
||||||
{ name: 'lidarr', value: '8686' },
|
|
||||||
{ name: 'readarr', value: '8787' },
|
|
||||||
{ name: 'deluge', value: '8112' },
|
|
||||||
{ name: 'transmission', value: '9091' },
|
|
||||||
{ name: 'plex', value: '32400' },
|
|
||||||
{ name: 'emby', value: '8096' },
|
|
||||||
{ name: 'overseerr', value: '5055' },
|
|
||||||
{ name: 'dash.', value: '3001' },
|
|
||||||
{ name: 'sabnzbd', value: '8080' },
|
|
||||||
{ name: 'nzbget', value: '6789' },
|
|
||||||
];
|
|
||||||
|
|
||||||
//TODO: Fix this to be used in the docker add to homarr button
|
|
||||||
export const MatchingImages: {
|
|
||||||
image: string;
|
|
||||||
type: ServiceType;
|
|
||||||
}[] = [
|
|
||||||
//Official images
|
|
||||||
{ image: 'mauricenino/dashdot', type: 'Dash.' },
|
|
||||||
{ image: 'emby/embyserver', type: 'Emby' },
|
|
||||||
{ image: 'plexinc/pms-docker', type: 'Plex' },
|
|
||||||
//Lidarr images
|
|
||||||
{ image: 'hotio/lidarr', type: 'Lidarr' },
|
|
||||||
{ image: 'ghcr.io/hotio/lidarr', type: 'Lidarr' },
|
|
||||||
{ image: 'cr.hotio.dev/hotio/lidarr', type: 'Lidarr' },
|
|
||||||
// Plex
|
|
||||||
{ image: 'hotio/plex', type: 'Plex' },
|
|
||||||
{ image: 'ghcr.io/hotio/plex', type: 'Plex' },
|
|
||||||
{ image: 'cr.hotio.dev/hotio/plex', type: 'Plex' },
|
|
||||||
// qbittorrent
|
|
||||||
{ image: 'hotio/qbittorrent', type: 'qBittorrent' },
|
|
||||||
{ image: 'ghcr.io/hotio/qbittorrent', type: 'qBittorrent' },
|
|
||||||
{ image: 'cr.hotio.dev/hotio/qbittorrent', type: 'qBittorrent' },
|
|
||||||
// Radarr
|
|
||||||
{ image: 'hotio/radarr', type: 'Radarr' },
|
|
||||||
{ image: 'ghcr.io/hotio/radarr', type: 'Radarr' },
|
|
||||||
{ image: 'cr.hotio.dev/hotio/radarr', type: 'Radarr' },
|
|
||||||
// Readarr
|
|
||||||
{ image: 'hotio/readarr', type: 'Readarr' },
|
|
||||||
{ image: 'ghcr.io/hotio/readarr', type: 'Readarr' },
|
|
||||||
{ image: 'cr.hotio.dev/hotio/readarr', type: 'Readarr' },
|
|
||||||
// Sonarr
|
|
||||||
{ image: 'hotio/sonarr', type: 'Sonarr' },
|
|
||||||
{ image: 'ghcr.io/hotio/sonarr', type: 'Sonarr' },
|
|
||||||
{ image: 'cr.hotio.dev/hotio/sonarr', type: 'Sonarr' },
|
|
||||||
//LinuxServer images
|
|
||||||
{ image: 'lscr.io/linuxserver/deluge', type: 'Deluge' },
|
|
||||||
{ image: 'lscr.io/linuxserver/emby', type: 'Emby' },
|
|
||||||
{ image: 'lscr.io/linuxserver/lidarr', type: 'Lidarr' },
|
|
||||||
{ image: 'lscr.io/linuxserver/plex', type: 'Plex' },
|
|
||||||
{ image: 'lscr.io/linuxserver/qbittorrent', type: 'qBittorrent' },
|
|
||||||
{ image: 'lscr.io/linuxserver/radarr', type: 'Radarr' },
|
|
||||||
{ image: 'lscr.io/linuxserver/readarr', type: 'Readarr' },
|
|
||||||
{ image: 'lscr.io/linuxserver/sonarr', type: 'Sonarr' },
|
|
||||||
{ image: 'lscr.io/linuxserver/transmission', type: 'Transmission' },
|
|
||||||
// LinuxServer but on Docker Hub
|
|
||||||
{ image: 'linuxserver/deluge', type: 'Deluge' },
|
|
||||||
{ image: 'linuxserver/emby', type: 'Emby' },
|
|
||||||
{ image: 'linuxserver/lidarr', type: 'Lidarr' },
|
|
||||||
{ image: 'linuxserver/plex', type: 'Plex' },
|
|
||||||
{ image: 'linuxserver/qbittorrent', type: 'qBittorrent' },
|
|
||||||
{ image: 'linuxserver/radarr', type: 'Radarr' },
|
|
||||||
{ image: 'linuxserver/readarr', type: 'Readarr' },
|
|
||||||
{ image: 'linuxserver/sonarr', type: 'Sonarr' },
|
|
||||||
{ image: 'linuxserver/transmission', type: 'Transmission' },
|
|
||||||
//High usage
|
|
||||||
{ image: 'markusmcnugen/qbittorrentvpn', type: 'qBittorrent' },
|
|
||||||
{ image: 'haugene/transmission-openvpn', type: 'Transmission' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface serviceItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: ServiceType;
|
|
||||||
url: string;
|
|
||||||
icon: string;
|
|
||||||
category?: string;
|
|
||||||
apiKey?: string;
|
|
||||||
password?: string;
|
|
||||||
username?: string;
|
|
||||||
openedUrl?: string;
|
|
||||||
newTab?: boolean;
|
|
||||||
ping?: boolean;
|
|
||||||
status?: string[];
|
|
||||||
}
|
|
||||||
@@ -23,6 +23,9 @@ interface AppBehaviourType {
|
|||||||
|
|
||||||
interface AppNetworkType {
|
interface AppNetworkType {
|
||||||
enabledStatusChecker: boolean;
|
enabledStatusChecker: boolean;
|
||||||
|
/**
|
||||||
|
* @deprecated replaced by statusCodes
|
||||||
|
*/
|
||||||
okStatus?: number[];
|
okStatus?: number[];
|
||||||
statusCodes: string[];
|
statusCodes: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { useConfigContext } from '~/config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
import { RouterInputs, api } from '~/utils/api';
|
import { RouterInputs, api } from '~/utils/api';
|
||||||
|
import { UsenetHistoryRequestParams, UsenetInfoRequestParams, UsenetPauseRequestParams, UsenetQueueRequestParams, UsenetResumeRequestParams } from '../useNet/types';
|
||||||
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 type { UsenetQueueRequestParams } from '../../../pages/api/modules/usenet/queue';
|
|
||||||
import { UsenetResumeRequestParams } from '../../../pages/api/modules/usenet/resume';
|
|
||||||
|
|
||||||
const POLLING_INTERVAL = 2000;
|
const POLLING_INTERVAL = 2000;
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ import { useEffect } from 'react';
|
|||||||
|
|
||||||
import { AppAvatar } from '../../components/AppAvatar';
|
import { AppAvatar } from '../../components/AppAvatar';
|
||||||
import { useConfigContext } from '../../config/provider';
|
import { useConfigContext } from '../../config/provider';
|
||||||
import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed';
|
import { useGetDownloadClientsQueue } from './useGetNetworkSpeed';
|
||||||
import { useColorTheme } from '../../tools/color';
|
import { useColorTheme } from '../../tools/color';
|
||||||
import { humanFileSize } from '../../tools/humanFileSize';
|
import { humanFileSize } from '../../tools/humanFileSize';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useTranslation } from 'next-i18next';
|
|||||||
import { AppAvatar } from '../../components/AppAvatar';
|
import { AppAvatar } from '../../components/AppAvatar';
|
||||||
import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore';
|
import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore';
|
||||||
import { useConfigContext } from '../../config/provider';
|
import { useConfigContext } from '../../config/provider';
|
||||||
import { useGetMediaServers } from '../../hooks/widgets/media-servers/useGetMediaServers';
|
import { useGetMediaServers } from './useGetMediaServers';
|
||||||
import { defineWidget } from '../helper';
|
import { defineWidget } from '../helper';
|
||||||
import { IWidget } from '../widgets';
|
import { IWidget } from '../widgets';
|
||||||
import { TableRow } from './TableRow';
|
import { TableRow } from './TableRow';
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import relativeTime from 'dayjs/plugin/relativeTime';
|
|||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
import { MIN_WIDTH_MOBILE } from '../../constants/constants';
|
import { MIN_WIDTH_MOBILE } from '../../constants/constants';
|
||||||
import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed';
|
import { useGetDownloadClientsQueue } from '../download-speed/useGetNetworkSpeed';
|
||||||
import { NormalizedDownloadQueueResponse } from '../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
import { NormalizedDownloadQueueResponse } from '../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
||||||
import { AppIntegrationType } from '../../types/app';
|
import { AppIntegrationType } from '../../types/app';
|
||||||
import { defineWidget } from '../helper';
|
import { defineWidget } from '../helper';
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
useGetUsenetInfo,
|
useGetUsenetInfo,
|
||||||
usePauseUsenetQueueMutation,
|
usePauseUsenetQueueMutation,
|
||||||
useResumeUsenetQueueMutation,
|
useResumeUsenetQueueMutation,
|
||||||
} from '../../hooks/widgets/dashDot/api';
|
} from '../dashDot/api';
|
||||||
import { humanFileSize } from '../../tools/humanFileSize';
|
import { humanFileSize } from '../../tools/humanFileSize';
|
||||||
import { AppIntegrationType } from '../../types/app';
|
import { AppIntegrationType } from '../../types/app';
|
||||||
import { defineWidget } from '../helper';
|
import { defineWidget } from '../helper';
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import duration from 'dayjs/plugin/duration';
|
|||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { FunctionComponent, useState } from 'react';
|
import { FunctionComponent, useState } from 'react';
|
||||||
|
|
||||||
import { useGetUsenetHistory } from '../../hooks/widgets/dashDot/api';
|
import { useGetUsenetHistory } from '../dashDot/api';
|
||||||
import { parseDuration } from '../../tools/client/parseDuration';
|
import { parseDuration } from '../../tools/client/parseDuration';
|
||||||
import { humanFileSize } from '../../tools/humanFileSize';
|
import { humanFileSize } from '../../tools/humanFileSize';
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import duration from 'dayjs/plugin/duration';
|
|||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { FunctionComponent, useState } from 'react';
|
import { FunctionComponent, useState } from 'react';
|
||||||
|
|
||||||
import { useGetUsenetDownloads } from '../../hooks/widgets/dashDot/api';
|
import { useGetUsenetDownloads } from '../dashDot/api';
|
||||||
import { humanFileSize } from '../../tools/humanFileSize';
|
import { humanFileSize } from '../../tools/humanFileSize';
|
||||||
|
|
||||||
dayjs.extend(duration);
|
dayjs.extend(duration);
|
||||||
|
|||||||
@@ -18,3 +18,45 @@ export interface UsenetHistoryItem {
|
|||||||
id: string;
|
id: string;
|
||||||
time: number;
|
time: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UsenetHistoryRequestParams {
|
||||||
|
appId: string;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsenetHistoryResponse {
|
||||||
|
items: UsenetHistoryItem[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsenetInfoRequestParams {
|
||||||
|
appId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsenetInfoResponse {
|
||||||
|
paused: boolean;
|
||||||
|
sizeLeft: number;
|
||||||
|
speed: number;
|
||||||
|
eta: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsenetPauseRequestParams {
|
||||||
|
appId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsenetQueueRequestParams {
|
||||||
|
appId: string;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsenetQueueResponse {
|
||||||
|
items: UsenetQueueItem[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsenetResumeRequestParams {
|
||||||
|
appId: string;
|
||||||
|
nzbId?: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user