implement queue info and pause/resume

This commit is contained in:
Jannes Vandepitte
2022-08-26 16:12:40 +02:00
parent 8aacfc8dd9
commit fb723f6ad6
15 changed files with 637 additions and 167 deletions

View File

@@ -3,9 +3,9 @@ module.exports = {
'mantine',
'plugin:@next/next/recommended',
'plugin:jest/recommended',
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
],
plugins: ['testing-library', 'jest', 'react-hooks', 'react', 'unused-imports'],
overrides: [
@@ -20,12 +20,13 @@ module.exports = {
rules: {
'react/react-in-jsx-scope': 'off',
'react/no-children-prop': 'off',
"unused-imports/no-unused-imports": "warn",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-unused-imports": "off",
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-shadow": "off",
"@typescript-eslint/no-use-before-define": "off",
'unused-imports/no-unused-imports': 'warn',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-unused-imports': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-shadow': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
},
};

68
@types/react-i18next.d.ts vendored Normal file
View File

@@ -0,0 +1,68 @@
import 'react-i18next';
import common from '../public/locales/en/common.json';
import appShelf from '../public/locales/en/layout/app-shelf.json';
import addServiceAppShelf from '../public/locales/en/layout/add-service-app-shelf.json';
import appShelfMenu from '../public/locales/en/layout/app-shelf-menu.json';
import commonSettings from '../public/locales/en/settings/common.json';
import themeSelector from '../public/locales/en/settings/general/theme-selector.json';
import configChanger from '../public/locales/en/settings/general/config-changer.json';
import i18n from '../public/locales/en/settings/general/internationalization.json';
import moduleEnabler from '../public/locales/en/settings/general/module-enabler.json';
import searchEngine from '../public/locales/en/settings/general/search-engine.json';
import widgetPositions from '../public/locales/en/settings/general/widget-positions.json';
import colorSelector from '../public/locales/en/settings/customization/color-selector.json';
import pageAppearance from '../public/locales/en/settings/customization/page-appearance.json';
import shadeSelector from '../public/locales/en/settings/customization/shade-selector.json';
import appWidth from '../public/locales/en/settings/customization/app-width.json';
import opacitySelector from '../public/locales/en/settings/customization/opacity-selector.json';
import commonModule from '../public/locales/en/modules/common.json';
import dateModule from '../public/locales/en/modules/date.json';
import calendarModule from '../public/locales/en/modules/calendar.json';
import dlSpeedModule from '../public/locales/en/modules/dlspeed.json';
import usenetModule from '../public/locales/en/modules/usenet.json';
import searchModule from '../public/locales/en/modules/search.json';
import torrentsModule from '../public/locales/en/modules/torrents-status.json';
import weatherModule from '../public/locales/en/modules/weather.json';
import pingModule from '../public/locales/en/modules/ping.json';
import dockerModule from '../public/locales/en/modules/docker.json';
import dashDotModule from '../public/locales/en/modules/dashdot.json';
import overseerrModule from '../public/locales/en/modules/overseerr.json';
import mediaCardsModule from '../public/locales/en/modules/common-media-cards.json';
declare module 'react-i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: {
common: typeof common;
'layout/app-shelf': typeof appShelf;
'layout/add-service-app-shelf': typeof addServiceAppShelf;
'layout/app-shelf-menu': typeof appShelfMenu;
'settings/common': typeof commonSettings;
'settings/general/theme-selector': typeof themeSelector;
'settings/general/config-changer': typeof configChanger;
'settings/general/internationalization': typeof i18n;
'settings/general/module-enabler': typeof moduleEnabler;
'settings/general/search-engine': typeof searchEngine;
'settings/general/widget-positions': typeof widgetPositions;
'settings/customization/color-selector': typeof colorSelector;
'settings/customization/page-appearance': typeof pageAppearance;
'settings/customization/shade-selector': typeof shadeSelector;
'settings/customization/app-width': typeof appWidth;
'settings/customization/opacity-selector': typeof opacitySelector;
'modules/common': typeof commonModule;
'modules/date': typeof dateModule;
'modules/calendar': typeof calendarModule;
'modules/dlspeed': typeof dlSpeedModule;
'modules/usenet': typeof usenetModule;
'modules/search': typeof searchModule;
'modules/torrents-status': typeof torrentsModule;
'modules/weather': typeof weatherModule;
'modules/ping': typeof pingModule;
'modules/docker': typeof dockerModule;
'modules/dashdot': typeof dashDotModule;
'modules/overseerr': typeof overseerrModule;
'modules/common-media-cards': typeof mediaCardsModule;
};
}
}

View File

@@ -7,14 +7,42 @@
"type": "Other",
"icon": "https://c.tenor.com/o656qFKDzeUAAAAC/rick-astley-never-gonna-give-you-up.gif",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
},
{
"id": "4cb1c2af-9f23-4f52-86e1-0eac25673b56",
"type": "Sabnzbd",
"name": "Sabnzbd",
"icon": "/favicon.png",
"url": "https://sabnzbd.jannesv.be",
"apiKey": "2982e4afbc6d42d6bb5863751a354d20",
"openedUrl": "https://sabnzbd.jannesv.be"
}
],
"settings": {
"searchUrl": "https://google.com/search?q="
"searchUrl": "https://google.com/search?q=",
"primaryColor": "grape"
},
"modules": {
"Search Bar": {
"enabled": true
},
"Download Speed": {
"enabled": false
},
"Torrent": {
"enabled": false
},
"Dash.": {
"enabled": true
},
"Usenet": {
"enabled": true
},
"usenet": {
"enabled": true
},
"docker": {
"enabled": true
}
}
}

View File

@@ -0,0 +1,35 @@
{
"descriptor": {
"name": "Usenet",
"description": "Show the queue and history of supported services"
},
"tabs": {
"queue": "Queue",
"history": "History"
},
"info": {
"sizeLeft": "Size left",
"paused": "Paused"
},
"queue": {
"header": {
"name": "Name",
"size": "Size",
"eta": "ETA",
"progress": "Progress"
},
"empty": "Queue is empty.",
"error": "Some error has occured while fetching data:",
"paused": "Paused"
},
"history": {
"header": {
"name": "Name",
"size": "Size",
"duration": "Download Duration"
},
"empty": "Queue is empty.",
"error": "Some error has occured while fetching data:",
"paused": "Paused"
}
}

View File

@@ -14,6 +14,7 @@ import { IconAlertCircle } from '@tabler/icons';
import { AxiosError } from 'axios';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { useTranslation } from 'next-i18next';
import { FunctionComponent, useState } from 'react';
import { useGetUsenetHistory } from '../../tools/hooks/api';
import { humanFileSize } from '../../tools/humanFileSize';
@@ -28,6 +29,7 @@ const PAGE_SIZE = 10;
export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ serviceId }) => {
const [page, setPage] = useState(1);
const { t } = useTranslation('modules/usenet');
const { data, isLoading, isError, error } = useGetUsenetHistory({
limit: PAGE_SIZE,
@@ -50,7 +52,7 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ s
return (
<Group position="center">
<Alert icon={<IconAlertCircle size={16} />} my="lg" title="Error!" color="red" radius="md">
Some error has occured while fetching data:
{t('history.error')}
<Code mt="sm" block>
{(error as AxiosError)?.response?.data as string}
</Code>
@@ -62,13 +64,13 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ s
if (!data || data.items.length <= 0) {
return (
<Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Title order={3}>History is empty</Title>
<Title order={3}>{t('history.empty')}</Title>
</Center>
);
}
return (
<div>
<>
<Table highlightOnHover style={{ tableLayout: 'fixed' }}>
<colgroup>
<col span={1} />
@@ -77,9 +79,9 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ s
</colgroup>
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>Download Duration</th>
<th>{t('history.header.name')}</th>
<th>{t('history.header.size')}</th>
<th>{t('history.header.duration')}</th>
</tr>
</thead>
<tbody>
@@ -113,14 +115,16 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ s
))}
</tbody>
</Table>
<Pagination
size="sm"
position="center"
mt="md"
total={totalPages}
page={page}
onChange={setPage}
/>
</div>
{totalPages > 1 && (
<Pagination
size="sm"
position="center"
mt="md"
total={totalPages}
page={page}
onChange={setPage}
/>
)}
</>
);
};

View File

@@ -1,16 +1,27 @@
import { Group, Select, Tabs } from '@mantine/core';
import { IconDownload } from '@tabler/icons';
import { Badge, Button, Group, Select, Tabs } from '@mantine/core';
import { IconDownload, IconPlayerPause, IconPlayerPlay } from '@tabler/icons';
import { FunctionComponent, useState } from 'react';
import { useTranslation } from 'next-i18next';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { IModule } from '../ModuleTypes';
import { UsenetQueueList } from './UsenetQueueList';
import { UsenetHistoryList } from './UsenetHistoryList';
import { useGetServiceByType } from '../../tools/hooks/useGetServiceByType';
import { useGetUsenetInfo, usePauseUsenetQueue, useResumeUsenetQueue } from '../../tools/hooks/api';
import { humanFileSize } from '../../tools/humanFileSize';
dayjs.extend(duration);
export const UsenetComponent: FunctionComponent = () => {
const downloadServices = useGetServiceByType('Sabnzbd');
const { t } = useTranslation('modules/usenet');
const [selectedServiceId, setSelectedService] = useState<string | null>(downloadServices[0]?.id);
const { data } = useGetUsenetInfo({ serviceId: selectedServiceId! });
const { mutate: pause } = usePauseUsenetQueue({ serviceId: selectedServiceId! });
const { mutate: resume } = useResumeUsenetQueue({ serviceId: selectedServiceId! });
if (!selectedServiceId) {
return null;
@@ -20,8 +31,26 @@ export const UsenetComponent: FunctionComponent = () => {
<Tabs keepMounted={false} defaultValue="queue">
<Group mb="md">
<Tabs.List style={{ flex: 1 }}>
<Tabs.Tab value="queue">Queue</Tabs.Tab>
<Tabs.Tab value="history">History</Tabs.Tab>
<Tabs.Tab value="queue">{t('tabs.queue')}</Tabs.Tab>
<Tabs.Tab value="history">{t('tabs.history')}</Tabs.Tab>
{data && (
<Group position="right" ml="auto" mb="lg">
<Badge>{humanFileSize(data?.speed)}/s</Badge>
<Badge>
{t('info.sizeLeft')}: {humanFileSize(data?.sizeLeft)}
</Badge>
{data.paused ? (
<Button uppercase onClick={() => resume()} radius="xl" size="xs">
<IconPlayerPlay size={16} style={{ marginRight: 5 }} /> {t('info.paused')}
</Button>
) : (
<Button uppercase onClick={() => pause()} radius="xl" size="xs">
<IconPlayerPause size={16} style={{ marginRight: 5 }} />{' '}
{dayjs.duration(data.eta, 's').format('HH:mm:ss')}
</Button>
)}
</Group>
)}
</Tabs.List>
{downloadServices.length > 1 && (
<Select

View File

@@ -1,8 +1,10 @@
import {
Alert,
Button,
Center,
Code,
Group,
Pagination,
Progress,
Skeleton,
Table,
@@ -15,6 +17,7 @@ import { IconAlertCircle, IconPlayerPause, IconPlayerPlay } from '@tabler/icons'
import { AxiosError } from 'axios';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { useTranslation } from 'next-i18next';
import { FunctionComponent, useState } from 'react';
import { useGetUsenetDownloads } from '../../tools/hooks/api';
import { humanFileSize } from '../../tools/humanFileSize';
@@ -29,12 +32,15 @@ const PAGE_SIZE = 10;
export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ serviceId }) => {
const theme = useMantineTheme();
const { t } = useTranslation('modules/usenet');
const [page, setPage] = useState(1);
const { data, isLoading, isError, error } = useGetUsenetDownloads({
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
serviceId,
});
const totalPages = Math.ceil((data?.total || 1) / PAGE_SIZE);
if (isLoading) {
return (
@@ -50,7 +56,7 @@ export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ servi
return (
<Group position="center">
<Alert icon={<IconAlertCircle size={16} />} my="lg" title="Error!" color="red" radius="md">
Some error has occured while fetching data:
{t('queue.error')}
<Code mt="sm" block>
{(error as AxiosError)?.response?.data as string}
</Code>
@@ -62,71 +68,88 @@ export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ servi
if (!data || data.items.length <= 0) {
return (
<Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Title order={3}>Queue is empty</Title>
<Title order={3}>{t('queue.empty')}</Title>
</Center>
);
}
return (
<Table highlightOnHover style={{ tableLayout: 'fixed' }}>
<thead>
<tr>
<th style={{ width: 40 }} />
<th style={{ width: '75%' }}>Name</th>
<th style={{ width: 100 }}>Size</th>
<th style={{ width: 100 }}>ETA</th>
<th style={{ width: 200 }}>Progress</th>
</tr>
</thead>
<tbody>
{data.items.map((nzb) => (
<tr key={nzb.id}>
<td>
{nzb.state === 'paused' ? (
<IconPlayerPause fill="grey" stroke={0} size="16" />
) : (
<IconPlayerPlay fill="black" stroke={0} size="16" />
)}
</td>
<td>
<Tooltip position="top" label={nzb.name}>
<Text
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
size="xs"
>
{nzb.name}
</Text>
</Tooltip>
</td>
<td>
<Text size="xs">{humanFileSize(nzb.size)}</Text>
</td>
<td>
{nzb.eta <= 0 ? (
<Text size="xs" color="dimmed">
Paused
</Text>
) : (
<Text size="xs">{dayjs.duration(nzb.eta, 's').format('H:mm:ss')}</Text>
)}
</td>
<td style={{ display: 'flex', alignItems: 'center' }}>
<Text mr="sm">{nzb.progress.toFixed(1)}%</Text>
<Progress
radius="lg"
color={nzb.eta > 0 ? theme.primaryColor : 'lightgrey'}
value={nzb.progress}
size="lg"
style={{ width: '100%' }}
/>
</td>
<>
<Table highlightOnHover style={{ tableLayout: 'fixed' }}>
<thead>
<tr>
<th style={{ width: 50 }} />
<th style={{ width: '75%' }}>{t('queue.header.name')}</th>
<th style={{ width: 100 }}>{t('queue.header.size')}</th>
<th style={{ width: 100 }}>{t('queue.header.eta')}</th>
<th style={{ width: 200 }}>{t('queue.header.progress')}</th>
</tr>
))}
</tbody>
</Table>
</thead>
<tbody>
{data.items.map((nzb) => (
<tr key={nzb.id}>
<td>
{nzb.state === 'paused' ? (
<Button disabled color="gray" variant="subtle" radius="lg" size="xs" compact>
<IconPlayerPlay size="16" />
</Button>
) : (
<Button disabled variant="subtle" radius="lg" size="xs" compact>
<IconPlayerPause size="16" />
</Button>
)}
</td>
<td>
<Tooltip position="top" label={nzb.name}>
<Text
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
size="xs"
color={nzb.state === 'paused' ? 'dimmed' : undefined}
>
{nzb.name}
</Text>
</Tooltip>
</td>
<td>
<Text size="xs">{humanFileSize(nzb.size)}</Text>
</td>
<td>
{nzb.eta <= 0 ? (
<Text size="xs" color="dimmed">
{t('queue.paused')}
</Text>
) : (
<Text size="xs">{dayjs.duration(nzb.eta, 's').format('H:mm:ss')}</Text>
)}
</td>
<td style={{ display: 'flex', alignItems: 'center' }}>
<Text mr="sm">{nzb.progress.toFixed(1)}%</Text>
<Progress
radius="lg"
color={nzb.eta > 0 ? theme.primaryColor : 'lightgrey'}
value={nzb.progress}
size="lg"
style={{ width: '100%' }}
/>
</td>
</tr>
))}
</tbody>
</Table>
{totalPages > 1 && (
<Pagination
size="sm"
position="center"
mt="md"
total={totalPages}
page={page}
onChange={setPage}
/>
)}
</>
);
};

View File

@@ -8,9 +8,11 @@ import { NotificationsProvider } from '@mantine/notifications';
import { useHotkeys } from '@mantine/hooks';
import { ModalsProvider } from '@mantine/modals';
import { appWithTranslation } from 'next-i18next';
import { QueryClientProvider } from '@tanstack/react-query';
import { ConfigProvider } from '../tools/state';
import { theme } from '../tools/theme';
import { ColorTheme } from '../tools/color';
import { queryClient } from '../tools/queryClient';
function App(this: any, props: AppProps & { colorScheme: ColorScheme }) {
const { Component, pageProps } = props;
@@ -40,43 +42,44 @@ function App(this: any, props: AppProps & { colorScheme: ColorScheme }) {
<Head>
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width" />
</Head>
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}>
<ColorTheme.Provider value={colorTheme}>
<MantineProvider
theme={{
...theme,
components: {
Checkbox: {
styles: {
input: { cursor: 'pointer' },
label: { cursor: 'pointer' },
<QueryClientProvider client={queryClient}>
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}>
<ColorTheme.Provider value={colorTheme}>
<MantineProvider
theme={{
...theme,
components: {
Checkbox: {
styles: {
input: { cursor: 'pointer' },
label: { cursor: 'pointer' },
},
},
Switch: {
styles: {
input: { cursor: 'pointer' },
label: { cursor: 'pointer' },
},
},
},
Switch: {
styles: {
input: { cursor: 'pointer' },
label: { cursor: 'pointer' },
},
},
},
primaryColor,
primaryShade,
colorScheme,
}}
withGlobalStyles
withNormalizeCSS
>
<NotificationsProvider limit={4} position="bottom-left">
<ModalsProvider>
<ConfigProvider>
<Component {...pageProps} />
</ConfigProvider>
</ModalsProvider>
</NotificationsProvider>
</MantineProvider>
</ColorTheme.Provider>
</ColorSchemeProvider>
primaryColor,
primaryShade,
colorScheme,
}}
withGlobalStyles
withNormalizeCSS
>
<NotificationsProvider limit={4} position="bottom-left">
<ModalsProvider>
<ConfigProvider>
<Component {...pageProps} />
</ConfigProvider>
</ModalsProvider>
</NotificationsProvider>
</MantineProvider>
</ColorTheme.Provider>
</ColorSchemeProvider>
</QueryClientProvider>
</>
);
}

View File

@@ -3,29 +3,28 @@ import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { NextApiRequest, NextApiResponse } from 'next';
import { Client } from 'sabnzbd-api';
import { UsenetQueueItem } from '../../../../modules';
import { getConfig } from '../../../../tools/getConfig';
import { getServiceById } from '../../../../tools/hooks/useGetServiceByType';
import { Config } from '../../../../tools/types';
dayjs.extend(duration);
export interface UsenetQueueRequestParams {
export interface UsenetInfoRequestParams {
serviceId: string;
offset: number;
limit: number;
}
export interface UsenetQueueResponse {
items: UsenetQueueItem[];
total: number;
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 }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
const { limit, offset, serviceId } = req.query as any as UsenetQueueRequestParams;
const { serviceId } = req.query as any as UsenetInfoRequestParams;
const service = getServiceById(config, serviceId);
@@ -36,29 +35,21 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
if (!service.apiKey) {
throw new Error(`API Key for service "${service.name}" is missing`);
}
const queue = await new Client(service.url, service.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);
const queue = await new Client(service.url, service.apiKey).queue(0, -1);
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 [hours, minutes, seconds] = queue.timeleft.split(':');
const eta = dayjs.duration({
hour: parseInt(hours, 10),
minutes: parseInt(minutes, 10),
seconds: parseInt(seconds, 10),
} as any);
const response: UsenetQueueResponse = {
items,
total: queue.noofslots_total,
const response: UsenetInfoResponse = {
paused: queue.paused,
sizeLeft: parseFloat(queue.mbleft) * 1024 * 1024,
speed: parseFloat(queue.kbpersec) * 1000,
eta: eta.asSeconds(),
};
return res.status(200).json(response);

View File

@@ -0,0 +1,49 @@
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 { getConfig } from '../../../../tools/getConfig';
import { getServiceById } from '../../../../tools/hooks/useGetServiceByType';
import { Config } from '../../../../tools/types';
dayjs.extend(duration);
export interface UsenetPauseRequestParams {
serviceId: string;
}
async function Post(req: NextApiRequest, res: NextApiResponse) {
try {
const configName = getCookie('config-name', { req });
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
const { serviceId } = req.query as any as UsenetPauseRequestParams;
const service = getServiceById(config, serviceId);
if (!service) {
throw new Error(`Service with ID "${req.query.serviceId}" could not be found.`);
}
if (!service.apiKey) {
throw new Error(`API Key for service "${service.name}" is missing`);
}
const result = await new Client(service.url, service.apiKey).queuePause();
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

@@ -0,0 +1,79 @@
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 { UsenetQueueItem } from '../../../../modules';
import { getConfig } from '../../../../tools/getConfig';
import { getServiceById } from '../../../../tools/hooks/useGetServiceByType';
import { Config } from '../../../../tools/types';
dayjs.extend(duration);
export interface UsenetQueueRequestParams {
serviceId: 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 }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
const { limit, offset, serviceId } = req.query as any as UsenetQueueRequestParams;
const service = getServiceById(config, serviceId);
if (!service) {
throw new Error(`Service with ID "${req.query.serviceId}" could not be found.`);
}
if (!service.apiKey) {
throw new Error(`API Key for service "${service.name}" is missing`);
}
const queue = await new Client(service.url, service.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,
};
});
const response: UsenetQueueResponse = {
items,
total: queue.noofslots,
};
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

@@ -0,0 +1,50 @@
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 { getConfig } from '../../../../tools/getConfig';
import { getServiceById } from '../../../../tools/hooks/useGetServiceByType';
import { Config } from '../../../../tools/types';
dayjs.extend(duration);
export interface UsenetResumeRequestParams {
serviceId: string;
nzbId?: string;
}
async function Post(req: NextApiRequest, res: NextApiResponse) {
try {
const configName = getCookie('config-name', { req });
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
const { serviceId } = req.query as any as UsenetResumeRequestParams;
const service = getServiceById(config, serviceId);
if (!service) {
throw new Error(`Service with ID "${req.query.serviceId}" could not be found.`);
}
if (!service.apiKey) {
throw new Error(`API Key for service "${service.name}" is missing`);
}
const result = await new Client(service.url, service.apiKey).queueResume();
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

@@ -3,8 +3,6 @@ import { GetServerSidePropsContext } from 'next';
import { useEffect } from 'react';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import AppShelf from '../components/AppShelf/AppShelf';
import LoadConfigComponent from '../components/Config/LoadConfig';
import { Config } from '../tools/types';
@@ -52,6 +50,7 @@ export async function getServerSideProps({
'modules/date',
'modules/calendar',
'modules/dlspeed',
'modules/usenet',
'modules/search',
'modules/torrents-status',
'modules/weather',
@@ -64,8 +63,6 @@ export async function getServerSideProps({
return getConfig(configName as string, translations);
}
const queryClient = new QueryClient();
export default function HomePage(props: any) {
const { config: initialConfig }: { config: Config } = props;
const { setConfig } = useConfig();
@@ -77,11 +74,9 @@ export default function HomePage(props: any) {
setConfig(migratedConfig);
}, [initialConfig]);
return (
<QueryClientProvider client={queryClient}>
<Layout>
<AppShelf />
<LoadConfigComponent />
</Layout>
</QueryClientProvider>
<Layout>
<AppShelf />
<LoadConfigComponent />
</Layout>
);
}

View File

@@ -1,17 +1,41 @@
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { UsenetQueueRequestParams, UsenetQueueResponse } from '../../pages/api/modules/usenet';
import { Results } from 'sabnzbd-api';
import {
UsenetQueueRequestParams,
UsenetQueueResponse,
} from '../../pages/api/modules/usenet/queue';
import {
UsenetHistoryRequestParams,
UsenetHistoryResponse,
} from '../../pages/api/modules/usenet/history';
import { UsenetInfoRequestParams, UsenetInfoResponse } from '../../pages/api/modules/usenet';
import { UsenetPauseRequestParams } from '../../pages/api/modules/usenet/pause';
import { queryClient } from '../queryClient';
import { UsenetResumeRequestParams } from '../../pages/api/modules/usenet/resume';
export const useGetUsenetInfo = (params: UsenetInfoRequestParams) =>
useQuery(
['usenetInfo', params.serviceId],
async () =>
(
await axios.get<UsenetInfoResponse>('/api/modules/usenet', {
params,
})
).data,
{
refetchInterval: 1000,
keepPreviousData: true,
retry: 2,
}
);
export const useGetUsenetDownloads = (params: UsenetQueueRequestParams) =>
useQuery(
['usenetDownloads', ...Object.values(params)],
async () =>
(
await axios.get<UsenetQueueResponse>('/api/modules/usenet', {
await axios.get<UsenetQueueResponse>('/api/modules/usenet/queue', {
params,
})
).data,
@@ -37,3 +61,91 @@ export const useGetUsenetHistory = (params: UsenetHistoryRequestParams) =>
retry: 2,
}
);
export const usePauseUsenetQueue = (params: UsenetPauseRequestParams) =>
useMutation(
['usenetPause', ...Object.values(params)],
async () =>
(
await axios.post<Results>(
'/api/modules/usenet/pause',
{},
{
params,
}
)
).data,
{
async onMutate() {
await queryClient.cancelQueries(['usenetInfo', params.serviceId]);
const previousInfo = queryClient.getQueryData<UsenetInfoResponse>([
'usenetInfo',
params.serviceId,
]);
if (previousInfo) {
queryClient.setQueryData<UsenetInfoResponse>(['usenetInfo', params.serviceId], {
...previousInfo,
paused: true,
});
}
return { previousInfo };
},
onError(err, _, context) {
if (context?.previousInfo) {
queryClient.setQueryData<UsenetInfoResponse>(
['usenetInfo', params.serviceId],
context.previousInfo
);
}
},
onSettled() {
queryClient.invalidateQueries(['usenetInfo', params.serviceId]);
},
}
);
export const useResumeUsenetQueue = (params: UsenetResumeRequestParams) =>
useMutation(
['usenetResume', ...Object.values(params)],
async () =>
(
await axios.post<Results>(
'/api/modules/usenet/resume',
{},
{
params,
}
)
).data,
{
async onMutate() {
await queryClient.cancelQueries(['usenetInfo', params.serviceId]);
const previousInfo = queryClient.getQueryData<UsenetInfoResponse>([
'usenetInfo',
params.serviceId,
]);
if (previousInfo) {
queryClient.setQueryData<UsenetInfoResponse>(['usenetInfo', params.serviceId], {
...previousInfo,
paused: false,
});
}
return { previousInfo };
},
onError(err, _, context) {
if (context?.previousInfo) {
queryClient.setQueryData<UsenetInfoResponse>(
['usenetInfo', params.serviceId],
context.previousInfo
);
}
},
onSettled() {
queryClient.invalidateQueries(['usenetInfo', params.serviceId]);
},
}
);

3
src/tools/queryClient.ts Normal file
View File

@@ -0,0 +1,3 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient();