feat: add tvdb indexer (#899)

* feat(tvdb): get tv seasons/episodes with tvdb

* fix: fix rate limiter index tvdb indexer

* fix(usersettings): remove unused column tvdbtoken

* refactor(tvdb): replace tvdb api by skyhook

* fix: error during get episodes

* fix: error if tmdb poster is null

* refactor: clean tvdb indexer code

* fix: wrong language with tmdb indexer

* style: replace avalaible to available

* style: tvdb.login to tvdb.test

* fix(test): fix  discover test

* fix(test): wrong url tv-details

* test(tvdb): add tvdb tests

* style(tvdb): rename pokemon to correct tv show

* refactor(indexer): remove unused getSeasonIdentifier method

* refactor(settings): replace tvdb object to boolean type

* refactor(tmdb): reduce still path condition

* test(tvdb): change 'use' to 'tvdb' condition check

* fix(tmdb): fix build

fix build after rebase

* fix(build): revert package.json

* fix(tvdb): ensure that seasons contain data

* refactor(swagger): fix /tvdb/test response

* fix(scanner): add tvdb indexer for scanner

* refactor(tvdb): remove skyhook api

* refactor(tvdb): use tvdb api

* fix(tvdb): rename tvdb to medatada

* refactor(medata): add tvdb settings

* refactor(metadata): rewrite metadata settings

* refactor(metadata): refactor metadata routes

* refactor(metadata): remove french comments

* refactor(metadata): refactor tvdb api calls

* style(prettier): run prettier

* fix(scanner): fix jellyfin scanner with tvdb provider

* fix(scanner): fix plex scanner tvdb provider

* style(provider): change provider name in info section

* style(provider): full provider name in select

* style(provider): remove french comment

* fix(tests): fix all cypress tests

* refactor(tvdb): fix apikey

* refactor(tmdb): apply prettier

* refactor(tvdb): remove logger info

* feat(metadata): replace fetch with axios for API calls

* feat(provider): replace indexer by provider

* fix(tests): fix cypress test

* chore: add project-wide apikey for tvdb

* chore: add correct application-wide key

* fix(test): fix test with default provider tmdb anime

* style(cypress): fix anime name variable

* chore(i18n): remove french translation + apply i18n:extract

* style(wording): standardize naming to "Metadata Provider" in UI text

* docs(comments): translate from French to English

* refactor(tvdb): remove unnecessary try/catch block

* feat(i18n): add missing translations

* fix(scanner): correct metadata provider ID from Tmdb to Tvdb

* style(settings): clarify navigation label from "Metadata" to "Metadata Providers"

* style(logs): update error log label from "Metadata" to "MetadataProvider"

* refactor(tvdb): replace indexer by metadata providers

* refactor(settings): remove metadata providers logo

* fix(config): restore missing config/db/.gitkeep file

---------

Co-authored-by: TOomaAh <ubuntu@PC>
Co-authored-by: fallenbagel <98979876+Fallenbagel@users.noreply.github.com>
This commit is contained in:
THOMAS B
2025-09-02 22:40:47 +02:00
committed by GitHub
parent 368ecf8771
commit 22b2824441
24 changed files with 1814 additions and 30 deletions

View File

@@ -0,0 +1,91 @@
import defineMessages from '@app/utils/defineMessages';
import { useIntl } from 'react-intl';
import Select, { type StylesConfig } from 'react-select';
enum MetadataProviderType {
TMDB = 'tmdb',
TVDB = 'tvdb',
}
type MetadataProviderOptionType = {
testId?: string;
value: MetadataProviderType;
label: string;
};
const messages = defineMessages('components.MetadataSelector', {
tmdbLabel: 'The Movie Database (TMDB)',
tvdbLabel: 'TheTVDB',
selectMetdataProvider: 'Select a metadata provider',
});
interface MetadataSelectorProps {
testId: string;
value: MetadataProviderType;
onChange: (value: MetadataProviderType) => void;
isDisabled?: boolean;
}
const MetadataSelector = ({
testId = 'metadata-provider-selector',
value,
onChange,
isDisabled = false,
}: MetadataSelectorProps) => {
const intl = useIntl();
const metadataProviderOptions: MetadataProviderOptionType[] = [
{
testId: 'tmdb-option',
value: MetadataProviderType.TMDB,
label: intl.formatMessage(messages.tmdbLabel),
},
{
testId: 'tvdb-option',
value: MetadataProviderType.TVDB,
label: intl.formatMessage(messages.tvdbLabel),
},
];
const customStyles: StylesConfig<MetadataProviderOptionType, false> = {
option: (base) => ({
...base,
display: 'flex',
alignItems: 'center',
}),
singleValue: (base) => ({
...base,
display: 'flex',
alignItems: 'center',
}),
};
const formatOptionLabel = (option: MetadataProviderOptionType) => (
<div className="flex items-center">
<span data-testid={option.testId}>{option.label}</span>
</div>
);
return (
<div data-testid={testId}>
<Select
options={metadataProviderOptions}
isDisabled={isDisabled}
className="react-select-container"
classNamePrefix="react-select"
value={metadataProviderOptions.find((option) => option.value === value)}
onChange={(selectedOption) => {
if (selectedOption) {
onChange(selectedOption.value);
}
}}
placeholder={intl.formatMessage(messages.selectMetdataProvider)}
styles={customStyles}
formatOptionLabel={formatOptionLabel}
/>
</div>
);
};
export { MetadataProviderType };
export default MetadataSelector;

View File

@@ -18,6 +18,7 @@ const messages = defineMessages('components.Settings', {
menuLogs: 'Logs',
menuJobs: 'Jobs & Cache',
menuAbout: 'About',
menuMetadataProviders: 'Metadata Providers',
});
type SettingsLayoutProps = {
@@ -59,6 +60,11 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => {
route: '/settings/network',
regex: /^\/settings\/network/,
},
{
text: intl.formatMessage(messages.menuMetadataProviders),
route: '/settings/metadata',
regex: /^\/settings\/metadata/,
},
{
text: intl.formatMessage(messages.menuNotifications),
route: '/settings/notifications/email',

View File

@@ -0,0 +1,476 @@
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, {
MetadataProviderType,
} 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 axios from 'axios';
import { Form, Formik } from 'formik';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('components.Settings', {
metadataProviderSettings: 'Metadata Providers',
general: 'General',
settings: 'Settings',
seriesMetadataProvider: 'Series metadata provider',
animeMetadataProvider: 'Anime metadata provider',
metadataSettings: 'Settings for metadata provider',
clickTest:
'Click on the "Test" button to check connectivity with metadata providers',
notTested: 'Not Tested',
failed: 'Does not work',
operational: 'Operational',
providerStatus: 'Metadata Provider Status',
chooseProvider: 'Choose metadata providers for different content types',
metadataProviderSelection: 'Metadata Provider Selection',
tmdbProviderDoesnotWork:
'TMDB provider does not work, please select another metadata provider',
tvdbProviderDoesnotWork:
'TVDB provider does not work, please select another metadata provider',
allChosenProvidersAreOperational:
'All chosen metadata providers are operational',
connectionTestFailed: 'Connection test failed',
failedToSaveMetadataSettings: 'Failed to save metadata provider settings',
metadataSettingsSaved: 'Metadata provider settings saved',
});
type ProviderStatus = 'ok' | 'not tested' | 'failed';
interface ProviderResponse {
tvdb: ProviderStatus;
tmdb: ProviderStatus;
}
interface MetadataValues {
tv: MetadataProviderType;
anime: MetadataProviderType;
}
interface MetadataSettings {
metadata: MetadataValues;
}
const SettingsMetadata = () => {
const intl = useIntl();
const { addToast } = useToasts();
const [isTesting, setIsTesting] = useState(false);
const defaultStatus: ProviderResponse = {
tmdb: 'not tested',
tvdb: 'not tested',
};
const [providerStatus, setProviderStatus] =
useState<ProviderResponse>(defaultStatus);
const { data, error } = useSWR<MetadataSettings>(
'/api/v1/settings/metadatas',
async (url: string) => {
const response = await axios.get<{
tv: MetadataProviderType;
anime: MetadataProviderType;
}>(url);
return {
metadata: {
tv: response.data.tv,
anime: response.data.anime,
},
};
}
);
const testConnection = async (
values: MetadataValues
): Promise<ProviderResponse> => {
const useTmdb =
values.tv === MetadataProviderType.TMDB ||
values.anime === MetadataProviderType.TMDB;
const useTvdb =
values.tv === MetadataProviderType.TVDB ||
values.anime === MetadataProviderType.TVDB;
const testData = {
tmdb: useTmdb,
tvdb: useTvdb,
};
try {
const response = await axios.post<{
success: boolean;
tests: ProviderResponse;
}>('/api/v1/settings/metadatas/test', testData);
const newStatus: ProviderResponse = {
tmdb: useTmdb ? response.data.tests.tmdb : 'not tested',
tvdb: useTvdb ? response.data.tests.tvdb : 'not tested',
};
setProviderStatus(newStatus);
return newStatus;
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
// If we receive an error response with a valid format
const errorData = error.response.data as {
success: boolean;
tests: ProviderResponse;
};
if (errorData.tests) {
const newStatus: ProviderResponse = {
tmdb: useTmdb ? errorData.tests.tmdb : 'not tested',
tvdb: useTvdb ? errorData.tests.tvdb : 'not tested',
};
setProviderStatus(newStatus);
return newStatus;
}
}
// In case of error without usable data
throw new Error('Failed to test connection');
}
};
const saveSettings = async (
values: MetadataValues
): Promise<MetadataSettings> => {
try {
const response = await axios.put<{
success: boolean;
tv: MetadataProviderType;
anime: MetadataProviderType;
tests?: {
tvdb: ProviderStatus;
tmdb: ProviderStatus;
};
}>('/api/v1/settings/metadatas', {
tv: values.tv,
anime: values.anime,
});
// Update metadata provider status if available
if (response.data.tests) {
const mapStatusValue = (status: string): ProviderStatus => {
if (status === 'ok') return 'ok';
if (status === 'failed') return 'failed';
return 'not tested';
};
setProviderStatus({
tmdb: mapStatusValue(response.data.tests.tmdb),
tvdb: mapStatusValue(response.data.tests.tvdb),
});
}
// Adapt the response to the format expected by the component
return {
metadata: {
tv: response.data.tv,
anime: response.data.anime,
},
};
} catch (error) {
// Retrieve test data in case of error
if (axios.isAxiosError(error) && error.response?.data) {
const errorData = error.response.data as {
success: boolean;
tests?: {
tvdb: string;
tmdb: string;
};
};
// If test data is available in the error response
if (errorData.tests) {
const mapStatusValue = (status: string): ProviderStatus => {
if (status === 'ok') return 'ok';
if (status === 'failed') return 'failed';
return 'not tested';
};
// Update metadata provider status with error data
setProviderStatus({
tmdb: mapStatusValue(errorData.tests.tmdb),
tvdb: mapStatusValue(errorData.tests.tvdb),
});
}
}
throw new Error('Failed to save Metadata settings');
}
};
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';
}
};
const getStatusMessage = (status: ProviderStatus): string => {
switch (status) {
case 'ok':
return intl.formatMessage(messages.operational);
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';
}
};
if (!data && !error) {
return <LoadingSpinner />;
}
const initialValues: MetadataValues = data?.metadata || {
tv: MetadataProviderType.TMDB,
anime: MetadataProviderType.TMDB,
};
return (
<>
<PageTitle
title={[
intl.formatMessage(messages.general),
intl.formatMessage(globalMessages.settings),
]}
/>
<div className="mb-6">
<h3 className="heading">
{intl.formatMessage(messages.metadataProviderSettings)}
</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-24">TheMovieDB:</span>
<span
className={`text-sm ${getStatusClass(providerStatus.tmdb)}`}
data-testid="tmdb-status-container"
>
<Badge badgeType={getBadgeType(providerStatus.tmdb)}>
{getStatusMessage(providerStatus.tmdb)}
</Badge>
</span>
</div>
<div className="flex items-center">
<span className="mr-2 w-24">TheTVDB:</span>
<span
className={`text-sm ${getStatusClass(providerStatus.tvdb)}`}
data-testid="tvdb-status"
>
<Badge badgeType={getBadgeType(providerStatus.tvdb)}>
{getStatusMessage(providerStatus.tvdb)}
</Badge>
</span>
</div>
</div>
</div>
<div className="section">
<Formik
initialValues={{ metadata: initialValues }}
onSubmit={async (values) => {
try {
const result = await saveSettings(values.metadata);
if (data) {
data.metadata = result.metadata;
}
addToast(intl.formatMessage(messages.metadataSettingsSaved), {
appearance: 'success',
});
} catch (e) {
addToast(
intl.formatMessage(messages.failedToSaveMetadataSettings),
{
appearance: 'error',
}
);
}
}}
>
{({ isSubmitting, isValid, values, setFieldValue }) => {
return (
<Form className="section" data-testid="settings-main-form">
<div className="mb-6">
<h2 className="heading">
{intl.formatMessage(messages.metadataProviderSelection)}
</h2>
<p className="description">
{intl.formatMessage(messages.chooseProvider)}
</p>
</div>
<div className="form-row">
<label
htmlFor="tv-metadata-provider"
className="checkbox-label"
>
<span className="mr-2">
{intl.formatMessage(messages.seriesMetadataProvider)}
</span>
</label>
<div className="form-input-area">
<MetadataSelector
testId="tv-metadata-provider-selector"
value={values.metadata.tv}
onChange={(value) => setFieldValue('metadata.tv', value)}
isDisabled={isSubmitting}
/>
</div>
</div>
<div className="form-row">
<label
htmlFor="anime-metadata-provider"
className="checkbox-label"
>
<span className="mr-2">
{intl.formatMessage(messages.animeMetadataProvider)}
</span>
</label>
<div className="form-input-area">
<MetadataSelector
testId="anime-metadata-provider-selector"
value={values.metadata.anime}
onChange={(value) =>
setFieldValue('metadata.anime', value)
}
isDisabled={isSubmitting}
/>
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
type="button"
disabled={isSubmitting || !isValid}
onClick={async () => {
setIsTesting(true);
try {
const resp = await testConnection(values.metadata);
if (resp.tvdb === 'failed') {
addToast(
intl.formatMessage(
messages.tvdbProviderDoesnotWork
),
{
appearance: 'error',
autoDismiss: true,
}
);
} else if (resp.tmdb === 'failed') {
addToast(
intl.formatMessage(
messages.tmdbProviderDoesnotWork
),
{
appearance: 'error',
autoDismiss: true,
}
);
} else {
addToast(
intl.formatMessage(
messages.allChosenProvidersAreOperational
),
{
appearance: 'success',
}
);
}
} catch (e) {
addToast(
intl.formatMessage(messages.connectionTestFailed),
{
appearance: 'error',
autoDismiss: true,
}
);
} finally {
setIsTesting(false);
}
}}
>
<BeakerIcon />
<span>
{isTesting
? intl.formatMessage(globalMessages.testing)
: intl.formatMessage(globalMessages.test)}
</span>
</Button>
</span>
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
data-testid="metadata-save-button"
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid || isTesting}
>
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</span>
</Button>
</span>
</div>
</div>
</Form>
);
}}
</Formik>
</div>
</>
);
};
export default SettingsMetadata;

View File

@@ -60,7 +60,7 @@ const Season = ({ seasonNumber, tvId }: SeasonProps) => {
<CachedImage
type="tmdb"
className="rounded-lg object-contain"
src={`https://image.tmdb.org/t/p/original/${episode.stillPath}`}
src={episode.stillPath}
alt=""
fill
/>

View File

@@ -35,6 +35,7 @@ import { sortCrewPriority } from '@app/utils/creditHelpers';
import defineMessages from '@app/utils/defineMessages';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { Disclosure, Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import {
ArrowRightCircleIcon,
CogIcon,
@@ -44,8 +45,7 @@ import {
MinusCircleIcon,
PlayIcon,
StarIcon,
} from '@heroicons/react/24/outline';
import { ChevronDownIcon } from '@heroicons/react/24/solid';
} from '@heroicons/react/24/solid';
import type { RTRating } from '@server/api/rating/rottentomatoes';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import { IssueStatus } from '@server/constants/issue';
@@ -118,9 +118,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
const intl = useIntl();
const { locale } = useLocale();
const [showRequestModal, setShowRequestModal] = useState(false);
const [showManager, setShowManager] = useState(
router.query.manage == '1' ? true : false
);
const [showManager, setShowManager] = useState(router.query.manage == '1');
const [showIssueModal, setShowIssueModal] = useState(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
@@ -156,7 +154,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
);
useEffect(() => {
setShowManager(router.query.manage == '1' ? true : false);
setShowManager(router.query.manage == '1');
}, [router.query.manage]);
const closeBlacklistModal = useCallback(