From 4878722030abb53abbc1cd4e0fcd092490a5a1d9 Mon Sep 17 00:00:00 2001 From: THOMAS B Date: Mon, 8 Sep 2025 14:20:21 +0200 Subject: [PATCH] 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 --- server/api/tvdb/index.ts | 155 ++++++++++++++++-- server/api/tvdb/interfaces.ts | 72 ++++++++ server/routes/tv.ts | 1 + server/types/languages.d.ts | 35 ++++ .../Layout/LanguagePicker/index.tsx | 2 +- src/components/Layout/index.tsx | 2 +- .../Settings/SettingsMain/index.tsx | 2 +- .../UserGeneralSettings/index.tsx | 2 +- src/context/LanguageContext.tsx | 37 +---- src/pages/_app.tsx | 2 +- 10 files changed, 259 insertions(+), 51 deletions(-) create mode 100644 server/types/languages.d.ts diff --git a/server/api/tvdb/index.ts b/server/api/tvdb/index.ts index 22ab150e9..62b6c85a0 100644 --- a/server/api/tvdb/index.ts +++ b/server/api/tvdb/index.ts @@ -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 { 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 { + 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>( + `/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 { + if (!season) { + logger.error( + `Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}` + ); + return this.createEmptySeasonResponse(tvId); + } + const resp = await this.get>( `/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, }; diff --git a/server/api/tvdb/interfaces.ts b/server/api/tvdb/interfaces.ts index 9c245208f..e32ffce56 100644 --- a/server/api/tvdb/interfaces.ts +++ b/server/api/tvdb/interfaces.ts @@ -1,6 +1,17 @@ +import { type AvailableLocale } from '@server/types/languages'; + export interface TvdbBaseResponse { 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 & { + [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 +} diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 5d3c3e097..f5632398e 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -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)); diff --git a/server/types/languages.d.ts b/server/types/languages.d.ts new file mode 100644 index 000000000..6692ee3cd --- /dev/null +++ b/server/types/languages.d.ts @@ -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'; diff --git a/src/components/Layout/LanguagePicker/index.tsx b/src/components/Layout/LanguagePicker/index.tsx index c213fe787..019b7fe9f 100644 --- a/src/components/Layout/LanguagePicker/index.tsx +++ b/src/components/Layout/LanguagePicker/index.tsx @@ -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'; diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 50d463cf0..b6b8de20b 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -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'; diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index 1d3dd68bd..4423010c0 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -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'; diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx index b187a22a8..71476c5be 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -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'; diff --git a/src/context/LanguageContext.tsx b/src/context/LanguageContext.tsx index cb4338aa8..3fad4eb2e 100644 --- a/src/context/LanguageContext.tsx +++ b/src/context/LanguageContext.tsx @@ -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 } diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 88513e3eb..3bb034bbe 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -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';