mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-09 23:15:46 +01:00
🗑️ Remove deprecated code (#1225)
This commit is contained in:
@@ -71,7 +71,7 @@ export const EditAppModal = ({
|
||||
behaviour: {
|
||||
externalUrl: (url: string) => {
|
||||
if (url === undefined || url.length < 1) {
|
||||
return null;
|
||||
return 'External URI is required';
|
||||
}
|
||||
|
||||
if (!url.match(appUrlWithAnyProtocolRegex)) {
|
||||
|
||||
@@ -101,7 +101,7 @@ export const AvailableElementTypes = ({
|
||||
},
|
||||
behaviour: {
|
||||
isOpeningNewTab: true,
|
||||
externalUrl: '',
|
||||
externalUrl: 'https://homarr.dev',
|
||||
},
|
||||
|
||||
area: {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { Button, Group } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
@@ -18,7 +17,6 @@ import { RouterInputs, api } from '~/utils/api';
|
||||
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions';
|
||||
import { MatchingImages, ServiceType, tryMatchPort } from '../../tools/types';
|
||||
import { AppType } from '../../types/app';
|
||||
|
||||
export interface ContainerActionBarProps {
|
||||
@@ -28,10 +26,15 @@ export interface ContainerActionBarProps {
|
||||
|
||||
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
|
||||
const { t } = useTranslation('modules/docker');
|
||||
const [isLoading, setisLoading] = useState(false);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const { config } = useConfigContext();
|
||||
const getLowestWrapper = () => config?.wrappers.sort((a, b) => a.position - b.position)[0];
|
||||
|
||||
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') {
|
||||
return null;
|
||||
@@ -42,10 +45,10 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
||||
<Button
|
||||
leftIcon={<IconRefresh />}
|
||||
onClick={() => {
|
||||
setisLoading(true);
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
reload();
|
||||
setisLoading(false);
|
||||
setLoading(false);
|
||||
}, 750);
|
||||
}}
|
||||
variant="light"
|
||||
@@ -57,8 +60,8 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<IconRotateClockwise />}
|
||||
onClick={() =>
|
||||
Promise.all(selected.map((container) => sendDockerCommand(container, 'restart')))
|
||||
onClick={async () =>
|
||||
await Promise.all(selected.map((container) => sendDockerCommand(container, 'restart')))
|
||||
}
|
||||
variant="light"
|
||||
color="orange"
|
||||
@@ -69,8 +72,8 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<IconPlayerStop />}
|
||||
onClick={() =>
|
||||
Promise.all(selected.map((container) => sendDockerCommand(container, 'stop')))
|
||||
onClick={async () =>
|
||||
await Promise.all(selected.map((container) => sendDockerCommand(container, 'stop')))
|
||||
}
|
||||
variant="light"
|
||||
color="red"
|
||||
@@ -81,8 +84,8 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<IconPlayerPlay />}
|
||||
onClick={() =>
|
||||
Promise.all(selected.map((container) => sendDockerCommand(container, 'start')))
|
||||
onClick={async () =>
|
||||
await Promise.all(selected.map((container) => sendDockerCommand(container, 'start')))
|
||||
}
|
||||
variant="light"
|
||||
color="green"
|
||||
@@ -96,8 +99,8 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
Promise.all(selected.map((container) => sendDockerCommand(container, 'remove')))
|
||||
onClick={async () =>
|
||||
await Promise.all(selected.map((container) => sendDockerCommand(container, 'remove')))
|
||||
}
|
||||
disabled={selected.length === 0}
|
||||
>
|
||||
@@ -108,29 +111,32 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
|
||||
color="indigo"
|
||||
variant="light"
|
||||
radius="md"
|
||||
disabled={selected.length === 0 || selected.length > 1}
|
||||
disabled={selected.length !== 1}
|
||||
onClick={() => {
|
||||
const app = tryMatchService(selected.at(0)!);
|
||||
const containerUrl = `http://localhost:${selected[0].Ports[0]?.PublicPort ?? 0}`;
|
||||
const containerInfo = selected[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 }>({
|
||||
modal: 'editApp',
|
||||
zIndex: 202,
|
||||
innerProps: {
|
||||
app: {
|
||||
id: uuidv4(),
|
||||
name: app.name ? app.name : selected[0].Names[0].substring(1),
|
||||
url: containerUrl,
|
||||
name: name,
|
||||
url: address,
|
||||
appearance: {
|
||||
iconUrl: app.icon ? app.icon : '/imgs/logo/logo.png',
|
||||
iconUrl: '/imgs/logo/logo.png',
|
||||
},
|
||||
network: {
|
||||
enabledStatusChecker: true,
|
||||
statusCodes: ['200'],
|
||||
okStatus: [200],
|
||||
statusCodes: ['200', '301', '302']
|
||||
},
|
||||
behaviour: {
|
||||
isOpeningNewTab: true,
|
||||
externalUrl: '',
|
||||
externalUrl: address
|
||||
},
|
||||
area: {
|
||||
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 { BackendConfigType, ConfigType } from '../../types/config';
|
||||
import { backendMigrateConfig } from './backendMigrateConfig';
|
||||
import { configExists } from './configExists';
|
||||
import { getFallbackConfig } from './getFallbackConfig';
|
||||
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
|
||||
// to the new format.
|
||||
const config = readConfig(name);
|
||||
if (config.schemaVersion === undefined) {
|
||||
Consola.log('Migrating config file...', config.name);
|
||||
return backendMigrateConfig(config, name);
|
||||
}
|
||||
|
||||
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 {
|
||||
enabledStatusChecker: boolean;
|
||||
/**
|
||||
* @deprecated replaced by statusCodes
|
||||
*/
|
||||
okStatus?: number[];
|
||||
statusCodes: string[];
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { RouterInputs, api } from '~/utils/api';
|
||||
|
||||
import { UsenetInfoRequestParams } from '../../../pages/api/modules/usenet';
|
||||
import type { UsenetHistoryRequestParams } from '../../../pages/api/modules/usenet/history';
|
||||
import { UsenetPauseRequestParams } from '../../../pages/api/modules/usenet/pause';
|
||||
import type { UsenetQueueRequestParams } from '../../../pages/api/modules/usenet/queue';
|
||||
import { UsenetResumeRequestParams } from '../../../pages/api/modules/usenet/resume';
|
||||
import { UsenetHistoryRequestParams, UsenetInfoRequestParams, UsenetPauseRequestParams, UsenetQueueRequestParams, UsenetResumeRequestParams } from '../useNet/types';
|
||||
|
||||
const POLLING_INTERVAL = 2000;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useEffect } from 'react';
|
||||
|
||||
import { AppAvatar } from '../../components/AppAvatar';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed';
|
||||
import { useGetDownloadClientsQueue } from './useGetNetworkSpeed';
|
||||
import { useColorTheme } from '../../tools/color';
|
||||
import { humanFileSize } from '../../tools/humanFileSize';
|
||||
import {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useTranslation } from 'next-i18next';
|
||||
import { AppAvatar } from '../../components/AppAvatar';
|
||||
import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { useGetMediaServers } from '../../hooks/widgets/media-servers/useGetMediaServers';
|
||||
import { useGetMediaServers } from './useGetMediaServers';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
import { TableRow } from './TableRow';
|
||||
|
||||
@@ -19,7 +19,7 @@ import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
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 { AppIntegrationType } from '../../types/app';
|
||||
import { defineWidget } from '../helper';
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
useGetUsenetInfo,
|
||||
usePauseUsenetQueueMutation,
|
||||
useResumeUsenetQueueMutation,
|
||||
} from '../../hooks/widgets/dashDot/api';
|
||||
} from '../dashDot/api';
|
||||
import { humanFileSize } from '../../tools/humanFileSize';
|
||||
import { AppIntegrationType } from '../../types/app';
|
||||
import { defineWidget } from '../helper';
|
||||
|
||||
@@ -18,7 +18,7 @@ import duration from 'dayjs/plugin/duration';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
|
||||
import { useGetUsenetHistory } from '../../hooks/widgets/dashDot/api';
|
||||
import { useGetUsenetHistory } from '../dashDot/api';
|
||||
import { parseDuration } from '../../tools/client/parseDuration';
|
||||
import { humanFileSize } from '../../tools/humanFileSize';
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import duration from 'dayjs/plugin/duration';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
|
||||
import { useGetUsenetDownloads } from '../../hooks/widgets/dashDot/api';
|
||||
import { useGetUsenetDownloads } from '../dashDot/api';
|
||||
import { humanFileSize } from '../../tools/humanFileSize';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
@@ -18,3 +18,45 @@ export interface UsenetHistoryItem {
|
||||
id: string;
|
||||
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