diff --git a/src/hooks/widgets/dashDot/api.ts b/src/hooks/widgets/dashDot/api.ts index 36c82a55e..5edca276e 100644 --- a/src/hooks/widgets/dashDot/api.ts +++ b/src/hooks/widgets/dashDot/api.ts @@ -13,25 +13,27 @@ import { UsenetInfoRequestParams, UsenetInfoResponse } from '../../../pages/api/ import { UsenetPauseRequestParams } from '../../../pages/api/modules/usenet/pause'; import { queryClient } from '../../../tools/server/configurations/tanstack/queryClient.tool'; import { UsenetResumeRequestParams } from '../../../pages/api/modules/usenet/resume'; +import { api } from '~/utils/api'; +import { useConfigContext } from '~/config/provider'; const POLLING_INTERVAL = 2000; -export const useGetUsenetInfo = (params: UsenetInfoRequestParams) => - useQuery( - ['usenetInfo', params.appId], - async () => - ( - await axios.get('/api/modules/usenet', { - params, - }) - ).data, +export const useGetUsenetInfo = ({ appId }: UsenetInfoRequestParams) => { + const { name: configName } = useConfigContext(); + + return api.usenet.info.useQuery( + { + appId, + configName: configName!, + }, { refetchInterval: POLLING_INTERVAL, keepPreviousData: true, retry: 2, - enabled: Boolean(params.appId), + enabled: !!appId, } ); +}; export const useGetUsenetDownloads = (params: UsenetQueueRequestParams) => useQuery( diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 3dd246da3..f76df3cbe 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -10,6 +10,7 @@ import { downloadRouter } from './routers/download'; import { mediaRequestsRouter } from './routers/media-request'; import { mediaServerRouter } from './routers/media-server'; import { overseerrRouter } from './routers/overseerr'; +import { usenetRouter } from './routers/usenet/route'; /** * This is the primary router for your server. @@ -28,6 +29,7 @@ export const rootRouter = createTRPCRouter({ mediaRequest: mediaRequestsRouter, mediaServer: mediaServerRouter, overseerr: overseerrRouter, + usenet: usenetRouter, }); // export type definition of API diff --git a/src/server/api/routers/download.ts b/src/server/api/routers/download.ts index d62fa10b1..c1171f50c 100644 --- a/src/server/api/routers/download.ts +++ b/src/server/api/routers/download.ts @@ -6,8 +6,8 @@ import Consola from 'consola'; import dayjs from 'dayjs'; import { Client } from 'sabnzbd-api'; import { z } from 'zod'; -import { NzbgetClient } from '~/pages/api/modules/usenet/nzbget/nzbget-client'; -import { NzbgetQueueItem, NzbgetStatus } from '~/pages/api/modules/usenet/nzbget/types'; +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, diff --git a/src/server/api/routers/usenet/nzbget/nzbget-api.d.ts b/src/server/api/routers/usenet/nzbget/nzbget-api.d.ts new file mode 100644 index 000000000..9c77b63dc --- /dev/null +++ b/src/server/api/routers/usenet/nzbget/nzbget-api.d.ts @@ -0,0 +1 @@ +declare module 'nzbget-api'; diff --git a/src/server/api/routers/usenet/nzbget/nzbget-client.ts b/src/server/api/routers/usenet/nzbget/nzbget-client.ts new file mode 100644 index 000000000..ef614dd57 --- /dev/null +++ b/src/server/api/routers/usenet/nzbget/nzbget-client.ts @@ -0,0 +1,22 @@ +import NZBGet from 'nzbget-api'; +import { NzbgetClientOptions } from './types'; + +export function NzbgetClient(options: NzbgetClientOptions) { + if (!options?.host) { + throw new Error('Cannot connect to NZBGet. Missing host in app config.'); + } + + if (!options?.port) { + throw new Error('Cannot connect to NZBGet. Missing port in app config.'); + } + + if (!options?.login) { + throw new Error('Cannot connect to NZBGet. Missing username in app config.'); + } + + if (!options?.hash) { + throw new Error('Cannot connect to NZBGet. Missing password in app config.'); + } + + return new NZBGet(options); +} diff --git a/src/server/api/routers/usenet/nzbget/types.ts b/src/server/api/routers/usenet/nzbget/types.ts new file mode 100644 index 000000000..e51f5861a --- /dev/null +++ b/src/server/api/routers/usenet/nzbget/types.ts @@ -0,0 +1,149 @@ +export interface NzbgetHistoryItem { + NZBID: number; + Kind: 'NZB' | 'URL' | 'DUP'; + NZBFilename: string; + Name: string; + URL: string; + HistoryTime: number; + DestDir: string; + FinalDir: string; + Category: string; + FileSizeLo: number; + FileSizeHi: number; + FileSizeMB: number; + FileCount: number; + RemainingFileCount: number; + MinPostTime: number; + MaxPostTime: number; + TotalArticles: number; + SuccessArticles: number; + FailedArticles: number; + Health: number; + DownloadedSizeLo: number; + DownloadedSizeHi: number; + DownloadedSizeMB: number; + DownloadTimeSec: number; + PostTotalTimeSec: number; + ParTimeSec: number; + RepairTimeSec: number; + UnpackTimeSec: number; + MessageCount: number; + DupeKey: string; + DupeScore: number; + DupeMode: 'SCORE' | 'ALL' | 'FORCE'; + Status: string; + ParStatus: 'NONE' | 'FAILURE' | 'REPAIR_POSSIBLE' | 'SUCCESS' | 'MANUAL'; + ExParStatus: 'RECIPIENT' | 'DONOR'; + UnpackStatus: 'NONE' | 'FAILURE' | 'SPACE' | 'PASSWORD' | 'SUCCESS'; + UrlStatus: 'NONE' | 'SUCCESS' | 'FAILURE' | 'SCAN_SKIPPED' | 'SCAN_FAILURE'; + ScriptStatus: 'NONE' | 'FAILURE' | 'SUCCESS'; + ScriptStatuses: []; + MoveStatus: 'NONE' | 'SUCCESS' | 'FAILURE'; + DeleteStatus: 'NONE' | 'MANUAL' | 'HEALTH' | 'DUPE' | 'BAD' | 'SCAN' | 'COPY'; + MarkStatus: 'NONE' | 'GOOD' | 'BAD'; + ExtraParBlocks: number; + Parameters: []; + ServerStats: []; +} + +export interface NzbgetQueueItem { + NZBID: number; + NZBFilename: string; + NZBName: string; + Kind: 'NZB' | 'URL'; + URL: string; + DestDir: string; + FinalDir: string; + Category: string; + FileSizeLo: number; + FileSizeHi: number; + FileSizeMB: number; + RemainingSizeLo: number; + RemainingSizeHi: number; + RemainingSizeMB: number; + PausedSizeLo: number; + PausedSizeHi: number; + PausedSizeMB: number; + FileCount: number; + RemainingFileCount: number; + RemainingParCount: number; + MinPostTime: number; + MaxPostTime: number; + MaxPriority: number; + ActiveDownloads: number; + Status: + | 'QUEUED' + | 'PAUSED' + | 'DOWNLOADING' + | 'FETCHING' + | 'PP_QUEUED' + | 'LOADING_PARS' + | 'VERIFYING_SOURCES' + | 'REPAIRING' + | 'VERIFYING_REPAIRED' + | 'RENAMING' + | 'UNPACKING' + | 'MOVING' + | 'EXECUTING_SCRIPT' + | 'PP_FINISHED'; + TotalArticles: number; + SuccessArticles: number; + FailedArticles: number; + Health: number; + CriticalHealth: number; + DownloadedSizeLo: number; + DownloadedSizeHi: number; + DownloadedSizeMB: number; + DownloadTimeSec: number; + MessageCount: number; + DupeKey: string; + DupeScore: number; + DupeMode: string; + Parameters: []; + ServerStats: []; + PostInfoText: string; + PostStageProgress: number; + PostTotalTimeSec: number; + PostStageTimeSec: number; +} + +export interface NzbgetStatus { + RemainingSizeLo: number; + RemainingSizeHi: number; + RemainingSizeMB: number; + ForcedSizeLo: number; + ForcedSizeHi: number; + ForcedSizeMB: number; + DownloadedSizeLo: number; + DownloadedSizeHi: number; + DownloadedSizeMB: number; + ArticleCacheLo: number; + ArticleCacheHi: number; + ArticleCacheMB: number; + DownloadRate: number; + AverageDownloadRate: number; + DownloadLimit: number; + ThreadCount: number; + PostJobCount: number; + UrlCount: number; + UpTimeSec: number; + DownloadTimeSec: number; + ServerStandBy: boolean; + DownloadPaused: boolean; + PostPaused: boolean; + ScanPaused: boolean; + ServerTime: number; + ResumeTime: number; + FeedActive: boolean; + FreeDiskSpaceLo: number; + FreeDiskSpaceHi: number; + FreeDiskSpaceMB: number; + NewsServers: []; +} + +export interface NzbgetClientOptions { + host: string; + port: string; + login: string | undefined; + hash: string | undefined; +} diff --git a/src/server/api/routers/usenet/route.ts b/src/server/api/routers/usenet/route.ts new file mode 100644 index 000000000..5c824a8f9 --- /dev/null +++ b/src/server/api/routers/usenet/route.ts @@ -0,0 +1,101 @@ +import dayjs from 'dayjs'; +import { Client } from 'sabnzbd-api'; +import { z } from 'zod'; +import { TRPCError } from '@trpc/server'; +import { NzbgetStatus } from '~/server/api/routers/usenet/nzbget/types'; +import { getConfig } from '~/tools/config/getConfig'; +import { createTRPCRouter, publicProcedure } from '../../trpc'; +import { NzbgetClient } from './nzbget/nzbget-client'; + +export const usenetRouter = createTRPCRouter({ + info: publicProcedure + .input( + z.object({ + configName: z.string(), + appId: z.string(), + }) + ) + .query(async ({ input }) => { + const config = getConfig(input.configName); + + const app = config.apps.find((x) => x.id === input.appId); + + if (!app || (app.integration?.type !== 'nzbGet' && app.integration?.type !== 'sabnzbd')) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `App with ID "${input.appId}" could not be found.`, + }); + } + + if (app.integration?.type === 'nzbGet') { + const url = new URL(app.url); + const options = { + host: url.hostname, + port: url.port || (url.protocol === 'https:' ? '443' : '80'), + login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined, + hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined, + }; + + 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 TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Error while getting NZBGet status', + }); + } + + const bytesRemaining = nzbgetStatus.RemainingSizeMB * 1000000; + const eta = bytesRemaining / nzbgetStatus.DownloadRate; + return { + paused: nzbgetStatus.DownloadPaused, + sizeLeft: bytesRemaining, + speed: nzbgetStatus.DownloadRate, + eta, + }; + } + + const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value; + if (!apiKey) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `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); + + return { + paused: queue.paused, + sizeLeft: parseFloat(queue.mbleft) * 1024 * 1024, + speed: parseFloat(queue.kbpersec) * 1000, + eta: eta.asSeconds(), + }; + }), +}); + +export interface UsenetInfoResponse { + paused: boolean; + sizeLeft: number; + speed: number; + eta: number; +}