mirror of
https://github.com/Viren070/AIOStreams.git
synced 2025-12-01 23:14:04 +01:00
feat: add template system (#438)
Co-authored-by: MidnightKittenCat <66114416+MidnightKittenCat@users.noreply.github.com> Co-authored-by: Viren070 <viren070@protonmail.com>
This commit is contained in:
committed by
GitHub
parent
5ddd111ec0
commit
481338afa3
@@ -993,3 +993,18 @@ export const RPDBIsValidResponse = z.object({
|
||||
valid: z.boolean(),
|
||||
});
|
||||
export type RPDBIsValidResponse = z.infer<typeof RPDBIsValidResponse>;
|
||||
|
||||
export const TemplateSchema = z.object({
|
||||
metadata: z.object({
|
||||
name: z.string().min(1).max(20), // name of the template
|
||||
description: z.string().min(1).max(500), // description of the template
|
||||
author: z.string().min(1).max(20), // author of the template (predefined templates will have Vire)
|
||||
predefined: z.boolean().optional(), // whether the template is predefined or not.
|
||||
category: z.string().min(1).max(20), // category of the template
|
||||
services: z.array(ServiceIds).optional(),
|
||||
serviceRequired: z.boolean().optional(), // whether a service is required for this template or not.
|
||||
}),
|
||||
config: UserDataSchema, // config of the template
|
||||
});
|
||||
|
||||
export type Template = z.infer<typeof TemplateSchema>;
|
||||
|
||||
@@ -542,6 +542,35 @@ const SERVICE_DETAILS: Record<
|
||||
},
|
||||
};
|
||||
|
||||
const TOP_LEVEL_OPTION_DETAILS: Record<
|
||||
'tmdbApiKey' | 'tmdbAccessToken' | 'rpdbApiKey' | 'tvdbApiKey',
|
||||
{
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
> = {
|
||||
tmdbApiKey: {
|
||||
name: 'TMDB API Key',
|
||||
description:
|
||||
'Get your free API key from [here](https://www.themoviedb.org/settings/api). Make sure to copy the 32 character API Key and not the Read Access Token.',
|
||||
},
|
||||
tmdbAccessToken: {
|
||||
name: 'TMDB Access Token',
|
||||
description:
|
||||
'Get your free access token from [here](https://www.themoviedb.org/settings/api). Make sure to copy the Read Access Token and not the 32 character API Key.',
|
||||
},
|
||||
rpdbApiKey: {
|
||||
name: 'RPDB API Key',
|
||||
description:
|
||||
'Get your free API key from [here](https://ratingposterdb.com/api-key/) for posters with ratings.',
|
||||
},
|
||||
tvdbApiKey: {
|
||||
name: 'TVDB API Key',
|
||||
description:
|
||||
'Sign up for a free API Key at [TVDB](https://www.thetvdb.com/api-information) and then get it from your [dashboard](https://www.thetvdb.com/dashboard/account/apikeys).',
|
||||
},
|
||||
};
|
||||
|
||||
export const DEDUPLICATOR_KEYS = [
|
||||
'filename',
|
||||
'infoHash',
|
||||
@@ -1042,5 +1071,6 @@ export {
|
||||
SEEDR_SERVICE,
|
||||
EASYNEWS_SERVICE,
|
||||
SERVICE_DETAILS,
|
||||
TOP_LEVEL_OPTION_DETAILS,
|
||||
HEADERS_FOR_IP_FORWARDING,
|
||||
};
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
port,
|
||||
EnvMissingError,
|
||||
} from 'envalid';
|
||||
import { ResourceManager } from './resources.js';
|
||||
import * as constants from './constants.js';
|
||||
import { randomBytes } from 'crypto';
|
||||
import fs from 'fs';
|
||||
|
||||
// Get __dirname equivalent in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -29,7 +29,19 @@ try {
|
||||
}
|
||||
let metadata: any = undefined;
|
||||
try {
|
||||
metadata = ResourceManager.getResource('metadata.json') || {};
|
||||
function getResource(resourceName: string) {
|
||||
const filePath = path.join(
|
||||
__dirname,
|
||||
'../../../../',
|
||||
'resources',
|
||||
resourceName
|
||||
);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Resource ${resourceName} not found at ${filePath}`);
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
metadata = getResource('metadata.json') || {};
|
||||
} catch (error) {
|
||||
console.error('Error loading metadata.json file', error);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export * from './cache.js';
|
||||
export * from './constants.js';
|
||||
export * from './env.js';
|
||||
export * from './logger.js';
|
||||
export * from './resources.js';
|
||||
export * from './templates.js';
|
||||
export * from './feature.js';
|
||||
export * from './crypto.js';
|
||||
export * from './http.js';
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Get __dirname equivalent in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export class ResourceManager {
|
||||
static getResource(resourceName: string) {
|
||||
// check existence
|
||||
const filePath = path.join(
|
||||
__dirname,
|
||||
'../../../../',
|
||||
'resources',
|
||||
resourceName
|
||||
);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Resource ${resourceName} not found at ${filePath}`);
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getDataFolder } from './general';
|
||||
import { Template, TemplateSchema } from '../db/schemas';
|
||||
import { ZodError } from 'zod';
|
||||
import { formatZodError } from './config';
|
||||
|
||||
// Get __dirname equivalent in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const RESOURCE_DIR = path.join(__dirname, '../../../../', 'resources');
|
||||
|
||||
export class TemplateManager {
|
||||
private static templates: Template[] = [];
|
||||
|
||||
static getTemplates(): Template[] {
|
||||
return TemplateManager.templates;
|
||||
}
|
||||
|
||||
static loadTemplates(): {
|
||||
detected: number;
|
||||
loaded: number;
|
||||
errors: { file: string; error: string }[];
|
||||
} {
|
||||
const predefinedTemplatePath = path.join(RESOURCE_DIR, 'templates');
|
||||
const userTemplatesPath = path.join(getDataFolder(), 'templates');
|
||||
|
||||
// load all predefined templates first. look for all JSON files in the predefined template path.
|
||||
const predefinedTemplates = this.loadTemplatesFromPath(
|
||||
predefinedTemplatePath,
|
||||
true
|
||||
);
|
||||
const userTemplates = this.loadTemplatesFromPath(userTemplatesPath, false);
|
||||
this.templates = [
|
||||
...predefinedTemplates.templates,
|
||||
...userTemplates.templates,
|
||||
];
|
||||
return {
|
||||
detected: predefinedTemplates.detected + userTemplates.detected,
|
||||
loaded: predefinedTemplates.loaded + userTemplates.loaded,
|
||||
errors: [...predefinedTemplates.errors, ...userTemplates.errors],
|
||||
};
|
||||
}
|
||||
|
||||
private static loadTemplatesFromPath(
|
||||
dirPath: string,
|
||||
predefined: boolean
|
||||
): {
|
||||
templates: Template[];
|
||||
detected: number;
|
||||
loaded: number;
|
||||
errors: { file: string; error: string }[];
|
||||
} {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return { templates: [], detected: 0, loaded: 0, errors: [] };
|
||||
}
|
||||
const errors: { file: string; error: string }[] = [];
|
||||
const templates = fs.readdirSync(dirPath);
|
||||
const templateList: Template[] = [];
|
||||
for (const file of templates) {
|
||||
const filePath = path.join(dirPath, file);
|
||||
try {
|
||||
if (file.endsWith('.json')) {
|
||||
const template = TemplateSchema.parse(
|
||||
JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||
);
|
||||
templateList.push({
|
||||
...template,
|
||||
metadata: {
|
||||
...template.metadata,
|
||||
predefined: predefined || false,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
file: file,
|
||||
error:
|
||||
error instanceof ZodError
|
||||
? `Failed to parse template: ${formatZodError(error)}`
|
||||
: `Failed to load template: ${error}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
templates: templateList,
|
||||
detected: templates.length,
|
||||
loaded: templateList.length,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CoffeeIcon,
|
||||
MessageCircleIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
} from 'lucide-react';
|
||||
import { FaGithub, FaDiscord, FaChevronRight } from 'react-icons/fa';
|
||||
import { BiDonateHeart, BiLogInCircle, BiLogOutCircle } from 'react-icons/bi';
|
||||
@@ -33,6 +34,7 @@ import { DonationModal } from '../shared/donation-modal';
|
||||
import { ModeSwitch } from '../ui/mode-switch/mode-switch';
|
||||
import { ModeSelectModal } from '../shared/mode-select-modal';
|
||||
import { ConfigModal } from '../config-modal';
|
||||
import { ConfigTemplatesModal } from '../shared/config-templates-modal';
|
||||
import {
|
||||
ConfirmationDialog,
|
||||
useConfirmationDialog,
|
||||
@@ -134,6 +136,8 @@ AIOStreams consolidates multiple Stremio addons and debrid services - including
|
||||
const donationModal = useDisclosure(false);
|
||||
const customizeModal = useDisclosure(false);
|
||||
const signInModal = useDisclosure(false);
|
||||
const templatesModal = useDisclosure(false);
|
||||
const setupChoiceModal = useDisclosure(false);
|
||||
const customHtml = status?.settings?.customHtml;
|
||||
|
||||
const confirmClearConfig = useConfirmationDialog({
|
||||
@@ -229,33 +233,46 @@ AIOStreams consolidates multiple Stremio addons and debrid services - including
|
||||
)}
|
||||
|
||||
{/* Setup Mode Row */}
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full justify-start md:pl-6">
|
||||
<div className="flex flex-col items-start md:items-start">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 self-start">
|
||||
Setup Mode
|
||||
</span>
|
||||
<ModeSwitch
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
className="w-[280px] h-12 text-base"
|
||||
/>
|
||||
<div className="flex flex-col items-center md:items-start gap-4 w-full md:pl-6">
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full justify-start">
|
||||
<div className="flex flex-col items-start md:items-start">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 self-start">
|
||||
Setup Mode
|
||||
</span>
|
||||
<ModeSwitch
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
className="w-[280px] h-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-gray-400 md:self-end md:pb-3">
|
||||
<FaChevronRight className="hidden md:block text-2xl" />
|
||||
<FaChevronRight className="md:hidden rotate-90 text-2xl" />
|
||||
</div>
|
||||
<div className="md:self-end">
|
||||
<Button
|
||||
intent="white"
|
||||
rounded
|
||||
leftIcon={<FaPlay />}
|
||||
className="h-12 px-6 text-lg font-semibold"
|
||||
onClick={setupChoiceModal.open}
|
||||
>
|
||||
START SETUP
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-400 md:self-end md:pb-3">
|
||||
<FaChevronRight className="hidden md:block text-2xl" />
|
||||
<FaChevronRight className="md:hidden rotate-90 text-2xl" />
|
||||
</div>
|
||||
<div className="md:self-end">
|
||||
<Button
|
||||
intent="white"
|
||||
rounded
|
||||
leftIcon={<FaPlay />}
|
||||
className="h-12 px-6 text-lg"
|
||||
onClick={() => {
|
||||
nextMenu();
|
||||
}}
|
||||
|
||||
{/* Template Wizard Link */}
|
||||
<div className="text-center md:text-left text-sm text-gray-400 max-w-2xl">
|
||||
New to AIOStreams? Try our{' '}
|
||||
<button
|
||||
onClick={templatesModal.open}
|
||||
className="text-[--brand] hover:text-[--brand]/80 hover:underline font-medium"
|
||||
>
|
||||
START SETUP
|
||||
</Button>
|
||||
Template Wizard
|
||||
</button>{' '}
|
||||
for a guided, step-by-step setup experience with pre-configured
|
||||
settings.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -403,6 +420,22 @@ AIOStreams consolidates multiple Stremio addons and debrid services - including
|
||||
}}
|
||||
/>
|
||||
<ConfirmationDialog {...confirmClearConfig} />
|
||||
<ConfigTemplatesModal
|
||||
open={templatesModal.isOpen}
|
||||
onOpenChange={templatesModal.toggle}
|
||||
/>
|
||||
<SetupChoiceModal
|
||||
open={setupChoiceModal.isOpen}
|
||||
onOpenChange={setupChoiceModal.toggle}
|
||||
onStartFresh={() => {
|
||||
setupChoiceModal.close();
|
||||
nextMenu();
|
||||
}}
|
||||
onUseTemplate={() => {
|
||||
setupChoiceModal.close();
|
||||
templatesModal.open();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -948,3 +981,66 @@ function CustomizeModal({
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupChoiceModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onStartFresh,
|
||||
onUseTemplate,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onStartFresh: () => void;
|
||||
onUseTemplate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Get Started"
|
||||
description="Choose how you'd like to set up AIOStreams"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={onStartFresh}
|
||||
className="w-full p-6 rounded-lg border-2 border-gray-700 bg-gray-800/50 hover:border-purple-500 hover:bg-purple-500/10 transition-all duration-200 text-left group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-purple-500/20 flex items-center justify-center group-hover:bg-purple-500/30 transition-colors">
|
||||
<FaPlay className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
Start Fresh
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Build your configuration from scratch. Perfect if you want
|
||||
complete control over every setting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onUseTemplate}
|
||||
className="w-full p-6 rounded-lg border-2 border-gray-700 bg-gray-800/50 hover:border-blue-500 hover:bg-blue-500/10 transition-all duration-200 text-left group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-blue-500/20 flex items-center justify-center group-hover:bg-blue-500/30 transition-colors">
|
||||
<PlusIcon className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
Use a Template
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Start with a pre-configured template. Great for getting up and
|
||||
running quickly with recommended settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PageControls } from '../shared/page-controls';
|
||||
import { useDisclosure } from '@/hooks/disclosure';
|
||||
import { Modal } from '../ui/modal';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { TemplateExportModal } from '../shared/template-export-modal';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -60,12 +61,14 @@ function Content() {
|
||||
const importFileRef = React.useRef<HTMLInputElement>(null);
|
||||
const installModal = useDisclosure(false);
|
||||
const passwordModal = useDisclosure(false);
|
||||
const [filterCredentialsInExport, setFilterCredentialsInExport] =
|
||||
React.useState(false);
|
||||
const deleteUserModal = useDisclosure(false);
|
||||
const [confirmDeletionPassword, setConfirmDeletionPassword] =
|
||||
React.useState('');
|
||||
const { setSelectedMenu, firstMenu } = useMenu();
|
||||
const templateExportModal = useDisclosure(false);
|
||||
const exportMenuModal = useDisclosure(false);
|
||||
const [filterCredentialsInExport, setFilterCredentialsInExport] =
|
||||
React.useState(false);
|
||||
const confirmResetProps = useConfirmationDialog({
|
||||
title: 'Confirm Reset',
|
||||
description: `Are you sure you want to reset your configuration? This will clear all your settings${uuid ? ` but keep your user account` : ''}. This action cannot be undone.`,
|
||||
@@ -196,64 +199,68 @@ function Content() {
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const filterCredentials = (data: UserData): UserData => {
|
||||
const clonedData = structuredClone(data);
|
||||
|
||||
return {
|
||||
...clonedData,
|
||||
ip: undefined,
|
||||
uuid: undefined,
|
||||
addonPassword: undefined,
|
||||
tmdbAccessToken: undefined,
|
||||
tmdbApiKey: undefined,
|
||||
tvdbApiKey: undefined,
|
||||
rpdbApiKey: undefined,
|
||||
services: clonedData?.services?.map((service) => ({
|
||||
...service,
|
||||
credentials: {},
|
||||
})),
|
||||
proxy: {
|
||||
...clonedData?.proxy,
|
||||
credentials: undefined,
|
||||
url: undefined,
|
||||
publicUrl: undefined,
|
||||
},
|
||||
presets: clonedData?.presets?.map((preset) => {
|
||||
const presetMeta = status?.settings.presets.find(
|
||||
(p) => p.ID === preset.type
|
||||
);
|
||||
return {
|
||||
...preset,
|
||||
options: Object.fromEntries(
|
||||
Object.entries(preset.options || {}).filter(([key]) => {
|
||||
const optionMeta = presetMeta?.OPTIONS?.find(
|
||||
(opt) => opt.id === key
|
||||
);
|
||||
return optionMeta?.type !== 'password';
|
||||
})
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
try {
|
||||
const filteredUserData: UserData = {
|
||||
...userData,
|
||||
ip: filterCredentialsInExport ? undefined : userData.ip,
|
||||
uuid: filterCredentialsInExport ? undefined : userData.uuid,
|
||||
addonPassword: filterCredentialsInExport
|
||||
? undefined
|
||||
: userData.addonPassword,
|
||||
tmdbAccessToken: filterCredentialsInExport
|
||||
? undefined
|
||||
: userData.tmdbAccessToken,
|
||||
tmdbApiKey: filterCredentialsInExport ? undefined : userData.tmdbApiKey,
|
||||
tvdbApiKey: filterCredentialsInExport ? undefined : userData.tvdbApiKey,
|
||||
rpdbApiKey: filterCredentialsInExport ? undefined : userData.rpdbApiKey,
|
||||
services: userData?.services?.map((service) => ({
|
||||
...service,
|
||||
credentials: filterCredentialsInExport ? {} : service.credentials,
|
||||
})),
|
||||
proxy: {
|
||||
...userData?.proxy,
|
||||
credentials: filterCredentialsInExport
|
||||
? undefined
|
||||
: userData?.proxy?.credentials,
|
||||
url: filterCredentialsInExport ? undefined : userData?.proxy?.url,
|
||||
publicUrl: filterCredentialsInExport
|
||||
? undefined
|
||||
: userData?.proxy?.publicUrl,
|
||||
},
|
||||
presets: userData?.presets?.map((preset) => {
|
||||
const presetMeta = status?.settings.presets.find(
|
||||
(p) => p.ID === preset.type
|
||||
);
|
||||
return {
|
||||
...preset,
|
||||
options: filterCredentialsInExport
|
||||
? Object.fromEntries(
|
||||
Object.entries(preset.options || {}).filter(([key]) => {
|
||||
const optionMeta = presetMeta?.OPTIONS?.find(
|
||||
(opt) => opt.id === key
|
||||
);
|
||||
return optionMeta?.type !== 'password';
|
||||
})
|
||||
)
|
||||
: preset.options,
|
||||
};
|
||||
}),
|
||||
};
|
||||
const dataStr = JSON.stringify(filteredUserData, null, 2);
|
||||
const exportData = filterCredentialsInExport
|
||||
? filterCredentials(userData)
|
||||
: structuredClone(userData);
|
||||
const dataStr = JSON.stringify(exportData, null, 2);
|
||||
const blob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'aiostreams-config.json';
|
||||
// format date as YYYY-MM-DD.HH-MM-SS
|
||||
const now = new Date();
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
const formattedDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}.${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
|
||||
a.download = `aiostreams-config-${formattedDate}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('Configuration exported successfully');
|
||||
exportMenuModal.close();
|
||||
} catch (err) {
|
||||
toast.error('Failed to export configuration');
|
||||
}
|
||||
@@ -523,9 +530,9 @@ function Content() {
|
||||
title="Backups"
|
||||
description="Export your settings or restore from a backup file"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
onClick={exportMenuModal.open}
|
||||
leftIcon={<UploadIcon />}
|
||||
intent="gray"
|
||||
>
|
||||
@@ -553,28 +560,6 @@ function Content() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4 w-full">
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="export-settings" className="w-full">
|
||||
<AccordionTrigger className="w-full">
|
||||
Export Settings
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="w-full">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<Switch
|
||||
value={filterCredentialsInExport ?? false}
|
||||
onValueChange={(value) =>
|
||||
setFilterCredentialsInExport(value)
|
||||
}
|
||||
side="right"
|
||||
help="This will not exclude any URLs you have provided, these may contain credentials and you should always double check the contents of the exported file before sharing it."
|
||||
label="Exclude Credentials"
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
@@ -649,6 +634,79 @@ function Content() {
|
||||
</Modal>
|
||||
<ConfirmationDialog {...confirmDelete} />
|
||||
<ConfirmationDialog {...confirmResetProps} />
|
||||
|
||||
<Modal
|
||||
open={exportMenuModal.isOpen}
|
||||
onOpenChange={exportMenuModal.toggle}
|
||||
title="Export Configuration"
|
||||
description="Choose how to export your configuration"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Exclude Credentials Option */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-white">
|
||||
Exclude Credentials
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
Remove sensitive information from export
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
value={filterCredentialsInExport}
|
||||
onValueChange={setFilterCredentialsInExport}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Standard Export Option */}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="group relative flex flex-col items-center gap-4 rounded-xl border-2 border-gray-700 bg-gradient-to-br from-gray-800/50 to-gray-800/30 p-6 text-center transition-all hover:border-brand-400 hover:from-brand-400/10 hover:to-brand-400/5 hover:shadow-lg hover:shadow-brand-400/20 hover:ring-1 hover:ring-brand-400 focus:outline-none focus-visible:ring-1 focus-visible:ring-brand-400"
|
||||
>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-lg transition-transform group-hover:scale-110">
|
||||
<UploadIcon className="h-8 w-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
Export Config
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-400">
|
||||
Download as JSON file for backup or sharing
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Template Export Option */}
|
||||
<button
|
||||
onClick={() => {
|
||||
exportMenuModal.close();
|
||||
templateExportModal.open();
|
||||
}}
|
||||
className="group relative flex flex-col items-center gap-4 rounded-xl border-2 border-gray-700 bg-gradient-to-br from-gray-800/50 to-gray-800/30 p-6 text-center transition-all hover:border-brand-400 hover:from-brand-400/10 hover:to-brand-400/5 hover:shadow-lg hover:shadow-brand-400/20 hover:ring-1 hover:ring-brand-400 focus:outline-none focus-visible:ring-1 focus-visible:ring-brand-400"
|
||||
>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-lg transition-transform group-hover:scale-110">
|
||||
<PlusIcon className="h-8 w-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
Export as Template
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-400">
|
||||
Create reusable template with custom metadata
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<TemplateExportModal
|
||||
open={templateExportModal.isOpen}
|
||||
onOpenChange={templateExportModal.toggle}
|
||||
userData={userData}
|
||||
filterCredentials={filterCredentials}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,18 @@
|
||||
import React from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface MarkdownLiteProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
stopPropagation?: boolean;
|
||||
}
|
||||
|
||||
// Supports [text](url) and `code` only
|
||||
const MarkdownLite: React.FC<MarkdownLiteProps> = ({ children, className }) => {
|
||||
const MarkdownLite: React.FC<MarkdownLiteProps> = ({
|
||||
children,
|
||||
className,
|
||||
stopPropagation = false,
|
||||
}) => {
|
||||
if (!children) return null;
|
||||
// Regex for [text](url) and `code`
|
||||
const regex = /(`[^`]+`|\[[^\]]+\]\([^\)]+\))/g;
|
||||
@@ -29,6 +35,19 @@ const MarkdownLite: React.FC<MarkdownLiteProps> = ({ children, className }) => {
|
||||
return (
|
||||
<code
|
||||
key={i}
|
||||
onClick={async (e) => {
|
||||
if (stopPropagation) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
// copy to clipboard
|
||||
try {
|
||||
await navigator.clipboard.writeText(match.slice(1, -1));
|
||||
toast.success('Copied to clipboard');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
toast.error('Failed to copy to clipboard');
|
||||
}
|
||||
}}
|
||||
className="bg-muted px-1 py-0.5 rounded text-[--brand] font-mono text-xs break-all"
|
||||
>
|
||||
{match.slice(1, -1)}
|
||||
@@ -46,6 +65,7 @@ const MarkdownLite: React.FC<MarkdownLiteProps> = ({ children, className }) => {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[--brand] hover:underline"
|
||||
onClick={(e) => stopPropagation && e.stopPropagation()}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal } from '../ui/modal';
|
||||
import { Button } from '../ui/button';
|
||||
import { Alert } from '../ui/alert';
|
||||
import { toast } from 'sonner';
|
||||
import { Template, UserData } from '@aiostreams/core';
|
||||
import { useStatus } from '@/context/status';
|
||||
import { TextInput } from '../ui/text-input';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
|
||||
export interface TemplateExportModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
userData: UserData;
|
||||
filterCredentials: (data: UserData) => UserData;
|
||||
}
|
||||
|
||||
export function TemplateExportModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
userData,
|
||||
filterCredentials,
|
||||
}: TemplateExportModalProps) {
|
||||
const { status } = useStatus();
|
||||
const [templateName, setTemplateName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [author, setAuthor] = useState('');
|
||||
const [category, setCategory] = useState('Debrid');
|
||||
const [customCategory, setCustomCategory] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Reset fields when modal opens
|
||||
setTemplateName('');
|
||||
setDescription('');
|
||||
setAuthor('');
|
||||
setCategory('Debrid');
|
||||
setCustomCategory('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleExport = () => {
|
||||
// Validate required fields
|
||||
if (!templateName.trim()) {
|
||||
toast.error('Please enter a template name');
|
||||
return;
|
||||
}
|
||||
if (!description.trim()) {
|
||||
toast.error('Please enter a description');
|
||||
return;
|
||||
}
|
||||
if (!author.trim()) {
|
||||
toast.error('Please enter an author name');
|
||||
return;
|
||||
}
|
||||
if (category === 'Custom' && !customCategory.trim()) {
|
||||
toast.error('Please enter a custom category name');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Start with filtered userData (credentials always removed for templates)
|
||||
const templateData = filterCredentials(userData);
|
||||
|
||||
// Smart handling for services - collect unique service IDs from enabled services
|
||||
const enabledServiceIds =
|
||||
userData.services
|
||||
?.filter((service) => service.enabled)
|
||||
.map((service) => service.id) || [];
|
||||
|
||||
// Add template placeholders to top-level API keys
|
||||
if (userData.tmdbApiKey) {
|
||||
templateData.tmdbApiKey = '<template_placeholder>';
|
||||
}
|
||||
if (userData.tmdbAccessToken) {
|
||||
templateData.tmdbAccessToken = '<template_placeholder>';
|
||||
}
|
||||
if (userData.tvdbApiKey) {
|
||||
templateData.tvdbApiKey = '<template_placeholder>';
|
||||
}
|
||||
if (userData.rpdbApiKey) {
|
||||
templateData.rpdbApiKey = '<template_placeholder>';
|
||||
}
|
||||
|
||||
// // Handle services - add template placeholders to credentials
|
||||
// if (templateData.services && templateData.services.length > 0) {
|
||||
// templateData.services = templateData.services.map((service) => {
|
||||
// const newCredentials: Record<string, string> = {};
|
||||
|
||||
// // Replace all credential values with template placeholders
|
||||
// Object.keys(service.credentials || {}).forEach((key) => {
|
||||
// newCredentials[key] = '<template_placeholder>';
|
||||
// });
|
||||
|
||||
// return {
|
||||
// ...service,
|
||||
// credentials: newCredentials,
|
||||
// };
|
||||
// });
|
||||
// }
|
||||
|
||||
// Handle proxy - if proxy was enabled, keep id and add template placeholders
|
||||
if (userData.proxy?.enabled) {
|
||||
templateData.proxy = {
|
||||
...templateData.proxy,
|
||||
url: userData.proxy.url ? '<template_placeholder>' : undefined,
|
||||
publicUrl: userData.proxy.publicUrl
|
||||
? '<template_placeholder>'
|
||||
: undefined,
|
||||
credentials: userData.proxy.credentials
|
||||
? '<template_placeholder>'
|
||||
: undefined,
|
||||
publicIp: userData.proxy.publicIp
|
||||
? '<template_placeholder>'
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle preset password options
|
||||
if (templateData.presets && templateData.presets.length > 0) {
|
||||
templateData.presets = templateData.presets.map((preset) => {
|
||||
const presetMeta = status?.settings.presets.find(
|
||||
(p) => p.ID === preset.type
|
||||
);
|
||||
const newOptions = { ...(preset.options || {}) };
|
||||
const presetInUserData = userData.presets?.find(
|
||||
(p) => p.instanceId == preset.instanceId
|
||||
);
|
||||
|
||||
// Replace password type options with template placeholders
|
||||
presetMeta?.OPTIONS?.filter((opt) => opt.type === 'password').forEach(
|
||||
(passwordOption) => {
|
||||
if (presetInUserData?.options?.[passwordOption.id]) {
|
||||
newOptions[passwordOption.id] = '<template_placeholder>';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
...preset,
|
||||
options: newOptions,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const finalCategory =
|
||||
category === 'Custom' ? customCategory.trim() : category;
|
||||
|
||||
// Create template with new structure
|
||||
const template: Template = {
|
||||
metadata: {
|
||||
name: templateName,
|
||||
description: description,
|
||||
author: author,
|
||||
category: finalCategory,
|
||||
services: undefined,
|
||||
serviceRequired: false,
|
||||
},
|
||||
config: templateData,
|
||||
};
|
||||
|
||||
const dataStr = JSON.stringify(template, null, 2);
|
||||
const blob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${templateName.toLowerCase().replace(/\s+/g, '-')}-template.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('Template exported successfully');
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error('Failed to export template');
|
||||
}
|
||||
};
|
||||
|
||||
const categories = ['Debrid', 'P2P', 'Custom'] as const;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Export as Template"
|
||||
description="Configure your template metadata and settings"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Alert
|
||||
intent="info"
|
||||
description={
|
||||
<div>
|
||||
A template is a configuration file that others can use as a
|
||||
starting point. All personal credentials will be replaced with
|
||||
placeholders.
|
||||
<br />
|
||||
<br />
|
||||
For more customisability, edit the JSON file after exporting
|
||||
manually. See the{' '}
|
||||
<a
|
||||
href="https://github.com/Viren070/AIOStreams/wiki/Templates"
|
||||
target="_blank"
|
||||
className="text-[--brand] hover:text-[--brand]/80 hover:underline"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Templates wiki
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<TextInput
|
||||
label="Template Name"
|
||||
placeholder="e.g. My AIOStreams setup"
|
||||
value={templateName}
|
||||
onValueChange={setTemplateName}
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Description"
|
||||
placeholder="Describe what makes this template useful..."
|
||||
value={description}
|
||||
onValueChange={setDescription}
|
||||
required
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Author"
|
||||
placeholder="Your name or username"
|
||||
value={author}
|
||||
onValueChange={setAuthor}
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Category
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{categories.map((cat) => (
|
||||
<Button
|
||||
key={cat}
|
||||
intent={category === cat ? 'primary' : 'gray-outline'}
|
||||
size="sm"
|
||||
onClick={() => setCategory(cat)}
|
||||
type="button"
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{category === 'Custom' && (
|
||||
<TextInput
|
||||
label="Custom Category"
|
||||
placeholder="Enter category name (max 20 characters)"
|
||||
value={customCategory}
|
||||
onValueChange={(value) => {
|
||||
if (value.length <= 20) {
|
||||
setCustomCategory(value);
|
||||
}
|
||||
}}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-gray-700">
|
||||
<Button intent="primary-outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button intent="white" rounded onClick={handleExport}>
|
||||
Export Template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
searchApi,
|
||||
animeApi,
|
||||
proxyApi,
|
||||
templatesApi,
|
||||
} from './routes/api/index.js';
|
||||
import {
|
||||
configure,
|
||||
@@ -96,6 +97,7 @@ if (Env.ENABLE_SEARCH_API) {
|
||||
}
|
||||
apiRouter.use('/anime', animeApi);
|
||||
apiRouter.use('/proxy', proxyApi);
|
||||
apiRouter.use('/templates', templatesApi);
|
||||
app.use(`/api/v${constants.API_VERSION}`, apiRouter);
|
||||
|
||||
// Stremio Routes
|
||||
|
||||
@@ -9,3 +9,4 @@ export { default as debridApi } from './debrid.js';
|
||||
export { default as searchApi } from './search.js';
|
||||
export { default as animeApi } from './anime.js';
|
||||
export { default as proxyApi } from './proxy.js';
|
||||
export { default as templatesApi } from './templates.js';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { createResponse } from '../../utils/responses.js';
|
||||
import {
|
||||
APIError,
|
||||
constants,
|
||||
createLogger,
|
||||
TemplateManager,
|
||||
} from '@aiostreams/core';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const router: Router = Router();
|
||||
const logger = createLogger('server');
|
||||
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const templates = TemplateManager.getTemplates();
|
||||
res.json(createResponse({ success: true, data: templates }));
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to load templates: ${error.message}`);
|
||||
next(
|
||||
new APIError(
|
||||
constants.ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
undefined,
|
||||
error.message
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,4 +1,6 @@
|
||||
import app from './app.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
Env,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
PTT,
|
||||
AnimeDatabase,
|
||||
ProwlarrAddon,
|
||||
TemplateManager,
|
||||
} from '@aiostreams/core';
|
||||
|
||||
const logger = createLogger('server');
|
||||
@@ -66,9 +69,26 @@ async function initialiseProwlarr() {
|
||||
}
|
||||
}
|
||||
|
||||
async function initialiseTemplates() {
|
||||
try {
|
||||
const templates = TemplateManager.loadTemplates();
|
||||
logger.info(
|
||||
`Loaded ${templates.loaded} templates from ${templates.detected} detected templates. ${templates.errors.length} errors occurred.`
|
||||
);
|
||||
if (templates.errors.length > 0) {
|
||||
logger.error(
|
||||
`Errors loading templates: \n${templates.errors.map((error) => `- ${error.file}: ${error.error}`).join('\n')}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialise templates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
logStartupInfo();
|
||||
await initialiseTemplates();
|
||||
await initialiseDatabase();
|
||||
await initialiseRedis();
|
||||
await initialisePTT();
|
||||
|
||||
Reference in New Issue
Block a user