feat: update builtin addons to support stremio nntp

feat: allow providing nntp servers in stremio nntp service

feat: support nzbUrl and archive url fields
This commit is contained in:
Viren070
2025-11-27 20:01:06 +00:00
parent 41ed026ed0
commit e92d68e925
14 changed files with 539 additions and 25 deletions
+61 -17
View File
@@ -2,6 +2,7 @@ import {
CacheAndPlaySchema, CacheAndPlaySchema,
Manifest, Manifest,
Meta, Meta,
NNTPServersSchema,
Stream, Stream,
} from '../../db/schemas.js'; } from '../../db/schemas.js';
import { z, ZodError } from 'zod'; import { z, ZodError } from 'zod';
@@ -217,7 +218,7 @@ export abstract class BaseDebridAddon<T extends BaseDebridConfig> {
(s) => !['nzbdav', 'altmount'].includes(s.id) // usenet only services excluded (s) => !['nzbdav', 'altmount'].includes(s.id) // usenet only services excluded
); );
const nzbServices = this.userData.services.filter( 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) { if (torrentServices.length === 0 && torrentResults.length > 0) {
@@ -255,16 +256,50 @@ export abstract class BaseDebridAddon<T extends BaseDebridConfig> {
), ),
]); ]);
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( const encryptedStoreAuths = this.userData.services.reduce(
(acc, service) => { (acc, service) => {
const auth = { const auth = {
id: service.id, id: service.id,
credential: service.credential, 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; return acc;
}, },
{} as Record<BuiltinServiceId, string> {} as Record<BuiltinServiceId, string | string[]>
); );
const debridTitleMetadata: DebridTitleMetadata = { const debridTitleMetadata: DebridTitleMetadata = {
titles: searchMetadata.titles, titles: searchMetadata.titles,
@@ -337,8 +372,8 @@ export abstract class BaseDebridAddon<T extends BaseDebridConfig> {
results.map((result) => { results.map((result) => {
const stream = this._createStream( const stream = this._createStream(
result, result,
encryptedStoreAuths, metadataId,
metadataId encryptedStoreAuths
); );
if ( if (
result.service?.id === 'nzbdav' && result.service?.id === 'nzbdav' &&
@@ -673,8 +708,8 @@ export abstract class BaseDebridAddon<T extends BaseDebridConfig> {
protected _createStream( protected _createStream(
torrentOrNzb: TorrentWithSelectedFile | NZBWithSelectedFile, torrentOrNzb: TorrentWithSelectedFile | NZBWithSelectedFile,
encryptedStoreAuths: Record<BuiltinServiceId, string>, metadataId: string,
metadataId: string encryptedStoreAuths: Record<BuiltinServiceId, string | string[]>
): Stream { ): Stream {
// Handle debrid streaming // Handle debrid streaming
const encryptedStoreAuth = torrentOrNzb.service const encryptedStoreAuth = torrentOrNzb.service
@@ -722,18 +757,27 @@ export abstract class BaseDebridAddon<T extends BaseDebridConfig> {
}`; }`;
return { return {
url: torrentOrNzb.service url:
? generatePlaybackUrl( torrentOrNzb.service && torrentOrNzb.service.id != 'stremio_nntp'
encryptedStoreAuth!, ? generatePlaybackUrl(
metadataId!, encryptedStoreAuth! as string,
fileInfo!, metadataId!,
torrentOrNzb.title, fileInfo!,
torrentOrNzb.file.name torrentOrNzb.title,
) torrentOrNzb.file.name
: undefined, )
: undefined,
nzbUrl: torrentOrNzb.type === 'usenet' ? torrentOrNzb.nzb : undefined,
servers:
torrentOrNzb.service?.id === 'stremio_nntp'
? (encryptedStoreAuth as string[])
: undefined,
name, name,
description, description,
type: torrentOrNzb.type, type:
torrentOrNzb.service?.id === 'stremio_nntp'
? 'stremio-usenet'
: torrentOrNzb.type,
age: torrentOrNzb.age, age: torrentOrNzb.age,
infoHash: torrentOrNzb.hash, infoHash: torrentOrNzb.hash,
fileIdx: torrentOrNzb.file.index, fileIdx: torrentOrNzb.file.index,
@@ -42,6 +42,7 @@ export class NewznabAddon extends BaseNabAddon<NewznabAddonConfig, NewznabApi> {
constants.TORBOX_SERVICE, constants.TORBOX_SERVICE,
constants.NZBDAV_SERVICE, constants.NZBDAV_SERVICE,
constants.ALTMOUNT_SERVICE, constants.ALTMOUNT_SERVICE,
constants.STREMIO_NNTP_SERVICE,
].includes(s.id) ].includes(s.id)
) )
) { ) {
+33
View File
@@ -193,6 +193,7 @@ const OptionDefinition = z.object({
'alert', 'alert',
'socials', 'socials',
'oauth', 'oauth',
'custom-nntp-servers',
]), ]),
oauth: z oauth: z
.object({ .object({
@@ -606,9 +607,34 @@ export const SubtitleResponseSchema = z.object({
export type SubtitleResponse = z.infer<typeof SubtitleResponseSchema>; export type SubtitleResponse = z.infer<typeof SubtitleResponseSchema>;
export type Subtitle = z.infer<typeof SubtitleSchema>; export type Subtitle = z.infer<typeof SubtitleSchema>;
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<typeof NNTPServersSchema>;
export const StreamSchema = z export const StreamSchema = z
.object({ .object({
url: z.string().or(z.null()).optional(), 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(), ytId: z.string().nullable().optional(),
infoHash: z.string().nullable().optional(), infoHash: z.string().nullable().optional(),
fileIdx: z.number().or(z.null()).optional(), fileIdx: z.number().or(z.null()).optional(),
@@ -718,6 +744,13 @@ export const ParsedStreamSchema = z.object({
duration: z.number().optional(), duration: z.number().optional(),
library: z.boolean().optional(), library: z.boolean().optional(),
url: z.string().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(), ytId: z.string().min(1).optional(),
externalUrl: z.string().min(1).optional(), externalUrl: z.string().min(1).optional(),
error: z error: z
+3
View File
@@ -12,6 +12,7 @@ import { TorboxDebridService } from './torbox.js';
import { StremThruPreset } from '../presets/stremthru.js'; import { StremThruPreset } from '../presets/stremthru.js';
import { NzbDAVService } from './nzbdav.js'; import { NzbDAVService } from './nzbdav.js';
import { AltmountService } from './altmount.js'; import { AltmountService } from './altmount.js';
import { StremioNNTPService } from './stremio-nntp.js';
export function getDebridService( export function getDebridService(
serviceName: ServiceId, serviceName: ServiceId,
@@ -30,6 +31,8 @@ export function getDebridService(
return new NzbDAVService(config); return new NzbDAVService(config);
case 'altmount': case 'altmount':
return new AltmountService(config); return new AltmountService(config);
case 'stremio_nntp':
return new StremioNNTPService(config);
default: default:
if (StremThruPreset.supportedServices.includes(serviceName)) { if (StremThruPreset.supportedServices.includes(serviceName)) {
return new StremThruInterface({ ...config, serviceName }); return new StremThruInterface({ ...config, serviceName });
+72
View File
@@ -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<DebridDownload[]> {
throw new Error('Method not implemented.');
}
listMagnets(): Promise<DebridDownload[]> {
throw new Error('Method not implemented.');
}
addMagnet(magnet: string): Promise<DebridDownload> {
throw new Error('Method not implemented.');
}
async checkNzbs(
nzbs: { name?: string; hash?: string }[],
checkOwned?: boolean
): Promise<DebridDownload[]> {
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<string | undefined> {
throw new Error('Method not implemented.');
}
generateTorrentLink(link: string, clientIp?: string): Promise<string> {
throw new Error('Method not implemented.');
}
}
+17
View File
@@ -75,6 +75,12 @@ class StreamParser {
type: 'http', type: 'http',
proxied: this.isProxied(stream), proxied: this.isProxied(stream),
url: this.applyUrlModifications(stream.url ?? undefined), 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, externalUrl: stream.externalUrl ?? undefined,
ytId: stream.ytId ?? undefined, ytId: stream.ytId ?? undefined,
requestHeaders: stream.behaviorHints?.proxyHeaders?.request, requestHeaders: stream.behaviorHints?.proxyHeaders?.request,
@@ -436,6 +442,17 @@ class StreamParser {
return 'youtube'; 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'); throw new Error('Invalid stream, missing a required stream property');
} }
+5 -1
View File
@@ -74,7 +74,9 @@ export class BuiltinStreamParser extends StreamParser {
service: ParsedStream['service'], service: ParsedStream['service'],
currentParsedStream: ParsedStream currentParsedStream: ParsedStream
): ParsedStream['type'] { ): 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, 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< const altmountSpecialCase: Partial<
Record<ServiceId, (credentials: any) => any> Record<ServiceId, (credentials: any) => any>
+1
View File
@@ -10,6 +10,7 @@ export class NewznabPreset extends BuiltinAddonPreset {
constants.TORBOX_SERVICE, constants.TORBOX_SERVICE,
constants.NZBDAV_SERVICE, constants.NZBDAV_SERVICE,
constants.ALTMOUNT_SERVICE, constants.ALTMOUNT_SERVICE,
constants.STREMIO_NNTP_SERVICE,
] as ServiceId[]; ] as ServiceId[];
const options: Option[] = [ const options: Option[] = [
{ {
+1
View File
@@ -10,6 +10,7 @@ export class NZBHydraPreset extends NewznabPreset {
constants.TORBOX_SERVICE, constants.TORBOX_SERVICE,
constants.NZBDAV_SERVICE, constants.NZBDAV_SERVICE,
constants.ALTMOUNT_SERVICE, constants.ALTMOUNT_SERVICE,
constants.STREMIO_NNTP_SERVICE,
] as ServiceId[]; ] as ServiceId[];
const options: Option[] = [ const options: Option[] = [
{ {
+6 -1
View File
@@ -139,7 +139,12 @@ class StreamDeduplicator {
const streamsByType = new Map<string, ParsedStream[]>(); const streamsByType = new Map<string, ParsedStream[]>();
for (const stream of group) { for (const stream of group) {
let type = stream.type as string; 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'; type = stream.service.cached ? 'cached' : 'uncached';
} }
if (stream.addon.resultPassthrough) { if (stream.addon.resultPassthrough) {
+12 -1
View File
@@ -2,6 +2,7 @@ import {
ParsedFileSchema, ParsedFileSchema,
ParsedStream, ParsedStream,
Resource, Resource,
SourceSchema,
SubtitleSchema, SubtitleSchema,
UserData, UserData,
} from '../db/index.js'; } from '../db/index.js';
@@ -27,6 +28,11 @@ const SearchApiResultSchema = z.object({
externalUrl: z.string().nullable(), externalUrl: z.string().nullable(),
fileIdx: z.number().nullable(), fileIdx: z.number().nullable(),
url: z.string().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(), proxied: z.boolean(),
filename: z.string().nullable(), filename: z.string().nullable(),
folderName: z.string().nullable(), folderName: z.string().nullable(),
@@ -64,9 +70,14 @@ export class ApiTransformer {
const { data, errors } = response; const { data, errors } = response;
let filteredCount = 0; let filteredCount = 0;
const results: SearchApiResult[] = data.streams const results: SearchApiResult[] = data.streams
.map((stream) => ({ .map((stream: ParsedStream) => ({
infoHash: stream.torrent?.infoHash ?? null, infoHash: stream.torrent?.infoHash ?? null,
url: stream.url ?? 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, seeders: stream.torrent?.seeders ?? null,
age: stream.age ?? null, age: stream.age ?? null,
sources: stream.torrent?.sources ?? null, sources: stream.torrent?.sources ?? null,
+28
View File
@@ -158,6 +158,34 @@ export class StremioTransformer {
ytId: stream.type === 'youtube' ? stream.ytId : undefined, ytId: stream.type === 'youtube' ? stream.ytId : undefined,
externalUrl: stream.type === 'external' ? stream.externalUrl : undefined, externalUrl: stream.type === 'external' ? stream.externalUrl : undefined,
sources: stream.type === 'p2p' ? stream.torrent?.sources : 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, subtitles: stream.subtitles,
behaviorHints: { behaviorHints: {
countryWhitelist: stream.countryWhitelist, countryWhitelist: stream.countryWhitelist,
+25
View File
@@ -205,6 +205,7 @@ const SEEDR_SERVICE = 'seedr';
const EASYNEWS_SERVICE = 'easynews'; const EASYNEWS_SERVICE = 'easynews';
const NZBDAV_SERVICE = 'nzbdav'; const NZBDAV_SERVICE = 'nzbdav';
const ALTMOUNT_SERVICE = 'altmount'; const ALTMOUNT_SERVICE = 'altmount';
const STREMIO_NNTP_SERVICE = 'stremio_nntp';
const SERVICES = [ const SERVICES = [
REALDEBRID_SERVICE, REALDEBRID_SERVICE,
@@ -221,6 +222,7 @@ const SERVICES = [
EASYNEWS_SERVICE, EASYNEWS_SERVICE,
NZBDAV_SERVICE, NZBDAV_SERVICE,
ALTMOUNT_SERVICE, ALTMOUNT_SERVICE,
STREMIO_NNTP_SERVICE,
] as const; ] as const;
export const BUILTIN_SUPPORTED_SERVICES = [ export const BUILTIN_SUPPORTED_SERVICES = [
@@ -235,6 +237,7 @@ export const BUILTIN_SUPPORTED_SERVICES = [
OFFCLOUD_SERVICE, OFFCLOUD_SERVICE,
NZBDAV_SERVICE, NZBDAV_SERVICE,
ALTMOUNT_SERVICE, ALTMOUNT_SERVICE,
STREMIO_NNTP_SERVICE,
] as const; ] as const;
export type ServiceId = (typeof SERVICES)[number]; 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]: { [NZBDAV_SERVICE]: {
id: NZBDAV_SERVICE, id: NZBDAV_SERVICE,
name: 'NzbDAV', name: 'NzbDAV',
@@ -1054,6 +1074,8 @@ const SORT_DIRECTIONS = ['asc', 'desc'] as const;
export const P2P_STREAM_TYPE = 'p2p' as const; export const P2P_STREAM_TYPE = 'p2p' as const;
export const LIVE_STREAM_TYPE = 'live' 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 USENET_STREAM_TYPE = 'usenet' as const;
export const DEBRID_STREAM_TYPE = 'debrid' as const; export const DEBRID_STREAM_TYPE = 'debrid' as const;
export const HTTP_STREAM_TYPE = 'http' 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 = [ const STREAM_TYPES = [
P2P_STREAM_TYPE, P2P_STREAM_TYPE,
LIVE_STREAM_TYPE, LIVE_STREAM_TYPE,
STREMIO_USENET_STREAM_TYPE,
ARCHIVE_STREAM_TYPE,
USENET_STREAM_TYPE, USENET_STREAM_TYPE,
DEBRID_STREAM_TYPE, DEBRID_STREAM_TYPE,
HTTP_STREAM_TYPE, HTTP_STREAM_TYPE,
@@ -1256,6 +1280,7 @@ export {
SEEDR_SERVICE, SEEDR_SERVICE,
NZBDAV_SERVICE, NZBDAV_SERVICE,
ALTMOUNT_SERVICE, ALTMOUNT_SERVICE,
STREMIO_NNTP_SERVICE,
EASYNEWS_SERVICE, EASYNEWS_SERVICE,
SERVICE_DETAILS, SERVICE_DETAILS,
TOP_LEVEL_OPTION_DETAILS, TOP_LEVEL_OPTION_DETAILS,
@@ -3,15 +3,17 @@ import { NumberInput } from '../ui/number-input';
import { Switch } from '../ui/switch'; import { Switch } from '../ui/switch';
import { Select } from '../ui/select'; import { Select } from '../ui/select';
import { Combobox } from '../ui/combobox'; import { Combobox } from '../ui/combobox';
import { Option } from '@aiostreams/core'; import { Option, NNTPServers } from '@aiostreams/core';
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import MarkdownLite from './markdown-lite'; import MarkdownLite from './markdown-lite';
import { Alert } from '../ui/alert'; import { Alert } from '../ui/alert';
import { SocialIcon } from './social-icon'; import { SocialIcon } from './social-icon';
import { PasswordInput } from '../ui/password-input'; import { PasswordInput } from '../ui/password-input';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { IconButton } 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. // this component, accepts an option and returns a component that renders the option.
// string - TextInput // string - TextInput
// number - NumberInput // number - NumberInput
@@ -341,7 +343,7 @@ const TemplateOption: React.FC<TemplateOptionProps> = ({
</p> </p>
</div> </div>
<IconButton <IconButton
icon={<KeyIcon />} icon={<FaKey />}
intent="primary-outline" intent="primary-outline"
onClick={() => { onClick={() => {
window.open(oauth?.authorisationUrl || '', '_blank'); window.open(oauth?.authorisationUrl || '', '_blank');
@@ -364,7 +366,7 @@ const TemplateOption: React.FC<TemplateOptionProps> = ({
Enter {oauth?.oauthResultField?.name || 'Authorization Code'} Enter {oauth?.oauthResultField?.name || 'Authorization Code'}
</h4> </h4>
<IconButton <IconButton
icon={<ArrowLeftIcon className="w-4 h-4" />} icon={<FaArrowLeft className="w-4 h-4" />}
intent="primary-subtle" intent="primary-subtle"
size="sm" size="sm"
onClick={() => setShowInput(false)} onClick={() => setShowInput(false)}
@@ -391,9 +393,276 @@ const TemplateOption: React.FC<TemplateOptionProps> = ({
</div> </div>
); );
} }
case 'custom-nntp-servers': {
return (
<NNTPServersInput
name={name}
description={description}
value={forcedValue ?? value ?? defaultValue}
onChange={onChange}
disabled={isDisabled}
/>
);
}
default: default:
return null; 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<NNTPServers>([]);
// 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 (
<div>
<div className="flex items-center gap-3 bg-[--subtle] p-4 rounded-lg">
<div className="flex-1">
<h4 className="font-medium mb-1">{name}</h4>
{description && (
<p className="text-sm text-[--muted]">
<MarkdownLite>{description}</MarkdownLite>
</p>
)}
{serverCount > 0 && (
<p className="text-sm text-[--brand] mt-1">
{serverCount} server{serverCount !== 1 ? 's' : ''} configured
</p>
)}
</div>
<IconButton
icon={<FaServer />}
intent="primary-outline"
onClick={() => setModalOpen(true)}
disabled={disabled}
className="shrink-0"
/>
</div>
<Modal
open={modalOpen}
onOpenChange={setModalOpen}
title="Configure NNTP Servers"
contentClass="max-w-4xl"
>
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
{servers.length === 0 ? (
<div className="text-center py-8 text-[--muted]">
<FaServer className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No servers configured</p>
<p className="text-sm">Click the button below to add a server</p>
</div>
) : (
servers.map((server, index) => (
<div
key={index}
className="border border-[--border] rounded-lg p-4 bg-[--subtle]"
>
<div className="flex items-center justify-between mb-3">
<span className="font-medium text-sm">
Server {index + 1}
</span>
<div className="flex items-center gap-1">
<IconButton
icon={<FaChevronUp className="w-4 h-4" />}
intent="gray-subtle"
size="sm"
onClick={() => handleMoveServer(index, 'up')}
disabled={index === 0}
/>
<IconButton
icon={<FaChevronDown className="w-4 h-4" />}
intent="gray-subtle"
size="sm"
onClick={() => handleMoveServer(index, 'down')}
disabled={index === servers.length - 1}
/>
<IconButton
icon={<FaTrashCan className="w-4 h-4" />}
intent="alert-subtle"
size="sm"
onClick={() => handleRemoveServer(index)}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<TextInput
label="Host"
value={server.host}
onValueChange={(v) => handleServerChange(index, 'host', v)}
placeholder="news.example.com"
required
/>
<NumberInput
label="Port"
value={server.port}
onValueChange={(v) => handleServerChange(index, 'port', v)}
min={1}
max={65535}
required
/>
<TextInput
label="Username"
value={server.username}
onValueChange={(v) =>
handleServerChange(index, 'username', v)
}
placeholder="username"
required
/>
<PasswordInput
label="Password"
value={server.password}
onValueChange={(v) =>
handleServerChange(index, 'password', v)
}
placeholder="password"
required
/>
<NumberInput
label="Connections"
value={server.connections}
onValueChange={(v) =>
handleServerChange(index, 'connections', v)
}
min={1}
max={500}
required
/>
<div className="flex items-end pb-1">
<Switch
label="Use SSL"
value={server.ssl}
onValueChange={(v) => handleServerChange(index, 'ssl', v)}
side="right"
/>
</div>
</div>
</div>
))
)}
<Button
type="button"
intent="primary-outline"
className="w-full"
leftIcon={<FaPlus className="w-4 h-4" />}
onClick={handleAddServer}
>
Add Server
</Button>
</div>
<div className="flex gap-2 mt-4 pt-4 border-t border-[--border]">
<Button
type="button"
className="w-full"
intent="primary-outline"
onClick={handleCancel}
>
Cancel
</Button>
<Button type="button" className="w-full" onClick={handleSave}>
Save
</Button>
</div>
</Modal>
</div>
);
}
export default TemplateOption; export default TemplateOption;