From bc66453fc036575d8ab28014c239be0674a5aad7 Mon Sep 17 00:00:00 2001 From: ndandan Date: Sat, 11 Jul 2026 19:23:54 -0500 Subject: [PATCH 1/3] tmdb: drive release/cert/provider region priority from locale, not FR (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design-critique deferred item. TMDb release dates, certifications, alt titles and watch/providers all picked countries from hardcoded FR-first lists, so an English user saw French release dates and providers first. - New TmdbClient::regionPriority(locale, append) returns a country-code priority led by the locale's home region (en → US/GB/CA/AU, fr → FR/BE/LU/CA, + es/de/pt/it), followed by a broad common fallback chain and any extra countries present in the payload, de-duplicated and order-preserving. - Wired at all six sites (via $this->translator->getLocale()): TmdbController alt-titles / pickProviders / pickMovieCertification / pickTvCertification, DashboardController tmdbMovieReleaseDates / quick-look providers. - Unit test for regionPriority (6 cases: fr/en lead, locale suffix, append dedup, unknown + empty locale fallbacks) — green. Co-Authored-By: Claude Opus 4.8 --- .../src/Controller/DashboardController.php | 11 ++-- symfony/src/Controller/TmdbController.php | 12 ++-- symfony/src/Service/Media/TmdbClient.php | 33 ++++++++++ .../Media/TmdbClientRegionPriorityTest.php | 64 +++++++++++++++++++ 4 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 symfony/tests/Service/Media/TmdbClientRegionPriorityTest.php diff --git a/symfony/src/Controller/DashboardController.php b/symfony/src/Controller/DashboardController.php index bd32e44f..271146bd 100644 --- a/symfony/src/Controller/DashboardController.php +++ b/symfony/src/Controller/DashboardController.php @@ -547,10 +547,9 @@ private function tmdbMovieReleaseDates(array $results, \DateTimeImmutable $today $byCountry[$cc][(int) ($rd['type'] ?? 0)] = $rd['release_date'] ?? null; } } - $order = ['FR', 'US']; - foreach (array_keys($byCountry) as $cc) { - if (!in_array($cc, $order, true)) $order[] = $cc; - } + // Locale-led country priority (was hardcoded FR→US), then any other + // countries the payload contains, so an English user gets US/GB dates. + $order = TmdbClient::regionPriority($this->translator->getLocale(), array_keys($byCountry)); $pick = function (array $types) use ($byCountry, $order): ?\DateTimeImmutable { foreach ($order as $cc) { foreach ($types as $t) { @@ -1041,10 +1040,10 @@ private function quickLookExtras(array $data): array ]; } - // Streaming (flatrate) providers, FR-first then common fallbacks — + // Streaming (flatrate) providers, locale-led then common fallbacks — // mirrors TmdbController::pickProviders' country priority. $providers = []; - foreach (['FR', 'BE', 'LU', 'US', 'GB'] as $cc) { + foreach (TmdbClient::regionPriority($this->translator->getLocale()) as $cc) { $flat = $data['watch/providers']['results'][$cc]['flatrate'] ?? []; if ($flat === []) { continue; diff --git a/symfony/src/Controller/TmdbController.php b/symfony/src/Controller/TmdbController.php index 143c3da2..b496e195 100644 --- a/symfony/src/Controller/TmdbController.php +++ b/symfony/src/Controller/TmdbController.php @@ -505,12 +505,14 @@ public function detail(string $type, int $id): JsonResponse } } - // Alternative titles (FR + EN priority) + // Alternative titles — keep the ones relevant to the user's region + // (locale-led) rather than a fixed FR-first whitelist. $altTitles = []; + $altRegions = TmdbClient::regionPriority($this->translator->getLocale()); $altSource = $isMovie ? ($d['alternative_titles']['titles'] ?? []) : ($d['alternative_titles']['results'] ?? []); foreach ($altSource as $at) { $cc = $at['iso_3166_1'] ?? ''; - if (!in_array($cc, ['FR', 'US', 'GB', 'CA', 'BE'], true)) continue; + if (!in_array($cc, $altRegions, true)) continue; $altTitles[] = ['country' => $cc, 'title' => $at['title'] ?? '']; if (count($altTitles) >= 6) break; } @@ -659,7 +661,7 @@ private function pickCrew(array $d, bool $isMovie): array private function pickProviders(array $byCountry): array { - foreach (['FR', 'BE', 'LU', 'US', 'GB'] as $cc) { + foreach (TmdbClient::regionPriority($this->translator->getLocale(), array_keys($byCountry)) as $cc) { if (empty($byCountry[$cc])) continue; $p = $byCountry[$cc]; $pack = [ @@ -685,7 +687,7 @@ private function pickProviders(array $byCountry): array private function pickMovieCertification(array $results): ?string { - foreach (['FR', 'US'] as $cc) { + foreach (TmdbClient::regionPriority($this->translator->getLocale()) as $cc) { foreach ($results as $r) { if (($r['iso_3166_1'] ?? '') !== $cc) continue; foreach ($r['release_dates'] ?? [] as $rd) { @@ -698,7 +700,7 @@ private function pickMovieCertification(array $results): ?string private function pickTvCertification(array $results): ?string { - foreach (['FR', 'US'] as $cc) { + foreach (TmdbClient::regionPriority($this->translator->getLocale()) as $cc) { foreach ($results as $r) { if (($r['iso_3166_1'] ?? '') !== $cc) continue; if (!empty($r['rating'])) return "[$cc] " . $r['rating']; diff --git a/symfony/src/Service/Media/TmdbClient.php b/symfony/src/Service/Media/TmdbClient.php index 49f34064..89a9fc61 100644 --- a/symfony/src/Service/Media/TmdbClient.php +++ b/symfony/src/Service/Media/TmdbClient.php @@ -268,6 +268,39 @@ public static function backdropUrl(?string $path, string $size = 'w1280'): ?stri return $path ? self::IMG_BASE . "/{$size}{$path}" : null; } + /** + * TMDb country-code priority for release dates / certifications / watch + * providers. Led by the region implied by $locale — an English user sees + * US/GB data first, a French user FR/BE — then a broad common fallback + * chain, then any extra country codes the payload actually contains + * ($append). Order-preserving and de-duplicated. Replaces the old + * hardcoded FR-first lists so localized users get relevant regions first. + * + * @param list $append extra country codes discovered in the payload + * @return list + */ + public static function regionPriority(string $locale, array $append = []): array + { + $lang = strtolower(substr($locale, 0, 2)); + $lead = match ($lang) { + 'fr' => ['FR', 'BE', 'LU', 'CA'], + 'en' => ['US', 'GB', 'CA', 'AU'], + 'es' => ['ES', 'MX', 'AR'], + 'de' => ['DE', 'AT', 'CH'], + 'pt' => ['PT', 'BR'], + 'it' => ['IT', 'CH'], + default => $lang !== '' ? [strtoupper($lang)] : [], + }; + $order = []; + foreach ([...$lead, 'FR', 'US', 'GB', 'BE', 'LU', 'CA', ...$append] as $cc) { + $cc = strtoupper((string) $cc); + if ($cc !== '' && !in_array($cc, $order, true)) { + $order[] = $cc; + } + } + return $order; + } + private function cachedGet(string $cacheKey, string $path, array $params, int $ttl): array { // Cache keyed by locale so switching `display_metadata_language` diff --git a/symfony/tests/Service/Media/TmdbClientRegionPriorityTest.php b/symfony/tests/Service/Media/TmdbClientRegionPriorityTest.php new file mode 100644 index 00000000..0860a681 --- /dev/null +++ b/symfony/tests/Service/Media/TmdbClientRegionPriorityTest.php @@ -0,0 +1,64 @@ + Date: Fri, 21 Aug 2026 21:43:11 -0500 Subject: [PATCH 2/3] docs(changelog): note locale-driven TMDb region priority Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d653acfd..aa47c0b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **Release dates, certifications and watch providers were pinned to French regions for everyone.** The Discover/quick-look TMDb lookups picked release dates, content ratings, alternative titles and streaming providers from a hardcoded FR-first country list (e.g. `['FR', 'BE', 'LU', 'US', 'GB']`), so an English-locale install saw French theatrical dates and provider availability instead of its own. Region priority is now derived from the active locale — a new `TmdbClient::regionPriority(locale, append)` leads with the locale's home region (en → US/GB/CA/AU, fr → FR/BE/LU/CA, plus es/de/pt/it), falls back through a broad common chain, and appends any extra countries actually present in the payload, de-duplicated and order-preserving. Wired at all six lookup sites; covered by a unit test. - **Whole-server lockup on Unraid when the data volume sits on a FUSE share (`/mnt/user`)**. PHP's native file session handler holds an exclusive `flock` on the session file for the entire request, and the dashboard fires ~6 widget fragments in parallel, so they all serialised on that one lock while their slow Radarr/Sonarr calls ran. On Unraid the session file lives on the shfs/FUSE share, where `flock` contention is expensive enough to peg every core and freeze the whole machine (mapping the volume to `/mnt/cache` "fixed" it only by bypassing FUSE). A new `SessionLockReleaseSubscriber` now closes the session right after authentication on read-only GET requests, releasing the lock immediately so the parallel fragments stop fighting over it. POSTs, the setup wizard and internal routes keep the session open and write normally. Unraid users should still map the data volume to `/mnt/cache/...` rather than `/mnt/user/...`. - **Gluetun integration with API key set, and incorrect endpoints.** The Gluetun client authenticated using `Authorization: Bearer `, but Gluetun expects it as `X-API-Key`, so it would return a 401 error when an API key is required. Additionally, referring to the older [Control Server Docs](https://github.com/qdm12/gluetun-wiki/blob/7025b1c0e4427d4477e47d4bbd2ef3f1b5c4da71/setup/advanced/control-server.md#openvpn-and-wireguard), WireGuard doesn't get its own endpoint, so the `/v1/wireguard/status` and `/v1/wireguard/portforwarded` calls were incorrect. The client now sends `X-API-Key` and uses the unified `/v1/vpn/status` and `/v1/portforward` endpoints, with the legacy `/v1/openvpn/` paths as a fallback. With that, the protocol selector in the settings becomes redundant and was removed. - **Radarr/Sonarr sidebar entry unusable with 4+ instances** ([#44](https://github.com/Shoshuo/Prismarr/issues/44)). The dropdown toggle shown for 4 or more instances of the same service used the native Bootstrap `data-bs-toggle`, but nothing in the app initializes Bootstrap's dropdown JS anymore — every other dropdown had already moved to a shared click-delegate. The triangle rendered but never opened, making every instance beyond the first three unreachable. The toggle now uses the same delegate as the rest of the app, and its label shows the active instance's name instead of staying stuck on the generic service name. From e3827ef186e09b38ad6e26fdbd0672492e361d66 Mon Sep 17 00:00:00 2001 From: ndandan Date: Fri, 21 Aug 2026 23:01:26 -0500 Subject: [PATCH 3/3] fix(tmdb): honor the locale's region subtag; never upcast a language code to a country MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regionPriority() fixes: - An explicit region subtag now leads the chain outright: en_GB users get GB-first (previously the 'en' language map still led with US), fr-CA leads CA, pt_BR leads BR. - The default arm no longer fabricates a country from the language code. 'sv' is Swedish but 'SV' is El Salvador — an unmapped language upcast that way would rank the wrong country's certifications/providers first ('ja'->JA, 'ko'->KO, 'uk'->UK are invalid or wrong codes). Unmapped languages now fall straight to the common chain + payload countries. Latent today (enabled_locales is en+fr, both mapped) but the method is a public static API; regression tests added for both behaviors. Co-Authored-By: Claude Fable 5 --- symfony/src/Service/Media/TmdbClient.php | 13 +++++++++++-- .../Media/TmdbClientRegionPriorityTest.php | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/symfony/src/Service/Media/TmdbClient.php b/symfony/src/Service/Media/TmdbClient.php index 89a9fc61..bc1cf1fe 100644 --- a/symfony/src/Service/Media/TmdbClient.php +++ b/symfony/src/Service/Media/TmdbClient.php @@ -281,6 +281,12 @@ public static function backdropUrl(?string $path, string $size = 'w1280'): ?stri */ public static function regionPriority(string $locale, array $append = []): array { + // An explicit region subtag wins outright: en_GB / fr-CA users have + // literally named their country, so it leads even the language map. + $region = ''; + if (preg_match('/^[a-zA-Z]{2,3}[_-]([a-zA-Z]{2})\b/', $locale, $m)) { + $region = strtoupper($m[1]); + } $lang = strtolower(substr($locale, 0, 2)); $lead = match ($lang) { 'fr' => ['FR', 'BE', 'LU', 'CA'], @@ -289,10 +295,13 @@ public static function regionPriority(string $locale, array $append = []): array 'de' => ['DE', 'AT', 'CH'], 'pt' => ['PT', 'BR'], 'it' => ['IT', 'CH'], - default => $lang !== '' ? [strtoupper($lang)] : [], + // Unknown language → no lead guess. (Never upcast the language + // code itself: 'sv' is Swedish, but 'SV' is El Salvador.) The + // common chain + payload countries below still apply. + default => [], }; $order = []; - foreach ([...$lead, 'FR', 'US', 'GB', 'BE', 'LU', 'CA', ...$append] as $cc) { + foreach ([$region, ...$lead, 'FR', 'US', 'GB', 'BE', 'LU', 'CA', ...$append] as $cc) { $cc = strtoupper((string) $cc); if ($cc !== '' && !in_array($cc, $order, true)) { $order[] = $cc; diff --git a/symfony/tests/Service/Media/TmdbClientRegionPriorityTest.php b/symfony/tests/Service/Media/TmdbClientRegionPriorityTest.php index 0860a681..c6c97a17 100644 --- a/symfony/tests/Service/Media/TmdbClientRegionPriorityTest.php +++ b/symfony/tests/Service/Media/TmdbClientRegionPriorityTest.php @@ -35,6 +35,25 @@ public function testLocaleWithRegionSuffixHandled(): void self::assertSame('FR', TmdbClient::regionPriority('fr-FR')[0]); } + public function testExplicitRegionSubtagLeadsEvenOverTheLanguageMap(): void + { + // The user literally named their country — it outranks the language + // map's guess (en would otherwise lead with US). + self::assertSame('GB', TmdbClient::regionPriority('en_GB')[0]); + self::assertSame('CA', TmdbClient::regionPriority('fr-CA')[0]); + self::assertSame('BR', TmdbClient::regionPriority('pt_BR')[0]); + } + + public function testLanguageCodeIsNeverUpcastToACountryCode(): void + { + // 'sv' is Swedish; 'SV' is El Salvador. An unmapped language must NOT + // fabricate a country from its own code — it falls to the common chain. + self::assertNotContains('SV', TmdbClient::regionPriority('sv')); + self::assertNotContains('JA', TmdbClient::regionPriority('ja')); + self::assertNotContains('KO', TmdbClient::regionPriority('ko')); + self::assertSame('FR', TmdbClient::regionPriority('sv')[0]); + } + public function testAppendedCountriesIncludedAndDeduped(): void { $order = TmdbClient::regionPriority('en', ['JP', 'US', 'KR']);