Skip to content

Commit 4878722

Browse files
authored
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
1 parent 479be0d commit 4878722

10 files changed

Lines changed: 259 additions & 51 deletions

File tree

server/api/tvdb/index.ts

Lines changed: 145 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ import type {
77
TmdbTvEpisodeResult,
88
TmdbTvSeasonResult,
99
} from '@server/api/themoviedb/interfaces';
10-
import type {
11-
TvdbBaseResponse,
12-
TvdbEpisode,
13-
TvdbLoginResponse,
14-
TvdbSeasonDetails,
15-
TvdbTvDetails,
10+
import {
11+
convertTmdbLanguageToTvdbWithFallback,
12+
type TvdbBaseResponse,
13+
type TvdbEpisode,
14+
type TvdbLoginResponse,
15+
type TvdbSeasonDetails,
16+
type TvdbTvDetails,
1617
} from '@server/api/tvdb/interfaces';
1718
import cacheManager, { type AvailableCacheIds } from '@server/lib/cache';
1819
import logger from '@server/logger';
@@ -215,7 +216,12 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
215216
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
216217
}
217218

218-
return await this.getTvdbSeasonData(tvdbId, seasonNumber, tvId);
219+
return await this.getTvdbSeasonData(
220+
tvdbId,
221+
seasonNumber,
222+
tvId,
223+
language
224+
);
219225
} catch (error) {
220226
this.handleError('Failed to fetch TV season details', error);
221227
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
@@ -316,8 +322,8 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
316322
private async getTvdbSeasonData(
317323
tvdbId: number,
318324
seasonNumber: number,
319-
tvId: number
320-
//language: string = Tvdb.DEFAULT_LANGUAGE
325+
tvId: number,
326+
language: string = Tvdb.DEFAULT_LANGUAGE
321327
): Promise<TmdbSeasonWithEpisodes> {
322328
const tvdbData = await this.fetchTvdbShowData(tvdbId);
323329

@@ -341,6 +347,132 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
341347
return this.createEmptySeasonResponse(tvId);
342348
}
343349

350+
const wantedTranslation = convertTmdbLanguageToTvdbWithFallback(
351+
language,
352+
Tvdb.DEFAULT_LANGUAGE
353+
);
354+
355+
// check if translation is available for the season
356+
const availableTranslation = season.nameTranslations.filter(
357+
(translation) =>
358+
translation === wantedTranslation ||
359+
translation === Tvdb.DEFAULT_LANGUAGE
360+
);
361+
362+
if (!availableTranslation) {
363+
return this.getSeasonWithOriginalLanguage(
364+
tvdbId,
365+
tvId,
366+
seasonNumber,
367+
season
368+
);
369+
}
370+
371+
return this.getSeasonWithTranslation(
372+
tvdbId,
373+
tvId,
374+
seasonNumber,
375+
season,
376+
wantedTranslation
377+
);
378+
}
379+
380+
private async getSeasonWithTranslation(
381+
tvdbId: number,
382+
tvId: number,
383+
seasonNumber: number,
384+
season: TvdbSeasonDetails,
385+
language: string
386+
): Promise<TmdbSeasonWithEpisodes> {
387+
if (!season) {
388+
logger.error(
389+
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
390+
);
391+
return this.createEmptySeasonResponse(tvId);
392+
}
393+
394+
const allEpisodes = [] as TvdbEpisode[];
395+
let page = 0;
396+
// Limit to max 50 pages to avoid infinite loops.
397+
// 50 pages with 500 items per page = 25_000 episodes in a series which should be more than enough
398+
const maxPages = 50;
399+
400+
while (page < maxPages) {
401+
const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>(
402+
`/series/${tvdbId}/episodes/default/${language}`,
403+
{
404+
headers: {
405+
Authorization: `Bearer ${this.token}`,
406+
},
407+
params: {
408+
page: page,
409+
},
410+
}
411+
);
412+
413+
if (!resp?.data?.episodes) {
414+
logger.warn(
415+
`No episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}`
416+
);
417+
break;
418+
}
419+
420+
const { episodes } = resp.data;
421+
422+
if (!episodes) {
423+
logger.debug(
424+
`No more episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}`
425+
);
426+
break;
427+
}
428+
429+
allEpisodes.push(...episodes);
430+
431+
const hasNextPage = resp.links?.next && episodes.length > 0;
432+
433+
if (!hasNextPage) {
434+
break;
435+
}
436+
437+
page++;
438+
}
439+
440+
if (page >= maxPages) {
441+
logger.warn(
442+
`Reached max pages (${maxPages}) for TVDB ID: ${tvdbId} on season ${seasonNumber} with language ${language}. There might be more episodes available.`
443+
);
444+
}
445+
446+
const episodes = this.processEpisodes(
447+
{ ...season, episodes: allEpisodes },
448+
seasonNumber,
449+
tvId
450+
);
451+
452+
return {
453+
episodes,
454+
external_ids: { tvdb_id: tvdbId },
455+
name: '',
456+
overview: '',
457+
id: season.id,
458+
air_date: season.firstAired,
459+
season_number: episodes.length,
460+
};
461+
}
462+
463+
private async getSeasonWithOriginalLanguage(
464+
tvdbId: number,
465+
tvId: number,
466+
seasonNumber: number,
467+
season: TvdbSeasonDetails
468+
): Promise<TmdbSeasonWithEpisodes> {
469+
if (!season) {
470+
logger.error(
471+
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
472+
);
473+
return this.createEmptySeasonResponse(tvId);
474+
}
475+
344476
const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>(
345477
`/seasons/${season.id}/extended`,
346478
{
@@ -394,7 +526,10 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
394526
season_number: episode.seasonNumber,
395527
production_code: '',
396528
show_id: tvId,
397-
still_path: episode.image ? episode.image : '',
529+
still_path:
530+
episode.image && !episode.image.startsWith('https://')
531+
? 'https://artworks.thetvdb.com' + episode.image
532+
: '',
398533
vote_average: 1,
399534
vote_count: 1,
400535
};

server/api/tvdb/interfaces.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
1+
import { type AvailableLocale } from '@server/types/languages';
2+
13
export interface TvdbBaseResponse<T> {
24
data: T;
35
errors: string;
6+
links?: TvdbPagination;
7+
}
8+
9+
export interface TvdbPagination {
10+
prev?: string;
11+
self: string;
12+
next?: string;
13+
totalItems: number;
14+
pageSize: number;
415
}
516

617
export interface TvdbLoginResponse {
@@ -142,3 +153,64 @@ export interface TvdbEpisodeTranslation {
142153
overview: string;
143154
language: string;
144155
}
156+
157+
const TMDB_TO_TVDB_MAPPING: Record<string, string> & {
158+
[key in AvailableLocale]: string;
159+
} = {
160+
ar: 'ara', // Arabic
161+
bg: 'bul', // Bulgarian
162+
ca: 'cat', // Catalan
163+
cs: 'ces', // Czech
164+
da: 'dan', // Danish
165+
de: 'deu', // German
166+
el: 'ell', // Greek
167+
en: 'eng', // English
168+
es: 'spa', // Spanish
169+
fi: 'fin', // Finnish
170+
fr: 'fra', // French
171+
he: 'heb', // Hebrew
172+
hi: 'hin', // Hindi
173+
hr: 'hrv', // Croatian
174+
hu: 'hun', // Hungarian
175+
it: 'ita', // Italian
176+
ja: 'jpn', // Japanese
177+
ko: 'kor', // Korean
178+
lt: 'lit', // Lithuanian
179+
nl: 'nld', // Dutch
180+
pl: 'pol', // Polish
181+
ro: 'ron', // Romanian
182+
ru: 'rus', // Russian
183+
sq: 'sqi', // Albanian
184+
sr: 'srp', // Serbian
185+
sv: 'swe', // Swedish
186+
tr: 'tur', // Turkish
187+
uk: 'ukr', // Ukrainian
188+
189+
'es-MX': 'spa', // Spanish (Latin America) -> Spanish
190+
'nb-NO': 'nor', // Norwegian Bokmål -> Norwegian
191+
'pt-BR': 'pt', // Portuguese (Brazil) -> Portuguese - Brazil (from TVDB data)
192+
'pt-PT': 'por', // Portuguese (Portugal) -> Portuguese - Portugal (from TVDB data)
193+
'zh-CN': 'zho', // Chinese (Simplified) -> Chinese - China
194+
'zh-TW': 'zhtw', // Chinese (Traditional) -> Chinese - Taiwan
195+
};
196+
197+
export function convertTMDBToTVDB(tmdbCode: string): string | null {
198+
const normalizedCode = tmdbCode.toLowerCase();
199+
200+
return (
201+
TMDB_TO_TVDB_MAPPING[tmdbCode] ||
202+
TMDB_TO_TVDB_MAPPING[normalizedCode] ||
203+
null
204+
);
205+
}
206+
207+
export function convertTmdbLanguageToTvdbWithFallback(
208+
tmdbCode: string,
209+
fallback: string
210+
): string {
211+
// First try exact match
212+
const tvdbCode = convertTMDBToTVDB(tmdbCode);
213+
if (tvdbCode) return tvdbCode;
214+
215+
return tvdbCode || fallback || 'eng'; // Default to English if no match found
216+
}

server/routes/tv.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
8080
const season = await metadataProvider.getTvSeason({
8181
tvId: Number(req.params.id),
8282
seasonNumber: Number(req.params.seasonNumber),
83+
language: (req.query.language as string) ?? req.locale,
8384
});
8485

8586
return res.status(200).json(mapSeasonWithEpisodes(season));

server/types/languages.d.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
export type AvailableLocale =
2+
| 'ar'
3+
| 'bg'
4+
| 'ca'
5+
| 'cs'
6+
| 'da'
7+
| 'de'
8+
| 'en'
9+
| 'el'
10+
| 'es'
11+
| 'es-MX'
12+
| 'fi'
13+
| 'fr'
14+
| 'hr'
15+
| 'he'
16+
| 'hi'
17+
| 'hu'
18+
| 'it'
19+
| 'ja'
20+
| 'ko'
21+
| 'lt'
22+
| 'nb-NO'
23+
| 'nl'
24+
| 'pl'
25+
| 'pt-BR'
26+
| 'pt-PT'
27+
| 'ro'
28+
| 'ru'
29+
| 'sq'
30+
| 'sr'
31+
| 'sv'
32+
| 'tr'
33+
| 'uk'
34+
| 'zh-CN'
35+
| 'zh-TW';

src/components/Layout/LanguagePicker/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import type { AvailableLocale } from '@app/context/LanguageContext';
21
import { availableLanguages } from '@app/context/LanguageContext';
32
import useClickOutside from '@app/hooks/useClickOutside';
43
import useLocale from '@app/hooks/useLocale';
54
import defineMessages from '@app/utils/defineMessages';
65
import { Transition } from '@headlessui/react';
76
import { LanguageIcon } from '@heroicons/react/24/solid';
7+
import type { AvailableLocale } from '@server/types/languages';
88
import { useRef, useState } from 'react';
99
import { useIntl } from 'react-intl';
1010

src/components/Layout/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import PullToRefresh from '@app/components/Layout/PullToRefresh';
33
import SearchInput from '@app/components/Layout/SearchInput';
44
import Sidebar from '@app/components/Layout/Sidebar';
55
import UserDropdown from '@app/components/Layout/UserDropdown';
6-
import type { AvailableLocale } from '@app/context/LanguageContext';
76
import useLocale from '@app/hooks/useLocale';
87
import useSettings from '@app/hooks/useSettings';
98
import { useUser } from '@app/hooks/useUser';
109
import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid';
10+
import type { AvailableLocale } from '@server/types/languages';
1111
import { useRouter } from 'next/router';
1212
import { useEffect, useState } from 'react';
1313
import useSWR from 'swr';

src/components/Settings/SettingsMain/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import LanguageSelector from '@app/components/LanguageSelector';
77
import RegionSelector from '@app/components/RegionSelector';
88
import CopyButton from '@app/components/Settings/CopyButton';
99
import SettingsBadge from '@app/components/Settings/SettingsBadge';
10-
import type { AvailableLocale } from '@app/context/LanguageContext';
1110
import { availableLanguages } from '@app/context/LanguageContext';
1211
import useLocale from '@app/hooks/useLocale';
1312
import { Permission, useUser } from '@app/hooks/useUser';
@@ -18,6 +17,7 @@ import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
1817
import { ArrowPathIcon } from '@heroicons/react/24/solid';
1918
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
2019
import type { MainSettings } from '@server/lib/settings';
20+
import type { AvailableLocale } from '@server/types/languages';
2121
import axios from 'axios';
2222
import { Field, Form, Formik } from 'formik';
2323
import { useIntl } from 'react-intl';

src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import PageTitle from '@app/components/Common/PageTitle';
55
import LanguageSelector from '@app/components/LanguageSelector';
66
import QuotaSelector from '@app/components/QuotaSelector';
77
import RegionSelector from '@app/components/RegionSelector';
8-
import type { AvailableLocale } from '@app/context/LanguageContext';
98
import { availableLanguages } from '@app/context/LanguageContext';
109
import useLocale from '@app/hooks/useLocale';
1110
import useSettings from '@app/hooks/useSettings';
@@ -16,6 +15,7 @@ import defineMessages from '@app/utils/defineMessages';
1615
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
1716
import { ApiErrorCode } from '@server/constants/error';
1817
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
18+
import type { AvailableLocale } from '@server/types/languages';
1919
import axios from 'axios';
2020
import { Field, Form, Formik } from 'formik';
2121
import { useRouter } from 'next/router';

0 commit comments

Comments
 (0)