Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>`, 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.
Expand Down
11 changes: 5 additions & 6 deletions symfony/src/Controller/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 7 additions & 5 deletions symfony/src/Controller/TmdbController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 = [
Expand All @@ -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) {
Expand All @@ -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'];
Expand Down
42 changes: 42 additions & 0 deletions symfony/src/Service/Media/TmdbClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,48 @@ 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<string> $append extra country codes discovered in the payload
* @return list<string>
*/
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'],
'en' => ['US', 'GB', 'CA', 'AU'],
'es' => ['ES', 'MX', 'AR'],
'de' => ['DE', 'AT', 'CH'],
'pt' => ['PT', 'BR'],
'it' => ['IT', 'CH'],
// 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 ([$region, ...$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`
Expand Down
83 changes: 83 additions & 0 deletions symfony/tests/Service/Media/TmdbClientRegionPriorityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php
namespace App\Tests\Service\Media;

use App\Service\Media\TmdbClient;
use PHPUnit\Framework\TestCase;

/**
* TmdbClient::regionPriority — the locale-led country ordering that replaced
* the old hardcoded FR-first lists for release dates / certifications /
* watch providers.
*/
final class TmdbClientRegionPriorityTest extends TestCase
{
public function testFrenchLeadsWithFrenchRegions(): void
{
$order = TmdbClient::regionPriority('fr');
self::assertSame('FR', $order[0]);
self::assertContains('US', $order);
// BE comes before US for a French user.
self::assertLessThan(array_search('US', $order, true), array_search('BE', $order, true));
}

public function testEnglishLeadsWithUsThenGb(): void
{
$order = TmdbClient::regionPriority('en');
self::assertSame('US', $order[0]);
self::assertContains('GB', $order);
// US comes before FR for an English user (the whole point of the fix).
self::assertLessThan(array_search('FR', $order, true), array_search('US', $order, true));
}

public function testLocaleWithRegionSuffixHandled(): void
{
self::assertSame('US', TmdbClient::regionPriority('en_US')[0]);
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']);
self::assertContains('JP', $order);
self::assertContains('KR', $order);
// Order-preserving with no duplicates, even though 'US' was appended
// while already present in the lead/fallback chain.
self::assertSame(array_values(array_unique($order)), $order);
self::assertCount(1, array_keys($order, 'US', true));
}

public function testUnknownLocaleStillHasCommonFallbackChain(): void
{
$order = TmdbClient::regionPriority('xx');
self::assertContains('FR', $order);
self::assertContains('US', $order);
self::assertContains('GB', $order);
}

public function testEmptyLocaleProducesCommonChainWithoutBlanks(): void
{
$order = TmdbClient::regionPriority('');
self::assertContains('US', $order);
self::assertContains('FR', $order);
self::assertNotContains('', $order);
}
}