feat!: stuff

This commit is contained in:
Viren070
2025-06-14 19:13:47 +01:00
parent e8971df66d
commit 0c9c86c218
40 changed files with 661 additions and 353 deletions
+26 -5
View File
@@ -105,7 +105,9 @@ export type Resource = z.infer<typeof ResourceSchema>;
const ResourceList = z.array(ResourceSchema);
const AddonSchema = z.object({
id: z.string().min(1).optional(),
instanceId: z.string().min(1).optional(), // uniquely identifies the addon in a given list of addons
presetType: z.string().min(1), // reference to the type of the preset that created this addon
presetInstanceId: z.string().min(1), // reference to the instance id of the preset that created this addon
manifestUrl: z.string().url(),
enabled: z.boolean(),
resources: ResourceList.optional(),
@@ -114,21 +116,20 @@ const AddonSchema = z.object({
timeout: z.number().min(1),
library: z.boolean().optional(),
streamPassthrough: z.boolean().optional(),
fromPresetId: z.string().min(1).optional(),
headers: z.record(z.string().min(1), z.string().min(1)).optional(),
ip: z.string().ip().optional(),
});
// preset objects are transformed into addons by a preset transformer.
const PresetSchema = z.object({
id: z.string().min(1),
type: z.string().min(1), // the preset type e.g. 'torrentio'
instanceId: z.string().min(1), // uniquely identifies the preset in a given list of presets
enabled: z.boolean(),
options: z.record(z.string().min(1), z.any()),
});
export type PresetObject = z.infer<typeof PresetSchema>;
const AddonList = z.array(AddonSchema);
const PresetList = z.array(PresetSchema);
export type Addon = z.infer<typeof AddonSchema>;
@@ -500,6 +501,7 @@ const MetaLinkSchema = z.object({
const MetaVideoSchema = z.object({
id: z.string().min(1),
title: z.string().optional(),
name: z.string().optional(),
released: z.string().datetime().optional(),
thumbnail: z.string().url().or(z.null()).optional(),
streams: z.array(StreamSchema).optional(),
@@ -722,6 +724,24 @@ const PresetMetadataSchema = z.object({
SUPPORTED_RESOURCES: z.array(ResourceSchema),
});
const PresetMinimalMetadataSchema = z.object({
ID: z.string(),
NAME: z.string(),
LOGO: z.string(),
DESCRIPTION: z.string(),
URL: z.string(),
DISABLED: z
.object({
reason: z.string(),
disabled: z.boolean(),
})
.optional(),
SUPPORTED_RESOURCES: z.array(ResourceSchema),
SUPPORTED_STREAM_TYPES: z.array(StreamTypes),
SUPPORTED_SERVICES: z.array(z.string()),
OPTIONS: z.array(OptionDefinition),
});
const StatusResponseSchema = z.object({
version: z.string(),
tag: z.string(),
@@ -758,7 +778,7 @@ const StatusResponseSchema = z.object({
}),
timeout: z.number().or(z.null()),
}),
presets: z.array(PresetMetadataSchema),
presets: z.array(PresetMinimalMetadataSchema),
services: z.record(
z.enum(constants.SERVICES),
z.object({
@@ -775,3 +795,4 @@ const StatusResponseSchema = z.object({
export type StatusResponse = z.infer<typeof StatusResponseSchema>;
export type PresetMetadata = z.infer<typeof PresetMetadataSchema>;
export type PresetMinimalMetadata = z.infer<typeof PresetMinimalMetadataSchema>;
+129 -72
View File
@@ -38,6 +38,7 @@ import { createFormatter } from './formatters';
import {
compileRegex,
formRegexFromKeywords,
parseRegex,
safeRegexTest,
} from './utils/regex';
import { isMatch } from 'super-regex';
@@ -59,8 +60,8 @@ export interface AIOStreamsResponse<T> {
export class AIOStreams {
private readonly userData: UserData;
private manifests: Record<number, Manifest | null>;
private supportedResources: Record<number, StrictManifestResource[]>;
private manifests: Record<string, Manifest | null>;
private supportedResources: Record<string, StrictManifestResource[]>;
private finalResources: StrictManifestResource[] = [];
private finalCatalogs: Manifest['catalogs'] = [];
private finalAddonCatalogs: Manifest['addonCatalogs'] = [];
@@ -244,16 +245,16 @@ export class AIOStreams {
// get the addon index from the id
logger.info(`Handling catalog request`, { type, id, extras });
const start = Date.now();
const addonIndex = id.split('.', 2)[0];
const addon = this.getAddon(Number(addonIndex));
const addonInstanceId = id.split('.', 2)[0];
const addon = this.getAddon(addonInstanceId);
if (!addon) {
logger.error(`Addon ${addonIndex} not found`);
logger.error(`Addon ${addonInstanceId} not found`);
return {
success: false,
data: [],
errors: [
{
title: `Addon ${addonIndex} not found`,
title: `Addon ${addonInstanceId} not found`,
description: 'Addon not found',
},
],
@@ -345,7 +346,9 @@ export class AIOStreams {
logger.info(`Handling meta request`, { type, id });
// step 1
// First try to find an addon that has a matching idPrefix
for (const [index, resources] of Object.entries(this.supportedResources)) {
for (const [instanceId, resources] of Object.entries(
this.supportedResources
)) {
const resource = resources.find(
(r) =>
r.name === 'meta' &&
@@ -353,10 +356,13 @@ export class AIOStreams {
r.idPrefixes?.some((prefix) => id.startsWith(prefix))
);
if (resource) {
const addon = this.getAddon(Number(index));
const addon = this.getAddon(instanceId);
if (!addon) {
continue;
}
logger.info(`Found addon with matching id prefix for meta resource`, {
addonName: addon.name,
addonIndex: index,
addonInstanceId: instanceId,
});
try {
const meta = await new Wrapper(addon).getMeta(type, id);
@@ -387,15 +393,20 @@ export class AIOStreams {
// step 2
// If no matching prefix found, use any addon that supports meta for this type
for (const [index, resources] of Object.entries(this.supportedResources)) {
for (const [instanceId, resources] of Object.entries(
this.supportedResources
)) {
const resource = resources.find(
(r) => r.name === 'meta' && r.types.includes(type)
);
if (resource) {
const addon = this.getAddon(Number(index));
const addon = this.getAddon(instanceId);
if (!addon) {
continue;
}
logger.info(`Using fallback addon for meta resource`, {
addonName: addon.name,
addonIndex: index,
addonInstanceId: instanceId,
});
try {
const meta = await new Wrapper(addon).getMeta(type, id);
@@ -438,7 +449,7 @@ export class AIOStreams {
// Find all addons that support subtitles for this type and id prefix
const supportedAddons = [];
for (const [addonIndex, addonResources] of Object.entries(
for (const [instanceId, addonResources] of Object.entries(
this.supportedResources
)) {
const resource = addonResources.find(
@@ -450,7 +461,7 @@ export class AIOStreams {
: true)
);
if (resource) {
const addon = this.getAddon(Number(addonIndex));
const addon = this.getAddon(instanceId);
if (addon) {
supportedAddons.push(addon);
}
@@ -501,16 +512,16 @@ export class AIOStreams {
): Promise<AIOStreamsResponse<AddonCatalog[]>> {
logger.info(`getAddonCatalog: ${id}`);
// step 1
// get the addon index from the id
const addonIndex = id.split('.', 2)[0];
const addon = this.getAddon(Number(addonIndex));
// get the addon instance id from the id
const addonInstanceId = id.split('.', 2)[0];
const addon = this.getAddon(addonInstanceId);
if (!addon) {
return {
success: false,
data: [],
errors: [
{
title: `Addon ${addonIndex} not found`,
title: `Addon ${addonInstanceId} not found`,
description: 'Addon not found',
},
],
@@ -556,15 +567,15 @@ export class AIOStreams {
}
for (const preset of this.userData.presets.filter((p) => p.enabled)) {
const addons = await PresetManager.fromId(preset.id).generateAddons(
const addons = await PresetManager.fromId(preset.type).generateAddons(
this.userData,
preset.options
);
this.addons.push(
...addons.map((a) => ({
...a,
id: JSON.stringify(preset),
presetInstanceId: preset.instanceId,
instanceId: `${preset.instanceId}${getSimpleTextHash(`${a.manifestUrl}`).slice(0, 4)}`,
}))
);
}
@@ -579,10 +590,10 @@ export class AIOStreams {
private async fetchManifests() {
this.manifests = Object.fromEntries(
await Promise.all(
this.addons.map(async (addon, index) => {
this.addons.map(async (addon) => {
try {
this.validateAddon(addon);
return [index, await new Wrapper(addon).getManifest()];
return [addon.instanceId, await new Wrapper(addon).getManifest()];
} catch (error: any) {
await this.handlePossibleRecursiveError(error);
if (this.skipFailedAddons) {
@@ -591,7 +602,7 @@ export class AIOStreams {
error: error.message,
});
logger.error(`${error.message}, skipping`);
return [index, null];
return [addon.instanceId, null];
}
throw error;
}
@@ -601,7 +612,7 @@ export class AIOStreams {
}
private async fetchResources() {
for (const [index, manifest] of Object.entries(this.manifests)) {
for (const [instanceId, manifest] of Object.entries(this.manifests)) {
if (!manifest) continue;
// Convert string resources to StrictManifestResource objects
@@ -616,10 +627,15 @@ export class AIOStreams {
return resource;
});
const addon = this.addons[Number(index)];
const addon = this.getAddon(instanceId);
if (!addon) {
logger.error(`Addon with instanceId ${instanceId} not found`);
continue;
}
logger.verbose(
`Determined that ${addon.identifyingName} (Index: ${index}) has support for the following resources: ${JSON.stringify(
`Determined that ${addon.identifyingName} (Instance ID: ${instanceId}) has support for the following resources: ${JSON.stringify(
addonResources
)}`
);
@@ -655,7 +671,7 @@ export class AIOStreams {
}
}
// Add catalogs with prefixed IDs (ensure to check that if addon.resources is defined and does not have catalog
// Add catalogs with prefixed IDs (ensure to check that if addon.resources is defined and does not have catalog
// then we do not add the catalogs)
if (
@@ -665,7 +681,7 @@ export class AIOStreams {
this.finalCatalogs.push(
...manifest.catalogs.map((catalog) => ({
...catalog,
id: `${index}.${catalog.id}`,
id: `${addon.instanceId}.${catalog.id}`,
}))
);
}
@@ -675,12 +691,12 @@ export class AIOStreams {
this.finalAddonCatalogs!.push(
...(manifest.addonCatalogs || []).map((catalog) => ({
...catalog,
id: `${index}.${catalog.id}`,
id: `${addon.instanceId}.${catalog.id}`,
}))
);
}
this.supportedResources[Number(index)] = addonResources;
this.supportedResources[instanceId] = addonResources;
}
logger.verbose(
@@ -780,8 +796,8 @@ export class AIOStreams {
return this.finalAddonCatalogs;
}
public getAddon(index: number): Addon {
return this.addons[index];
public getAddon(instanceId: string): Addon | undefined {
return this.addons.find((a) => a.instanceId === instanceId);
}
private shouldProxyStream(stream: ParsedStream): boolean {
@@ -793,7 +809,7 @@ export class AIOStreams {
const proxyAddon =
!proxy.proxiedAddons?.length ||
proxy.proxiedAddons.includes(stream.addon.id || '');
proxy.proxiedAddons.includes(stream.addon.presetInstanceId);
const proxyService =
!proxy.proxiedServices?.length ||
proxy.proxiedServices.includes(streamService);
@@ -857,7 +873,9 @@ export class AIOStreams {
const proxy =
this.userData.proxy &&
(!this.userData.proxy?.proxiedAddons?.length ||
this.userData.proxy.proxiedAddons.includes(addon.id || ''));
this.userData.proxy.proxiedAddons.includes(
addon.presetInstanceId || ''
));
logger.debug(
`Using ${proxy ? 'proxy' : 'user'} ip for ${addon.identifyingName}: ${
proxy
@@ -876,7 +894,7 @@ export class AIOStreams {
private async getStreamsFromAddons(type: string, id: string) {
// get a list of all addons that support the stream resource with the given type and id.
const supportedAddons = [];
for (const [index, addonResources] of Object.entries(
for (const [instanceId, addonResources] of Object.entries(
this.supportedResources
)) {
const resource = addonResources.find(
@@ -888,7 +906,7 @@ export class AIOStreams {
: true) // if no id prefixes are defined, assume it supports all IDs
);
if (resource) {
const addon = this.getAddon(Number(index));
const addon = this.getAddon(instanceId);
if (addon) {
supportedAddons.push(addon);
}
@@ -1006,7 +1024,8 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
// Always fetch from first group
const firstGroupAddons = supportedAddons.filter(
(addon) =>
addon.id && this.userData.groups![0].addons.includes(addon.id)
addon.presetInstanceId &&
this.userData.groups![0].addons.includes(addon.presetInstanceId)
);
logger.info(
@@ -1039,7 +1058,9 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
logger.info(`Condition met for group ${i + 1}, fetching streams`);
const groupAddons = supportedAddons.filter(
(addon) => addon.id && group.addons.includes(addon.id)
(addon) =>
addon.presetInstanceId &&
group.addons.includes(addon.presetInstanceId)
);
const groupResult = await fetchFromGroup(groupAddons);
@@ -1085,12 +1106,12 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
);
}
if (
addon.fromPresetId &&
FeatureControl.disabledAddons.has(addon.fromPresetId)
addon.presetInstanceId &&
FeatureControl.disabledAddons.has(addon.presetInstanceId)
) {
throw new Error(
`Addon ${addon.identifyingName} is disabled: ${FeatureControl.disabledAddons.get(
addon.fromPresetId
addon.presetType
)}`
);
} else if (
@@ -1187,9 +1208,10 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
) {
return true;
}
if (
titleMatchingOptions.addons?.length &&
!titleMatchingOptions.addons.includes(stream.addon.id!)
!titleMatchingOptions.addons.includes(stream.addon.presetInstanceId)
) {
return true;
}
@@ -1238,7 +1260,9 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
if (
seasonEpisodeMatchingOptions.addons?.length &&
!seasonEpisodeMatchingOptions.addons.includes(stream.addon.id!)
!seasonEpisodeMatchingOptions.addons.includes(
stream.addon.presetInstanceId
)
) {
return true;
}
@@ -1267,7 +1291,9 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
};
const excludedRegexPatterns =
isRegexAllowed && this.userData.excludedRegexPatterns
isRegexAllowed &&
this.userData.excludedRegexPatterns &&
this.userData.excludedRegexPatterns.length > 0
? await Promise.all(
this.userData.excludedRegexPatterns.map(
async (pattern) => await compileRegex(pattern)
@@ -1276,7 +1302,9 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
: undefined;
const requiredRegexPatterns =
isRegexAllowed && this.userData.requiredRegexPatterns
isRegexAllowed &&
this.userData.requiredRegexPatterns &&
this.userData.requiredRegexPatterns.length > 0
? await Promise.all(
this.userData.requiredRegexPatterns.map(
async (pattern) => await compileRegex(pattern)
@@ -1285,7 +1313,9 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
: undefined;
const includedRegexPatterns =
isRegexAllowed && this.userData.includedRegexPatterns
isRegexAllowed &&
this.userData.includedRegexPatterns &&
this.userData.includedRegexPatterns.length > 0
? await Promise.all(
this.userData.includedRegexPatterns.map(
async (pattern) => await compileRegex(pattern)
@@ -1337,7 +1367,7 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
const isAddonFilteredOut =
addonIds &&
addonIds.length > 0 &&
addonIds.some((addonId) => stream.addon.id === addonId) &&
addonIds.some((addonId) => stream.addon.presetInstanceId === addonId) &&
stream.service?.cached === cached;
const isServiceFilteredOut =
serviceIds &&
@@ -1835,6 +1865,7 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
}
if (
requiredRegexPatterns &&
requiredRegexPatterns.length > 0 &&
!(await testRegexes(stream, requiredRegexPatterns))
) {
skipReasons.requiredRegex.total++;
@@ -2131,11 +2162,11 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
const aAddonIndex =
this.userData.presets.findIndex(
(preset) => JSON.stringify(preset) === a.addon.id
(preset) => preset.instanceId === a.addon.presetInstanceId
) ?? -1;
const bAddonIndex =
this.userData.presets.findIndex(
(preset) => JSON.stringify(preset) === b.addon.id
(preset) => preset.instanceId === b.addon.presetInstanceId
) ?? -1;
// the addon index MUST exist, its not possible for it to not exist
@@ -2183,11 +2214,11 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
return serviceStreams.sort((a, b) => {
const aAddonIndex =
this.userData.presets.findIndex(
(preset) => JSON.stringify(preset) === a.addon.id
(preset) => preset.instanceId === a.addon.presetInstanceId
) ?? -1;
const bAddonIndex =
this.userData.presets.findIndex(
(preset) => JSON.stringify(preset) === b.addon.id
(preset) => preset.instanceId === b.addon.presetInstanceId
) ?? -1;
if (aAddonIndex !== bAddonIndex) {
return aAddonIndex - bAddonIndex;
@@ -2227,8 +2258,9 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
let perAddonStreams = Object.values(
typeStreams.reduce(
(acc, stream) => {
acc[stream.addon.id!] = acc[stream.addon.id!] || [];
acc[stream.addon.id!].push(stream);
acc[stream.addon.presetInstanceId] =
acc[stream.addon.presetInstanceId] || [];
acc[stream.addon.presetInstanceId].push(stream);
return acc;
},
{} as Record<string, ParsedStream[]>
@@ -2278,6 +2310,7 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
this.userData.preferredRegexPatterns.map(async (pattern) => {
return {
name: pattern.name,
negate: parseRegex(pattern.pattern).flags.includes('n'),
pattern: await compileRegex(pattern.pattern),
};
})
@@ -2302,24 +2335,48 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
isMatch(preferredKeywordsPatterns, stream.indexer || '');
});
}
const determineMatch = (
stream: ParsedStream,
regexPattern: { pattern: RegExp; negate: boolean },
attribute?: string
) => {
if (regexPattern.negate) {
return attribute ? !isMatch(regexPattern.pattern, attribute) : true;
}
return attribute ? isMatch(regexPattern.pattern, attribute) : false;
};
if (preferredRegexPatterns) {
streams.forEach((stream) => {
for (let i = 0; i < preferredRegexPatterns.length; i++) {
// if negate, then the pattern must not match any of the attributes
// and if the attribute is undefined, then we can consider that as a non-match so true
const regexPattern = preferredRegexPatterns[i];
if (
regexPattern &&
!stream.regexMatched &&
((stream.filename &&
isMatch(regexPattern.pattern, stream.filename)) ||
(stream.folderName &&
isMatch(regexPattern.pattern, stream.folderName)) ||
(stream.parsedFile?.releaseGroup &&
isMatch(
regexPattern.pattern,
stream.parsedFile?.releaseGroup || ''
)) ||
(stream.indexer && isMatch(regexPattern.pattern, stream.indexer)))
) {
const filenameMatch = determineMatch(
stream,
regexPattern,
stream.filename
);
const folderNameMatch = determineMatch(
stream,
regexPattern,
stream.folderName
);
const releaseGroupMatch = determineMatch(
stream,
regexPattern,
stream.parsedFile?.releaseGroup
);
const indexerMatch = determineMatch(
stream,
regexPattern,
stream.indexer
);
let match =
filenameMatch ||
folderNameMatch ||
releaseGroupMatch ||
indexerMatch;
if (match) {
stream.regexMatched = {
name: regexPattern.name,
pattern: regexPattern.pattern.source,
@@ -2452,7 +2509,7 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
case 'addon':
// find the first occurence of the stream.addon.id in the addons array
const idx = userData.presets.findIndex(
(p) => JSON.stringify(p) === stream.addon.id
(p) => p.instanceId === stream.addon.presetInstanceId
);
return multiplier * (idx !== -1 ? -idx : 0);
@@ -2671,13 +2728,13 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
}
// Check addon limit
if (addon && stream.addon.id) {
const count = counts.addon.get(stream.addon.id) || 0;
if (addon) {
const count = counts.addon.get(stream.addon.presetInstanceId) || 0;
if (count >= addon) {
indexesToRemove.add(index);
return;
}
counts.addon.set(stream.addon.id, count + 1);
counts.addon.set(stream.addon.presetInstanceId, count + 1);
}
// Check stream type limit
+2 -1
View File
@@ -142,7 +142,8 @@ export class AIOStreamsPreset extends Preset {
library: false,
resources: options.resources || undefined,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -54,7 +54,8 @@ export class AnimeKitsuPreset extends Preset {
library: false,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -143,7 +143,8 @@ export class CometPreset extends Preset {
enabled: true,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -99,7 +99,8 @@ export class CustomPreset extends Preset {
library: options.libraryAddon ?? false,
resources: options.resources || undefined,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
streamPassthrough: options.streamPassthrough ?? false,
headers: {
'User-Agent': this.METADATA.USER_AGENT,
+2 -1
View File
@@ -116,7 +116,8 @@ export class DcUniversePreset extends Preset {
library: false,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -93,7 +93,8 @@ export class DebridioPreset extends Preset {
enabled: true,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -134,7 +134,8 @@ export class DebridioTmdbPreset extends Preset {
library: false,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -117,7 +117,8 @@ export class DebridioTvPreset extends Preset {
library: false,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -81,7 +81,8 @@ export class DebridioTvdbPreset extends Preset {
library: false,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
@@ -45,8 +45,7 @@ class DebridioWatchtowerStreamParser extends StreamParser {
])
);
}
parsedStream.filename = undefined;
parsedStream.filename = stream.behaviorHints?.filename;
parsedStream.folderName = undefined;
parsedStream.message = stream.description?.replace(/\d+p?/g, '');
@@ -149,7 +148,8 @@ export class DebridioWatchtowerPreset extends Preset {
library: false,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -135,7 +135,8 @@ export class DMMCastPreset extends Preset {
library: false,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -71,7 +71,8 @@ export class EasynewsPreset extends Preset {
enabled: true,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -125,7 +125,8 @@ export class JackettioPreset extends Preset {
enabled: true,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -92,7 +92,8 @@ export class MarvelPreset extends Preset {
library: false,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+3 -1
View File
@@ -282,7 +282,9 @@ export class MediaFusionPreset extends Preset {
enabled: true,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
encoded_user_data: this.generateEncodedUserData(
+2 -1
View File
@@ -175,7 +175,8 @@ export class NuvioStreamsPreset extends Preset {
streamPassthrough: options.streamPassthrough ?? true,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -48,7 +48,8 @@ export class OpenSubtitlesPreset extends Preset {
library: false,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -137,7 +137,8 @@ export class OrionPreset extends Preset {
enabled: true,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -125,7 +125,8 @@ export class PeerflixPreset extends Preset {
enabled: true,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+15 -3
View File
@@ -1,4 +1,4 @@
import { PresetMetadata } from '../db';
import { PresetMetadata, PresetMinimalMetadata } from '../db';
import { CometPreset } from './comet';
import { CustomPreset } from './custom';
import { MediaFusionPreset } from './mediafusion';
@@ -64,8 +64,20 @@ const PRESET_LIST: string[] = [
];
export class PresetManager {
static getPresetList(): PresetMetadata[] {
return PRESET_LIST.map((presetId) => this.fromId(presetId).METADATA);
static getPresetList(): PresetMinimalMetadata[] {
return PRESET_LIST.map((presetId) => this.fromId(presetId).METADATA).map(
(metadata) => ({
ID: metadata.ID,
NAME: metadata.NAME,
LOGO: metadata.LOGO,
DESCRIPTION: metadata.DESCRIPTION,
URL: metadata.URL,
SUPPORTED_RESOURCES: metadata.SUPPORTED_RESOURCES,
SUPPORTED_STREAM_TYPES: metadata.SUPPORTED_STREAM_TYPES,
SUPPORTED_SERVICES: metadata.SUPPORTED_SERVICES,
OPTIONS: metadata.OPTIONS,
})
);
}
static fromId(id: string) {
+2 -1
View File
@@ -78,7 +78,8 @@ export class RpdbCatalogsPreset extends Preset {
library: false,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
@@ -120,7 +120,8 @@ export class StarWarsUniversePreset extends Preset {
library: false,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -153,7 +153,8 @@ export class StreamFusionPreset extends Preset {
enabled: true,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -107,7 +107,8 @@ export class StremthruStorePreset extends Preset {
library: true,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -127,7 +127,8 @@ export class StremthruTorzPreset extends Preset {
enabled: true,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -95,7 +95,8 @@ export class TmdbCollectionsPreset extends Preset {
library: false,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -128,7 +128,8 @@ export class TorboxAddonPreset extends Preset {
enabled: true,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -50,7 +50,8 @@ export class TorrentCatalogsPreset extends Preset {
library: false,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+2 -1
View File
@@ -148,7 +148,8 @@ export class TorrentioPreset extends Preset {
enabled: true,
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
timeout: options.timeout || this.METADATA.TIMEOUT,
fromPresetId: this.METADATA.ID,
presetType: this.METADATA.ID,
presetInstanceId: '',
headers: {
'User-Agent': this.METADATA.USER_AGENT,
},
+6 -10
View File
@@ -367,22 +367,18 @@ function ensureDecrypted(config: UserData): UserData {
if (!service.credentials) continue;
for (const [credential, value] of Object.entries(service.credentials)) {
service.credentials[credential] = tryDecrypt(
decodeURIComponent(value),
value,
`credential ${credential}`
);
}
}
// Decrypt proxy config
if (decryptedConfig.proxy) {
decryptedConfig.proxy.credentials = decryptedConfig.proxy.credentials
? tryDecrypt(
decodeURIComponent(decryptedConfig.proxy.credentials),
'proxy credentials'
)
? tryDecrypt(decryptedConfig.proxy.credentials, 'proxy credentials')
: undefined;
decryptedConfig.proxy.url = decryptedConfig.proxy.url
? tryDecrypt(decodeURIComponent(decryptedConfig.proxy.url), 'proxy URL')
? tryDecrypt(decryptedConfig.proxy.url, 'proxy URL')
: undefined;
}
@@ -422,7 +418,7 @@ function validateService(
}
function validatePreset(preset: PresetObject) {
const presetMeta = PresetManager.fromId(preset.id).METADATA;
const presetMeta = PresetManager.fromId(preset.type).METADATA;
const optionMetas = presetMeta.OPTIONS;
@@ -521,9 +517,9 @@ function validateOption(
}
if (option.forced) {
value = encryptString(option.forced).data;
// option.forced is already encrypted
value = option.forced;
}
value = decodeURIComponent(value);
if (isEncrypted(value) && decryptValues) {
const { success, data, error } = decryptString(value);
if (!success) {
+33 -6
View File
@@ -15,6 +15,25 @@ const logger = createLogger('crypto');
const saltRounds = 10;
function base64UrlSafe(data: string): string {
return Buffer.from(data)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
function fromUrlSafeBase64(data: string): string {
// Add padding if needed
const padding = data.length % 4;
const paddedData = padding ? data + '='.repeat(4 - padding) : data;
return Buffer.from(
paddedData.replace(/-/g, '+').replace(/_/g, '/'),
'base64'
).toString('utf-8');
}
const compressData = (data: string): Buffer => {
return deflateSync(Buffer.from(data, 'utf-8'), {
level: 9,
@@ -72,8 +91,15 @@ type ErrorResponse = {
export type Response = SuccessResponse | ErrorResponse;
export function isEncrypted(data: string): boolean {
return data?.startsWith('aioEncrypt:') ?? false;
try {
// parse the data as json
const json = JSON.parse(fromUrlSafeBase64(data));
return json.type === 'aioEncrypt';
} catch (error) {
return false;
}
}
/**
* Encrypts a string using AES-256-CBC encryption, returns a string in the format "iv:encrypted" where
* iv and encrypted are url encoded.
@@ -90,7 +116,9 @@ export function encryptString(data: string, secretKey?: Buffer): Response {
const { iv, data: encrypted } = encryptData(secretKey, compressed);
return {
success: true,
data: encodeURIComponent(`aioEncrypt:${iv}:${encrypted}`),
data: base64UrlSafe(
JSON.stringify({ iv, encrypted, type: 'aioEncrypt' })
),
error: null,
};
} catch (error: any) {
@@ -114,13 +142,12 @@ export function decryptString(data: string, secretKey?: Buffer): Response {
secretKey = Buffer.from(Env.SECRET_KEY, 'hex');
}
try {
data = decodeURIComponent(data);
if (!isEncrypted(data)) {
throw new Error('The data was not in an expected encrypted format');
}
const [_, ivHex, encryptedHex] = data.split(':');
const iv = Buffer.from(ivHex, 'base64');
const encrypted = Buffer.from(encryptedHex, 'base64');
const json = JSON.parse(fromUrlSafeBase64(data));
const iv = Buffer.from(json.iv, 'base64');
const encrypted = Buffer.from(json.encrypted, 'base64');
const decrypted = decryptData(secretKey, encrypted, iv);
const decompressed = decompressData(decrypted);
return {
+7 -3
View File
@@ -40,12 +40,12 @@ export async function safeRegexTest(
return false;
}
}
// parses regex and flags, also checks for existence of a custom flag - n - for negate
export function parseRegex(pattern: string): {
regex: string;
flags: string;
} {
const regexFormatMatch = /^\/(.+)\/([gimuy]*)$/.exec(pattern);
const regexFormatMatch = /^\/(.+)\/([gimun]*)$/.exec(pattern);
return regexFormatMatch
? { regex: regexFormatMatch[1], flags: regexFormatMatch[2] }
: { regex: pattern, flags: '' };
@@ -55,7 +55,11 @@ export async function compileRegex(
pattern: string,
bypassCache: boolean = false
): Promise<RegExp> {
const { regex, flags } = parseRegex(pattern);
let { regex, flags } = parseRegex(pattern);
// the n flag is not to be used when compiling the regex
if (flags.includes('n')) {
flags = flags.replace('n', '');
}
if (bypassCache) {
return new RegExp(regex, flags);
}
+2 -2
View File
@@ -160,8 +160,8 @@ export class Wrapper {
{ type, id },
validator
);
const Parser = this.addon.fromPresetId
? PresetManager.fromId(this.addon.fromPresetId).getParser()
const Parser = this.addon.presetType
? PresetManager.fromId(this.addon.presetType).getParser()
: StreamParser;
const parser = new Parser(this.addon);
return streams.map((stream: Stream) => parser.parse(stream));
+366 -205
View File
@@ -40,7 +40,7 @@ import {
LuChevronsDown,
LuShuffle,
} from 'react-icons/lu';
import { TbSmartHomeOff } from 'react-icons/tb';
import { TbSmartHome, TbSmartHomeOff } from 'react-icons/tb';
import { AnimatePresence } from 'framer-motion';
import { PageControls } from '../shared/page-controls';
import Image from 'next/image';
@@ -54,6 +54,14 @@ import {
import { MdRefresh } from 'react-icons/md';
import { Alert } from '../ui/alert';
import MarkdownLite from '../shared/markdown-lite';
import {
Accordion,
AccordionTrigger,
AccordionContent,
AccordionItem,
} from '../ui/accordion';
import { FaArrowRightLong, FaRankingStar, FaShuffle } from 'react-icons/fa6';
import { PiStarFill, PiStarBold } from 'react-icons/pi';
interface CatalogModification {
id: string;
@@ -143,17 +151,31 @@ function Content() {
setEditingAddonId(null);
setModalOpen(true);
}
function getUniqueId() {
// generate a 3 character long hex string, ensuring it doesn't already exist in the user's presets
const id = Math.floor(Math.random() * 0xfff)
.toString(16)
.padStart(3, '0');
if (userData.presets.some((a) => a.instanceId === id)) {
return getUniqueId();
}
return id;
}
function handleModalSubmit(values: Record<string, any>) {
if (modalMode === 'add' && modalPreset) {
// Always add a new preset with default values, never edit
const newPreset = {
id: modalPreset.ID,
type: modalPreset.ID,
instanceId: getUniqueId(),
enabled: true,
options: values.options,
};
const newKey = getPresetUniqueKey(newPreset);
// Prevent adding if a preset with the same unique key already exists
// dont use instanceId here, as that will always be unique
// only prevent adding the same preset type with the same options
// so we use getPresetUniqueKey here.
if (userData.presets.some((a) => getPresetUniqueKey(a) === newKey)) {
toast.error('You already have an addon with the same options added.');
setModalOpen(false);
@@ -170,7 +192,7 @@ function Content() {
setUserData((prev) => ({
...prev,
presets: prev.presets.map((a) =>
getPresetUniqueKey(a) === editingAddonId
a.instanceId === editingAddonId
? { ...a, options: values.options }
: a
),
@@ -186,10 +208,10 @@ function Content() {
if (!over) return;
if (active.id !== over.id) {
const oldIndex = userData.presets.findIndex(
(a) => getPresetUniqueKey(a) === active.id
(a) => a.instanceId === active.id
);
const newIndex = userData.presets.findIndex(
(a) => getPresetUniqueKey(a) === over.id
(a) => a.instanceId === over.id
);
const newPresets = arrayMove(userData.presets, oldIndex, newIndex);
setUserData((prev) => ({
@@ -321,7 +343,7 @@ function Content() {
sensors={sensors}
>
<SortableContext
items={userData.presets.map((a) => getPresetUniqueKey(a))}
items={userData.presets.map((a) => a.instanceId)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
@@ -337,42 +359,39 @@ function Content() {
</div>
</li>
) : (
userData.presets.map((addon) => {
const preset = status?.settings?.presets.find(
(p: any) => p.ID === addon.id
userData.presets.map((preset) => {
const presetMetadata = status?.settings?.presets.find(
(p: any) => p.ID === preset.type
);
return (
<SortableAddonItem
key={getPresetUniqueKey(addon)}
addon={addon}
key={getPresetUniqueKey(preset)}
preset={preset}
presetMetadata={presetMetadata}
onEdit={() => {
setModalPreset(preset);
setModalPreset(presetMetadata);
setModalInitialValues({
options: { ...addon.options },
options: { ...preset.options },
});
setModalMode('edit');
setEditingAddonId(getPresetUniqueKey(addon));
setEditingAddonId(preset.instanceId);
setModalOpen(true);
}}
onRemove={() => {
setUserData((prev) => ({
...prev,
presets: prev.presets.filter(
(a) =>
getPresetUniqueKey(a) !==
getPresetUniqueKey(addon)
(a) => a.instanceId !== preset.instanceId
),
}));
}}
onToggleEnabled={(v: boolean) => {
setUserData((prev) => ({
...prev,
presets: prev.presets.map((a) =>
getPresetUniqueKey(a) ===
getPresetUniqueKey(addon)
? { ...a, enabled: v }
: a
presets: prev.presets.map((p) =>
p.instanceId === preset.instanceId
? { ...p, enabled: v }
: p
),
}));
}}
@@ -477,14 +496,16 @@ function Content() {
);
}
// Helper to generate a unique key for a user preset
// Helper to generate a key based on an addons id and options
function getPresetUniqueKey(preset: {
id: string;
type: string;
instanceId: string;
enabled: boolean;
options: Record<string, any>;
}) {
// dont include the unique instanceId
return JSON.stringify({
id: preset.id,
type: preset.type,
enabled: preset.enabled,
options: preset.options,
});
@@ -492,14 +513,14 @@ function getPresetUniqueKey(preset: {
// Sortable Addon Item for DND (handles both preset and custom addon)
function SortableAddonItem({
addon,
preset,
presetMetadata,
onEdit,
onRemove,
onToggleEnabled,
}: {
addon: any;
preset: any;
presetMetadata: any;
onEdit: () => void;
onRemove: () => void;
onToggleEnabled: (v: boolean) => void;
@@ -512,7 +533,7 @@ function SortableAddonItem({
transition,
isDragging,
} = useSortable({
id: getPresetUniqueKey(addon),
id: preset.instanceId,
});
const style = {
transform: CSS.Transform.toString(transform),
@@ -529,12 +550,12 @@ function SortableAddonItem({
/>
<div className="flex items-center gap-2 sm:gap-3 flex-1 min-w-0">
<div className="relative flex-shrink-0 h-8 w-8 hidden sm:block">
{preset.ID === 'custom' ? (
{presetMetadata.ID === 'custom' ? (
<PlusIcon className="w-full h-full object-contain" />
) : (
<Image
src={preset.LOGO}
alt={preset.NAME}
src={presetMetadata.LOGO}
alt={presetMetadata.NAME}
fill
className="w-full h-full object-contain rounded-md"
/>
@@ -542,13 +563,13 @@ function SortableAddonItem({
</div>
<p className="text-base line-clamp-1 truncate block">
{addon.options.name}
{preset.options.name}
</p>
</div>
<div className="flex items-center gap-1 sm:gap-2">
<Switch
value={!!addon.enabled}
value={!!preset.enabled}
onValueChange={onToggleEnabled}
size="sm"
/>
@@ -913,12 +934,11 @@ function AddonGroupCard() {
return userData.presets
.filter((preset) => {
const presetStr = JSON.stringify(preset);
return !presetsInOtherGroups.has(presetStr);
return !presetsInOtherGroups.has(preset.instanceId);
})
.map((preset) => ({
label: preset.options.name,
value: JSON.stringify(preset),
value: preset.instanceId,
textValue: preset.options.name,
}));
};
@@ -1056,8 +1076,22 @@ function CatalogSettingsCard() {
existingMods.map((mod) => `${mod.id}-${mod.type}`)
);
// Keep existing modifications with their settings
const modifications = [...existingMods];
// first we need to handle existing modifications, to ensure that they keep their order
const modifications = existingMods.map((eMod) => {
const nMod = response.data!.find(
(c) => c.id === eMod.id && c.type === eMod.type
);
if (nMod) {
return {
// keep all the existing attributes, except addonName, type, hideable
...eMod,
addonName: nMod.addonName,
type: nMod.type,
hideable: nMod.hideable,
};
}
return eMod;
});
// Add new catalogs at the bottom
response.data!.forEach((catalog) => {
@@ -1069,7 +1103,6 @@ function CatalogSettingsCard() {
enabled: true,
shuffle: false,
rpdb: userData.rpdbApiKey ? true : false,
// Store these properties directly in the modification object
hideable: catalog.hideable,
addonName: catalog.addonName,
});
@@ -1100,19 +1133,6 @@ function CatalogSettingsCard() {
}
};
const [editingCatalog, setEditingCatalog] = useState<{
id: string;
type: string;
name?: string;
shuffle: boolean;
rpdb: boolean;
onlyOnDiscover: boolean;
hideable?: boolean;
addonName?: string;
} | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const capitalise = (str: string | undefined) => {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
@@ -1192,16 +1212,6 @@ function CatalogSettingsCard() {
setIsDragging(true);
};
// const confirmClearConfig = useConfirmationDialog({
// title: 'Sign Out',
// description: 'Are you sure you want to sign out?',
// onConfirm: () => {
// user.setUserData(null);
// user.setUuid(null);
// user.setPassword(null);
// },
// });
const confirmRefreshCatalogs = useConfirmationDialog({
title: 'Refresh Catalogs',
description:
@@ -1261,25 +1271,12 @@ function CatalogSettingsCard() {
)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
<ul className="space-y-2">
{(userData.catalogModifications || []).map(
(catalog: CatalogModification) => (
<SortableCatalogItem
key={`${catalog.id}-${catalog.type}`}
catalog={catalog}
onEdit={() => {
setEditingCatalog({
id: catalog.id,
type: catalog.type,
name: catalog.name,
shuffle: catalog.shuffle ?? false,
rpdb: catalog.rpdb ?? false,
onlyOnDiscover: catalog.onlyOnDiscover ?? false,
hideable: catalog.hideable,
addonName: catalog.addonName,
});
setModalOpen(true);
}}
onToggleEnabled={(enabled) => {
setUserData((prev) => ({
...prev,
@@ -1295,98 +1292,11 @@ function CatalogSettingsCard() {
/>
)
)}
</div>
</ul>
</SortableContext>
</DndContext>
)}
<Modal
open={modalOpen}
onOpenChange={setModalOpen}
title={
<div className="max-w-[calc(100vw-4rem)] sm:max-w-[400px] truncate">
Edit Catalog: {editingCatalog?.name || editingCatalog?.id} -{' '}
{capitalise(editingCatalog?.type)} - {editingCatalog?.addonName}
</div>
}
>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (!editingCatalog) return;
setUserData((prev) => ({
...prev,
catalogModifications: prev.catalogModifications?.map((c) =>
c.id === editingCatalog.id && c.type === editingCatalog.type
? {
...c,
name: editingCatalog.name,
shuffle: editingCatalog.shuffle,
rpdb: editingCatalog.rpdb,
onlyOnDiscover: editingCatalog.onlyOnDiscover,
}
: c
),
}));
setModalOpen(false);
}}
>
<div className="flex flex-col gap-4">
<TextInput
label="Name"
placeholder="Enter catalog name"
value={editingCatalog?.name || ''}
onValueChange={(name) => {
setEditingCatalog((prev) => (prev ? { ...prev, name } : null));
}}
/>
<Switch
label="Shuffle Results"
help="This will shuffle the items in the catalog on each request"
side="right"
className="ml-2"
value={editingCatalog?.shuffle ?? false}
onValueChange={(shuffle) => {
setEditingCatalog((prev) =>
prev ? { ...prev, shuffle } : null
);
}}
/>
<Switch
label="Use RPDB"
help="Replace posters with RPDB posters if supported"
side="right"
className="ml-2"
value={editingCatalog?.rpdb ?? false}
onValueChange={(rpdb) => {
setEditingCatalog((prev) => (prev ? { ...prev, rpdb } : null));
}}
/>
{editingCatalog?.hideable && (
<Switch
label="Only show on Discover"
help="This will prevent the catalog from showing on the home page, and only show it on the 'Discover' page"
moreHelp="This can potentially break the catalog!"
side="right"
className="ml-2"
value={editingCatalog?.onlyOnDiscover ?? false}
onValueChange={(onlyOnDiscover) => {
setEditingCatalog((prev) =>
prev ? { ...prev, onlyOnDiscover } : null
);
}}
/>
)}
</div>
<Button className="w-full mt-4" type="submit">
Save Changes
</Button>
</form>
</Modal>
<ConfirmationDialog {...confirmRefreshCatalogs} />
</div>
);
@@ -1395,12 +1305,10 @@ function CatalogSettingsCard() {
// Add the SortableCatalogItem component
function SortableCatalogItem({
catalog,
onEdit,
onToggleEnabled,
capitalise,
}: {
catalog: CatalogModification;
onEdit: () => void;
onToggleEnabled: (enabled: boolean) => void;
capitalise: (str: string | undefined) => string;
}) {
@@ -1451,52 +1359,305 @@ function SortableCatalogItem({
});
};
const [modalOpen, setModalOpen] = useState(false);
const [newName, setNewName] = useState(catalog.name || '');
const dynamicIconSize = `text-xl h-8 w-8 lg:text-2xl lg:h-10 lg:w-10`;
const handleNameEdit = () => {
setUserData((prev) => ({
...prev,
catalogModifications: prev.catalogModifications?.map((c) =>
c.id === catalog.id && c.type === catalog.type
? { ...c, name: newName }
: c
),
}));
setModalOpen(false);
};
return (
<div ref={setNodeRef} style={style}>
<div className="px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border flex gap-2 sm:gap-3 relative">
<li ref={setNodeRef} style={style}>
<div className="relative px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border overflow-hidden">
{/* Full-height drag handle - rounded vertical oval with spacing */}
<div
className="rounded-full w-6 h-auto bg-[--muted] md:bg-[--subtle] md:hover:bg-[--subtle-highlight] cursor-move flex-shrink-0"
className="absolute top-2 bottom-2 left-2 w-5 bg-[var(--muted)] md:bg-[var(--subtle)] md:hover:bg-[var(--subtle-highlight)] cursor-move flex-shrink-0 rounded-full"
{...attributes}
{...listeners}
/>
<div className="flex items-center gap-3 flex-1 min-w-0">
<p className="text-base line-clamp-1 truncate block">
{catalog.name ?? catalog.id} - {capitalise(catalog.type)}
</p>
</div>
<div className="flex items-center gap-1 md:gap-2">
{catalog.shuffle && (
<LuShuffle className="text-md text-[--brand] h-4 w-4 md:h-6 md:w-6 hidden md:flex" />
)}
{catalog.onlyOnDiscover && (
<TbSmartHomeOff className="text-md text-[--brand] h-4 w-4 md:h-6 md:w-6 hidden md:flex" />
)}
<Switch
value={catalog.enabled ?? true}
onValueChange={onToggleEnabled}
size="sm"
/>
<IconButton
className="rounded-full h-8 w-8 md:h-10 md:w-10"
icon={<BiEdit />}
intent="primary-subtle"
onClick={onEdit}
/>
<IconButton
className="rounded-full h-8 w-8 md:h-10 md:w-10"
icon={<LuChevronsUp />}
intent="primary-subtle"
onClick={moveToTop}
/>
<IconButton
className="rounded-full h-8 w-8 md:h-10 md:w-10"
icon={<LuChevronsDown />}
intent="primary-subtle"
onClick={moveToBottom}
/>
{/* Content wrapper */}
<div className="pl-8 pr-3 py-3">
{/* Header section */}
<div className="mb-4 md:mb-6 md:pr-40">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm md:text-base font-medium line-clamp-1 truncate text-ellipsis">
{catalog.addonName} - {catalog.name ?? catalog.id}
</h3>
<IconButton
className="rounded-full h-5 w-5 md:h-6 md:w-6 flex-shrink-0"
icon={<BiEdit />}
intent="primary-subtle"
onClick={() => setModalOpen(true)}
/>
</div>
<p className="text-xs md:text-sm text-[var(--muted-foreground)] capitalize mb-2 md:mb-0">
{catalog.type}
</p>
{/* Mobile Controls Row - only visible on small screens */}
<div className="flex md:hidden items-center justify-between">
{/* Position controls - aligned left */}
<div className="flex items-center gap-1">
<IconButton
rounded
className={dynamicIconSize}
icon={<LuChevronsUp />}
intent="primary-subtle"
onClick={moveToTop}
title="Move to top"
/>
<IconButton
rounded
className={dynamicIconSize}
icon={<LuChevronsDown />}
intent="primary-subtle"
onClick={moveToBottom}
title="Move to bottom"
/>
</div>
{/* Enable/disable toggle - aligned right */}
<Switch
value={catalog.enabled ?? true}
onValueChange={onToggleEnabled}
moreHelp="Enable or disable this catalog from being used"
/>
</div>
{/* Desktop Controls - only visible on medium screens and up */}
<div className="hidden md:flex items-center justify-end gap-2 absolute top-4 right-4">
<Switch
value={catalog.enabled ?? true}
onValueChange={onToggleEnabled}
moreHelp="Enable or disable this catalog from being used"
/>
<div className="flex items-center gap-1">
<IconButton
rounded
icon={<LuChevronsUp />}
intent="primary-subtle"
onClick={moveToTop}
title="Move to top"
/>
<IconButton
rounded
icon={<LuChevronsDown />}
intent="primary-subtle"
onClick={moveToBottom}
title="Move to bottom"
/>
</div>
</div>
</div>
{/* Settings section */}
<Accordion type="single" collapsible>
<AccordionItem value="settings">
<AccordionTrigger>
<div className="flex items-center justify-between w-full">
<h4 className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
Settings
</h4>
{/* Active modifier icons */}
<div className="flex items-center gap-2 mr-2">
<Tooltip
trigger={
<IconButton
className={dynamicIconSize}
icon={
catalog.shuffle ? (
<FaShuffle />
) : (
<FaArrowRightLong />
)
}
intent="primary-subtle"
rounded
onClick={(e) => {
e.stopPropagation();
setUserData((prev) => ({
...prev,
catalogModifications:
prev.catalogModifications?.map((c) =>
c.id === catalog.id && c.type === catalog.type
? { ...c, shuffle: !c.shuffle }
: c
),
}));
}}
/>
}
>
Shuffle
</Tooltip>
<Tooltip
trigger={
<IconButton
className={dynamicIconSize}
icon={catalog.rpdb ? <PiStarFill /> : <PiStarBold />}
intent="primary-subtle"
rounded
onClick={(e) => {
e.stopPropagation();
setUserData((prev) => ({
...prev,
catalogModifications:
prev.catalogModifications?.map((c) =>
c.id === catalog.id && c.type === catalog.type
? { ...c, rpdb: !c.rpdb }
: c
),
}));
}}
/>
}
>
RPDB
</Tooltip>
{catalog.hideable && (
<Tooltip
trigger={
<IconButton
className={dynamicIconSize}
icon={
catalog.onlyOnDiscover ? (
<TbSmartHomeOff />
) : (
<TbSmartHome />
)
}
intent="primary-subtle"
rounded
onClick={(e) => {
e.stopPropagation();
setUserData((prev) => ({
...prev,
catalogModifications:
prev.catalogModifications?.map((c) =>
c.id === catalog.id &&
c.type === catalog.type
? {
...c,
onlyOnDiscover: !c.onlyOnDiscover,
}
: c
),
}));
}}
/>
}
>
Discover Only
</Tooltip>
)}
</div>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
{/* Large screens: horizontal layout, Medium and below: vertical layout */}
<div className="flex flex-col gap-4">
<Switch
label="Shuffle"
help="Randomize the order of catalog items on each request"
side="right"
value={catalog.shuffle ?? false}
onValueChange={(shuffle) => {
setUserData((prev) => ({
...prev,
catalogModifications: prev.catalogModifications?.map(
(c) =>
c.id === catalog.id && c.type === catalog.type
? { ...c, shuffle }
: c
),
}));
}}
/>
<Switch
label="RPDB"
help="Replace movie/show posters with RPDB posters when supported"
side="right"
value={catalog.rpdb ?? false}
onValueChange={(rpdb) => {
setUserData((prev) => ({
...prev,
catalogModifications: prev.catalogModifications?.map(
(c) =>
c.id === catalog.id && c.type === catalog.type
? { ...c, rpdb }
: c
),
}));
}}
/>
{catalog.hideable && (
<Switch
label="Discover Only"
help="Hide this catalog from the home page and only show it on the Discover page"
moreHelp="This can potentially break the catalog!"
side="right"
value={catalog.onlyOnDiscover ?? false}
onValueChange={(onlyOnDiscover) => {
setUserData((prev) => ({
...prev,
catalogModifications:
prev.catalogModifications?.map((c) =>
c.id === catalog.id && c.type === catalog.type
? { ...c, onlyOnDiscover }
: c
),
}));
}}
/>
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
{/* Name edit modal */}
<Modal
open={modalOpen}
onOpenChange={setModalOpen}
title="Edit Catalog Name"
>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
handleNameEdit();
}}
>
<TextInput
label="Name"
placeholder="Enter catalog name"
value={newName}
onValueChange={setNewName}
/>
<Button className="w-full" type="submit">
Save Changes
</Button>
</form>
</Modal>
</li>
);
}
@@ -333,9 +333,9 @@ function Content() {
}));
}}
options={userData.presets.map((preset) => ({
label: preset.options.name || preset.id,
value: JSON.stringify(preset),
textValue: preset.options.name || preset.id,
label: preset.options.name || preset.type,
value: preset.instanceId,
textValue: preset.options.name || preset.type,
}))}
emptyMessage="You haven't installed any addons..."
placeholder="Select addons..."
@@ -435,8 +435,8 @@ function Content() {
}));
}}
options={userData.presets.map((preset) => ({
label: preset.options.name,
value: JSON.stringify(preset),
label: preset.options.name || preset.type,
value: preset.instanceId,
textValue: preset.options.name,
}))}
emptyMessage="You haven't installed any addons..."
@@ -1192,11 +1192,11 @@ function Content() {
disabled={!userData.titleMatching?.enabled}
label="Request Types"
emptyMessage="There aren't any request types to choose from..."
help="Request types that will use strict title matching. Leave blank to apply to all request types."
help="Request types that will use title matching. Leave blank to apply to all request types."
options={TYPES.map((type) => ({
label: type,
value: type,
text: type,
textValue: type,
}))}
value={userData.titleMatching?.requestTypes}
onValueChange={(value) => {
@@ -1216,9 +1216,9 @@ function Content() {
help="Addons that will use strict title matching. Leave blank to apply to all addons."
emptyMessage="You haven't installed any addons yet..."
options={userData.presets.map((preset) => ({
label: preset.options.name,
type: preset.options.name,
value: JSON.stringify(preset),
label: preset.options.name || preset.type,
type: preset.options.name || preset.type,
value: preset.instanceId,
}))}
value={userData.titleMatching?.addons || []}
onValueChange={(value) => {
@@ -1265,7 +1265,7 @@ function Content() {
options={TYPES.map((type) => ({
label: type,
value: type,
text: type,
textValue: type,
}))}
value={userData.seasonEpisodeMatching?.requestTypes}
onValueChange={(value) => {
@@ -1285,9 +1285,9 @@ function Content() {
help="Addons that will use season/episode matching. Leave blank to apply to all addons."
emptyMessage="You haven't installed any addons yet..."
options={userData.presets.map((preset) => ({
label: preset.options.name,
type: preset.options.name,
value: JSON.stringify(preset),
label: preset.options.name || preset.type,
type: preset.options.name || preset.type,
value: preset.instanceId,
}))}
value={userData.seasonEpisodeMatching?.addons || []}
onValueChange={(value) => {
@@ -191,6 +191,8 @@ function Content() {
addon: {
name: addonName,
identifyingName: addonName,
presetType: 'custom',
presetInstanceId: '',
enabled: true,
manifestUrl: 'http://localhost:2000/manifest.json',
timeout: 10000,
@@ -64,8 +64,8 @@ function Content() {
const addonOptions = userData.presets.map((preset) => {
return {
label: preset.options.name,
value: JSON.stringify(preset),
label: preset.options.name || preset.type,
value: preset.instanceId,
textValue: preset.options.name,
};
});
+1 -1
View File
@@ -39,7 +39,7 @@ router.post('/', async (req: Request, res: Response, next: NextFunction) => {
id: catalog.id,
name: catalog.name,
type: catalog.type,
addonName: aio.getAddon(parseInt(catalog.id.split(':')[0])).name,
addonName: aio.getAddon(catalog.id.split('.')[0])?.name,
hideable: catalog.extra
? catalog.extra?.findIndex(
(extra) =>