New: Add regional translation support (fr-CA, en-CA, es-MX, pt-BR)

Adds the ability to fetch regional translation titles from TMDb and use
them in indexer searches. This addresses requests for supporting regional
language variants like French Canadian (fr-CA) which have different movie
titles than the base language (fr).

Changes:
- Add config settings: FetchRegionalTranslations, RegionalTranslationVariants,
  and RegionalTranslationRateLimit in Media Management
- Add UI settings in Media Management page for configuring regional variants
- Extend TranslationResource to parse TMDb API format (iso_639_1, iso_3166_1)
- Implement FetchRegionalTranslationsFromTMDb in SkyHookProxy
- Fix MovieTranslationService to use DistinctBy(CleanTitle) instead of
  DistinctBy(Language) allowing multiple translations for the same language
  with different titles (e.g., fr 'À nous quatre' and fr-CA 'L'attrape-parents')

Closes #10482
Related: #7788, #4612, #2644
This commit is contained in:
KrZ 2026-01-15 22:21:02 -05:00
parent 89110c2cc8
commit bd52c075a5
16 changed files with 418 additions and 7 deletions

View file

@ -461,6 +461,124 @@ function MediaManagement() {
</FormGroup>
</FieldSet>
<FieldSet legend={translate('MovieInfo')}>
<FormGroup
advancedSettings={showAdvancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('FetchRegionalTranslations')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="fetchRegionalTranslations"
helpText={translate('FetchRegionalTranslationsHelpText')}
onChange={handleInputChange}
{...settings.fetchRegionalTranslations}
/>
</FormGroup>
{settings.fetchRegionalTranslations.value ? (
<>
<FormGroup
advancedSettings={showAdvancedSettings}
isAdvanced={true}
>
<FormLabel>{translate('RegionalTranslationVariants')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="regionalTranslationVariants"
helpTexts={[
translate('RegionalTranslationVariantsHelpText'),
translate('RegionalTranslationVariantsHelpTextExamples'),
]}
onChange={handleInputChange}
{...settings.regionalTranslationVariants}
/>
</FormGroup>
<FormGroup
advancedSettings={showAdvancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('RegionalTranslationRateLimit')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
unit="ms"
name="regionalTranslationRateLimit"
helpText={translate('RegionalTranslationRateLimitHelpText')}
min={100}
max={5000}
onChange={handleInputChange}
{...settings.regionalTranslationRateLimit}
/>
</FormGroup>
</>
) : null}
</FieldSet>
<FieldSet legend={translate('MovieInfo')}>
<FormGroup
advancedSettings={showAdvancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('FetchRegionalTranslations')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="fetchRegionalTranslations"
helpText={translate('FetchRegionalTranslationsHelpText')}
onChange={handleInputChange}
{...settings.fetchRegionalTranslations}
/>
</FormGroup>
{settings.fetchRegionalTranslations.value ? (
<>
<FormGroup
advancedSettings={showAdvancedSettings}
isAdvanced={true}
>
<FormLabel>{translate('RegionalTranslationVariants')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="regionalTranslationVariants"
helpTexts={[
translate('RegionalTranslationVariantsHelpText'),
translate('RegionalTranslationVariantsHelpTextExamples'),
]}
onChange={handleInputChange}
{...settings.regionalTranslationVariants}
/>
</FormGroup>
<FormGroup
advancedSettings={showAdvancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('RegionalTranslationRateLimit')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
unit="ms"
name="regionalTranslationRateLimit"
helpText={translate('RegionalTranslationRateLimitHelpText')}
min={100}
max={5000}
onChange={handleInputChange}
{...settings.regionalTranslationRateLimit}
/>
</FormGroup>
</>
) : null}
</FieldSet>
{showAdvancedSettings && !isWindows ? (
<FieldSet legend={translate('Permissions')}>
<FormGroup

View file

@ -18,4 +18,7 @@ export default interface MediaManagement {
importExtraFiles: boolean;
extraFileExtensions: string;
enableMediaInfo: boolean;
fetchRegionalTranslations: boolean;
regionalTranslationVariants: string;
regionalTranslationRateLimit: number;
}

View file

@ -1,5 +1,5 @@
{
"sdk": {
"version": "8.0.405"
"version": "8.0.122"
}
}
}

View file

@ -156,5 +156,6 @@
"volta": {
"node": "20.11.1",
"yarn": "1.22.19"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

Binary file not shown.

View file

@ -121,5 +121,23 @@ public void should_ignore_null_properties()
Mocker.GetMock<IConfigRepository>().Verify(c => c.Upsert("downloadedepisodesfolder", It.IsAny<string>()), Times.Never());
}
[Test]
public void FetchRegionalTranslations_should_default_to_true()
{
Subject.FetchRegionalTranslations.Should().BeTrue();
}
[Test]
public void RegionalTranslationVariants_should_have_default_value()
{
Subject.RegionalTranslationVariants.Should().Be("fr-CA,en-CA,es-MX,pt-BR");
}
[Test]
public void RegionalTranslationRateLimit_should_default_to_500()
{
Subject.RegionalTranslationRateLimit.Should().Be(500);
}
}
}

View file

@ -22,7 +22,6 @@ public void should_return_iso_language_for_English(string isoCode)
[TestCase("enus")]
[TestCase("enusa")]
[TestCase("wo")]
[TestCase("fr-CA")]
public void unknown_or_invalid_code_should_return_null(string isoCode)
{
var result = IsoLanguages.Find(isoCode);
@ -129,5 +128,83 @@ public void should_return_georgian(string isoCode)
var result = IsoLanguages.Find(isoCode);
result.Language.Should().Be(Language.Georgian);
}
[TestCase("fr-CA")]
[TestCase("fr-ca")]
[TestCase("FR-CA")]
public void should_return_french_for_french_canadian(string isoCode)
{
var result = IsoLanguages.Find(isoCode);
result.Should().NotBeNull();
result.Language.Should().Be(Language.French);
}
[TestCase("en-CA")]
[TestCase("en-ca")]
[TestCase("EN-CA")]
public void should_return_english_for_english_canadian(string isoCode)
{
var result = IsoLanguages.Find(isoCode);
result.Should().NotBeNull();
result.Language.Should().Be(Language.English);
}
[Test]
public void french_canadian_should_map_to_same_language_as_french()
{
var frCA = IsoLanguages.Find("fr-CA");
var fr = IsoLanguages.Find("fr");
frCA.Should().NotBeNull();
fr.Should().NotBeNull();
frCA.Language.Should().Be(fr.Language);
}
[Test]
public void english_canadian_should_map_to_same_language_as_english()
{
var enCA = IsoLanguages.Find("en-CA");
var en = IsoLanguages.Find("en");
enCA.Should().NotBeNull();
en.Should().NotBeNull();
enCA.Language.Should().Be(en.Language);
}
[TestCase("de-AT")]
[TestCase("de-at")]
public void should_return_german_for_german_austria(string isoCode)
{
var result = IsoLanguages.Find(isoCode);
result.Should().NotBeNull();
result.Language.Should().Be(Language.German);
}
[TestCase("de-CH")]
[TestCase("de-ch")]
public void should_return_german_for_german_switzerland(string isoCode)
{
var result = IsoLanguages.Find(isoCode);
result.Should().NotBeNull();
result.Language.Should().Be(Language.German);
}
[TestCase("zh-TW")]
[TestCase("zh-tw")]
public void should_return_chinese_for_chinese_taiwan(string isoCode)
{
var result = IsoLanguages.Find(isoCode);
result.Should().NotBeNull();
result.Language.Should().Be(Language.Chinese);
}
[TestCase("zh-HK")]
[TestCase("zh-hk")]
public void should_return_chinese_for_chinese_hong_kong(string isoCode)
{
var result = IsoLanguages.Find(isoCode);
result.Should().NotBeNull();
result.Language.Should().Be(Language.Chinese);
}
}
}

View file

@ -135,6 +135,24 @@ public TMDbCountryCode CertificationCountry
set { SetValue("CertificationCountry", value); }
}
public bool FetchRegionalTranslations
{
get { return GetValueBoolean("FetchRegionalTranslations", false); }
set { SetValue("FetchRegionalTranslations", value); }
}
public string RegionalTranslationVariants
{
get { return GetValue("RegionalTranslationVariants", "fr-CA,en-CA,es-MX,pt-BR"); }
set { SetValue("RegionalTranslationVariants", value); }
}
public int RegionalTranslationRateLimit
{
get { return GetValueInt("RegionalTranslationRateLimit", 500); }
set { SetValue("RegionalTranslationRateLimit", value); }
}
public int MaximumSize
{
get { return GetValueInt("MaximumSize", 0); }

View file

@ -65,6 +65,9 @@ public interface IConfigService
// Metadata Provider
TMDbCountryCode CertificationCountry { get; set; }
bool FetchRegionalTranslations { get; set; }
string RegionalTranslationVariants { get; set; }
int RegionalTranslationRateLimit { get; set; }
// UI
int FirstDayOfWeek { get; set; }

View file

@ -1150,6 +1150,14 @@
"MovieInfoLanguage": "Movie Info Language",
"MovieInfoLanguageHelpText": "Language that {appName} will use for Movie Information in UI",
"MovieInfoLanguageHelpTextWarning": "Browser Reload Required",
"MovieInfo": "Movie Info",
"FetchRegionalTranslations": "Fetch Regional Translations",
"FetchRegionalTranslationsHelpText": "Automatically fetch regional language variants (fr-CA, en-CA, etc.) from TMDb to include in indexer searches. Regional variants map to their base language (fr-CA → French, en-CA → English).",
"RegionalTranslationVariants": "Regional Translation Variants",
"RegionalTranslationVariantsHelpText": "Comma-separated list of regional language variants to fetch from TMDb (e.g., fr-CA, en-CA, es-MX, pt-BR). These will be included in searches when the base language is selected in quality profiles.",
"RegionalTranslationVariantsHelpTextExamples": "Examples: 'fr-CA,en-CA' or 'fr-CA, en-CA, es-MX, pt-BR'",
"RegionalTranslationRateLimit": "Regional Translation Rate Limit",
"RegionalTranslationRateLimitHelpText": "Delay in milliseconds between regional translation requests to TMDb API. This coordinates with all other TMDb requests to prevent rate limiting. Default: 500ms.",
"MovieInvalidFormat": "Movie: Invalid Format",
"MovieIsDownloading": "Movie is downloading",
"MovieIsMonitored": "Movie is monitored",

View file

@ -0,0 +1,8 @@
namespace NzbDrone.Core.MetadataSource.SkyHook.Resource
{
public class TMDbTranslationsResponse
{
public int Id { get; set; }
public TranslationResource[] Translations { get; set; }
}
}

View file

@ -1,3 +1,5 @@
using Newtonsoft.Json;
namespace NzbDrone.Core.MetadataSource.SkyHook.Resource
{
public class TranslationResource
@ -5,5 +7,24 @@ public class TranslationResource
public string Title { get; set; }
public string Overview { get; set; }
public string Language { get; set; }
// For TMDb direct API response (iso codes + nested data)
[JsonProperty("iso_639_1")]
public string Iso6391 { get; set; }
[JsonProperty("iso_3166_1")]
public string Iso31661 { get; set; }
[JsonProperty("data")]
public TranslationDataResource Data { get; set; }
}
public class TranslationDataResource
{
[JsonProperty("title")]
public string Title { get; set; }
[JsonProperty("overview")]
public string Overview { get; set; }
}
}

View file

@ -29,6 +29,7 @@ public class SkyHookProxy : IProvideMovieInfo, ISearchForNewMovie
private readonly Logger _logger;
private readonly IHttpRequestBuilderFactory _radarrMetadata;
private readonly IHttpRequestBuilderFactory _tmdb;
private readonly IConfigService _configService;
private readonly IMovieService _movieService;
private readonly IMovieMetadataService _movieMetadataService;
@ -44,6 +45,7 @@ public SkyHookProxy(IHttpClient httpClient,
{
_httpClient = httpClient;
_radarrMetadata = requestBuilder.RadarrMetadata;
_tmdb = requestBuilder.TMDB;
_configService = configService;
_movieService = movieService;
_movieMetadataService = movieMetadataService;
@ -241,6 +243,11 @@ public MovieMetadata MapMovie(MovieResource resource)
movie.Translations.AddRange(resource.Translations.Select(MapTranslation));
// Fetch additional regional translations from TMDb (fr-CA, en-CA, etc.)
// Add these as Translations so they are used for indexer searches
var regionalTranslations = FetchRegionalTranslationsFromTMDb(resource.TmdbId);
movie.Translations.AddRange(regionalTranslations);
movie.OriginalLanguage = IsoLanguages.Find(resource.OriginalLanguage.ToLower())?.Language ?? Language.English;
movie.Website = resource.Homepage;
@ -638,6 +645,121 @@ private static MovieTranslation MapTranslation(TranslationResource arg)
return newAlternativeTitle;
}
private List<MovieTranslation> FetchRegionalTranslationsFromTMDb(int tmdbId)
{
// Check if feature is enabled
if (!_configService.FetchRegionalTranslations)
{
return new List<MovieTranslation>();
}
// Parse configured variants
var wantedVariants = _configService.RegionalTranslationVariants
.Split(',')
.Select(v => v.Trim().ToLower())
.Where(v => !string.IsNullOrEmpty(v))
.ToHashSet();
if (!wantedVariants.Any())
{
_logger.Debug("No regional translation variants configured, skipping fetch");
return new List<MovieTranslation>();
}
try
{
var request = _tmdb.Create()
.SetSegment("api", "3")
.SetSegment("route", "movie")
.SetSegment("id", tmdbId.ToString())
.SetSegment("secondaryRoute", "/translations")
.Build();
// Use configured rate limit with coordination across all TMDb requests
request.RateLimit = TimeSpan.FromMilliseconds(_configService.RegionalTranslationRateLimit);
request.RateLimitKey = "regional-translations";
_logger.Info("Fetching regional translations for TMDb ID {0}", tmdbId);
var response = _httpClient.Get<TMDbTranslationsResponse>(request);
// Filter for configured regional variants and create MovieTranslations
var regionalTranslations = new List<MovieTranslation>();
_logger.Info("TMDb returned {0} translations, wanted variants: {1}", response.Resource.Translations?.Length ?? 0, string.Join(",", wantedVariants));
foreach (var translation in response.Resource.Translations)
{
// Build full regional code from iso_639_1 (language) and iso_3166_1 (country)
// e.g., "fr" + "CA" = "fr-ca"
var languageCode = translation.Iso6391?.ToLower();
var countryCode = translation.Iso31661?.ToLower();
if (string.IsNullOrEmpty(languageCode))
{
continue;
}
// Create regional variant code (e.g., "fr-ca")
var regionalCode = !string.IsNullOrEmpty(countryCode)
? $"{languageCode}-{countryCode}"
: languageCode;
// Check if this is a wanted regional variant
if (wantedVariants.Contains(regionalCode))
{
// Get title and overview from nested data object (TMDb API format)
var title = translation.Data?.Title ?? translation.Title;
var overview = translation.Data?.Overview ?? translation.Overview;
if (string.IsNullOrEmpty(title))
{
_logger.Info("Matched {0} but title is empty, skipping", regionalCode);
continue;
}
// Map the language code to Radarr's Language enum
var language = IsoLanguages.Find(languageCode)?.Language;
if (language == null)
{
_logger.Info("Could not map language code {0} to Radarr language for regional translation", languageCode);
continue;
}
// Create a MovieTranslation so it's used in indexer searches
var movieTranslation = new MovieTranslation
{
Title = title,
Overview = overview,
CleanTitle = title.CleanMovieTitle(),
Language = language
};
regionalTranslations.Add(movieTranslation);
_logger.Info("Added regional translation {0} ({1}) for movie {2}", regionalCode, title, tmdbId);
}
}
_logger.Info("Fetched {0} regional translations for TMDb ID {1}", regionalTranslations.Count, tmdbId);
return regionalTranslations;
}
catch (TooManyRequestsException ex)
{
_logger.Warn("Rate limit hit fetching regional translations for TMDb ID {0}. Will retry on next refresh. Retry after: {1}", tmdbId, ex.RetryAfter);
return new List<MovieTranslation>();
}
catch (HttpException ex)
{
_logger.Debug(ex, "HTTP error fetching regional translations for TMDb ID {0}", tmdbId);
return new List<MovieTranslation>();
}
catch (Exception ex)
{
_logger.Error(ex, "Unexpected error fetching regional translations for TMDb ID {0}", tmdbId);
return new List<MovieTranslation>();
}
}
private static Ratings MapRatings(RatingResource ratings)
{
if (ratings == null)

View file

@ -51,8 +51,10 @@ public List<MovieTranslation> UpdateTranslations(List<MovieTranslation> translat
// Then throw out any we don't have languages for
translations = translations.Where(t => t.Language != null).ToList();
// Then make sure they are all distinct languages
translations = translations.DistinctBy(t => t.Language).ToList();
// Make sure translations are distinct by their CleanTitle
// This allows multiple translations for the same language with different titles
// (e.g., fr="À nous quatre" and fr-CA="L'attrape-parents" both map to Language.French)
translations = translations.DistinctBy(t => t.CleanTitle).ToList();
// Now find translations to delete, update and insert
var existingTranslations = _translationRepo.FindByMovieMetadataId(movieMetadataId);

View file

@ -11,15 +11,21 @@ public static class IsoLanguages
private static readonly HashSet<IsoLanguage> All = new HashSet<IsoLanguage>
{
new IsoLanguage("en", "", "eng", "English", Language.English),
new IsoLanguage("en", "ca", "eng", "English (Canada)", Language.English),
new IsoLanguage("fr", "fr", "fra", "French", Language.French),
new IsoLanguage("fr", "ca", "fra", "French (Canada)", Language.French),
new IsoLanguage("es", "", "spa", "Spanish", Language.Spanish),
new IsoLanguage("de", "de", "deu", "German", Language.German),
new IsoLanguage("de", "at", "deu", "German (Austria)", Language.German),
new IsoLanguage("de", "ch", "deu", "German (Switzerland)", Language.German),
new IsoLanguage("it", "", "ita", "Italian", Language.Italian),
new IsoLanguage("da", "", "dan", "Danish", Language.Danish),
new IsoLanguage("nl", "", "nld", "Dutch", Language.Dutch),
new IsoLanguage("ja", "", "jpn", "Japanese", Language.Japanese),
new IsoLanguage("is", "", "isl", "Icelandic", Language.Icelandic),
new IsoLanguage("zh", "cn", "zho", "Chinese", Language.Chinese),
new IsoLanguage("zh", "tw", "zho", "Chinese (Taiwan)", Language.Chinese),
new IsoLanguage("zh", "hk", "zho", "Chinese (Hong Kong)", Language.Chinese),
new IsoLanguage("ru", "", "rus", "Russian", Language.Russian),
new IsoLanguage("pl", "", "pol", "Polish", Language.Polish),
new IsoLanguage("vi", "", "vie", "Vietnamese", Language.Vietnamese),

View file

@ -30,6 +30,9 @@ public class MediaManagementConfigResource : RestResource
public bool ImportExtraFiles { get; set; }
public string ExtraFileExtensions { get; set; }
public bool EnableMediaInfo { get; set; }
public bool FetchRegionalTranslations { get; set; }
public string RegionalTranslationVariants { get; set; }
public int RegionalTranslationRateLimit { get; set; }
}
public static class MediaManagementConfigResourceMapper
@ -59,7 +62,10 @@ public static MediaManagementConfigResource ToResource(IConfigService model)
ScriptImportPath = model.ScriptImportPath,
ImportExtraFiles = model.ImportExtraFiles,
ExtraFileExtensions = model.ExtraFileExtensions,
EnableMediaInfo = model.EnableMediaInfo
EnableMediaInfo = model.EnableMediaInfo,
FetchRegionalTranslations = model.FetchRegionalTranslations,
RegionalTranslationVariants = model.RegionalTranslationVariants,
RegionalTranslationRateLimit = model.RegionalTranslationRateLimit
};
}
}