🗑️ Remove deprecated code (#1225)

This commit is contained in:
Manuel
2023-07-28 23:09:21 +02:00
committed by GitHub
parent a45a1bdb18
commit c99c06c0bb
44 changed files with 83 additions and 4126 deletions

View File

@@ -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)) {

View File

@@ -101,7 +101,7 @@ export const AvailableElementTypes = ({
},
behaviour: {
isOpeningNewTab: true,
externalUrl: '',
externalUrl: 'https://homarr.dev',
},
area: {

View File

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

View File

@@ -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).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&apos;ll
help you migrate your old configuration to the new format. This process is automatic
and should take less than a minute. Then, you&apos;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&apos;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&apos;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>
);
}

View File

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

View File

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

View File

@@ -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: [],
};
}
};

View File

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

View File

@@ -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[];
}

View File

@@ -23,6 +23,9 @@ interface AppBehaviourType {
interface AppNetworkType {
enabledStatusChecker: boolean;
/**
* @deprecated replaced by statusCodes
*/
okStatus?: number[];
statusCodes: string[];
}

View File

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

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

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

View File

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