diff --git a/packages/core/src/builtins/base/debrid.ts b/packages/core/src/builtins/base/debrid.ts
index afa5c4ec..1f2974dc 100644
--- a/packages/core/src/builtins/base/debrid.ts
+++ b/packages/core/src/builtins/base/debrid.ts
@@ -2,6 +2,7 @@ import {
CacheAndPlaySchema,
Manifest,
Meta,
+ NNTPServersSchema,
Stream,
} from '../../db/schemas.js';
import { z, ZodError } from 'zod';
@@ -217,7 +218,7 @@ export abstract class BaseDebridAddon {
(s) => !['nzbdav', 'altmount'].includes(s.id) // usenet only services excluded
);
const nzbServices = this.userData.services.filter(
- (s) => ['nzbdav', 'altmount', 'torbox'].includes(s.id) // only keep services that support usenet
+ (s) => ['nzbdav', 'altmount', 'torbox', 'stremio_nntp'].includes(s.id) // only keep services that support usenet
);
if (torrentServices.length === 0 && torrentResults.length > 0) {
@@ -255,16 +256,50 @@ export abstract class BaseDebridAddon {
),
]);
+ let servers: string[] | undefined;
+ const encodedNntpServers = this.userData?.services.find(
+ (s) => s.id === 'stremio_nntp'
+ )?.credential;
+ try {
+ if (encodedNntpServers) {
+ const nntpServers = NNTPServersSchema.parse(
+ JSON.parse(
+ Buffer.from(encodedNntpServers, 'base64').toString('utf-8')
+ )
+ );
+ // servers - array, a list of strings that each represent a connection to a NNTP (usenet) server (for nzbUrl) in the form of nntp(s)://{user}:{pass}@{nntpDomain}:{nntpPort}/{nntpConnections} (nntps = SSL; nntp = no encryption) (example: nntps://myuser:mypass@news.example.com/4)
+ servers = nntpServers.map(
+ (s) =>
+ `${s.ssl ? 'nntps' : 'nntp'}://${encodeURIComponent(
+ s.username
+ )}:${encodeURIComponent(s.password)}@${s.host}:${s.port}/${
+ s.connections
+ }`
+ );
+ }
+ } catch (error) {
+ if (error instanceof ZodError) {
+ this.logger.error(
+ `Failed to parse NNTP servers for Stremio NNTP stream: ${formatZodError(error)}`
+ );
+ }
+ throw error;
+ }
+
const encryptedStoreAuths = this.userData.services.reduce(
(acc, service) => {
const auth = {
id: service.id,
credential: service.credential,
};
- acc[service.id] = encryptString(JSON.stringify(auth)).data ?? '';
+ if (service.id === 'stremio_nntp' && servers) {
+ acc[service.id] = servers;
+ } else {
+ acc[service.id] = encryptString(JSON.stringify(auth)).data ?? '';
+ }
return acc;
},
- {} as Record
+ {} as Record
);
const debridTitleMetadata: DebridTitleMetadata = {
titles: searchMetadata.titles,
@@ -337,8 +372,8 @@ export abstract class BaseDebridAddon {
results.map((result) => {
const stream = this._createStream(
result,
- encryptedStoreAuths,
- metadataId
+ metadataId,
+ encryptedStoreAuths
);
if (
result.service?.id === 'nzbdav' &&
@@ -673,8 +708,8 @@ export abstract class BaseDebridAddon {
protected _createStream(
torrentOrNzb: TorrentWithSelectedFile | NZBWithSelectedFile,
- encryptedStoreAuths: Record,
- metadataId: string
+ metadataId: string,
+ encryptedStoreAuths: Record
): Stream {
// Handle debrid streaming
const encryptedStoreAuth = torrentOrNzb.service
@@ -722,18 +757,27 @@ export abstract class BaseDebridAddon {
}`;
return {
- url: torrentOrNzb.service
- ? generatePlaybackUrl(
- encryptedStoreAuth!,
- metadataId!,
- fileInfo!,
- torrentOrNzb.title,
- torrentOrNzb.file.name
- )
- : undefined,
+ url:
+ torrentOrNzb.service && torrentOrNzb.service.id != 'stremio_nntp'
+ ? generatePlaybackUrl(
+ encryptedStoreAuth! as string,
+ metadataId!,
+ fileInfo!,
+ torrentOrNzb.title,
+ torrentOrNzb.file.name
+ )
+ : undefined,
+ nzbUrl: torrentOrNzb.type === 'usenet' ? torrentOrNzb.nzb : undefined,
+ servers:
+ torrentOrNzb.service?.id === 'stremio_nntp'
+ ? (encryptedStoreAuth as string[])
+ : undefined,
name,
description,
- type: torrentOrNzb.type,
+ type:
+ torrentOrNzb.service?.id === 'stremio_nntp'
+ ? 'stremio-usenet'
+ : torrentOrNzb.type,
age: torrentOrNzb.age,
infoHash: torrentOrNzb.hash,
fileIdx: torrentOrNzb.file.index,
diff --git a/packages/core/src/builtins/newznab/addon.ts b/packages/core/src/builtins/newznab/addon.ts
index 43796f7e..40098cef 100644
--- a/packages/core/src/builtins/newznab/addon.ts
+++ b/packages/core/src/builtins/newznab/addon.ts
@@ -42,6 +42,7 @@ export class NewznabAddon extends BaseNabAddon {
constants.TORBOX_SERVICE,
constants.NZBDAV_SERVICE,
constants.ALTMOUNT_SERVICE,
+ constants.STREMIO_NNTP_SERVICE,
].includes(s.id)
)
) {
diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts
index fa187431..206992ca 100644
--- a/packages/core/src/db/schemas.ts
+++ b/packages/core/src/db/schemas.ts
@@ -193,6 +193,7 @@ const OptionDefinition = z.object({
'alert',
'socials',
'oauth',
+ 'custom-nntp-servers',
]),
oauth: z
.object({
@@ -606,9 +607,34 @@ export const SubtitleResponseSchema = z.object({
export type SubtitleResponse = z.infer;
export type Subtitle = z.infer;
+export const SourceSchema = z.object({
+ url: z.string(),
+ bytes: z.number().nullable().optional(),
+});
+
+const NNTPServerSchema = z.object({
+ username: z.string(),
+ password: z.string(),
+ host: z.string(),
+ port: z.number(),
+ ssl: z.boolean(),
+ connections: z.number(),
+});
+
+export const NNTPServersSchema = z.array(NNTPServerSchema);
+
+export type NNTPServers = z.infer;
+
export const StreamSchema = z
.object({
url: z.string().or(z.null()).optional(),
+ nzbUrl: z.string().or(z.null()).optional(),
+ servers: z.array(z.string().min(1)).nullable().optional(),
+ rarUrls: z.array(SourceSchema).nullable().optional(),
+ zipUrls: z.array(SourceSchema).nullable().optional(),
+ '7zipUrls': z.array(SourceSchema).nullable().optional(),
+ tgzUrls: z.array(SourceSchema).nullable().optional(),
+ tarUrls: z.array(SourceSchema).nullable().optional(),
ytId: z.string().nullable().optional(),
infoHash: z.string().nullable().optional(),
fileIdx: z.number().or(z.null()).optional(),
@@ -718,6 +744,13 @@ export const ParsedStreamSchema = z.object({
duration: z.number().optional(),
library: z.boolean().optional(),
url: z.string().optional(),
+ nzbUrl: z.string().optional(),
+ servers: z.array(z.string().min(1)).optional(),
+ rarUrls: z.array(SourceSchema).nullable().optional(),
+ zipUrls: z.array(SourceSchema).nullable().optional(),
+ '7zipUrls': z.array(SourceSchema).nullable().optional(),
+ tgzUrls: z.array(SourceSchema).nullable().optional(),
+ tarUrls: z.array(SourceSchema).nullable().optional(),
ytId: z.string().min(1).optional(),
externalUrl: z.string().min(1).optional(),
error: z
diff --git a/packages/core/src/debrid/index.ts b/packages/core/src/debrid/index.ts
index 154ef6ee..118c1e64 100644
--- a/packages/core/src/debrid/index.ts
+++ b/packages/core/src/debrid/index.ts
@@ -12,6 +12,7 @@ import { TorboxDebridService } from './torbox.js';
import { StremThruPreset } from '../presets/stremthru.js';
import { NzbDAVService } from './nzbdav.js';
import { AltmountService } from './altmount.js';
+import { StremioNNTPService } from './stremio-nntp.js';
export function getDebridService(
serviceName: ServiceId,
@@ -30,6 +31,8 @@ export function getDebridService(
return new NzbDAVService(config);
case 'altmount':
return new AltmountService(config);
+ case 'stremio_nntp':
+ return new StremioNNTPService(config);
default:
if (StremThruPreset.supportedServices.includes(serviceName)) {
return new StremThruInterface({ ...config, serviceName });
diff --git a/packages/core/src/debrid/stremio-nntp.ts b/packages/core/src/debrid/stremio-nntp.ts
new file mode 100644
index 00000000..828a4197
--- /dev/null
+++ b/packages/core/src/debrid/stremio-nntp.ts
@@ -0,0 +1,72 @@
+// Credit goes to Sanket9225 for the idea and inspiration
+// https://github.com/Sanket9225/UsenetStreamer/blob/master/server.js
+
+import { z } from 'zod';
+import {
+ UsenetStreamService,
+ UsenetStreamServiceConfig,
+} from './usenet-stream-base.js';
+import {
+ DebridDownload,
+ DebridService,
+ DebridServiceConfig,
+ PlaybackInfo,
+} from './base.js';
+import { ServiceId, createLogger, fromUrlSafeBase64 } from '../utils/index.js';
+import { NNTPServers, NNTPServersSchema } from '../db/schemas.js';
+
+const logger = createLogger('stremio-nntp');
+
+export class StremioNNTPService implements DebridService {
+ readonly serviceName: ServiceId = 'stremio_nntp';
+ readonly serviceLogger = logger;
+
+ private servers: NNTPServers;
+
+ supportsUsenet: boolean = true;
+
+ constructor(config: DebridServiceConfig) {
+ const parsedConfig = NNTPServersSchema.parse(
+ JSON.parse(Buffer.from(config.token, 'base64').toString())
+ );
+ this.servers = parsedConfig;
+ }
+
+ checkMagnets(magnets: string[], sid?: string): Promise {
+ throw new Error('Method not implemented.');
+ }
+
+ listMagnets(): Promise {
+ throw new Error('Method not implemented.');
+ }
+
+ addMagnet(magnet: string): Promise {
+ throw new Error('Method not implemented.');
+ }
+ async checkNzbs(
+ nzbs: { name?: string; hash?: string }[],
+ checkOwned?: boolean
+ ): Promise {
+ return nzbs.map(({ hash: h, name: n }, index) => {
+ return {
+ id: index,
+ status: 'cached',
+ library: false,
+ hash: h,
+ name: n,
+ };
+ });
+ }
+
+ resolve(
+ playbackInfo: PlaybackInfo,
+ filename: string,
+ cacheAndPlay: boolean
+ ): Promise {
+ throw new Error('Method not implemented.');
+ }
+
+ generateTorrentLink(link: string, clientIp?: string): Promise {
+ throw new Error('Method not implemented.');
+ }
+}
diff --git a/packages/core/src/parser/streams.ts b/packages/core/src/parser/streams.ts
index 313f6363..4151d2dd 100644
--- a/packages/core/src/parser/streams.ts
+++ b/packages/core/src/parser/streams.ts
@@ -75,6 +75,12 @@ class StreamParser {
type: 'http',
proxied: this.isProxied(stream),
url: this.applyUrlModifications(stream.url ?? undefined),
+ nzbUrl: stream.nzbUrl ?? undefined,
+ tarUrls: stream.tarUrls ?? undefined,
+ tgzUrls: stream.tgzUrls ?? undefined,
+ '7zipUrls': stream['7zipUrls'] ?? undefined,
+ rarUrls: stream.rarUrls ?? undefined,
+ servers: stream.servers ?? undefined,
externalUrl: stream.externalUrl ?? undefined,
ytId: stream.ytId ?? undefined,
requestHeaders: stream.behaviorHints?.proxyHeaders?.request,
@@ -436,6 +442,17 @@ class StreamParser {
return 'youtube';
}
+ if (stream.nzbUrl) {
+ return 'stremio-usenet';
+ }
+ if (
+ stream['7zipUrls']?.length ||
+ stream.rarUrls?.length ||
+ stream?.tarUrls?.length ||
+ stream.tgzUrls?.length
+ ) {
+ return 'archive';
+ }
throw new Error('Invalid stream, missing a required stream property');
}
diff --git a/packages/core/src/presets/builtin.ts b/packages/core/src/presets/builtin.ts
index d21015f0..c343ae05 100644
--- a/packages/core/src/presets/builtin.ts
+++ b/packages/core/src/presets/builtin.ts
@@ -74,7 +74,9 @@ export class BuiltinStreamParser extends StreamParser {
service: ParsedStream['service'],
currentParsedStream: ParsedStream
): ParsedStream['type'] {
- return (stream as any).type === 'usenet' ? 'usenet' : 'debrid';
+ return stream.type === 'torrent'
+ ? 'debrid'
+ : (stream.type as 'usenet' | 'stremio-usenet');
}
}
@@ -102,6 +104,8 @@ export class BuiltinAddonPreset extends Preset {
aiostreamsAuth: credentials.aiostreamsAuth,
})
),
+ [constants.STREMIO_NNTP_SERVICE]: (credentials: any) =>
+ credentials.servers, // this will be a base64 encoded json string of the nntp server config { username, password, host, port, useSsl, connections }[]
};
const altmountSpecialCase: Partial<
Record any>
diff --git a/packages/core/src/presets/newznab.ts b/packages/core/src/presets/newznab.ts
index be2d02b6..fdb24de1 100644
--- a/packages/core/src/presets/newznab.ts
+++ b/packages/core/src/presets/newznab.ts
@@ -10,6 +10,7 @@ export class NewznabPreset extends BuiltinAddonPreset {
constants.TORBOX_SERVICE,
constants.NZBDAV_SERVICE,
constants.ALTMOUNT_SERVICE,
+ constants.STREMIO_NNTP_SERVICE,
] as ServiceId[];
const options: Option[] = [
{
diff --git a/packages/core/src/presets/nzbhydra.ts b/packages/core/src/presets/nzbhydra.ts
index 79d98073..663d66be 100644
--- a/packages/core/src/presets/nzbhydra.ts
+++ b/packages/core/src/presets/nzbhydra.ts
@@ -10,6 +10,7 @@ export class NZBHydraPreset extends NewznabPreset {
constants.TORBOX_SERVICE,
constants.NZBDAV_SERVICE,
constants.ALTMOUNT_SERVICE,
+ constants.STREMIO_NNTP_SERVICE,
] as ServiceId[];
const options: Option[] = [
{
diff --git a/packages/core/src/streams/deduplicator.ts b/packages/core/src/streams/deduplicator.ts
index 5d892081..0df808e9 100644
--- a/packages/core/src/streams/deduplicator.ts
+++ b/packages/core/src/streams/deduplicator.ts
@@ -139,7 +139,12 @@ class StreamDeduplicator {
const streamsByType = new Map();
for (const stream of group) {
let type = stream.type as string;
- if ((type === 'debrid' || type === 'usenet') && stream.service) {
+ if (
+ (type === 'debrid' ||
+ type === 'usenet' ||
+ type === 'stremio-usenet') &&
+ stream.service
+ ) {
type = stream.service.cached ? 'cached' : 'uncached';
}
if (stream.addon.resultPassthrough) {
diff --git a/packages/core/src/transformers/api.ts b/packages/core/src/transformers/api.ts
index 14c3d78f..814987c5 100644
--- a/packages/core/src/transformers/api.ts
+++ b/packages/core/src/transformers/api.ts
@@ -2,6 +2,7 @@ import {
ParsedFileSchema,
ParsedStream,
Resource,
+ SourceSchema,
SubtitleSchema,
UserData,
} from '../db/index.js';
@@ -27,6 +28,11 @@ const SearchApiResultSchema = z.object({
externalUrl: z.string().nullable(),
fileIdx: z.number().nullable(),
url: z.string().nullable(),
+ nzbUrl: z.string().nullable(),
+ rarUrls: z.array(SourceSchema).nullable(),
+ '7zipUrls': z.array(SourceSchema).nullable(),
+ tarUrls: z.array(SourceSchema).nullable(),
+ tgzUrls: z.array(SourceSchema).nullable(),
proxied: z.boolean(),
filename: z.string().nullable(),
folderName: z.string().nullable(),
@@ -64,9 +70,14 @@ export class ApiTransformer {
const { data, errors } = response;
let filteredCount = 0;
const results: SearchApiResult[] = data.streams
- .map((stream) => ({
+ .map((stream: ParsedStream) => ({
infoHash: stream.torrent?.infoHash ?? null,
url: stream.url ?? null,
+ nzbUrl: stream.nzbUrl ?? null,
+ rarUrls: stream.rarUrls ?? null,
+ '7zipUrls': stream['7zipUrls'] ?? null,
+ tarUrls: stream.tarUrls ?? null,
+ tgzUrls: stream.tgzUrls ?? null,
seeders: stream.torrent?.seeders ?? null,
age: stream.age ?? null,
sources: stream.torrent?.sources ?? null,
diff --git a/packages/core/src/transformers/stremio.ts b/packages/core/src/transformers/stremio.ts
index 192c5f70..fd565fac 100644
--- a/packages/core/src/transformers/stremio.ts
+++ b/packages/core/src/transformers/stremio.ts
@@ -158,6 +158,34 @@ export class StremioTransformer {
ytId: stream.type === 'youtube' ? stream.ytId : undefined,
externalUrl: stream.type === 'external' ? stream.externalUrl : undefined,
sources: stream.type === 'p2p' ? stream.torrent?.sources : undefined,
+ nzbUrl:
+ stream.type === constants.STREMIO_USENET_STREAM_TYPE
+ ? stream.nzbUrl
+ : undefined,
+ servers:
+ stream.type === constants.STREMIO_USENET_STREAM_TYPE
+ ? stream.servers
+ : undefined,
+ rarUrls:
+ stream.type === constants.ARCHIVE_STREAM_TYPE
+ ? stream.rarUrls
+ : undefined,
+ zipUrls:
+ stream.type === constants.ARCHIVE_STREAM_TYPE
+ ? stream.zipUrls
+ : undefined,
+ '7zipUrls':
+ stream.type === constants.ARCHIVE_STREAM_TYPE
+ ? stream['7zipUrls']
+ : undefined,
+ tgzUrls:
+ stream.type === constants.ARCHIVE_STREAM_TYPE
+ ? stream.tgzUrls
+ : undefined,
+ tarUrls:
+ stream.type === constants.ARCHIVE_STREAM_TYPE
+ ? stream.tarUrls
+ : undefined,
subtitles: stream.subtitles,
behaviorHints: {
countryWhitelist: stream.countryWhitelist,
diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts
index 21f1b429..7c28117f 100644
--- a/packages/core/src/utils/constants.ts
+++ b/packages/core/src/utils/constants.ts
@@ -205,6 +205,7 @@ const SEEDR_SERVICE = 'seedr';
const EASYNEWS_SERVICE = 'easynews';
const NZBDAV_SERVICE = 'nzbdav';
const ALTMOUNT_SERVICE = 'altmount';
+const STREMIO_NNTP_SERVICE = 'stremio_nntp';
const SERVICES = [
REALDEBRID_SERVICE,
@@ -221,6 +222,7 @@ const SERVICES = [
EASYNEWS_SERVICE,
NZBDAV_SERVICE,
ALTMOUNT_SERVICE,
+ STREMIO_NNTP_SERVICE,
] as const;
export const BUILTIN_SUPPORTED_SERVICES = [
@@ -235,6 +237,7 @@ export const BUILTIN_SUPPORTED_SERVICES = [
OFFCLOUD_SERVICE,
NZBDAV_SERVICE,
ALTMOUNT_SERVICE,
+ STREMIO_NNTP_SERVICE,
] as const;
export type ServiceId = (typeof SERVICES)[number];
@@ -386,6 +389,23 @@ const SERVICE_DETAILS: Record<
},
],
},
+ [STREMIO_NNTP_SERVICE]: {
+ id: STREMIO_NNTP_SERVICE,
+ name: 'Stremio NNTP',
+ shortName: 'SN',
+ knownNames: ['SN', 'Stremio NNTP', 'StremioNntp', 'Stremio-NNTP'],
+ signUpText:
+ "Stream usenet directly from your provider via Stremio's NNTP capabilities.",
+ credentials: [
+ {
+ id: 'servers',
+ name: 'NNTP Servers',
+ description: 'Provide your Usenet NNTP server addresses',
+ type: 'custom-nntp-servers',
+ required: true,
+ },
+ ],
+ },
[NZBDAV_SERVICE]: {
id: NZBDAV_SERVICE,
name: 'NzbDAV',
@@ -1054,6 +1074,8 @@ const SORT_DIRECTIONS = ['asc', 'desc'] as const;
export const P2P_STREAM_TYPE = 'p2p' as const;
export const LIVE_STREAM_TYPE = 'live' as const;
+export const STREMIO_USENET_STREAM_TYPE = 'stremio-usenet' as const;
+export const ARCHIVE_STREAM_TYPE = 'archive' as const;
export const USENET_STREAM_TYPE = 'usenet' as const;
export const DEBRID_STREAM_TYPE = 'debrid' as const;
export const HTTP_STREAM_TYPE = 'http' as const;
@@ -1065,6 +1087,8 @@ export const STATISTIC_STREAM_TYPE = 'statistic' as const;
const STREAM_TYPES = [
P2P_STREAM_TYPE,
LIVE_STREAM_TYPE,
+ STREMIO_USENET_STREAM_TYPE,
+ ARCHIVE_STREAM_TYPE,
USENET_STREAM_TYPE,
DEBRID_STREAM_TYPE,
HTTP_STREAM_TYPE,
@@ -1256,6 +1280,7 @@ export {
SEEDR_SERVICE,
NZBDAV_SERVICE,
ALTMOUNT_SERVICE,
+ STREMIO_NNTP_SERVICE,
EASYNEWS_SERVICE,
SERVICE_DETAILS,
TOP_LEVEL_OPTION_DETAILS,
diff --git a/packages/frontend/src/components/shared/template-option.tsx b/packages/frontend/src/components/shared/template-option.tsx
index 846e0383..52c3c743 100644
--- a/packages/frontend/src/components/shared/template-option.tsx
+++ b/packages/frontend/src/components/shared/template-option.tsx
@@ -3,15 +3,17 @@ import { NumberInput } from '../ui/number-input';
import { Switch } from '../ui/switch';
import { Select } from '../ui/select';
import { Combobox } from '../ui/combobox';
-import { Option } from '@aiostreams/core';
-import React, { useState } from 'react';
+import { Option, NNTPServers } from '@aiostreams/core';
+import React, { useState, useEffect } from 'react';
import MarkdownLite from './markdown-lite';
import { Alert } from '../ui/alert';
import { SocialIcon } from './social-icon';
import { PasswordInput } from '../ui/password-input';
import { Button } from '../ui/button';
import { IconButton } from '../ui/button';
-import { ArrowLeftIcon, KeyIcon } from 'lucide-react';
+import { FaKey, FaChevronUp, FaChevronDown, FaArrowLeft } from 'react-icons/fa';
+import { Modal } from '../ui/modal';
+import { FaPlus, FaServer, FaTrashCan } from 'react-icons/fa6';
// this component, accepts an option and returns a component that renders the option.
// string - TextInput
// number - NumberInput
@@ -341,7 +343,7 @@ const TemplateOption: React.FC = ({
}
+ icon={}
intent="primary-outline"
onClick={() => {
window.open(oauth?.authorisationUrl || '', '_blank');
@@ -364,7 +366,7 @@ const TemplateOption: React.FC = ({
Enter {oauth?.oauthResultField?.name || 'Authorization Code'}
}
+ icon={}
intent="primary-subtle"
size="sm"
onClick={() => setShowInput(false)}
@@ -391,9 +393,276 @@ const TemplateOption: React.FC = ({
);
}
+ case 'custom-nntp-servers': {
+ return (
+
+ );
+ }
default:
return null;
}
};
+// Default empty server template
+const createEmptyServer = (): NNTPServers[number] => ({
+ username: '',
+ password: '',
+ host: '',
+ port: 563,
+ ssl: true,
+ connections: 8,
+});
+
+// Decode base64 value to servers array
+const decodeServers = (value: string | undefined): NNTPServers => {
+ if (!value) return [];
+ try {
+ const decoded = Buffer.from(value, 'base64').toString('utf-8');
+ return JSON.parse(decoded) as NNTPServers;
+ } catch {
+ return [];
+ }
+};
+
+// Encode servers array to base64
+const encodeServers = (servers: NNTPServers): string | undefined => {
+ if (servers.length === 0) return undefined;
+ return Buffer.from(JSON.stringify(servers)).toString('base64');
+};
+
+interface NNTPServersInputProps {
+ name: string;
+ description?: string;
+ value: string | undefined;
+ onChange: (value: string | undefined) => void;
+ disabled?: boolean;
+}
+
+function NNTPServersInput({
+ name,
+ description,
+ value,
+ onChange,
+ disabled,
+}: NNTPServersInputProps) {
+ const [modalOpen, setModalOpen] = useState(false);
+ const [servers, setServers] = useState([]);
+
+ // Sync servers state when modal opens
+ useEffect(() => {
+ if (modalOpen) {
+ setServers(decodeServers(value));
+ }
+ }, [modalOpen, value]);
+
+ const serverCount = decodeServers(value).length;
+
+ const handleAddServer = () => {
+ setServers((prev) => [...prev, createEmptyServer()]);
+ };
+
+ const handleRemoveServer = (index: number) => {
+ setServers((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ const handleMoveServer = (index: number, direction: 'up' | 'down') => {
+ setServers((prev) => {
+ const newServers = [...prev];
+ const targetIndex = direction === 'up' ? index - 1 : index + 1;
+ if (targetIndex < 0 || targetIndex >= newServers.length) return prev;
+ [newServers[index], newServers[targetIndex]] = [
+ newServers[targetIndex],
+ newServers[index],
+ ];
+ return newServers;
+ });
+ };
+
+ const handleServerChange = (
+ index: number,
+ field: keyof NNTPServers[number],
+ fieldValue: any
+ ) => {
+ setServers((prev) =>
+ prev.map((server, i) =>
+ i === index ? { ...server, [field]: fieldValue } : server
+ )
+ );
+ };
+
+ const handleSave = () => {
+ // Filter out servers with empty required fields
+ const validServers = servers.filter((s) => s.host.trim() !== '');
+ onChange(encodeServers(validServers));
+ setModalOpen(false);
+ };
+
+ const handleCancel = () => {
+ setModalOpen(false);
+ };
+
+ return (
+
+
+
+
{name}
+ {description && (
+
+ {description}
+
+ )}
+ {serverCount > 0 && (
+
+ {serverCount} server{serverCount !== 1 ? 's' : ''} configured
+
+ )}
+
+
}
+ intent="primary-outline"
+ onClick={() => setModalOpen(true)}
+ disabled={disabled}
+ className="shrink-0"
+ />
+
+
+
+
+ {servers.length === 0 ? (
+
+
+
No servers configured
+
Click the button below to add a server
+
+ ) : (
+ servers.map((server, index) => (
+
+
+
+ Server {index + 1}
+
+
+ }
+ intent="gray-subtle"
+ size="sm"
+ onClick={() => handleMoveServer(index, 'up')}
+ disabled={index === 0}
+ />
+ }
+ intent="gray-subtle"
+ size="sm"
+ onClick={() => handleMoveServer(index, 'down')}
+ disabled={index === servers.length - 1}
+ />
+ }
+ intent="alert-subtle"
+ size="sm"
+ onClick={() => handleRemoveServer(index)}
+ />
+
+
+
+
+
handleServerChange(index, 'host', v)}
+ placeholder="news.example.com"
+ required
+ />
+ handleServerChange(index, 'port', v)}
+ min={1}
+ max={65535}
+ required
+ />
+
+ handleServerChange(index, 'username', v)
+ }
+ placeholder="username"
+ required
+ />
+
+ handleServerChange(index, 'password', v)
+ }
+ placeholder="password"
+ required
+ />
+
+ handleServerChange(index, 'connections', v)
+ }
+ min={1}
+ max={500}
+ required
+ />
+
+ handleServerChange(index, 'ssl', v)}
+ side="right"
+ />
+
+
+
+ ))
+ )}
+
+
}
+ onClick={handleAddServer}
+ >
+ Add Server
+
+
+
+
+
+
+
+
+
+ );
+}
+
export default TemplateOption;