mirror of
https://github.com/Viren070/AIOStreams.git
synced 2025-12-01 23:14:04 +01:00
feat!: stuff
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user