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,
|
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)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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',
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user