From 7161c10e7a14bd4315795b71e36c4ee7b63d60e5 Mon Sep 17 00:00:00 2001 From: ndandan Date: Tue, 16 Jun 2026 20:58:12 -0500 Subject: [PATCH] perf(media): cache library payload + batch arr endpoints Radarr/Sonarr library pages re-fetched and re-normalised the full getMovies()/getSeries() payload on every visit, and fetched status, queue, indexers, health and calendar sequentially. - MediaLibraryCache: 45s per-instance cache of the normalised payload; empty results not cached; write-through invalidation on library mutations. - RadarrClient/SonarrClient multiGet(): one curl_multi batch for the per-page endpoints (same SSRF guard, timeouts and circuit breaker as get()). - MediaController films()/series() rewired to cache + batch; public normalizers reused on the batch payloads. Revisits ~3x faster; a slow/unreachable instance costs one timeout window instead of stacking. Covered by MediaLibraryCacheTest, MediaLibraryCacheInvalidationTest, RadarrClientMultiGetTest and a MediaLibraryPageTest smoke test (renders when the arr is unreachable). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 + symfony/src/Controller/MediaController.php | 172 +++++++++++++----- .../src/Service/Media/MediaLibraryCache.php | 70 +++++++ symfony/src/Service/Media/RadarrClient.php | 120 +++++++++++- symfony/src/Service/Media/SonarrClient.php | 126 ++++++++++++- .../tests/Controller/MediaFilteredIdsTest.php | 2 + .../MediaLibraryCacheInvalidationTest.php | 102 +++++++++++ .../tests/Controller/MediaLibraryPageTest.php | 33 ++++ .../Controller/MediaReleasesSearchTest.php | 2 + .../Service/Media/MediaLibraryCacheTest.php | 94 ++++++++++ .../Media/RadarrClientMultiGetTest.php | 51 ++++++ 11 files changed, 729 insertions(+), 48 deletions(-) create mode 100644 symfony/src/Service/Media/MediaLibraryCache.php create mode 100644 symfony/tests/Controller/MediaLibraryCacheInvalidationTest.php create mode 100644 symfony/tests/Controller/MediaLibraryPageTest.php create mode 100644 symfony/tests/Service/Media/MediaLibraryCacheTest.php create mode 100644 symfony/tests/Service/Media/RadarrClientMultiGetTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e923b9f..a7741b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to Prismarr are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Performance +- **Faster Radarr / Sonarr library pages.** The heavy `getMovies()` / `getSeries()` payload is now cached per instance for 45 s (`MediaLibraryCache`) instead of being re-fetched and re-normalised on every visit, and the per-page status / queue / indexers / health / calendar calls run in a single `curl_multi` batch (`multiGet()`) rather than sequentially. Cold loads are unchanged, but revisits within the window are roughly 3× faster, and a slow or unreachable instance now costs one timeout window for the whole page instead of stacking one timeout per call. Empty results are not cached and library mutations invalidate the entry, so user changes still show immediately. Same per-handle semantics as the existing `get()` (SSRF protocol guard, connect/total timeouts, per-instance circuit breaker). + ## [1.1.1] - 2026-06-10 ### Fixed diff --git a/symfony/src/Controller/MediaController.php b/symfony/src/Controller/MediaController.php index 2ed3e5f..63619f7 100644 --- a/symfony/src/Controller/MediaController.php +++ b/symfony/src/Controller/MediaController.php @@ -6,6 +6,7 @@ use App\Entity\ServiceInstance; use App\Service\ConfigService; use App\Service\DisplayPreferencesService; +use App\Service\Media\MediaLibraryCache; use App\Service\Media\MovieLibraryFilter; use App\Service\Media\MovieLibraryQuery; use App\Service\Media\ProwlarrClient; @@ -42,8 +43,31 @@ public function __construct( private readonly ServiceInstanceProvider $instances, private readonly LoggerInterface $logger, private readonly TranslatorInterface $translator, + private readonly MediaLibraryCache $libraryCache, ) {} + /** + * Drop the cached library list for the currently-bound instance after a + * mutating action so the change shows on the next page load. No-op-safe + * when no instance is bound (guards/subscribers normally prevent that). + */ + private function invalidateRadarrLibrary(): void + { + $slug = $this->radarr->getInstance()?->getSlug(); + if ($slug !== null) { + $this->libraryCache->invalidate('radarr', $slug); + } + } + + /** Drop the cached series list for the bound Sonarr instance. */ + private function invalidateSonarrLibrary(): void + { + $slug = $this->sonarr->getInstance()?->getSlug(); + if ($slug !== null) { + $this->libraryCache->invalidate('sonarr', $slug); + } + } + /** * v1.1.0 Phase C — slug is mandatory, injected via the class-level * /medias/{slug} prefix. The autowired RadarrClient is already bound @@ -68,41 +92,46 @@ public function films( $indexerCount = 0; $warnings = []; + + $instance = $this->radarr->getInstance() ?? $this->instances->getDefault(ServiceInstance::TYPE_RADARR); + if ($instance === null) { + throw $this->createNotFoundException('No Radarr instance configured.'); + } + $slug = $instance->getSlug(); + try { - // Check that Radarr is reachable - $status = $this->radarr->getSystemStatus(); - if ($status === null) { + // One concurrent batch for the cheap, volatile endpoints. The + // heavy movie list is fetched separately and cached (slow-changing). + $batch = $this->radarr->multiGet([ + 'status' => ['path' => '/api/v3/system/status'], + 'queue' => ['path' => '/api/v3/queue', 'params' => ['pageSize' => 50, 'includeMovie' => 'true']], + 'indexers' => ['path' => '/api/v3/indexer'], + 'health' => ['path' => '/api/v3/health'], + ]); + + if ($batch['status'] === null) { $error = true; - } + } else { + $movies = $this->libraryCache->movies($slug, fn() => $this->radarr->getMovies()); - if (!$error) { - $movies = $this->radarr->getMovies(); - $queue = $this->radarr->getQueue(); - $indexers = $this->radarr->getRadarrIndexers(); - $activeIndexers = array_filter($indexers, fn($i) => ($i['enableAutomaticSearch'] ?? false) || ($i['enableInteractiveSearch'] ?? false)); - $indexerCount = count($activeIndexers); + $queue = $this->radarr->normalizeQueueRecords($batch['queue']['records'] ?? []); - // Check indexer status - if ($indexerCount === 0) { - $warnings[] = $this->translator->trans('media.api.no_indexer'); - } + $indexers = $batch['indexers'] ?? []; + $activeIndexers = array_filter($indexers, fn($i) => ($i['enableAutomaticSearch'] ?? false) || ($i['enableInteractiveSearch'] ?? false)); + $indexerCount = count($activeIndexers); + if ($indexerCount === 0) { + $warnings[] = $this->translator->trans('media.api.no_indexer'); + } - // Check Radarr health - try { - $health = $this->radarr->getSystemHealth(); - foreach ($health as $h) { + foreach ($batch['health'] ?? [] as $h) { $warnings[] = $this->translator->trans('media.api.warning_format', ['source' => $h['source'] ?? 'Radarr', 'message' => $h['message'] ?? '?']); } - } catch (\Throwable $e) { - $this->logger->warning('Media films failed', ['exception' => $e::class, 'message' => $e->getMessage()]); - } - // Check for blocked items in the queue - $blocked = array_filter($queue, fn($q) => ($q['trackedState'] ?? '') === 'importBlocked'); - if (count($blocked) > 0) { - $warnings[] = $this->translator->trans('media.import.blocked_warning', ['count' => count($blocked)]); + $blocked = array_filter($queue, fn($q) => ($q['trackedState'] ?? '') === 'importBlocked'); + if (count($blocked) > 0) { + $warnings[] = $this->translator->trans('media.import.blocked_warning', ['count' => count($blocked)]); + } } - } // end if (!$error) } catch (\Throwable $e) { $this->logger->warning('Media films failed', ['exception' => $e::class, 'message' => $e->getMessage()]); $error = true; @@ -147,14 +176,7 @@ public function films( $library = $filter->apply($movies, $query); - $current = $this->radarr->getInstance() ?? $this->instances->getDefault(ServiceInstance::TYPE_RADARR); - // Defensive guard. ServiceRouteGuardSubscriber should redirect long - // before we reach this point when no Radarr instance is configured, - // but if a worker keeps a stale binding around we'd otherwise render - // a template that calls path() with a null slug → 500. - if ($current === null) { - throw $this->createNotFoundException('No Radarr instance configured.'); - } + $current = $instance; return $this->render('media/films.html.twig', [ 'movies' => $library->items, 'library' => $library, @@ -184,13 +206,26 @@ public function series( $calendar = []; $error = false; + $instance = $this->sonarr->getInstance() ?? $this->instances->getDefault(ServiceInstance::TYPE_SONARR); + if ($instance === null) { + throw $this->createNotFoundException('No Sonarr instance configured.'); + } + $slug = $instance->getSlug(); + try { - if ($this->sonarr->getSystemStatus() === null) { + // getCalendar(14) → now through +14 days; keep the multiGet window in sync. + $batch = $this->sonarr->multiGet([ + 'status' => ['path' => '/api/v3/system/status'], + 'queue' => ['path' => '/api/v3/queue', 'params' => ['pageSize' => 50, 'includeSeries' => 'true', 'includeEpisode' => 'true']], + 'calendar' => ['path' => '/api/v3/calendar', 'params' => ['start' => (new \DateTimeImmutable('now'))->format('Y-m-d'), 'end' => (new \DateTimeImmutable('+14 days'))->format('Y-m-d'), 'includeSeries' => 'true']], + ]); + + if ($batch['status'] === null) { $error = true; } else { - $series = $this->sonarr->getSeries(); - $queue = $this->sonarr->getQueue(); - $calendar = $this->sonarr->getCalendar(14); + $series = $this->libraryCache->series($slug, fn() => $this->sonarr->getSeries()); + $queue = $this->sonarr->normalizeQueueRecords($batch['queue']['records'] ?? []); + $calendar = $this->sonarr->normalizeCalendarEntries($batch['calendar'] ?? []); } } catch (\Throwable $e) { $this->logger->warning('Media series failed', ['exception' => $e::class, 'message' => $e->getMessage()]); @@ -234,10 +269,7 @@ public function series( $library = $filter->apply($series, $query); - $current = $this->sonarr->getInstance() ?? $this->instances->getDefault(ServiceInstance::TYPE_SONARR); - if ($current === null) { - throw $this->createNotFoundException('No Sonarr instance configured.'); - } + $current = $instance; return $this->render('media/series.html.twig', [ 'series' => $library->items, 'library' => $library, @@ -414,6 +446,9 @@ public function filmMonitor(int $id, Request $request): JsonResponse { $monitored = (bool) ($request->toArray()['monitored'] ?? true); $ok = $this->radarr->setMonitored($id, $monitored); + if ($ok) { + $this->invalidateRadarrLibrary(); + } return $this->json(['ok' => $ok, 'monitored' => $monitored]); } @@ -424,6 +459,9 @@ public function filmDelete(int $id, Request $request): JsonResponse $deleteFiles = (bool) ($data['deleteFiles'] ?? false); $addExclusion = (bool) ($data['addExclusion'] ?? false); $ok = $this->radarr->deleteMovie($id, $deleteFiles, $addExclusion); + if ($ok) { + $this->invalidateRadarrLibrary(); + } return $this->json(['ok' => $ok]); } @@ -457,6 +495,9 @@ public function filmAdd(Request $request): JsonResponse $payload['rootFolderPath'] = $data['rootFolderPath'] ?? ''; } $movie = $this->radarr->addMovie($payload); + if ($movie !== null) { + $this->invalidateRadarrLibrary(); + } return $this->json(['ok' => $movie !== null, 'movie' => $movie, 'movieId' => $movie['id'] ?? null]); } @@ -568,6 +609,9 @@ public function filmFiles(int $id): JsonResponse public function filmFileDelete(int $fileId): JsonResponse { $ok = $this->radarr->deleteMovieFile($fileId); + if ($ok) { + $this->invalidateRadarrLibrary(); + } return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Radarr', $this->radarr); } @@ -599,6 +643,11 @@ public function filmFileUpdate(int $fileId, Request $request): JsonResponse // 3. PUT via RadarrClient $result = $this->radarr->updateMovieFile($fileId, $current); + if ($result !== null) { + // quality / languages / releaseGroup feed normalizeMovie's cached + // quality + language fields, so the list must refresh. + $this->invalidateRadarrLibrary(); + } return $this->json(['ok' => $result !== null, 'file' => $result]); } @@ -698,6 +747,9 @@ public function filmsBulkEdit(Request $request): JsonResponse if (isset($data['tags'])) $changes['tags'] = $data['tags']; if (isset($data['applyTags'])) $changes['applyTags'] = $data['applyTags']; // add, remove, replace $ok = $this->radarr->bulkUpdateMovies($ids, $changes); + if ($ok) { + $this->invalidateRadarrLibrary(); + } return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Radarr', $this->radarr); } @@ -710,6 +762,9 @@ public function filmsBulkDelete(Request $request): JsonResponse $addExclusion = (bool) ($data['addExclusion'] ?? false); if (!$ids) return $this->json(['ok' => false]); $ok = $this->radarr->bulkDeleteMovies($ids, $deleteFiles, $addExclusion); + if ($ok) { + $this->invalidateRadarrLibrary(); + } return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Radarr', $this->radarr); } @@ -879,6 +934,9 @@ public function seriesAdd(Request $request): JsonResponse ]; $result = $this->sonarr->addSeries($raw); + if ($result !== null) { + $this->invalidateSonarrLibrary(); + } return $this->json(['ok' => $result !== null, 'series' => $result]); } @@ -944,6 +1002,9 @@ public function seriesBulkEdit(Request $request): JsonResponse if (isset($data['applyTags'])) $payload['applyTags'] = $data['applyTags']; $result = $this->sonarr->bulkEditSeries($payload); + if ($result['ok'] ?? false) { + $this->invalidateSonarrLibrary(); + } return $this->json($result); } @@ -956,6 +1017,9 @@ public function seriesBulkDelete(Request $request): JsonResponse $deleteFiles = (bool) ($data['deleteFiles'] ?? false); $addExclusion = (bool) ($data['addImportExclusion'] ?? false); $ok = $this->sonarr->bulkDeleteSeries($ids, $deleteFiles, $addExclusion); + if ($ok) { + $this->invalidateSonarrLibrary(); + } return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Sonarr', $this->sonarr); } @@ -964,7 +1028,11 @@ public function seriesImportBatch(Request $request): JsonResponse { $series = $request->toArray(); if (empty($series)) return $this->json(['ok' => false, 'error' => $this->translator->trans('media.api.no_series')]); - return $this->json($this->sonarr->importSeries($series)); + $result = $this->sonarr->importSeries($series); + if ($result['ok'] ?? false) { + $this->invalidateSonarrLibrary(); + } + return $this->json($result); } #[Route('/series/{id}/refresh', name: 'series_refresh', methods: ['POST'], requirements: ['id' => '\d+'])] @@ -1204,6 +1272,9 @@ public function filmEdit(int $id, Request $request): JsonResponse } $merged = array_merge($fullMovie, $raw); $updated = $this->radarr->updateMovie($id, $merged); + if ($updated !== null) { + $this->invalidateRadarrLibrary(); + } return $this->json(['ok' => $updated !== null, 'movie' => $updated]); } @@ -1477,6 +1548,9 @@ public function seriesFiles(int $id): JsonResponse public function seriesFileDelete(int $id): JsonResponse { $ok = $this->sonarr->deleteEpisodeFile($id); + if ($ok) { + $this->invalidateSonarrLibrary(); + } return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Sonarr', $this->sonarr); } @@ -1494,6 +1568,9 @@ public function seasonMonitor(Request $request): JsonResponse $seasonNum = (int) ($data['seasonNumber'] ?? 0); $monitored = (bool) ($data['monitored'] ?? true); $ok = $this->sonarr->setSeasonMonitored($seriesId, $seasonNum, $monitored); + if ($ok) { + $this->invalidateSonarrLibrary(); + } return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Sonarr', $this->sonarr); } @@ -1769,6 +1846,9 @@ public function serieEdit(int $id, Request $request): JsonResponse if (isset($data['path'])) $series['path'] = $data['path']; $result = $this->sonarr->updateSeries($id, $series); + if ($result !== null) { + $this->invalidateSonarrLibrary(); + } return $this->json(['ok' => $result !== null]); } @@ -1871,6 +1951,9 @@ public function serieMonitor(int $id, Request $request): JsonResponse { $monitored = (bool) ($request->toArray()['monitored'] ?? true); $ok = $this->sonarr->setMonitored($id, $monitored); + if ($ok) { + $this->invalidateSonarrLibrary(); + } return $this->json(['ok' => $ok, 'monitored' => $monitored]); } @@ -1879,6 +1962,9 @@ public function serieDelete(int $id, Request $request): JsonResponse { $deleteFiles = (bool) ($request->toArray()['deleteFiles'] ?? false); $ok = $this->sonarr->deleteSeries($id, $deleteFiles); + if ($ok) { + $this->invalidateSonarrLibrary(); + } return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Sonarr', $this->sonarr); } diff --git a/symfony/src/Service/Media/MediaLibraryCache.php b/symfony/src/Service/Media/MediaLibraryCache.php new file mode 100644 index 0000000..4b61bfa --- /dev/null +++ b/symfony/src/Service/Media/MediaLibraryCache.php @@ -0,0 +1,70 @@ + + */ + public function movies(string $slug, callable $fetch): array + { + return $this->fetchCached($this->key('movies', $slug), $fetch); + } + + /** + * @param callable():array $fetch + * @return array + */ + public function series(string $slug, callable $fetch): array + { + return $this->fetchCached($this->key('series', $slug), $fetch); + } + + /** Drop the cached list for an instance after a mutating action. */ + public function invalidate(string $type, string $slug): void + { + $kind = $type === 'sonarr' ? 'series' : 'movies'; + $this->cache->delete($this->key($kind, $slug)); + } + + /** + * @param callable():array $fetch + * @return array + */ + private function fetchCached(string $key, callable $fetch): array + { + return $this->cache->get($key, function (ItemInterface $item) use ($fetch) { + $result = $fetch(); + $item->expiresAfter($result === [] ? 0 : self::TTL); + return $result; + }); + } + + private function key(string $kind, string $slug): string + { + return 'media.' . $kind . '.' . $slug; + } +} diff --git a/symfony/src/Service/Media/RadarrClient.php b/symfony/src/Service/Media/RadarrClient.php index 58f506c..121d2c1 100644 --- a/symfony/src/Service/Media/RadarrClient.php +++ b/symfony/src/Service/Media/RadarrClient.php @@ -214,7 +214,20 @@ public function getMovies(): array $data = $this->get('/api/v3/movie'); if ($data === null) return []; - return array_map(fn($m) => $this->normalizeMovie($m), $data); + return $this->normalizeMovies($data); + } + + /** + * Normalize a raw `/api/v3/movie` list payload. Public so callers that + * obtained the raw list another way (e.g. a concurrent multiGet batch) + * can reuse the exact same transform instead of duplicating it. + * + * @param array> $rawMovies + * @return list> + */ + public function normalizeMovies(array $rawMovies): array + { + return array_map(fn($m) => $this->normalizeMovie($m), $rawMovies); } /** Returns raw movies without normalization (for lightweight cache) */ @@ -324,6 +337,18 @@ public function getQueue(): array $data = $this->get('/api/v3/queue', ['pageSize' => 50, 'includeMovie' => 'true']); if ($data === null || empty($data['records'])) return []; + return $this->normalizeQueueRecords($data['records']); + } + + /** + * Normalize raw queue `records` into the shape the films UI expects. + * Public so a concurrent multiGet batch can reuse it. + * + * @param array> $records + * @return list> + */ + public function normalizeQueueRecords(array $records): array + { return array_map(fn($r) => [ 'id' => $r['id'] ?? null, 'movieId' => $r['movieId'] ?? null, @@ -345,7 +370,7 @@ public function getQueue(): array 'title' => $m['title'] ?? '', 'messages' => $m['messages'] ?? [], ], $r['statusMessages'] ?? []), - ], $data['records']); + ], $records); } public function getRawQueue(): array @@ -1602,6 +1627,97 @@ private function get(string $path, array $params = []): ?array return json_decode($body, true); } + /** + * Concurrent GET of several endpoints in a single curl_multi batch. + * + * Same per-handle semantics as get(): SSRF protocol guard, 4 s connect / + * 8 s total timeout, NOSIGNAL, X-Api-Key + Accept headers. Returns a map + * name => decoded array, or null for any handle that errored / returned + * non-200. The whole batch short-circuits to nulls instantly when the + * instance's circuit breaker is open, so a down service costs one timeout + * window across the page instead of one per call (the old sequential + * get() calls stacked N × 8 s). + * + * @param array $requests + * @return array + */ + public function multiGet(array $requests): array + { + $out = array_fill_keys(array_keys($requests), null); + if ($requests === []) { + return $out; + } + if ($this->health->isDown(self::SERVICE_KEY, $this->instance?->getSlug()) || $this->serviceUnavailable) { + $this->serviceUnavailable = true; + return $out; + } + $this->lastError = null; + $this->ensureConfig(); + + $mh = curl_multi_init(); + $handles = []; + foreach ($requests as $name => $spec) { + $url = rtrim($this->baseUrl, '/') . $spec['path']; + if (!empty($spec['params'])) { + $url .= '?' . http_build_query($spec['params']); + } + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, // SSRF guard + CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_TIMEOUT => 8, + CURLOPT_CONNECTTIMEOUT => 4, + CURLOPT_NOSIGNAL => 1, + CURLOPT_HTTPHEADER => ["X-Api-Key: {$this->apiKey}", 'Accept: application/json'], + ]); + curl_multi_add_handle($mh, $ch); + $handles[$name] = $ch; + } + + do { + $status = curl_multi_exec($mh, $running); + if ($running) { + curl_multi_select($mh, 1.0); + } + } while ($running && $status === CURLM_OK); + + $networkError = false; + foreach ($handles as $name => $ch) { + $body = curl_multi_getcontent($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_multi_remove_handle($mh, $ch); + curl_close($ch); + + if ($err !== '' || (int) $code === 0) { + $networkError = true; + $this->logger->warning("RadarrClient multiGet {$name} → HTTP {$code} {$err}"); + continue; // leave null + } + if ((int) $code !== 200) { + $this->logger->warning("RadarrClient multiGet {$name} → HTTP {$code}"); + continue; // leave null + } + $decoded = json_decode((string) $body, true); + $out[$name] = is_array($decoded) ? $decoded : null; + } + curl_multi_close($mh); + + // Mirror get(): a transport-level failure trips the breaker for the + // rest of the request + the cross-request window; a clean batch clears + // any stale down-marker. + if ($networkError) { + $this->serviceUnavailable = true; + $this->health->markDown(self::SERVICE_KEY, $this->instance?->getSlug()); + } else { + $this->health->clear(self::SERVICE_KEY, $this->instance?->getSlug()); + } + + return $out; + } + private function put(string $path, array $body): bool { return $this->request('PUT', $path, [], $body) !== null; diff --git a/symfony/src/Service/Media/SonarrClient.php b/symfony/src/Service/Media/SonarrClient.php index c5d2981..e8bc66d 100644 --- a/symfony/src/Service/Media/SonarrClient.php +++ b/symfony/src/Service/Media/SonarrClient.php @@ -201,7 +201,19 @@ public function getSeries(): array $data = $this->get('/api/v3/series'); if ($data === null) return []; - return array_map(fn($s) => $this->normalizeSeries($s), $data); + return $this->normalizeSeriesList($data); + } + + /** + * Normalize a raw `/api/v3/series` list. Public so a concurrent multiGet + * batch can reuse the exact transform. + * + * @param array> $rawSeries + * @return list> + */ + public function normalizeSeriesList(array $rawSeries): array + { + return array_map(fn($s) => $this->normalizeSeries($s), $rawSeries); } /** Returns raw series without normalization (for lightweight cache) */ @@ -438,6 +450,18 @@ public function getQueue(): array $data = $this->get('/api/v3/queue', ['pageSize' => 50, 'includeSeries' => 'true', 'includeEpisode' => 'true']); if ($data === null || empty($data['records'])) return []; + return $this->normalizeQueueRecords($data['records']); + } + + /** + * Normalize raw queue `records` into the series-UI shape. Public so a + * concurrent multiGet batch can reuse it. + * + * @param array> $records + * @return list> + */ + public function normalizeQueueRecords(array $records): array + { return array_map(fn($r) => [ 'id' => $r['id'] ?? null, 'seriesId' => $r['seriesId'] ?? null, @@ -463,7 +487,7 @@ public function getQueue(): array 'title' => $m['title'] ?? '', 'messages' => $m['messages'] ?? [], ], $r['statusMessages'] ?? []), - ], $data['records']); + ], $records); } public function getQueueDetails(): array @@ -512,6 +536,19 @@ public function getCalendar(int $daysAhead = 30, int $daysBefore = 0): array $data = $this->get('/api/v3/calendar', ['start' => $start, 'end' => $end, 'includeSeries' => 'true']); if ($data === null) return []; + return $this->normalizeCalendarEntries($data); + } + + /** + * Normalize raw `/api/v3/calendar` entries. Public so a concurrent + * multiGet batch can reuse the transform. Date handling preserved + * exactly (see the airDate vs airDateUtc note, issue #26). + * + * @param array> $entries + * @return list> + */ + public function normalizeCalendarEntries(array $entries): array + { return array_map(fn($e) => [ 'id' => $e['id'] ?? null, 'seriesId' => $e['seriesId'] ?? null, @@ -543,7 +580,7 @@ public function getCalendar(int $daysAhead = 30, int $daysBefore = 0): array 'runtime' => $e['runtime'] ?? ($e['series']['runtime'] ?? null), 'network' => $e['series']['network'] ?? null, 'genres' => $e['series']['genres'] ?? [], - ], $data); + ], $entries); } // ── Wanted ──────────────────────────────────────────────────────────────── @@ -1734,6 +1771,89 @@ private function get(string $path, array $params = [], int $timeout = 4): ?array return json_decode($body, true); } + /** + * Concurrent GET of several endpoints in one curl_multi batch. Same + * per-handle semantics as get(); see RadarrClient::multiGet() for the + * full rationale (collapses N sequential calls and N × timeout-stacking + * into a single batch). + * + * @param array $requests + * @return array + */ + public function multiGet(array $requests): array + { + $out = array_fill_keys(array_keys($requests), null); + if ($requests === []) { + return $out; + } + if ($this->health->isDown(self::SERVICE_KEY, $this->instance?->getSlug()) || $this->serviceUnavailable) { + $this->serviceUnavailable = true; + return $out; + } + $this->lastError = null; + $this->ensureConfig(); + + $mh = curl_multi_init(); + $handles = []; + foreach ($requests as $name => $spec) { + $url = rtrim($this->baseUrl, '/') . $spec['path']; + if (!empty($spec['params'])) { + $url .= '?' . http_build_query($spec['params']); + } + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, // SSRF guard + CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_TIMEOUT => 8, + CURLOPT_CONNECTTIMEOUT => 4, + CURLOPT_NOSIGNAL => 1, + CURLOPT_HTTPHEADER => ["X-Api-Key: {$this->apiKey}", 'Accept: application/json'], + ]); + curl_multi_add_handle($mh, $ch); + $handles[$name] = $ch; + } + + do { + $status = curl_multi_exec($mh, $running); + if ($running) { + curl_multi_select($mh, 1.0); + } + } while ($running && $status === CURLM_OK); + + $networkError = false; + foreach ($handles as $name => $ch) { + $body = curl_multi_getcontent($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_multi_remove_handle($mh, $ch); + curl_close($ch); + + if ($err !== '' || (int) $code === 0) { + $networkError = true; + $this->logger->warning("SonarrClient multiGet {$name} → HTTP {$code} {$err}"); + continue; + } + if ((int) $code !== 200) { + $this->logger->warning("SonarrClient multiGet {$name} → HTTP {$code}"); + continue; + } + $decoded = json_decode((string) $body, true); + $out[$name] = is_array($decoded) ? $decoded : null; + } + curl_multi_close($mh); + + if ($networkError) { + $this->serviceUnavailable = true; + $this->health->markDown(self::SERVICE_KEY, $this->instance?->getSlug()); + } else { + $this->health->clear(self::SERVICE_KEY, $this->instance?->getSlug()); + } + + return $out; + } + private function delete(string $path, array $params = []): bool { if ($this->health->isDown(self::SERVICE_KEY, $this->instance?->getSlug())) { diff --git a/symfony/tests/Controller/MediaFilteredIdsTest.php b/symfony/tests/Controller/MediaFilteredIdsTest.php index d1b62f9..729d1eb 100644 --- a/symfony/tests/Controller/MediaFilteredIdsTest.php +++ b/symfony/tests/Controller/MediaFilteredIdsTest.php @@ -4,6 +4,7 @@ use App\Controller\MediaController; use App\Service\ConfigService; +use App\Service\Media\MediaLibraryCache; use App\Service\Media\MovieLibraryFilter; use App\Service\Media\ProwlarrClient; use App\Service\Media\QBittorrentClient; @@ -49,6 +50,7 @@ private function controller(?RadarrClient $radarr = null, ?SonarrClient $sonarr $this->createMock(ServiceInstanceProvider::class), new NullLogger(), $translator, + $this->createMock(MediaLibraryCache::class), ); $container = $this->createMock(ContainerInterface::class); $container->method('has')->willReturn(false); diff --git a/symfony/tests/Controller/MediaLibraryCacheInvalidationTest.php b/symfony/tests/Controller/MediaLibraryCacheInvalidationTest.php new file mode 100644 index 0000000..09d9e53 --- /dev/null +++ b/symfony/tests/Controller/MediaLibraryCacheInvalidationTest.php @@ -0,0 +1,102 @@ +createMock(SonarrClient::class), + $this->createMock(ProwlarrClient::class), + $this->createMock(QBittorrentClient::class), + $this->createMock(CacheInterface::class), + $this->createMock(ConfigService::class), + $this->createMock(ServiceInstanceProvider::class), + new NullLogger(), + $this->createMock(TranslatorInterface::class), + $cache, + ); + $container = $this->createMock(ContainerInterface::class); + $container->method('has')->willReturn(false); + $controller->setContainer($container); + return $controller; + } + + private function boundRadarr(): RadarrClient + { + $instance = $this->createMock(ServiceInstance::class); + $instance->method('getSlug')->willReturn('radarr-1'); + + $radarr = $this->createMock(RadarrClient::class); + $radarr->method('getInstance')->willReturn($instance); + $radarr->method('getMovieFile')->willReturn(['id' => 5, 'quality' => ['quality' => ['id' => 1]]]); + return $radarr; + } + + private function updateRequest(): Request + { + return Request::create( + '/medias/radarr-1/films/files/5/update', + 'POST', + content: json_encode(['quality' => ['quality' => ['id' => 7, 'name' => '1080p']]]), + ); + } + + public function testMovieFileUpdateInvalidatesLibraryCacheOnSuccess(): void + { + $radarr = $this->boundRadarr(); + $radarr->method('updateMovieFile')->willReturn(['id' => 5]); // upstream write succeeds + + $cache = $this->createMock(MediaLibraryCache::class); + $cache->expects($this->once()) + ->method('invalidate') + ->with('radarr', 'radarr-1'); + + $res = $this->controller($radarr, $cache)->filmFileUpdate(5, $this->updateRequest()); + + $this->assertSame(200, $res->getStatusCode()); + $this->assertStringContainsString('"ok":true', (string) $res->getContent()); + } + + public function testMovieFileUpdateDoesNotInvalidateWhenUpstreamWriteFails(): void + { + $radarr = $this->boundRadarr(); + $radarr->method('updateMovieFile')->willReturn(null); // upstream write fails + + $cache = $this->createMock(MediaLibraryCache::class); + $cache->expects($this->never())->method('invalidate'); + + $res = $this->controller($radarr, $cache)->filmFileUpdate(5, $this->updateRequest()); + + $this->assertStringContainsString('"ok":false', (string) $res->getContent()); + } +} diff --git a/symfony/tests/Controller/MediaLibraryPageTest.php b/symfony/tests/Controller/MediaLibraryPageTest.php new file mode 100644 index 0000000..0f3d6bd --- /dev/null +++ b/symfony/tests/Controller/MediaLibraryPageTest.php @@ -0,0 +1,33 @@ +client->request('GET', '/medias/radarr-1/films'); + + self::assertResponseIsSuccessful(); + } + + public function testSeriesPageRendersWhenSonarrUnreachable(): void + { + $this->client->request('GET', '/medias/sonarr-1/series'); + + self::assertResponseIsSuccessful(); + } +} diff --git a/symfony/tests/Controller/MediaReleasesSearchTest.php b/symfony/tests/Controller/MediaReleasesSearchTest.php index 32a624d..2cc37c3 100644 --- a/symfony/tests/Controller/MediaReleasesSearchTest.php +++ b/symfony/tests/Controller/MediaReleasesSearchTest.php @@ -4,6 +4,7 @@ use App\Controller\MediaController; use App\Service\ConfigService; +use App\Service\Media\MediaLibraryCache; use App\Service\Media\ProwlarrClient; use App\Service\Media\QBittorrentClient; use App\Service\Media\RadarrClient; @@ -38,6 +39,7 @@ private function controller(?RadarrClient $radarr = null, ?SonarrClient $sonarr $this->createMock(ServiceInstanceProvider::class), new NullLogger(), $this->createMock(TranslatorInterface::class), + $this->createMock(MediaLibraryCache::class), ); // Empty container so AbstractController::json() falls back to a plain // JsonResponse instead of looking up the serializer service. diff --git a/symfony/tests/Service/Media/MediaLibraryCacheTest.php b/symfony/tests/Service/Media/MediaLibraryCacheTest.php new file mode 100644 index 0000000..59265dd --- /dev/null +++ b/symfony/tests/Service/Media/MediaLibraryCacheTest.php @@ -0,0 +1,94 @@ + 1]]; }; + + $first = $cache->movies('radarr-1', $fetch); + $second = $cache->movies('radarr-1', $fetch); + + $this->assertSame([['id' => 1]], $first); + $this->assertSame([['id' => 1]], $second); + $this->assertSame(1, $calls, 'second call must hit the cache, not re-fetch'); + } + + public function testEmptyResultIsNotCached(): void + { + $cache = new MediaLibraryCache(new ArrayAdapter()); + $calls = 0; + $fetch = function () use (&$calls) { $calls++; return []; }; + + $cache->movies('radarr-1', $fetch); + $cache->movies('radarr-1', $fetch); + + $this->assertSame(2, $calls, 'empty result must expire immediately so the next load retries'); + } + + public function testInstancesAreKeyedIndependently(): void + { + $cache = new MediaLibraryCache(new ArrayAdapter()); + + $a = $cache->movies('radarr-1', fn() => [['id' => 1]]); + $b = $cache->movies('radarr-4k', fn() => [['id' => 99]]); + + $this->assertSame([['id' => 1]], $a); + $this->assertSame([['id' => 99]], $b); + } + + public function testMoviesAndSeriesDoNotCollide(): void + { + $cache = new MediaLibraryCache(new ArrayAdapter()); + + $movies = $cache->movies('x-1', fn() => [['id' => 1]]); + $series = $cache->series('x-1', fn() => [['id' => 2]]); + + $this->assertSame([['id' => 1]], $movies); + $this->assertSame([['id' => 2]], $series); + } + + public function testInvalidateDropsTheCachedList(): void + { + $cache = new MediaLibraryCache(new ArrayAdapter()); + $calls = 0; + $fetch = function () use (&$calls) { $calls++; return [['id' => 1]]; }; + + $cache->movies('radarr-1', $fetch); + $cache->invalidate('radarr', 'radarr-1'); + $cache->movies('radarr-1', $fetch); + + $this->assertSame(2, $calls, 'invalidate() must force a re-fetch on the next load'); + } + + public function testInvalidateSonarrTargetsSeriesKey(): void + { + $cache = new MediaLibraryCache(new ArrayAdapter()); + $movieCalls = 0; + $seriesCalls = 0; + + $cache->movies('s-1', function () use (&$movieCalls) { $movieCalls++; return [['id' => 1]]; }); + $cache->series('s-1', function () use (&$seriesCalls) { $seriesCalls++; return [['id' => 2]]; }); + + $cache->invalidate('sonarr', 's-1'); + + $cache->movies('s-1', function () use (&$movieCalls) { $movieCalls++; return [['id' => 1]]; }); + $cache->series('s-1', function () use (&$seriesCalls) { $seriesCalls++; return [['id' => 2]]; }); + + $this->assertSame(1, $movieCalls, 'invalidating sonarr must not drop the movies cache'); + $this->assertSame(2, $seriesCalls, 'invalidating sonarr must drop the series cache'); + } +} diff --git a/symfony/tests/Service/Media/RadarrClientMultiGetTest.php b/symfony/tests/Service/Media/RadarrClientMultiGetTest.php new file mode 100644 index 0000000..dac9c3c --- /dev/null +++ b/symfony/tests/Service/Media/RadarrClientMultiGetTest.php @@ -0,0 +1,51 @@ +markDown('radarr'); // unkeyed: matches the default (null-slug) instance + + // The instance provider must never be consulted — the breaker check + // happens before ensureConfig(). A real-but-unconfigured provider mock + // proves we never reached config resolution. + $instances = $this->createMock(ServiceInstanceProvider::class); + $instances->expects($this->never())->method('getDefault'); + + $client = new RadarrClient($instances, new NullLogger(), $health); + + $result = $client->multiGet([ + 'status' => ['path' => '/api/v3/system/status'], + 'queue' => ['path' => '/api/v3/queue'], + ]); + + $this->assertSame(['status' => null, 'queue' => null], $result); + } + + public function testEmptyRequestListReturnsEmptyArray(): void + { + $health = new ServiceHealthCache(new ArrayAdapter()); + $instances = $this->createMock(ServiceInstanceProvider::class); + $client = new RadarrClient($instances, new NullLogger(), $health); + + $this->assertSame([], $client->multiGet([])); + } +}