mirror of
https://github.com/Viren070/AIOStreams.git
synced 2025-12-01 23:14:04 +01:00
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:
@@ -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<T extends BaseDebridConfig> {
|
||||
(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<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(
|
||||
(acc, service) => {
|
||||
const auth = {
|
||||
id: service.id,
|
||||
credential: service.credential,
|
||||
};
|
||||
if (service.id === 'stremio_nntp' && servers) {
|
||||
acc[service.id] = servers;
|
||||
} else {
|
||||
acc[service.id] = encryptString(JSON.stringify(auth)).data ?? '';
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<BuiltinServiceId, string>
|
||||
{} as Record<BuiltinServiceId, string | string[]>
|
||||
);
|
||||
const debridTitleMetadata: DebridTitleMetadata = {
|
||||
titles: searchMetadata.titles,
|
||||
@@ -337,8 +372,8 @@ export abstract class BaseDebridAddon<T extends BaseDebridConfig> {
|
||||
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<T extends BaseDebridConfig> {
|
||||
|
||||
protected _createStream(
|
||||
torrentOrNzb: TorrentWithSelectedFile | NZBWithSelectedFile,
|
||||
encryptedStoreAuths: Record<BuiltinServiceId, string>,
|
||||
metadataId: string
|
||||
metadataId: string,
|
||||
encryptedStoreAuths: Record<BuiltinServiceId, string | string[]>
|
||||
): Stream {
|
||||
// Handle debrid streaming
|
||||
const encryptedStoreAuth = torrentOrNzb.service
|
||||
@@ -722,18 +757,27 @@ export abstract class BaseDebridAddon<T extends BaseDebridConfig> {
|
||||
}`;
|
||||
|
||||
return {
|
||||
url: torrentOrNzb.service
|
||||
url:
|
||||
torrentOrNzb.service && torrentOrNzb.service.id != 'stremio_nntp'
|
||||
? generatePlaybackUrl(
|
||||
encryptedStoreAuth!,
|
||||
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,
|
||||
|
||||
@@ -42,6 +42,7 @@ export class NewznabAddon extends BaseNabAddon<NewznabAddonConfig, NewznabApi> {
|
||||
constants.TORBOX_SERVICE,
|
||||
constants.NZBDAV_SERVICE,
|
||||
constants.ALTMOUNT_SERVICE,
|
||||
constants.STREMIO_NNTP_SERVICE,
|
||||
].includes(s.id)
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -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<typeof SubtitleResponseSchema>;
|
||||
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
|
||||
.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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ServiceId, (credentials: any) => any>
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -139,7 +139,12 @@ class StreamDeduplicator {
|
||||
const streamsByType = new Map<string, ParsedStream[]>();
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TemplateOptionProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
<IconButton
|
||||
icon={<KeyIcon />}
|
||||
icon={<FaKey />}
|
||||
intent="primary-outline"
|
||||
onClick={() => {
|
||||
window.open(oauth?.authorisationUrl || '', '_blank');
|
||||
@@ -364,7 +366,7 @@ const TemplateOption: React.FC<TemplateOptionProps> = ({
|
||||
Enter {oauth?.oauthResultField?.name || 'Authorization Code'}
|
||||
</h4>
|
||||
<IconButton
|
||||
icon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||
icon={<FaArrowLeft className="w-4 h-4" />}
|
||||
intent="primary-subtle"
|
||||
size="sm"
|
||||
onClick={() => setShowInput(false)}
|
||||
@@ -391,9 +393,276 @@ const TemplateOption: React.FC<TemplateOptionProps> = ({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'custom-nntp-servers': {
|
||||
return (
|
||||
<NNTPServersInput
|
||||
name={name}
|
||||
description={description}
|
||||
value={forcedValue ?? value ?? defaultValue}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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<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;
|
||||
|
||||
Reference in New Issue
Block a user