mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-23 18:29:19 -05:00
fix(tvdb): respect display language when fetching metadata (#1889)
* fix(tvdb): respect display language when fetching metadata * refactor(tvdb): use seasons translation * refactor(tvdb): limit while loop * fix(tvdb): fix translation with '-' * refactor(tvdb): remove logs * style(tvdb): remove useless logs * refactor(tvdb): simplify wanted translation condition * refactor(languages): move AvailableLocale from context to types
This commit is contained in:
@@ -7,12 +7,13 @@ import type {
|
||||
TmdbTvEpisodeResult,
|
||||
TmdbTvSeasonResult,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type {
|
||||
TvdbBaseResponse,
|
||||
TvdbEpisode,
|
||||
TvdbLoginResponse,
|
||||
TvdbSeasonDetails,
|
||||
TvdbTvDetails,
|
||||
import {
|
||||
convertTmdbLanguageToTvdbWithFallback,
|
||||
type TvdbBaseResponse,
|
||||
type TvdbEpisode,
|
||||
type TvdbLoginResponse,
|
||||
type TvdbSeasonDetails,
|
||||
type TvdbTvDetails,
|
||||
} from '@server/api/tvdb/interfaces';
|
||||
import cacheManager, { type AvailableCacheIds } from '@server/lib/cache';
|
||||
import logger from '@server/logger';
|
||||
@@ -215,7 +216,12 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
|
||||
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
|
||||
}
|
||||
|
||||
return await this.getTvdbSeasonData(tvdbId, seasonNumber, tvId);
|
||||
return await this.getTvdbSeasonData(
|
||||
tvdbId,
|
||||
seasonNumber,
|
||||
tvId,
|
||||
language
|
||||
);
|
||||
} catch (error) {
|
||||
this.handleError('Failed to fetch TV season details', error);
|
||||
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
|
||||
@@ -316,8 +322,8 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
|
||||
private async getTvdbSeasonData(
|
||||
tvdbId: number,
|
||||
seasonNumber: number,
|
||||
tvId: number
|
||||
//language: string = Tvdb.DEFAULT_LANGUAGE
|
||||
tvId: number,
|
||||
language: string = Tvdb.DEFAULT_LANGUAGE
|
||||
): Promise<TmdbSeasonWithEpisodes> {
|
||||
const tvdbData = await this.fetchTvdbShowData(tvdbId);
|
||||
|
||||
@@ -341,6 +347,132 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
|
||||
return this.createEmptySeasonResponse(tvId);
|
||||
}
|
||||
|
||||
const wantedTranslation = convertTmdbLanguageToTvdbWithFallback(
|
||||
language,
|
||||
Tvdb.DEFAULT_LANGUAGE
|
||||
);
|
||||
|
||||
// check if translation is available for the season
|
||||
const availableTranslation = season.nameTranslations.filter(
|
||||
(translation) =>
|
||||
translation === wantedTranslation ||
|
||||
translation === Tvdb.DEFAULT_LANGUAGE
|
||||
);
|
||||
|
||||
if (!availableTranslation) {
|
||||
return this.getSeasonWithOriginalLanguage(
|
||||
tvdbId,
|
||||
tvId,
|
||||
seasonNumber,
|
||||
season
|
||||
);
|
||||
}
|
||||
|
||||
return this.getSeasonWithTranslation(
|
||||
tvdbId,
|
||||
tvId,
|
||||
seasonNumber,
|
||||
season,
|
||||
wantedTranslation
|
||||
);
|
||||
}
|
||||
|
||||
private async getSeasonWithTranslation(
|
||||
tvdbId: number,
|
||||
tvId: number,
|
||||
seasonNumber: number,
|
||||
season: TvdbSeasonDetails,
|
||||
language: string
|
||||
): Promise<TmdbSeasonWithEpisodes> {
|
||||
if (!season) {
|
||||
logger.error(
|
||||
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
|
||||
);
|
||||
return this.createEmptySeasonResponse(tvId);
|
||||
}
|
||||
|
||||
const allEpisodes = [] as TvdbEpisode[];
|
||||
let page = 0;
|
||||
// Limit to max 50 pages to avoid infinite loops.
|
||||
// 50 pages with 500 items per page = 25_000 episodes in a series which should be more than enough
|
||||
const maxPages = 50;
|
||||
|
||||
while (page < maxPages) {
|
||||
const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>(
|
||||
`/series/${tvdbId}/episodes/default/${language}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
},
|
||||
params: {
|
||||
page: page,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!resp?.data?.episodes) {
|
||||
logger.warn(
|
||||
`No episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const { episodes } = resp.data;
|
||||
|
||||
if (!episodes) {
|
||||
logger.debug(
|
||||
`No more episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
allEpisodes.push(...episodes);
|
||||
|
||||
const hasNextPage = resp.links?.next && episodes.length > 0;
|
||||
|
||||
if (!hasNextPage) {
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
if (page >= maxPages) {
|
||||
logger.warn(
|
||||
`Reached max pages (${maxPages}) for TVDB ID: ${tvdbId} on season ${seasonNumber} with language ${language}. There might be more episodes available.`
|
||||
);
|
||||
}
|
||||
|
||||
const episodes = this.processEpisodes(
|
||||
{ ...season, episodes: allEpisodes },
|
||||
seasonNumber,
|
||||
tvId
|
||||
);
|
||||
|
||||
return {
|
||||
episodes,
|
||||
external_ids: { tvdb_id: tvdbId },
|
||||
name: '',
|
||||
overview: '',
|
||||
id: season.id,
|
||||
air_date: season.firstAired,
|
||||
season_number: episodes.length,
|
||||
};
|
||||
}
|
||||
|
||||
private async getSeasonWithOriginalLanguage(
|
||||
tvdbId: number,
|
||||
tvId: number,
|
||||
seasonNumber: number,
|
||||
season: TvdbSeasonDetails
|
||||
): Promise<TmdbSeasonWithEpisodes> {
|
||||
if (!season) {
|
||||
logger.error(
|
||||
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
|
||||
);
|
||||
return this.createEmptySeasonResponse(tvId);
|
||||
}
|
||||
|
||||
const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>(
|
||||
`/seasons/${season.id}/extended`,
|
||||
{
|
||||
@@ -394,7 +526,10 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
|
||||
season_number: episode.seasonNumber,
|
||||
production_code: '',
|
||||
show_id: tvId,
|
||||
still_path: episode.image ? episode.image : '',
|
||||
still_path:
|
||||
episode.image && !episode.image.startsWith('https://')
|
||||
? 'https://artworks.thetvdb.com' + episode.image
|
||||
: '',
|
||||
vote_average: 1,
|
||||
vote_count: 1,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { type AvailableLocale } from '@server/types/languages';
|
||||
|
||||
export interface TvdbBaseResponse<T> {
|
||||
data: T;
|
||||
errors: string;
|
||||
links?: TvdbPagination;
|
||||
}
|
||||
|
||||
export interface TvdbPagination {
|
||||
prev?: string;
|
||||
self: string;
|
||||
next?: string;
|
||||
totalItems: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface TvdbLoginResponse {
|
||||
@@ -142,3 +153,64 @@ export interface TvdbEpisodeTranslation {
|
||||
overview: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
const TMDB_TO_TVDB_MAPPING: Record<string, string> & {
|
||||
[key in AvailableLocale]: string;
|
||||
} = {
|
||||
ar: 'ara', // Arabic
|
||||
bg: 'bul', // Bulgarian
|
||||
ca: 'cat', // Catalan
|
||||
cs: 'ces', // Czech
|
||||
da: 'dan', // Danish
|
||||
de: 'deu', // German
|
||||
el: 'ell', // Greek
|
||||
en: 'eng', // English
|
||||
es: 'spa', // Spanish
|
||||
fi: 'fin', // Finnish
|
||||
fr: 'fra', // French
|
||||
he: 'heb', // Hebrew
|
||||
hi: 'hin', // Hindi
|
||||
hr: 'hrv', // Croatian
|
||||
hu: 'hun', // Hungarian
|
||||
it: 'ita', // Italian
|
||||
ja: 'jpn', // Japanese
|
||||
ko: 'kor', // Korean
|
||||
lt: 'lit', // Lithuanian
|
||||
nl: 'nld', // Dutch
|
||||
pl: 'pol', // Polish
|
||||
ro: 'ron', // Romanian
|
||||
ru: 'rus', // Russian
|
||||
sq: 'sqi', // Albanian
|
||||
sr: 'srp', // Serbian
|
||||
sv: 'swe', // Swedish
|
||||
tr: 'tur', // Turkish
|
||||
uk: 'ukr', // Ukrainian
|
||||
|
||||
'es-MX': 'spa', // Spanish (Latin America) -> Spanish
|
||||
'nb-NO': 'nor', // Norwegian Bokmål -> Norwegian
|
||||
'pt-BR': 'pt', // Portuguese (Brazil) -> Portuguese - Brazil (from TVDB data)
|
||||
'pt-PT': 'por', // Portuguese (Portugal) -> Portuguese - Portugal (from TVDB data)
|
||||
'zh-CN': 'zho', // Chinese (Simplified) -> Chinese - China
|
||||
'zh-TW': 'zhtw', // Chinese (Traditional) -> Chinese - Taiwan
|
||||
};
|
||||
|
||||
export function convertTMDBToTVDB(tmdbCode: string): string | null {
|
||||
const normalizedCode = tmdbCode.toLowerCase();
|
||||
|
||||
return (
|
||||
TMDB_TO_TVDB_MAPPING[tmdbCode] ||
|
||||
TMDB_TO_TVDB_MAPPING[normalizedCode] ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function convertTmdbLanguageToTvdbWithFallback(
|
||||
tmdbCode: string,
|
||||
fallback: string
|
||||
): string {
|
||||
// First try exact match
|
||||
const tvdbCode = convertTMDBToTVDB(tmdbCode);
|
||||
if (tvdbCode) return tvdbCode;
|
||||
|
||||
return tvdbCode || fallback || 'eng'; // Default to English if no match found
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
|
||||
const season = await metadataProvider.getTvSeason({
|
||||
tvId: Number(req.params.id),
|
||||
seasonNumber: Number(req.params.seasonNumber),
|
||||
language: (req.query.language as string) ?? req.locale,
|
||||
});
|
||||
|
||||
return res.status(200).json(mapSeasonWithEpisodes(season));
|
||||
|
||||
35
server/types/languages.d.ts
vendored
Normal file
35
server/types/languages.d.ts
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
export type AvailableLocale =
|
||||
| 'ar'
|
||||
| 'bg'
|
||||
| 'ca'
|
||||
| 'cs'
|
||||
| 'da'
|
||||
| 'de'
|
||||
| 'en'
|
||||
| 'el'
|
||||
| 'es'
|
||||
| 'es-MX'
|
||||
| 'fi'
|
||||
| 'fr'
|
||||
| 'hr'
|
||||
| 'he'
|
||||
| 'hi'
|
||||
| 'hu'
|
||||
| 'it'
|
||||
| 'ja'
|
||||
| 'ko'
|
||||
| 'lt'
|
||||
| 'nb-NO'
|
||||
| 'nl'
|
||||
| 'pl'
|
||||
| 'pt-BR'
|
||||
| 'pt-PT'
|
||||
| 'ro'
|
||||
| 'ru'
|
||||
| 'sq'
|
||||
| 'sr'
|
||||
| 'sv'
|
||||
| 'tr'
|
||||
| 'uk'
|
||||
| 'zh-CN'
|
||||
| 'zh-TW';
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
||||
import { availableLanguages } from '@app/context/LanguageContext';
|
||||
import useClickOutside from '@app/hooks/useClickOutside';
|
||||
import useLocale from '@app/hooks/useLocale';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { LanguageIcon } from '@heroicons/react/24/solid';
|
||||
import type { AvailableLocale } from '@server/types/languages';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import PullToRefresh from '@app/components/Layout/PullToRefresh';
|
||||
import SearchInput from '@app/components/Layout/SearchInput';
|
||||
import Sidebar from '@app/components/Layout/Sidebar';
|
||||
import UserDropdown from '@app/components/Layout/UserDropdown';
|
||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
||||
import useLocale from '@app/hooks/useLocale';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid';
|
||||
import type { AvailableLocale } from '@server/types/languages';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
@@ -7,7 +7,6 @@ import LanguageSelector from '@app/components/LanguageSelector';
|
||||
import RegionSelector from '@app/components/RegionSelector';
|
||||
import CopyButton from '@app/components/Settings/CopyButton';
|
||||
import SettingsBadge from '@app/components/Settings/SettingsBadge';
|
||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
||||
import { availableLanguages } from '@app/context/LanguageContext';
|
||||
import useLocale from '@app/hooks/useLocale';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
@@ -18,6 +17,7 @@ import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||
import { ArrowPathIcon } from '@heroicons/react/24/solid';
|
||||
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
|
||||
import type { MainSettings } from '@server/lib/settings';
|
||||
import type { AvailableLocale } from '@server/types/languages';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
@@ -5,7 +5,6 @@ import PageTitle from '@app/components/Common/PageTitle';
|
||||
import LanguageSelector from '@app/components/LanguageSelector';
|
||||
import QuotaSelector from '@app/components/QuotaSelector';
|
||||
import RegionSelector from '@app/components/RegionSelector';
|
||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
||||
import { availableLanguages } from '@app/context/LanguageContext';
|
||||
import useLocale from '@app/hooks/useLocale';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
@@ -16,6 +15,7 @@ import defineMessages from '@app/utils/defineMessages';
|
||||
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
|
||||
import type { AvailableLocale } from '@server/types/languages';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
@@ -1,41 +1,6 @@
|
||||
import { type AvailableLocale } from '@server/types/languages';
|
||||
import React from 'react';
|
||||
|
||||
export type AvailableLocale =
|
||||
| 'ar'
|
||||
| 'bg'
|
||||
| 'ca'
|
||||
| 'cs'
|
||||
| 'da'
|
||||
| 'de'
|
||||
| 'en'
|
||||
| 'el'
|
||||
| 'es'
|
||||
| 'es-MX'
|
||||
| 'fi'
|
||||
| 'fr'
|
||||
| 'hr'
|
||||
| 'he'
|
||||
| 'hi'
|
||||
| 'hu'
|
||||
| 'it'
|
||||
| 'ja'
|
||||
| 'ko'
|
||||
| 'lt'
|
||||
| 'nb-NO'
|
||||
| 'nl'
|
||||
| 'pl'
|
||||
| 'pt-BR'
|
||||
| 'pt-PT'
|
||||
| 'ro'
|
||||
| 'ru'
|
||||
| 'sq'
|
||||
| 'sr'
|
||||
| 'sv'
|
||||
| 'tr'
|
||||
| 'uk'
|
||||
| 'zh-CN'
|
||||
| 'zh-TW';
|
||||
|
||||
type AvailableLanguageObject = Record<
|
||||
string,
|
||||
{ code: AvailableLocale; display: string }
|
||||
|
||||
@@ -6,7 +6,6 @@ import StatusChecker from '@app/components/StatusChecker';
|
||||
import Toast from '@app/components/Toast';
|
||||
import ToastContainer from '@app/components/ToastContainer';
|
||||
import { InteractionProvider } from '@app/context/InteractionContext';
|
||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
||||
import { LanguageContext } from '@app/context/LanguageContext';
|
||||
import { SettingsProvider } from '@app/context/SettingsContext';
|
||||
import { UserContext } from '@app/context/UserContext';
|
||||
@@ -16,6 +15,7 @@ import '@app/styles/globals.css';
|
||||
import { polyfillIntl } from '@app/utils/polyfillIntl';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces';
|
||||
import type { AvailableLocale } from '@server/types/languages';
|
||||
import axios from 'axios';
|
||||
import type { AppInitialProps, AppProps } from 'next/app';
|
||||
import App from 'next/app';
|
||||
|
||||
Reference in New Issue
Block a user