refactor(metadata): rewrite metadata settings

This commit is contained in:
TOomaAh
2025-03-19 19:43:29 +01:00
parent 4104f3dadd
commit 4b0652d7ba
8 changed files with 456 additions and 220 deletions

View File

@@ -533,18 +533,6 @@ components:
type: string
enum: [tvdb, tmdb]
example: 'tvdb'
providers:
type: object
properties:
tvdb:
type: object
properties:
apiKey:
type: string
example: '123456789'
pin:
type: string
example: '1234'
TautulliSettings:
type: object
properties:
@@ -2631,6 +2619,19 @@ paths:
description: Tests if the TVDB configuration is valid. Returns a list of available languages on success.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
tmdb:
type: boolean
example: true
tvdb:
type: boolean
example: true
responses:
'200':
description: Succesfully connected to TVDB

View File

@@ -10,17 +10,17 @@ export const getMetadataProvider = async (
try {
const settings = await getSettings();
if (!settings.tvdb.apiKey || mediaType == 'movie') {
if (mediaType == 'movie') {
return new TheMovieDb();
}
if (mediaType == 'tv' && settings.metadataType.tv == IndexerType.TVDB) {
if (mediaType == 'tv' && settings.metadataSettings.tv == IndexerType.TVDB) {
return await Tvdb.getInstance();
}
if (
mediaType == 'anime' &&
settings.metadataType.anime == IndexerType.TVDB
settings.metadataSettings.anime == IndexerType.TVDB
) {
return await Tvdb.getInstance();
}

View File

@@ -14,7 +14,6 @@ import type {
TvdbTvDetails,
} from '@server/api/tvdb/interfaces';
import cacheManager, { type AvailableCacheIds } from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
interface TvdbConfig {
@@ -45,12 +44,12 @@ class Tvdb extends ExternalAPI implements TvShowIndexer {
private apiKey?: string;
private pin?: string;
constructor(apiKey: string, pin?: string) {
constructor(pin?: string) {
const finalConfig = { ...DEFAULT_CONFIG };
super(
finalConfig.baseUrl,
{
apiKey: apiKey,
apiKey: '',
},
{
nodeCache: cacheManager.getCache(finalConfig.cachePrefix).data,
@@ -60,28 +59,19 @@ class Tvdb extends ExternalAPI implements TvShowIndexer {
},
}
);
this.apiKey = apiKey;
this.pin = pin;
this.tmdb = new TheMovieDb();
}
public static async getInstance(): Promise<Tvdb> {
if (!this.instance) {
const settings = await getSettings();
if (!settings.tvdb.apiKey) {
throw new Error('TVDB API key is not set');
}
try {
this.instance = new Tvdb(settings.tvdb.apiKey, settings.tvdb.pin);
this.instance = new Tvdb();
await this.instance.login();
} catch (error) {
logger.error(`Failed to login to TVDB: ${error.message}`);
throw new Error('TVDB API key is not set');
}
this.instance = new Tvdb(settings.tvdb.apiKey, settings.tvdb.pin);
}
return this.instance;

View File

@@ -101,24 +101,11 @@ interface Quota {
}
export enum IndexerType {
TMDB,
TVDB,
TMDB = 'tmdb',
TVDB = 'tvdb',
}
export interface MetadataSettings {
settings: MetadataTypeSettings;
providers: ProviderSettings;
}
export interface TvdbSettings {
apiKey?: string;
pin?: string;
}
export interface ProviderSettings {
tvdb: TvdbSettings;
}
export interface MetadataTypeSettings {
tv: IndexerType;
anime: IndexerType;
}
@@ -373,7 +360,6 @@ export interface AllSettings {
notifications: NotificationSettings;
jobs: Record<JobId, JobSettings>;
network: NetworkSettings;
tvdb: TvdbSettings;
metadataSettings: MetadataSettings;
}
@@ -436,20 +422,8 @@ class Settings {
},
tautulli: {},
metadataSettings: {
settings: {
tv: IndexerType.TMDB,
anime: IndexerType.TVDB,
},
providers: {
tvdb: {
apiKey: '',
pin: '',
},
},
},
tvdb: {
apiKey: '',
pin: '',
tv: IndexerType.TMDB,
anime: IndexerType.TVDB,
},
radarr: [],
sonarr: [],
@@ -653,22 +627,6 @@ class Settings {
this.data.metadataSettings = data;
}
get tvdb(): TvdbSettings {
return this.data.tvdb;
}
set tvdb(data: TvdbSettings) {
this.data.tvdb = data;
}
get metadataType(): MetadataTypeSettings {
return this.data.metadataSettings.settings;
}
set metadataType(data: MetadataTypeSettings) {
this.data.metadataSettings.settings = data;
}
get radarr(): RadarrSettings[] {
return this.data.radarr;
}

View File

@@ -1,5 +1,10 @@
import TheMovieDb from '@server/api/themoviedb';
import Tvdb from '@server/api/tvdb';
import { getSettings, type MetadataSettings } from '@server/lib/settings';
import {
getSettings,
IndexerType,
type MetadataSettings,
} from '@server/lib/settings';
import logger from '@server/logger';
import { Router } from 'express';
@@ -8,39 +13,137 @@ const metadataRoutes = Router();
metadataRoutes.get('/', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.metadataSettings);
res.status(200).json({
metadata: {
tv: settings.metadataSettings.tv === IndexerType.TMDB ? 'tmdb' : 'tvdb',
anime:
settings.metadataSettings.anime === IndexerType.TMDB ? 'tmdb' : 'tvdb',
},
});
});
metadataRoutes.put('/', (req, res) => {
metadataRoutes.put('/', async (req, res) => {
const settings = getSettings();
const body = req.body as MetadataSettings;
settings.metadataSettings = {
providers: body.providers,
settings: body.settings,
};
settings.save();
// test indexers
let tvdbTest = -1;
let tmdbTest = -1;
return res.status(200).json({
tvdb: settings.tvdb,
try {
if (body.tv === IndexerType.TVDB || body.anime === IndexerType.TVDB) {
tvdbTest = 0;
const tvdb = await Tvdb.getInstance();
await tvdb.test();
tvdbTest = 1;
}
} catch (e) {
logger.error('Failed to test indexers', {
label: 'Metadata',
message: e.message,
});
}
try {
if (body.tv === IndexerType.TMDB || body.anime === IndexerType.TMDB) {
tmdbTest = 0;
const tmdb = new TheMovieDb();
await tmdb.getTvShow({ tvId: 1054 });
tmdbTest = 1;
}
} catch (e) {
logger.error('Failed to test indexers', {
label: 'Metadata',
message: e.message,
});
}
logger.info('Updated metadata settings', {
label: 'Metadata',
body: body,
tmdb: tmdbTest,
tvdb: tvdbTest,
tv: body.tv,
anime: body.anime,
});
if (tvdbTest === 0 || tmdbTest === 0) {
return res.status(500).json({
tvdb: tvdbTest === 1 ? 'ok' : 'failed',
tmdb: tmdbTest === 1 ? 'ok' : 'failed',
});
}
settings.metadataSettings = {
tv: body.tv,
anime: body.anime,
};
await settings.save();
res.status(200).json({
tv: settings.metadataSettings.tv === IndexerType.TMDB ? 'tmdb' : 'tvdb',
anime:
settings.metadataSettings.anime === IndexerType.TMDB ? 'tmdb' : 'tvdb',
});
});
metadataRoutes.post('/test', async (req, res, next) => {
try {
const tvdb = await Tvdb.getInstance();
await tvdb.test();
metadataRoutes.post('/test', async (req, res) => {
let tvdbTest = -1;
let tmdbTest = -1;
// TODO: add tmdb test
return res.status(200).json({ tvdb: true });
try {
const body = req.body as { tmdb: boolean; tvdb: boolean };
try {
if (body.tmdb) {
tmdbTest = 0;
const tmdb = new TheMovieDb();
await tmdb.getTvShow({ tvId: 1054 });
tmdbTest = 1;
}
} catch (e) {
logger.error('Failed to test indexers', {
label: 'Metadata',
message: e.message,
});
}
try {
if (body.tvdb) {
tvdbTest = 0;
const tvdb = await Tvdb.getInstance();
await tvdb.test();
tvdbTest = 1;
}
} catch (e) {
logger.error('Failed to test indexers', {
label: 'Metadata',
message: e.message,
});
}
const response = {
tmdb: tmdbTest === -1 ? 'not tested' : tmdbTest === 0 ? 'failed' : 'ok',
tvdb: tvdbTest === -1 ? 'not tested' : tvdbTest === 0 ? 'failed' : 'ok',
};
return res.status(200).json(response);
} catch (e) {
logger.error('Failed to test Tvdb', {
label: 'Tvdb',
logger.error('Failed to test indexers', {
label: 'Metadata',
message: e.message,
});
return next({ status: 500, message: 'Failed to connect to Tvdb' });
// if tmdbTest != -1 (tested) and tmdbTest === 1 (ok) then fail
// if tvdbTest != -1 (tested) and tvdbTest === 1 (ok) then fail
// if test === -1 = 'not tested' if test === 0 = 'failed' if test === 1 = 'ok'
const response = {
tmdb: tmdbTest === -1 ? 'not tested' : tmdbTest === 0 ? 'failed' : 'ok',
tvdb: tvdbTest === -1 ? 'not tested' : tvdbTest === 0 ? 'failed' : 'ok',
};
return res.status(500).json(response);
}
});

View File

@@ -0,0 +1,94 @@
import TmdbLogo from '@app/assets/services/tmdb.svg';
import TvdbLogo from '@app/assets/services/tvdb.svg';
import defineMessages from '@app/utils/defineMessages';
import React from 'react';
import { useIntl } from 'react-intl';
import Select, { type StylesConfig } from 'react-select';
enum IndexerType {
TMDB = 'tmdb',
TVDB = 'tvdb',
}
type IndexerOptionType = {
value: IndexerType;
label: string;
icon: React.ReactNode;
};
const messages = defineMessages('components.MetadataSelector', {
tmdbLabel: 'TMDB',
tvdbLabel: 'TVDB',
selectIndexer: 'Select an indexer',
});
interface MetadataSelectorProps {
value: IndexerType;
onChange: (value: IndexerType) => void;
isDisabled?: boolean;
}
const MetadataSelector = ({
value,
onChange,
isDisabled = false,
}: MetadataSelectorProps) => {
const intl = useIntl();
// Options pour les sélecteurs d'indexeurs avec leurs logos
const indexerOptions: IndexerOptionType[] = [
{
value: IndexerType.TMDB,
label: intl.formatMessage(messages.tmdbLabel),
icon: <TmdbLogo />,
},
{
value: IndexerType.TVDB,
label: intl.formatMessage(messages.tvdbLabel),
icon: <TvdbLogo />,
},
];
// Style personnalisé pour inclure les icônes
const customStyles: StylesConfig<IndexerOptionType, false> = {
option: (base) => ({
...base,
display: 'flex',
alignItems: 'center',
}),
singleValue: (base) => ({
...base,
display: 'flex',
alignItems: 'center',
}),
};
// Format personnalisé pour les options avec logo
const formatOptionLabel = (option: IndexerOptionType) => (
<div className="flex items-center">
{option.icon}
<span>{option.label}</span>
</div>
);
return (
<Select
options={indexerOptions}
isDisabled={isDisabled}
className="react-select-container"
classNamePrefix="react-select"
value={indexerOptions.find((option) => option.value === value)}
onChange={(selectedOption) => {
if (selectedOption) {
onChange(selectedOption.value);
}
}}
placeholder={intl.formatMessage(messages.selectIndexer)}
styles={customStyles}
formatOptionLabel={formatOptionLabel}
/>
);
};
export { IndexerType };
export default MetadataSelector;

View File

@@ -1,10 +1,14 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import MetadataSelector, {
IndexerType,
} from '@app/components/MetadataSelector';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import { Field, Form, Formik } from 'formik';
import { Form, Formik } from 'formik';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
@@ -13,91 +17,171 @@ import useSWR from 'swr';
const messages = defineMessages('components.Settings', {
general: 'General',
settings: 'Settings',
apiKey: 'Api Key',
pin: 'Pin',
enableTip:
'Enable Tvdb (only for season and episode).' +
' Due to a limitation of the api used, only English is available.',
seriesIndexer: 'Series Indexer',
animeIndexer: 'Anime Indexer',
metadataSettings: 'Settings for metadata provider',
clickTest: 'Click on the "Test" button to check connectivity with providers',
notTested: 'Not Tested',
failed: 'Error',
ok: 'OK',
providerStatus: 'Provider Status',
chooseProvider: 'Choose metadata providers for different content types',
indexerSelection: 'Provider Selection',
});
interface providerResponse {
tvdb: boolean;
tmdb: boolean;
// Types
type ProviderStatus = 'ok' | 'not tested' | 'failed';
interface ProviderResponse {
tvdb: ProviderStatus;
tmdb: ProviderStatus;
}
enum indexerType {
TMDB,
TVDB,
interface MetadataValues {
tv: IndexerType;
anime: IndexerType;
}
interface metadataSettings {
settings: metadataTypeSettings;
providers: providerSettings;
}
interface metadataTypeSettings {
tv: indexerType;
anime: indexerType;
}
interface providerSettings {
tvdb: tvdbSettings;
}
interface tvdbSettings {
apiKey: string;
pin: string;
interface MetadataSettings {
metadata: MetadataValues;
}
const SettingsMetadata = () => {
const intl = useIntl();
const [isTesting, setIsTesting] = useState(false);
const { addToast } = useToasts();
const [isTesting, setIsTesting] = useState(false);
// Valeurs par défaut pour les statuts
const defaultStatus: ProviderResponse = {
tmdb: 'not tested',
tvdb: 'not tested',
};
const [providerStatus, setProviderStatus] =
useState<ProviderResponse>(defaultStatus);
// SWR hook pour récupérer les données
const { data, error } = useSWR<MetadataSettings>(
'/api/v1/settings/metadatas'
);
// Tester la connexion avec les fournisseurs
const testConnection = async (
values: MetadataValues
): Promise<ProviderResponse> => {
// Déterminer quels indexeurs sont utilisés
const useTmdb =
values.tv === IndexerType.TMDB || values.anime === IndexerType.TMDB;
const useTvdb =
values.tv === IndexerType.TVDB || values.anime === IndexerType.TVDB;
// Préparer les données pour le test
const testData = {
tmdb: useTmdb,
tvdb: useTvdb,
};
const testConnection = async () => {
const response = await fetch('/api/v1/settings/metadatas/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(testData),
});
const body = (await response.json()) as providerResponse;
if (!response.ok) {
throw new Error('Failed to test Tvdb connection');
throw new Error('Failed to test connection');
}
console.log(body);
const body = (await response.json()) as ProviderResponse;
// Créer un nouvel objet de statut en conservant 'not tested'
// pour les services que nous n'avons pas testés
const newStatus: ProviderResponse = {
tmdb: useTmdb ? body.tmdb : 'not tested',
tvdb: useTvdb ? body.tvdb : 'not tested',
};
setProviderStatus(newStatus);
return newStatus;
};
// Sauvegarder les paramètres
const saveSettings = async (
value: metadataSettings
): Promise<metadataSettings> => {
values: MetadataValues
): Promise<MetadataSettings> => {
const response = await fetch('/api/v1/settings/metadatas', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(value),
body: JSON.stringify({
anime: values.anime,
tv: values.tv,
}),
});
if (!response.ok) {
throw new Error('Failed to save Metadata settings');
}
return (await response.json()) as metadataSettings;
return (await response.json()) as MetadataSettings;
};
const { data, error } = useSWR<metadataSettings>(
'/api/v1/settings/metadatas'
);
// Obtenir la classe CSS pour l'affichage du statut
const getStatusClass = (status: ProviderStatus): string => {
switch (status) {
case 'ok':
return 'text-green-500';
case 'not tested':
return 'text-yellow-500';
case 'failed':
return 'text-red-500';
}
};
// Obtenir le message à afficher pour le statut
const getStatusMessage = (status: ProviderStatus): string => {
switch (status) {
case 'ok':
return intl.formatMessage(messages.ok);
case 'not tested':
return intl.formatMessage(messages.notTested);
case 'failed':
return intl.formatMessage(messages.failed);
}
};
const getBadgeType = (
status: ProviderStatus
):
| 'default'
| 'primary'
| 'danger'
| 'warning'
| 'success'
| 'dark'
| 'light'
| undefined => {
switch (status) {
case 'ok':
return 'success';
case 'not tested':
return 'warning';
case 'failed':
return 'danger';
}
};
// Afficher un spinner pendant le chargement
if (!data && !error) {
return <LoadingSpinner />;
}
const initialValues: MetadataValues = data?.metadata || {
tv: IndexerType.TMDB,
anime: IndexerType.TMDB,
};
return (
<>
<PageTitle
@@ -106,107 +190,104 @@ const SettingsMetadata = () => {
intl.formatMessage(globalMessages.settings),
]}
/>
<div className="mb-6">
<h3 className="heading">{'Metadata'}</h3>
<p className="description">{'Settings for metadata indexer'}</p>
<h3 className="heading">Metadata</h3>
<p className="description">
{intl.formatMessage(messages.metadataSettings)}
</p>
</div>
<div className="mb-6 rounded-lg bg-gray-800 p-4">
<h4 className="mb-3 text-lg font-medium">
{intl.formatMessage(messages.providerStatus)}
</h4>
<div className="flex flex-col space-y-3">
<div className="flex items-center">
<span className="mr-2 w-12">TMDB:</span>
<span className={`text-sm ${getStatusClass(providerStatus.tmdb)}`}>
<Badge badgeType={getBadgeType(providerStatus.tmdb)}>
{getStatusMessage(providerStatus.tmdb)}
</Badge>
</span>
</div>
<div className="flex items-center">
<span className="mr-2 w-12">TVDB:</span>
<span className={`text-sm ${getStatusClass(providerStatus.tvdb)}`}>
<Badge badgeType={getBadgeType(providerStatus.tvdb)}>
{getStatusMessage(providerStatus.tvdb)}
</Badge>
</span>
</div>
</div>
</div>
<div className="section">
<Formik
initialValues={{
settings: data?.settings ?? {
tv: indexerType.TMDB,
anime: indexerType.TMDB,
},
providers: data?.providers ?? {
tvdb: {
apiKey: '',
pin: '',
},
},
}}
initialValues={{ metadata: initialValues }}
onSubmit={async (values) => {
try {
await saveSettings(
data ?? {
providers: {
tvdb: {
apiKey: '',
pin: '',
},
},
settings: {
tv: indexerType.TMDB,
anime: indexerType.TMDB,
},
}
);
await saveSettings(values.metadata);
if (data) {
data.providers = values.providers;
data.settings = values.settings;
data.metadata = values.metadata;
}
addToast('Metadata settings saved', { appearance: 'success' });
} catch (e) {
addToast('Failed to save Tvdb settings', { appearance: 'error' });
return;
addToast('Failed to save metadata settings', {
appearance: 'error',
});
}
addToast('Tvdb settings saved', { appearance: 'success' });
}}
>
{({ isSubmitting, isValid, values }) => {
{({ isSubmitting, isValid, values, setFieldValue }) => {
return (
<Form className="section" data-testid="settings-main-form">
<div className="mb-6">
<h2 className="heading">{'TVDB'}</h2>
<p className="description">{'Settings for TVDB indexer'}</p>
</div>
<div className="form-row">
<label htmlFor="trustProxy" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.apiKey)}
</span>
<span className="label-tip">
{intl.formatMessage(messages.enableTip)}
</span>
</label>
<div className="form-input-area">
<Field
data-testid="tvdb-apiKey"
type="text"
id="apiKey"
name="apiKey"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
values.providers.tvdb.apiKey = e.target.value;
}}
/>
</div>
<div className="error"></div>
<h2 className="heading">
{intl.formatMessage(messages.indexerSelection)}
</h2>
<p className="description">
{intl.formatMessage(messages.chooseProvider)}
</p>
</div>
<div className="form-row">
<label htmlFor="trustProxy" className="checkbox-label">
<label htmlFor="tvIndexer" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.pin)}
</span>
<span className="label-tip">
{intl.formatMessage(messages.enableTip)}
{intl.formatMessage(messages.seriesIndexer)}
</span>
</label>
<div className="form-input-area">
<Field
data-testid="tvdb-pin"
type="text"
id="pin"
name="pin"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
values.providers.tvdb.pin = e.target.value;
}}
<MetadataSelector
value={values.metadata.tv}
onChange={(value) => setFieldValue('metadata.tv', value)}
isDisabled={isSubmitting}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="animeIndexer" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.animeIndexer)}
</span>
</label>
<div className="form-input-area">
<MetadataSelector
value={values.metadata.anime}
onChange={(value) =>
setFieldValue('metadata.anime', value)
}
isDisabled={isSubmitting}
/>
</div>
<div className="error"></div>
</div>
<div className="actions">
<div className="flex justify-end">
{/* Bouton de test */}
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
@@ -215,17 +296,25 @@ const SettingsMetadata = () => {
onClick={async () => {
setIsTesting(true);
try {
await testConnection();
addToast('Tvdb connection successful', {
appearance: 'success',
});
const resp = await testConnection(values.metadata);
if (
resp.tvdb === 'failed' ||
resp.tmdb === 'failed'
) {
addToast('Test failed', { appearance: 'error' });
} else {
addToast('Connection test successful', {
appearance: 'success',
});
}
} catch (e) {
addToast(
'Tvdb connection error, check your credentials',
{ appearance: 'error' }
);
addToast('Connection test failed', {
appearance: 'error',
});
} finally {
setIsTesting(false);
}
setIsTesting(false);
}}
>
<BeakerIcon />
@@ -236,12 +325,13 @@ const SettingsMetadata = () => {
</span>
</Button>
</span>
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
data-testid="tvbd-save-button"
data-testid="metadata-save-button"
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
disabled={isSubmitting || !isValid || isTesting}
>
<ArrowDownOnSquareIcon />
<span>