diff --git a/package.json b/package.json index e9750de18..46b61bcb0 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "prism-react-renderer": "^1.3.5", "react": "^18.2.0", "react-dom": "^18.2.0", + "sabnzbd-api": "^1.5.0", "sharp": "^0.30.7", "systeminformation": "^5.12.1", "uuid": "^8.3.2", diff --git a/src/components/AppShelf/AddAppShelfItem.tsx b/src/components/AppShelf/AddAppShelfItem.tsx index efeba042a..fc5d3960e 100644 --- a/src/components/AppShelf/AddAppShelfItem.tsx +++ b/src/components/AppShelf/AddAppShelfItem.tsx @@ -269,7 +269,8 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & form.values.type === 'Lidarr' || form.values.type === 'Overseerr' || form.values.type === 'Jellyseerr' || - form.values.type === 'Readarr') && ( + form.values.type === 'Readarr' || + form.values.type === 'Sabnzbd') && ( <> { const { config, setConfig } = useConfig(); @@ -150,7 +150,7 @@ const AppShelf = (props: any) => { {/* Return the item for all services without category */} {noCategory && noCategory.length > 0 ? ( - {t('accordions.others.text')} + Other {getItems()} ) : null} @@ -170,8 +170,8 @@ const AppShelf = (props: any) => { ${(config.settings.appOpacity || 100) / 100}`, }} > - - + + @@ -183,7 +183,8 @@ const AppShelf = (props: any) => { return ( {getItems()} - + + ); }; diff --git a/src/modules/index.ts b/src/modules/index.ts index 88cb1ad02..c903fdbad 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,9 +1,10 @@ export * from './calendar'; export * from './dashdot'; export * from './date'; -export * from './downloads'; +export * from './torrents'; export * from './ping'; export * from './search'; export * from './weather'; export * from './docker'; export * from './overseerr'; +export * from './nzb'; diff --git a/src/modules/nzb/NzbModule.tsx b/src/modules/nzb/NzbModule.tsx new file mode 100644 index 000000000..31374c550 --- /dev/null +++ b/src/modules/nzb/NzbModule.tsx @@ -0,0 +1,137 @@ +import { Center, Progress, ScrollArea, Skeleton, Table, Text, Title, Tooltip } from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { IconDownload, IconPlayerPause, IconPlayerPlay } from '@tabler/icons'; +import axios from 'axios'; +import dayjs from 'dayjs'; +import { FunctionComponent, useEffect, useState } from 'react'; +import duration from 'dayjs/plugin/duration'; +import { humanFileSize } from '../../tools/humanFileSize'; +import { DownloadItem } from '../../tools/types'; +import { IModule } from '../ModuleTypes'; + +dayjs.extend(duration); + +export const NzbComponent: FunctionComponent = () => { + const [nzbs, setNzbs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + + const getData = async () => { + try { + const response = await axios.get('/api/modules/nzbs'); + setNzbs(response.data); + } catch (error) { + setNzbs([]); + showNotification({ + title: 'Error fetching torrents', + autoClose: 1000, + disallowClose: true, + id: 'fail-torrent-downloads-module', + color: 'red', + message: + 'Please check your config for any potential errors, check the console for more info', + }); + } finally { + setIsLoading(false); + } + }; + + const interval = setInterval(getData, 10000); + getData(); + + () => { + clearInterval(interval); + }; + }, []); + + const ths = ( + + + Name + Size + ETA + Progress + + ); + + const rows = nzbs.map((nzb) => ( + + + {nzb.state === 'paused' ? ( + + ) : ( + + )} + + + + + {nzb.name} + + + + + {humanFileSize(nzb.size * 1000 * 1000)} + + + {nzb.eta <= 0 ? ( + + Paused + + ) : ( + {dayjs.duration(nzb.eta, 's').format('H:mm:ss')} + )} + + + {nzb.progress.toFixed(1)}% + + + + )); + + if (isLoading) { + return ( + <> + + + + + ); + } + + return ( + + {rows.length > 0 ? ( + + {ths} + {rows} +
+ ) : ( +
+ Queue is empty +
+ )} +
+ ); +}; + +export const NzbModule: IModule = { + id: 'usenet', + title: 'Usenet', + icon: IconDownload, + component: NzbComponent, +}; + +export default NzbComponent; diff --git a/src/modules/nzb/index.ts b/src/modules/nzb/index.ts new file mode 100644 index 000000000..cd8cec9fc --- /dev/null +++ b/src/modules/nzb/index.ts @@ -0,0 +1 @@ +export { NzbModule } from './NzbModule'; diff --git a/src/modules/downloads/DownloadsModule.tsx b/src/modules/torrents/TorrentsModule.tsx similarity index 83% rename from src/modules/downloads/DownloadsModule.tsx rename to src/modules/torrents/TorrentsModule.tsx index 51afc5301..04a9b6b3b 100644 --- a/src/modules/downloads/DownloadsModule.tsx +++ b/src/modules/torrents/TorrentsModule.tsx @@ -8,35 +8,33 @@ import { Skeleton, ScrollArea, Center, - Stack, } from '@mantine/core'; import { IconDownload as Download } from '@tabler/icons'; import { useEffect, useState } from 'react'; import axios from 'axios'; -import { NormalizedTorrent } from '@ctrl/shared-torrent'; import { useViewportSize } from '@mantine/hooks'; import { showNotification } from '@mantine/notifications'; -import { useTranslation } from 'next-i18next'; +import { NormalizedTorrent } from '@ctrl/shared-torrent'; import { IModule } from '../ModuleTypes'; import { useConfig } from '../../tools/state'; import { AddItemShelfButton } from '../../components/AppShelf/AddAppShelfItem'; import { useSetSafeInterval } from '../../tools/hooks/useSetSafeInterval'; import { humanFileSize } from '../../tools/humanFileSize'; -export const DownloadsModule: IModule = { +export const TorrentsModule: IModule = { + id: 'torrent', title: 'Torrent', icon: Download, - component: DownloadComponent, + component: TorrentsComponent, options: { hidecomplete: { - name: 'descriptor.settings.hideComplete', + name: 'Hide completed torrents', value: false, }, }, - id: 'torrents-status', }; -export default function DownloadComponent() { +export default function TorrentsComponent() { const { config } = useConfig(); const { height, width } = useViewportSize(); const downloadServices = @@ -44,23 +42,22 @@ export default function DownloadComponent() { (service) => service.type === 'qBittorrent' || service.type === 'Transmission' || - service.type === 'Deluge' + service.type === 'Deluge' || + service.type === 'Sabnzbd' ) ?? []; + const hideComplete: boolean = - (config?.modules?.[DownloadsModule.id]?.options?.hidecomplete?.value as boolean) ?? false; + (config?.modules?.[TorrentsModule.title]?.options?.hidecomplete?.value as boolean) ?? false; const [torrents, setTorrents] = useState([]); const setSafeInterval = useSetSafeInterval(); const [isLoading, setIsLoading] = useState(true); - - const { t } = useTranslation(`modules/${DownloadsModule.id}`); - useEffect(() => { setIsLoading(true); if (downloadServices.length === 0) return; const interval = setInterval(() => { // Send one request with each download service inside axios - .post('/api/modules/downloads') + .post('/api/modules/torrents') .then((response) => { setTorrents(response.data); setIsLoading(false); @@ -86,13 +83,13 @@ export default function DownloadComponent() { if (downloadServices.length === 0) { return ( - - {t('card.errors.noDownloadClients.title')} + + No supported download clients found! - {t('card.errors.noDownloadClients.text')} + Add a download service to view your current downloads - + ); } @@ -110,12 +107,12 @@ export default function DownloadComponent() { const DEVICE_WIDTH = 576; const ths = ( - {t('card.table.header.name')} - {t('card.table.header.size')} - {width > 576 ? {t('card.table.header.download')} : ''} - {width > 576 ? {t('card.table.header.upload')} : ''} - {t('card.table.header.estimatedTimeOfArrival')} - {t('card.table.header.progress')} + Name + Size + {width > 576 ? Down : ''} + {width > 576 ? Up : ''} + ETA + Progress ); // Convert Seconds to readable format. @@ -200,7 +197,7 @@ export default function DownloadComponent() { ) : (
- {t('card.table.body.nothingFound')} + No torrents found
)} diff --git a/src/modules/downloads/TotalDownloadsModule.tsx b/src/modules/torrents/TotalDownloadsModule.tsx similarity index 84% rename from src/modules/downloads/TotalDownloadsModule.tsx rename to src/modules/torrents/TotalDownloadsModule.tsx index 0addfd712..ade7bb870 100644 --- a/src/modules/downloads/TotalDownloadsModule.tsx +++ b/src/modules/torrents/TotalDownloadsModule.tsx @@ -4,7 +4,6 @@ import { useEffect, useState } from 'react'; import axios from 'axios'; import { NormalizedTorrent } from '@ctrl/shared-torrent'; import { linearGradientDef } from '@nivo/core'; -import { useTranslation } from 'next-i18next'; import { Datum, ResponsiveLine } from '@nivo/line'; import { useListState } from '@mantine/hooks'; import { showNotification } from '@mantine/notifications'; @@ -15,10 +14,10 @@ import { IModule } from '../ModuleTypes'; import { useSetSafeInterval } from '../../tools/hooks/useSetSafeInterval'; export const TotalDownloadsModule: IModule = { + id: 'totalDownload', title: 'Download Speed', icon: Download, component: TotalDownloadsComponent, - id: 'dlspeed', }; interface torrentHistory { @@ -35,9 +34,9 @@ export default function TotalDownloadsComponent() { (service) => service.type === 'qBittorrent' || service.type === 'Transmission' || - service.type === 'Deluge' + service.type === 'Deluge' || + 'Sabnzbd' ) ?? []; - const { t } = useTranslation(`modules/${TotalDownloadsModule.id}`); const [torrentHistory, torrentHistoryHandlers] = useListState([]); const [torrents, setTorrents] = useState([]); @@ -71,30 +70,6 @@ export default function TotalDownloadsComponent() { }, 1000); }, [config.services]); - useEffect(() => { - torrentHistoryHandlers.append({ - x: Date.now(), - down: totalDownloadSpeed, - up: totalUploadSpeed, - }); - }, [totalDownloadSpeed, totalUploadSpeed]); - - if (downloadServices.length === 0) { - return ( - - {t('card.errors.noDownloadClients.title')} -
- - {t('card.errors.noDownloadClients.text')} -
-
- ); - } - const theme = useMantineTheme(); // Load the last 10 values from the history const history = torrentHistory.slice(-10); @@ -107,21 +82,41 @@ export default function TotalDownloadsComponent() { y: load.down, })) as Datum[]; + useEffect(() => { + torrentHistoryHandlers.append({ + x: Date.now(), + down: totalDownloadSpeed, + up: totalUploadSpeed, + }); + }, [totalDownloadSpeed, totalUploadSpeed]); + + if (downloadServices.length === 0) { + return ( + + No supported download clients found! +
+ + Add a download service to view your current downloads +
+
+ ); + } + return ( - {t('card.lineChart.title')} + Current download speed - - {t('card.lineChart.totalDownload', { download: humanFileSize(totalDownloadSpeed) })} - + Download: {humanFileSize(totalDownloadSpeed)}/s - - {t('card.lineChart.totalUpload', { upload: humanFileSize(totalUploadSpeed) })} - + Upload: {humanFileSize(totalUploadSpeed)}/s - {t('card.lineChart.timeSpan', { seconds: roundedSeconds })} + {roundedSeconds} seconds ago - - {t('card.lineChart.download', { download: humanFileSize(Download) })} - + Download: {humanFileSize(Download)} - - {t('card.lineChart.upload', { upload: humanFileSize(Upload) })} - + Upload: {humanFileSize(Upload)} diff --git a/src/modules/downloads/index.ts b/src/modules/torrents/index.ts similarity index 54% rename from src/modules/downloads/index.ts rename to src/modules/torrents/index.ts index f2d2c9beb..c5337e5fb 100644 --- a/src/modules/downloads/index.ts +++ b/src/modules/torrents/index.ts @@ -1,2 +1,2 @@ -export { DownloadsModule } from './DownloadsModule'; +export { TorrentsModule } from './TorrentsModule'; export { TotalDownloadsModule } from './TotalDownloadsModule'; diff --git a/src/pages/api/modules/nzbs.ts b/src/pages/api/modules/nzbs.ts new file mode 100644 index 000000000..4eb098277 --- /dev/null +++ b/src/pages/api/modules/nzbs.ts @@ -0,0 +1,60 @@ +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 { Config, DownloadItem } from '../../../tools/types'; + +dayjs.extend(duration); + +async function Get(req: NextApiRequest, res: NextApiResponse) { + try { + const configName = getCookie('config-name', { req }); + const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props; + const nzbServices = config.services.filter((service) => service.type === 'Sabnzbd'); + + const downloads: DownloadItem[] = []; + + await Promise.all( + nzbServices.map(async (service) => { + if (!service.apiKey) { + throw new Error(`API Key for service "${service.name}" is missing`); + } + const queue = await new Client(service.url, service.apiKey).queue(); + + queue.slots.forEach((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); + downloads.push({ + id: slot.nzo_id, + eta: eta.asSeconds(), + name: slot.filename, + progress: parseFloat(slot.percentage), + size: parseFloat(slot.mb), + state: slot.status.toLowerCase() as any, + }); + }); + }) + ); + + return res.status(200).json(downloads); + } catch (err) { + return res.status(401).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', + }); +}; diff --git a/src/pages/api/modules/downloads.ts b/src/pages/api/modules/torrents.ts similarity index 100% rename from src/pages/api/modules/downloads.ts rename to src/pages/api/modules/torrents.ts diff --git a/src/tools/types.ts b/src/tools/types.ts index e08b491e4..7eab83639 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -72,6 +72,7 @@ export const ServiceTypeList = [ 'Transmission', 'Overseerr', 'Jellyseerr', + 'Sabnzbd', ]; export type ServiceType = | 'Other' @@ -86,7 +87,8 @@ export type ServiceType = | 'Sonarr' | 'Overseerr' | 'Jellyseerr' - | 'Transmission'; + | 'Transmission' + | 'Sabnzbd'; export function tryMatchPort(name: string | undefined, form?: any) { if (!name) { @@ -112,6 +114,7 @@ export const portmap = [ { name: 'emby', value: '8096' }, { name: 'overseerr', value: '5055' }, { name: 'dash.', value: '3001' }, + { name: 'sabnzbd', value: '8080' }, ]; export const MatchingImages: { @@ -185,3 +188,12 @@ export interface serviceItem { newTab?: boolean; status?: string[]; } + +export interface DownloadItem { + name: string; + progress: number; + size: number; + id: string; + state: 'paused' | 'downloading' | 'queued'; + eta: number; +} diff --git a/yarn.lock b/yarn.lock index 1488fe927..905d3b641 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1860,7 +1860,7 @@ __metadata: languageName: node linkType: hard -"@sindresorhus/is@npm:^4.6.0": +"@sindresorhus/is@npm:^4.0.0, @sindresorhus/is@npm:^4.6.0": version: 4.6.0 resolution: "@sindresorhus/is@npm:4.6.0" checksum: 83839f13da2c29d55c97abc3bc2c55b250d33a0447554997a85c539e058e57b8da092da396e252b11ec24a0279a0bed1f537fa26302209327060643e327f81d2 @@ -1885,6 +1885,15 @@ __metadata: languageName: node linkType: hard +"@szmarczak/http-timer@npm:^4.0.5": + version: 4.0.6 + resolution: "@szmarczak/http-timer@npm:4.0.6" + dependencies: + defer-to-connect: ^2.0.0 + checksum: c29df3bcec6fc3bdec2b17981d89d9c9fc9bd7d0c9bcfe92821dc533f4440bc890ccde79971838b4ceed1921d456973c4180d7175ee1d0023ad0562240a58d95 + languageName: node + linkType: hard + "@szmarczak/http-timer@npm:^5.0.1": version: 5.0.1 resolution: "@szmarczak/http-timer@npm:5.0.1" @@ -1957,7 +1966,7 @@ __metadata: languageName: node linkType: hard -"@types/cacheable-request@npm:^6.0.2": +"@types/cacheable-request@npm:^6.0.1, @types/cacheable-request@npm:^6.0.2": version: 6.0.2 resolution: "@types/cacheable-request@npm:6.0.2" dependencies: @@ -2805,6 +2814,13 @@ __metadata: languageName: node linkType: hard +"cacheable-lookup@npm:^5.0.3": + version: 5.0.4 + resolution: "cacheable-lookup@npm:5.0.4" + checksum: 763e02cf9196bc9afccacd8c418d942fc2677f22261969a4c2c2e760fa44a2351a81557bd908291c3921fe9beb10b976ba8fa50c5ca837c5a0dd945f16468f2d + languageName: node + linkType: hard + "cacheable-lookup@npm:^6.0.4": version: 6.0.4 resolution: "cacheable-lookup@npm:6.0.4" @@ -3397,7 +3413,7 @@ __metadata: languageName: node linkType: hard -"defer-to-connect@npm:^2.0.1": +"defer-to-connect@npm:^2.0.0, defer-to-connect@npm:^2.0.1": version: 2.0.1 resolution: "defer-to-connect@npm:2.0.1" checksum: 8a9b50d2f25446c0bfefb55a48e90afd58f85b21bcf78e9207cd7b804354f6409032a1705c2491686e202e64fc05f147aa5aa45f9aa82627563f045937f5791b @@ -4597,6 +4613,25 @@ __metadata: languageName: node linkType: hard +"got@npm:^11.8.2": + version: 11.8.5 + resolution: "got@npm:11.8.5" + dependencies: + "@sindresorhus/is": ^4.0.0 + "@szmarczak/http-timer": ^4.0.5 + "@types/cacheable-request": ^6.0.1 + "@types/responselike": ^1.0.0 + cacheable-lookup: ^5.0.3 + cacheable-request: ^7.0.2 + decompress-response: ^6.0.0 + http2-wrapper: ^1.0.0-beta.5.2 + lowercase-keys: ^2.0.0 + p-cancelable: ^2.0.0 + responselike: ^2.0.0 + checksum: 2de8a1bbda4e9b6b2b72b2d2100bc055a59adc1740529e631f61feb44a8b9a1f9f8590941ed9da9df0090b6d6d0ed8ffee94cd9ac086ec3409b392b33440f7d2 + languageName: node + linkType: hard + "got@npm:^12.1.0": version: 12.1.0 resolution: "got@npm:12.1.0" @@ -4777,6 +4812,7 @@ __metadata: prism-react-renderer: ^1.3.5 react: ^18.2.0 react-dom: ^18.2.0 + sabnzbd-api: ^1.5.0 sharp: ^0.30.7 systeminformation: ^5.12.1 typescript: ^4.7.4 @@ -4870,6 +4906,16 @@ __metadata: languageName: node linkType: hard +"http2-wrapper@npm:^1.0.0-beta.5.2": + version: 1.0.3 + resolution: "http2-wrapper@npm:1.0.3" + dependencies: + quick-lru: ^5.1.1 + resolve-alpn: ^1.0.0 + checksum: 74160b862ec699e3f859739101ff592d52ce1cb207b7950295bf7962e4aa1597ef709b4292c673bece9c9b300efad0559fc86c71b1409c7a1e02b7229456003e + languageName: node + linkType: hard + "http2-wrapper@npm:^2.1.10": version: 2.1.11 resolution: "http2-wrapper@npm:2.1.11" @@ -6603,6 +6649,13 @@ __metadata: languageName: node linkType: hard +"p-cancelable@npm:^2.0.0": + version: 2.1.1 + resolution: "p-cancelable@npm:2.1.1" + checksum: 3dba12b4fb4a1e3e34524535c7858fc82381bbbd0f247cc32dedc4018592a3950ce66b106d0880b4ec4c2d8d6576f98ca885dc1d7d0f274d1370be20e9523ddf + languageName: node + linkType: hard + "p-cancelable@npm:^3.0.0": version: 3.0.0 resolution: "p-cancelable@npm:3.0.0" @@ -7128,7 +7181,7 @@ __metadata: languageName: node linkType: hard -"resolve-alpn@npm:^1.2.0": +"resolve-alpn@npm:^1.0.0, resolve-alpn@npm:^1.2.0": version: 1.2.1 resolution: "resolve-alpn@npm:1.2.1" checksum: f558071fcb2c60b04054c99aebd572a2af97ef64128d59bef7ab73bd50d896a222a056de40ffc545b633d99b304c259ea9d0c06830d5c867c34f0bfa60b8eae0 @@ -7260,6 +7313,16 @@ __metadata: languageName: node linkType: hard +"sabnzbd-api@npm:^1.5.0": + version: 1.5.0 + resolution: "sabnzbd-api@npm:1.5.0" + dependencies: + form-data: ^4.0.0 + got: ^11.8.2 + checksum: e52b6978f7f4c4df1857b3be5a400182c3f494bf68f1c496bb0e56d7a629947cdd088aff9ae0cb331337574b1302ff13c7d75228761876d7f0e825c7269b54ff + languageName: node + linkType: hard + "safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1"