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:
MidnightKittenCat
2025-10-19 07:11:11 +10:00
committed by GitHub
parent 5ddd111ec0
commit 481338afa3
15 changed files with 2040 additions and 126 deletions
+15
View File
@@ -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>;
+30
View File
@@ -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 -2
View File
@@ -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);
}
+1 -1
View File
@@ -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';
-23
View File
@@ -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'));
}
}
+94
View File
@@ -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,
};
}
}
+121 -25
View File
@@ -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>
);
}
+2
View File
@@ -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
+1
View File
@@ -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;
+20
View File
@@ -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();