diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.tsx b/frontend/src/Settings/MediaManagement/MediaManagement.tsx index 6547415336..e516104a6a 100644 --- a/frontend/src/Settings/MediaManagement/MediaManagement.tsx +++ b/frontend/src/Settings/MediaManagement/MediaManagement.tsx @@ -461,6 +461,124 @@ function MediaManagement() { +
+ + {translate('FetchRegionalTranslations')} + + + + + {settings.fetchRegionalTranslations.value ? ( + <> + + {translate('RegionalTranslationVariants')} + + + + + + {translate('RegionalTranslationRateLimit')} + + + + + ) : null} +
+ +
+ + {translate('FetchRegionalTranslations')} + + + + + {settings.fetchRegionalTranslations.value ? ( + <> + + {translate('RegionalTranslationVariants')} + + + + + + {translate('RegionalTranslationRateLimit')} + + + + + ) : null} +
+ {showAdvancedSettings && !isWindows ? (
().Verify(c => c.Upsert("downloadedepisodesfolder", It.IsAny()), 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); + } } } diff --git a/src/NzbDrone.Core.Test/ParserTests/IsoLanguagesFixture.cs b/src/NzbDrone.Core.Test/ParserTests/IsoLanguagesFixture.cs index 0d398a771a..c37d6b8605 100644 --- a/src/NzbDrone.Core.Test/ParserTests/IsoLanguagesFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/IsoLanguagesFixture.cs @@ -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); + } } } diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 361d997eb0..eae0ea5b9d 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -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); } diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index f425f540e7..9b37376e45 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -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; } diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 23a3b71ab8..88b60363ba 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -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", diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/TMDbTranslationsResponse.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/TMDbTranslationsResponse.cs new file mode 100644 index 0000000000..4a0304e220 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/TMDbTranslationsResponse.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.MetadataSource.SkyHook.Resource +{ + public class TMDbTranslationsResponse + { + public int Id { get; set; } + public TranslationResource[] Translations { get; set; } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/TranslationResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/TranslationResource.cs index 686cd3c29c..9b81c7e19a 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/TranslationResource.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/TranslationResource.cs @@ -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; } } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs index 6afb732698..3f6a1d05b5 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs @@ -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 FetchRegionalTranslationsFromTMDb(int tmdbId) + { + // Check if feature is enabled + if (!_configService.FetchRegionalTranslations) + { + return new List(); + } + + // 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(); + } + + 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(request); + + // Filter for configured regional variants and create MovieTranslations + var regionalTranslations = new List(); + _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(); + } + catch (HttpException ex) + { + _logger.Debug(ex, "HTTP error fetching regional translations for TMDb ID {0}", tmdbId); + return new List(); + } + catch (Exception ex) + { + _logger.Error(ex, "Unexpected error fetching regional translations for TMDb ID {0}", tmdbId); + return new List(); + } + } + + + private static Ratings MapRatings(RatingResource ratings) { if (ratings == null) diff --git a/src/NzbDrone.Core/Movies/Translations/MovieTranslationService.cs b/src/NzbDrone.Core/Movies/Translations/MovieTranslationService.cs index 41f7434238..4b30cb832b 100644 --- a/src/NzbDrone.Core/Movies/Translations/MovieTranslationService.cs +++ b/src/NzbDrone.Core/Movies/Translations/MovieTranslationService.cs @@ -51,8 +51,10 @@ public List UpdateTranslations(List 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); diff --git a/src/NzbDrone.Core/Parser/IsoLanguages.cs b/src/NzbDrone.Core/Parser/IsoLanguages.cs index be75aeb92d..74896c18d6 100644 --- a/src/NzbDrone.Core/Parser/IsoLanguages.cs +++ b/src/NzbDrone.Core/Parser/IsoLanguages.cs @@ -11,15 +11,21 @@ public static class IsoLanguages private static readonly HashSet All = new HashSet { 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), diff --git a/src/Radarr.Api.V3/Config/MediaManagementConfigResource.cs b/src/Radarr.Api.V3/Config/MediaManagementConfigResource.cs index 37db584089..659f327151 100644 --- a/src/Radarr.Api.V3/Config/MediaManagementConfigResource.cs +++ b/src/Radarr.Api.V3/Config/MediaManagementConfigResource.cs @@ -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 }; } }