From 6c7d06c7bf50fc93cd0f153a751b696b1149040d Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Sun, 17 May 2026 12:00:21 -0400 Subject: [PATCH 1/2] Step D.1: add Auth ProviderInterface + AuthResult + UserInfo (v0.3.0) --- CHANGELOG.md | 15 +++++ src/Auth/AuthResult.php | 117 +++++++++++++++++++++++++++++++++ src/Auth/ProviderInterface.php | 101 ++++++++++++++++++++++++++++ src/Auth/UserInfo.php | 92 ++++++++++++++++++++++++++ src/Version.php | 2 +- 5 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 src/Auth/AuthResult.php create mode 100644 src/Auth/ProviderInterface.php create mode 100644 src/Auth/UserInfo.php diff --git a/CHANGELOG.md b/CHANGELOG.md index eeecb64..f94cb91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +## [0.3.0] — 2026-05-17 + +### Added +- `Phlex\Shared\Auth\ProviderInterface` — core interface for pluggable external + authentication providers (OIDC, LDAP, SAML, passkeys). Zero I/O dependencies + so both phlex-server and phlex-hub can implement providers without pulling in + server/runtime dependencies. +- `Phlex\Shared\Auth\AuthResult` — immutable value object returned by + `ProviderInterface::authenticate()`. Captures success/failure, local userId, + provider externalId, error code, and arbitrary attributes (email, name, + avatarUrl …). +- `Phlex\Shared\Auth\UserInfo` — immutable value object returned by + `ProviderInterface::getUserInfo()`. Describes an external identity for + account linking and profile display. + ## [0.2.0] — 2026-05-17 ### Added diff --git a/src/Auth/AuthResult.php b/src/Auth/AuthResult.php new file mode 100644 index 0000000..942dcfa --- /dev/null +++ b/src/Auth/AuthResult.php @@ -0,0 +1,117 @@ + 'alice@example.com', 'name' => 'Alice'] + * ); + * + * // Failure case + * $result = new AuthResult( + * success: false, + * error: 'token_expired', + * ); + * ); + * ``` + */ +final readonly class AuthResult +{ + /** + * @param bool $success True when authentication succeeded. + * @param string|null $userId Local Phlex user UUID (null on failure). + * @param string|null $externalId Provider-specific ID (null on failure). + * @param string|null $error Machine-readable error code (null on success). + * @param array $attributes Arbitrary provider-returned claims + * (email, name, avatarUrl, etc.). + */ + public function __construct( + public bool $success, + public ?string $userId = null, + public ?string $externalId = null, + public ?string $error = null, + public array $attributes = [], + ) { + } + + /** + * Return true when the authentication attempt succeeded. + * + * @return bool + */ + public function isSuccess(): bool + { + return $this->success; + } + + /** + * Return true when the authentication attempt failed. + * + * @return bool + */ + public function isFailure(): bool + { + return !$this->success; + } + + /** + * Convenience: return the email from attributes, if present. + * + * @return string|null + */ + public function getEmail(): ?string + { + /** @var mixed $email */ + $email = $this->attributes['email'] ?? null; + + return is_string($email) ? $email : null; + } + + /** + * Convenience: return the display name from attributes, if present. + * + * @return string|null + */ + public function getDisplayName(): ?string + { + /** @var mixed $name */ + $name = $this->attributes['name'] ?? null; + + return is_string($name) ? $name : null; + } + + /** + * Convenience: return the avatar URL from attributes, if present. + * + * @return string|null + */ + public function getAvatarUrl(): ?string + { + /** @var mixed $avatarUrl */ + $avatarUrl = $this->attributes['avatarUrl'] ?? null; + + return is_string($avatarUrl) ? $avatarUrl : null; + } +} diff --git a/src/Auth/ProviderInterface.php b/src/Auth/ProviderInterface.php new file mode 100644 index 0000000..b6bf1b5 --- /dev/null +++ b/src/Auth/ProviderInterface.php @@ -0,0 +1,101 @@ + $credentials Provider-specific credential bag. + * @return bool True when this provider's authenticate() would not immediately fail. + */ + public function supportsAuthentication(array $credentials): bool; + + /** + * Authenticate a user with the given credentials. + * + * This is the main entry point. Implementations are responsible + * for all I/O (token validation, userinfo endpoint calls, etc.) + * + * @param array $credentials Provider-specific credential bag. + * @return AuthResult Success includes userId (local) and externalId (provider-specific). + */ + public function authenticate(array $credentials): AuthResult; + + /** + * Look up user information by the provider's external identifier. + * + * Used when linking an existing local account to this provider. + * + * @param string $externalId The provider-specific user ID. + * @return UserInfo|null User info when found; null when the external ID is unknown. + */ + public function getUserInfo(string $externalId): ?UserInfo; + + /** + * Link an existing local Phlex user account to this provider. + * + * Called when a user who already has a local account chooses to + * connect it to an external identity (e.g. after first login via OIDC). + * Implementations may store linkage metadata in $externalIds for later + * use during authentication. + * + * @param string $localUserId The local Phlex user UUID. + * @param array $externalIds Map of provider names to their external IDs. + * @return void + */ + public function linkAccount(string $localUserId, array $externalIds): void; +} diff --git a/src/Auth/UserInfo.php b/src/Auth/UserInfo.php new file mode 100644 index 0000000..31a4af0 --- /dev/null +++ b/src/Auth/UserInfo.php @@ -0,0 +1,92 @@ + '12345', 'email_verified' => true] + * ); + * ``` + */ +final readonly class UserInfo +{ + /** + * @param string $externalId Provider-specific unique identifier. + * @param string|null $email User's email address (may be null for some providers). + * @param string|null $displayName Human-readable display name. + * @param string|null $avatarUrl URL to the user's avatar / profile picture. + * @param array $rawAttributes All provider-returned claims as key-value pairs. + */ + public function __construct( + public string $externalId, + public ?string $email = null, + public ?string $displayName = null, + public ?string $avatarUrl = null, + public array $rawAttributes = [], + ) { + } + + /** + * Return true when the external user has an email address on record. + * + * @return bool + */ + public function hasEmail(): bool + { + return $this->email !== null; + } + + /** + * Return true when the external user has a display name on record. + * + * @return bool + */ + public function hasDisplayName(): bool + { + return $this->displayName !== null; + } + + /** + * Return true when the external user has an avatar URL on record. + * + * @return bool + */ + public function hasAvatarUrl(): bool + { + return $this->avatarUrl !== null; + } + + /** + * Return a claim from rawAttributes by name, with optional default. + * + * @param string $name The claim key. + * @param mixed $default Value to return when the key is absent. + * @return mixed The claim value or $default. + */ + public function getClaim(string $name, mixed $default = null): mixed + { + return $this->rawAttributes[$name] ?? $default; + } +} diff --git a/src/Version.php b/src/Version.php index 00c9425..761ad4f 100644 --- a/src/Version.php +++ b/src/Version.php @@ -24,7 +24,7 @@ final class Version * * @var non-empty-string */ - public const VERSION = '0.2.0'; + public const VERSION = '0.3.0'; /** * Prevent instantiation — static marker only. From 03494d5c0bed915447460996f391489ebc7094b9 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Mon, 18 May 2026 22:21:16 -0400 Subject: [PATCH 2/2] post-O.7 Fix 1 (K.1): move arr clients from phlex-server to phlex-shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The K.1 plan (typed Sonarr/Radarr/Bazarr/Prowlarr clients) explicitly requires these to live in phlex-shared so both phlex-server AND phlex-hub can consume them. The post-O.7 architecture review found them living in phlex-server/src/Arr/ instead. Moved to phlex-shared (namespace Phlex\Shared\Arr): - ArrClientInterface - ArrClientFactory - SyncResult (DTO) - SonarrClient, RadarrClient, BazarrClient, ProwlarrClient (typed HTTP clients) - TrashGuidesProvider (URL-only fetcher) Stayed in phlex-server (Workerman MySQL coupling): - CustomFormatSyncer — orchestrates sync writes via $db->query(); will be updated in the companion phlex-server PR to import Phlex\Shared\Arr\*. Logger swap: arr classes now type-hint Psr\Log\LoggerInterface instead of phlex-server's concrete Phlex\Common\Logger\StructuredLogger. Adds psr/log to composer.json runtime deps. Interface fix: ArrClientInterface::getQueue() PHPDoc now reflects the real Sonarr/Radarr paginated response shape ({records, page, pageSize, ...}) rather than the incorrect array> claim. Bumps Version::VERSION 0.3.0 -> 0.4.0 and adds CHANGELOG entry. phpunit 164/164 green, phpstan level 9 clean (full tree including tests/), phpcs PSR-12 clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 23 ++ composer.json | 4 +- src/Arr/.gitkeep | 0 src/Arr/ArrClientFactory.php | 77 ++++ src/Arr/ArrClientInterface.php | 46 +++ src/Arr/BazarrClient.php | 254 +++++++++++++ src/Arr/ProwlarrClient.php | 246 +++++++++++++ src/Arr/RadarrClient.php | 495 ++++++++++++++++++++++++++ src/Arr/SonarrClient.php | 314 ++++++++++++++++ src/Arr/SyncResult.php | 94 +++++ src/Arr/TrashGuidesProvider.php | 296 +++++++++++++++ src/Version.php | 2 +- tests/Arr/ArrClientFactoryTest.php | 175 +++++++++ tests/Arr/BazarrClientTest.php | 198 +++++++++++ tests/Arr/ProwlarrClientTest.php | 183 ++++++++++ tests/Arr/RadarrClientTest.php | 240 +++++++++++++ tests/Arr/SonarrClientTest.php | 270 ++++++++++++++ tests/Arr/TrashGuidesProviderTest.php | 146 ++++++++ 18 files changed, 3061 insertions(+), 2 deletions(-) delete mode 100644 src/Arr/.gitkeep create mode 100644 src/Arr/ArrClientFactory.php create mode 100644 src/Arr/ArrClientInterface.php create mode 100644 src/Arr/BazarrClient.php create mode 100644 src/Arr/ProwlarrClient.php create mode 100644 src/Arr/RadarrClient.php create mode 100644 src/Arr/SonarrClient.php create mode 100644 src/Arr/SyncResult.php create mode 100644 src/Arr/TrashGuidesProvider.php create mode 100644 tests/Arr/ArrClientFactoryTest.php create mode 100644 tests/Arr/BazarrClientTest.php create mode 100644 tests/Arr/ProwlarrClientTest.php create mode 100644 tests/Arr/RadarrClientTest.php create mode 100644 tests/Arr/SonarrClientTest.php create mode 100644 tests/Arr/TrashGuidesProviderTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f94cb91..966b706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +## [0.4.0] — 2026-05-18 + +### Added +- `Phlex\Shared\Arr\ArrClientInterface` — common interface for Sonarr/Radarr + HTTP clients (queue, quality profiles, tags, test-connection). +- `Phlex\Shared\Arr\ArrClientFactory` — factory that instantiates Sonarr/Radarr + clients from instance config arrays. +- `Phlex\Shared\Arr\SyncResult` — immutable value object returned by sync flows. +- `Phlex\Shared\Arr\SonarrClient` — typed Sonarr v3 HTTP client. +- `Phlex\Shared\Arr\RadarrClient` — typed Radarr v3 HTTP client. +- `Phlex\Shared\Arr\BazarrClient` — typed Bazarr HTTP client. +- `Phlex\Shared\Arr\ProwlarrClient` — typed Prowlarr HTTP client. +- `Phlex\Shared\Arr\TrashGuidesProvider` — fetches TRaSH-Guides quality + profile + custom format JSON. +- `psr/log` runtime dependency to allow optional PSR-3 loggers on the arr + clients without pulling phlex-server's concrete `StructuredLogger`. + +### Changed +- Arr classes now type-hint `Psr\Log\LoggerInterface` instead of phlex-server's + `StructuredLogger`, allowing the hub and any other PSR-3 consumer to inject + its own logger. Required for Step K.1 (arr clients shared between + phlex-server and phlex-hub) and K.3 (hub-side request fulfillment). + ## [0.3.0] — 2026-05-17 ### Added diff --git a/composer.json b/composer.json index ec44872..c711342 100644 --- a/composer.json +++ b/composer.json @@ -5,8 +5,10 @@ "license": "MIT", "require": { "php": "^8.3", + "ext-curl": "*", "psr/container": "^2.0", - "psr/event-dispatcher": "^1.0" + "psr/event-dispatcher": "^1.0", + "psr/log": "^3.0" }, "require-dev": { "phpunit/phpunit": "^10.0", diff --git a/src/Arr/.gitkeep b/src/Arr/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Arr/ArrClientFactory.php b/src/Arr/ArrClientFactory.php new file mode 100644 index 0000000..751991e --- /dev/null +++ b/src/Arr/ArrClientFactory.php @@ -0,0 +1,77 @@ +config['sonarr'] ?? []; + + if (!($sonarrConfig['enabled'] ?? false)) { + return null; + } + + $url = $sonarrConfig['url'] ?? 'http://localhost:8989'; + $apiKey = $sonarrConfig['api_key'] ?? ''; + + if ($apiKey === '') { + $logger?->warning('Sonarr API key is empty but client is enabled'); + return null; + } + + return new SonarrClient($url, $apiKey, $logger); + } + + /** + * Creates a RadarrClient from the config. + * + * @param LoggerInterface|null $logger Optional logger instance. + * @return RadarrClient|null Client instance, or null if Radarr is not enabled. + */ + public function createRadarrClient(?LoggerInterface $logger = null): ?RadarrClient + { + $radarrConfig = $this->config['radarr'] ?? []; + + if (!($radarrConfig['enabled'] ?? false)) { + return null; + } + + $url = $radarrConfig['url'] ?? 'http://localhost:7878'; + $apiKey = $radarrConfig['api_key'] ?? ''; + + if ($apiKey === '') { + $logger?->warning('Radarr API key is empty but client is enabled'); + return null; + } + + return new RadarrClient($url, $apiKey, $logger); + } +} diff --git a/src/Arr/ArrClientInterface.php b/src/Arr/ArrClientInterface.php new file mode 100644 index 0000000..b575d5d --- /dev/null +++ b/src/Arr/ArrClientInterface.php @@ -0,0 +1,46 @@ +` rather than a list. + * + * @return array Paginated queue response. + */ + public function getQueue(): array; + + /** + * Returns available quality profiles. + * + * @return array> Quality profiles. + */ + public function getQualityProfiles(): array; + + /** + * Returns all configured tags. + * + * @return array> Tags. + */ + public function getTagList(): array; + + /** + * Tests connectivity and authentication with the *arr instance. + * + * @return bool True if connection is successful, false otherwise. + */ + public function testConnection(): bool; +} diff --git a/src/Arr/BazarrClient.php b/src/Arr/BazarrClient.php new file mode 100644 index 0000000..6072110 --- /dev/null +++ b/src/Arr/BazarrClient.php @@ -0,0 +1,254 @@ +baseUrl = rtrim($baseUrl, '/'); + $this->apiKey = $apiKey; + $this->logger = $logger; + $this->timeout = $timeout; + } + + /** + * Returns subtitles for a Sonarr series (and optionally a specific episode). + * + * @param string $sonarrSeriesId The Sonarr series ID. + * @param int|null $episodeFileId Optional episode file ID to filter by. + * @return array> Subtitles list. + */ + public function getSubtitles(string $sonarrSeriesId, ?int $episodeFileId = null): array + { + $path = '/api/v1/subtitles?sonarrSeriesId=' . urlencode($sonarrSeriesId); + if ($episodeFileId !== null) { + $path .= '&episodeFileId=' . $episodeFileId; + } + + /** @var array> */ + return $this->get($path); + } + + /** + * Returns available subtitle languages for a specific video file. + * + * @param string $videoFilePath The full path to the video file. + * @return array> Languages list. + */ + public function getSubtitleLanguages(string $videoFilePath): array + { + /** @var array> */ + return $this->get('/api/v1/languages?path=' . urlencode($videoFilePath)); + } + + /** + * Downloads a subtitle for a specific video file and language. + * + * @param string $videoFilePath The full path to the video file. + * @param string $languageCode The language code for the subtitle (e.g. `en`, `es`, `pt-BR`). + * @return array Download result. + */ + public function downloadSubtitle(string $videoFilePath, string $languageCode): array + { + $payload = [ + 'path' => $videoFilePath, + 'language' => $languageCode, + ]; + + return $this->post('/api/v1/subtitles/download', $payload); + } + + /** + * Returns all available subtitle languages configured in Bazarr. + * + * @return array> Languages list. + */ + public function getLanguages(): array + { + /** @var array> */ + return $this->get('/api/v1/languages/list'); + } + + /** + * Tests connectivity and authentication with the Bazarr instance. + * + * @return bool True if connection is successful, false otherwise. + */ + public function testConnection(): bool + { + try { + $response = $this->get('/api/v1/system'); + return isset($response['version']) || isset($response['bazarr']); + } catch (RuntimeException $e) { + $this->logger?->warning('Bazarr connection test failed: ' . $e->getMessage()); + return false; + } + } + + /** + * Performs a GET request. + * + * @param string $path Request path. + * @return array Decoded JSON response. + * @throws RuntimeException On network or HTTP errors. + */ + protected function get(string $path): array + { + $url = $this->baseUrl . $path; + assert($url !== ''); + + $ch = curl_init(); + if ($ch === false) { + throw new RuntimeException('curl_init() failed'); + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_HTTPHEADER => $this->buildHeaders(), + ]); + + /** @var string|false */ + $responseBody = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErrno = curl_errno($ch); + $curlError = curl_error($ch); + curl_close($ch); + + if ($responseBody === false || $curlErrno !== 0) { + throw new RuntimeException('cURL error: ' . $curlError, $curlErrno); + } + + if ($httpCode === 401) { + throw new RuntimeException('Bazarr API authentication failed (401)'); + } + + if ($httpCode === 404) { + throw new RuntimeException('Bazarr API resource not found (404): ' . $path); + } + + if ($httpCode >= 400) { + throw new RuntimeException('Bazarr API error: HTTP ' . $httpCode); + } + + /** @var array */ + $decoded = json_decode($responseBody, true); + if (!is_array($decoded)) { + throw new RuntimeException('Invalid JSON response from Bazarr'); + } + + return $decoded; + } + + /** + * Performs a POST request with a JSON body. + * + * @param string $path Request path. + * @param array $body JSON-serializable body. + * @return array Decoded JSON response. + * @throws RuntimeException On network or HTTP errors. + */ + protected function post(string $path, array $body): array + { + $url = $this->baseUrl . $path; + assert($url !== ''); + $encodedBody = json_encode($body); + + if ($encodedBody === false) { + throw new RuntimeException('json_encode failed for Bazarr request body'); + } + + $ch = curl_init(); + if ($ch === false) { + throw new RuntimeException('curl_init() failed'); + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $encodedBody, + CURLOPT_HTTPHEADER => $this->buildHeaders(), + ]); + + /** @var string|false */ + $responseBody = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErrno = curl_errno($ch); + $curlError = curl_error($ch); + curl_close($ch); + + if ($responseBody === false || $curlErrno !== 0) { + throw new RuntimeException('cURL error: ' . $curlError, $curlErrno); + } + + if ($httpCode === 401) { + throw new RuntimeException('Bazarr API authentication failed (401)'); + } + + if ($httpCode === 404) { + throw new RuntimeException('Bazarr API resource not found (404): ' . $path); + } + + if ($httpCode >= 400) { + throw new RuntimeException('Bazarr API error: HTTP ' . $httpCode); + } + + if ($responseBody === '') { + return []; + } + + /** @var array */ + $decoded = json_decode($responseBody, true); + if (!is_array($decoded)) { + throw new RuntimeException('Invalid JSON response from Bazarr'); + } + + return $decoded; + } + + /** + * Builds the HTTP headers for Bazarr API requests. + * + * @return array Headers array. + */ + private function buildHeaders(): array + { + return [ + 'Content-Type: application/json', + 'Accept: application/json', + 'X-Api-Key: ' . $this->apiKey, + ]; + } +} diff --git a/src/Arr/ProwlarrClient.php b/src/Arr/ProwlarrClient.php new file mode 100644 index 0000000..f3d3768 --- /dev/null +++ b/src/Arr/ProwlarrClient.php @@ -0,0 +1,246 @@ +baseUrl = rtrim($baseUrl, '/'); + $this->apiKey = $apiKey; + $this->logger = $logger; + $this->timeout = $timeout; + } + + /** + * Returns all configured indexers. + * + * @return array> Indexers list. + */ + public function getIndexers(): array + { + /** @var array> */ + return $this->get('/api/v1/indexer'); + } + + /** + * Returns statistics for a specific indexer. + * + * @param int $indexerId The indexer ID. + * @return array Indexer stats. + */ + public function getIndexerStats(int $indexerId): array + { + return $this->get('/api/v1/indexer/' . $indexerId); + } + + /** + * Returns the health check results for Prowlarr. + * + * @return array> Health issues. + */ + public function getHealth(): array + { + /** @var array> */ + return $this->get('/api/v1/health'); + } + + /** + * Triggers a reindex check for a specific indexer. + * + * @param int $indexerId The indexer ID to recheck. + * @return bool True if the recheck was triggered successfully, false otherwise. + */ + public function triggerReindexerCheck(int $indexerId): bool + { + try { + $this->post('/api/v1/indexer/' . $indexerId . '/recheck', []); + return true; + } catch (RuntimeException $e) { + $this->logger?->warning('Prowlarr trigger reindexer check failed: ' . $e->getMessage()); + return false; + } + } + + /** + * Tests connectivity and authentication with the Prowlarr instance. + * + * @return bool True if connection is successful, false otherwise. + */ + public function testConnection(): bool + { + try { + $response = $this->get('/api/v1/system/status'); + return isset($response['version']); + } catch (RuntimeException $e) { + $this->logger?->warning('Prowlarr connection test failed: ' . $e->getMessage()); + return false; + } + } + + /** + * Performs a GET request. + * + * @param string $path Request path. + * @return array Decoded JSON response. + * @throws RuntimeException On network or HTTP errors. + */ + protected function get(string $path): array + { + $url = $this->baseUrl . $path; + assert($url !== ''); + + $ch = curl_init(); + if ($ch === false) { + throw new RuntimeException('curl_init() failed'); + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_HTTPHEADER => $this->buildHeaders(), + ]); + + /** @var string|false */ + $responseBody = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErrno = curl_errno($ch); + $curlError = curl_error($ch); + curl_close($ch); + + if ($responseBody === false || $curlErrno !== 0) { + throw new RuntimeException('cURL error: ' . $curlError, $curlErrno); + } + + if ($httpCode === 401) { + throw new RuntimeException('Prowlarr API authentication failed (401)'); + } + + if ($httpCode === 404) { + throw new RuntimeException('Prowlarr API resource not found (404): ' . $path); + } + + if ($httpCode >= 400) { + throw new RuntimeException('Prowlarr API error: HTTP ' . $httpCode); + } + + /** @var array */ + $decoded = json_decode($responseBody, true); + if (!is_array($decoded)) { + throw new RuntimeException('Invalid JSON response from Prowlarr'); + } + + return $decoded; + } + + /** + * Performs a POST request with a JSON body. + * + * @param string $path Request path. + * @param array $body JSON-serializable body. + * @return array Decoded JSON response. + * @throws RuntimeException On network or HTTP errors. + */ + protected function post(string $path, array $body): array + { + $url = $this->baseUrl . $path; + assert($url !== ''); + $encodedBody = json_encode($body); + + if ($encodedBody === false) { + throw new RuntimeException('json_encode failed for Prowlarr request body'); + } + + $ch = curl_init(); + if ($ch === false) { + throw new RuntimeException('curl_init() failed'); + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $encodedBody, + CURLOPT_HTTPHEADER => $this->buildHeaders(), + ]); + + /** @var string|false */ + $responseBody = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErrno = curl_errno($ch); + $curlError = curl_error($ch); + curl_close($ch); + + if ($responseBody === false || $curlErrno !== 0) { + throw new RuntimeException('cURL error: ' . $curlError, $curlErrno); + } + + if ($httpCode === 401) { + throw new RuntimeException('Prowlarr API authentication failed (401)'); + } + + if ($httpCode === 404) { + throw new RuntimeException('Prowlarr API resource not found (404): ' . $path); + } + + if ($httpCode >= 400) { + throw new RuntimeException('Prowlarr API error: HTTP ' . $httpCode); + } + + if ($responseBody === '') { + return []; + } + + /** @var array */ + $decoded = json_decode($responseBody, true); + if (!is_array($decoded)) { + throw new RuntimeException('Invalid JSON response from Prowlarr'); + } + + return $decoded; + } + + /** + * Builds the HTTP headers for Prowlarr API requests. + * + * @return array Headers array. + */ + private function buildHeaders(): array + { + return [ + 'Content-Type: application/json', + 'Accept: application/json', + 'X-Api-Key: ' . $this->apiKey, + ]; + } +} diff --git a/src/Arr/RadarrClient.php b/src/Arr/RadarrClient.php new file mode 100644 index 0000000..07070e9 --- /dev/null +++ b/src/Arr/RadarrClient.php @@ -0,0 +1,495 @@ +baseUrl = rtrim($baseUrl, '/'); + $this->apiKey = $apiKey; + $this->logger = $logger; + $this->timeout = $timeout; + } + + /** + * {@inheritdoc} + */ + public function getQueue(): array + { + /** @var array $response Paginated response with keys: records, page, pageSize, totalRecords */ + $response = $this->get('/api/v3/queue'); + return $response; + } + + /** + * Returns all tracked movies. + * + * @return array> Movies list. + */ + public function getMovies(): array + { + /** @var array> */ + return $this->get('/api/v3/movie'); + } + + /** + * Returns a specific movie by its Radarr ID. + * + * @param int $radarrId The Radarr movie ID. + * @return array Movie data. + */ + public function getMovieById(int $radarrId): array + { + return $this->get('/api/v3/movie/' . $radarrId); + } + + /** + * {@inheritdoc} + */ + public function getQualityProfiles(): array + { + /** @var array> */ + return $this->get('/api/v3/qualityprofile'); + } + + /** + * Returns all custom formats. + * + * @return array> Custom formats. + */ + public function getCustomFormats(): array + { + /** @var array> */ + return $this->get('/api/v3/customformat'); + } + + /** + * Creates a new custom format in Radarr. + * + * @param array $payload The custom format payload. + * @return int The ID of the created custom format. + */ + public function createCustomFormat(array $payload): int + { + $response = $this->post('/api/v3/customformat', $payload); + $id = $response['id'] ?? null; + + return is_numeric($id) ? (int) $id : 0; + } + + /** + * Updates an existing custom format in Radarr. + * + * @param int $id The custom format ID to update. + * @param array $payload The custom format payload. + * @return bool True on success. + */ + public function updateCustomFormat(int $id, array $payload): bool + { + $this->put('/api/v3/customformat/' . $id, $payload); + return true; + } + + /** + * Deletes a custom format from Radarr. + * + * @param int $id The custom format ID to delete. + * @return bool True on success. + */ + public function deleteCustomFormat(int $id): bool + { + $this->delete('/api/v3/customformat/' . $id); + return true; + } + + /** + * Creates a new quality profile in Radarr. + * + * @param array $payload The quality profile payload. + * @return int The ID of the created quality profile. + */ + public function createQualityProfile(array $payload): int + { + $response = $this->post('/api/v3/qualityprofile', $payload); + $id = $response['id'] ?? null; + + return is_numeric($id) ? (int) $id : 0; + } + + /** + * Updates an existing quality profile in Radarr. + * + * @param int $id The quality profile ID to update. + * @param array $payload The quality profile payload. + * @return bool True on success. + */ + public function updateQualityProfile(int $id, array $payload): bool + { + $this->put('/api/v3/qualityprofile/' . $id, $payload); + return true; + } + + /** + * {@inheritdoc} + */ + public function getTagList(): array + { + /** @var array> */ + return $this->get('/api/v3/tag'); + } + + /** + * Adds a new movie to Radarr. + * + * @param int|array $tmdbId The TMDB ID or array of TMDB IDs. + * @param int $qualityProfileId The quality profile ID to use. + * @param string $rootFolder The root folder path. + * @param bool $monitored Whether to monitor the movie (default true). + * @return array Created movie data. + */ + public function addMovie( + int|array $tmdbId, + int $qualityProfileId, + string $rootFolder, + bool $monitored = true + ): array { + $payload = [ + 'tmdbId' => $tmdbId, + 'qualityProfileId' => $qualityProfileId, + 'rootFolder' => $rootFolder, + 'monitored' => $monitored, + 'addOptions' => [ + 'searchForMovie' => true, + ], + ]; + + return $this->post('/api/v3/movie', $payload); + } + + /** + * Triggers a download for a movie by marking it as wanted and forcing a search. + * + * @param int $movieId The movie ID to trigger download for. + * @return bool True if successful, false otherwise. + */ + public function triggerDownload(int $movieId): bool + { + try { + $this->post('/api/v3/release/' . $movieId, []); + return true; + } catch (RuntimeException $e) { + $this->logger?->warning('Radarr trigger download failed: ' . $e->getMessage()); + return false; + } + } + + /** + * {@inheritdoc} + */ + public function testConnection(): bool + { + try { + $response = $this->get('/api/v3/system/status'); + return isset($response['version']); + } catch (RuntimeException $e) { + $this->logger?->warning('Radarr connection test failed: ' . $e->getMessage()); + return false; + } + } + + /** + * Performs a GET request. + * + * @param string $path Request path. + * @return array Decoded JSON response. + * @throws RuntimeException On network or HTTP errors. + */ + protected function get(string $path): array + { + $url = $this->baseUrl . $path; + assert($url !== ''); + + $ch = curl_init(); + if ($ch === false) { + throw new RuntimeException('curl_init() failed'); + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_HTTPHEADER => $this->buildHeaders(), + ]); + + /** @var string|false */ + $responseBody = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErrno = curl_errno($ch); + $curlError = curl_error($ch); + curl_close($ch); + + if ($responseBody === false || $curlErrno !== 0) { + throw new RuntimeException('cURL error: ' . $curlError, $curlErrno); + } + + if ($httpCode === 401) { + throw new RuntimeException('Radarr API authentication failed (401)'); + } + + if ($httpCode === 404) { + throw new RuntimeException('Radarr API resource not found (404): ' . $path); + } + + if ($httpCode >= 400) { + throw new RuntimeException('Radarr API error: HTTP ' . $httpCode); + } + + /** @var array */ + $decoded = json_decode($responseBody, true); + if (!is_array($decoded)) { + throw new RuntimeException('Invalid JSON response from Radarr'); + } + + return $decoded; + } + + /** + * Performs a POST request with a JSON body. + * + * @param string $path Request path. + * @param array $body JSON-serializable body. + * @return array Decoded JSON response. + * @throws RuntimeException On network or HTTP errors. + */ + protected function post(string $path, array $body): array + { + $url = $this->baseUrl . $path; + assert($url !== ''); + $encodedBody = json_encode($body); + + if ($encodedBody === false) { + throw new RuntimeException('json_encode failed for Radarr request body'); + } + + $ch = curl_init(); + if ($ch === false) { + throw new RuntimeException('curl_init() failed'); + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $encodedBody, + CURLOPT_HTTPHEADER => $this->buildHeaders(), + ]); + + /** @var string|false */ + $responseBody = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErrno = curl_errno($ch); + $curlError = curl_error($ch); + curl_close($ch); + + if ($responseBody === false || $curlErrno !== 0) { + throw new RuntimeException('cURL error: ' . $curlError, $curlErrno); + } + + if ($httpCode === 401) { + throw new RuntimeException('Radarr API authentication failed (401)'); + } + + if ($httpCode === 404) { + throw new RuntimeException('Radarr API resource not found (404): ' . $path); + } + + if ($httpCode >= 400) { + throw new RuntimeException('Radarr API error: HTTP ' . $httpCode); + } + + if ($responseBody === '') { + return []; + } + + /** @var array */ + $decoded = json_decode($responseBody, true); + if (!is_array($decoded)) { + throw new RuntimeException('Invalid JSON response from Radarr'); + } + + return $decoded; + } + + /** + * Performs a PUT request with a JSON body. + * + * @param string $path Request path. + * @param array $body JSON-serializable body. + * @return array Decoded JSON response. + * @throws RuntimeException On network or HTTP errors. + */ + protected function put(string $path, array $body): array + { + $url = $this->baseUrl . $path; + assert($url !== ''); + $encodedBody = json_encode($body); + + if ($encodedBody === false) { + throw new RuntimeException('json_encode failed for Radarr request body'); + } + + $ch = curl_init(); + if ($ch === false) { + throw new RuntimeException('curl_init() failed'); + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_CUSTOMREQUEST => 'PUT', + CURLOPT_POSTFIELDS => $encodedBody, + CURLOPT_HTTPHEADER => $this->buildHeaders(), + ]); + + /** @var string|false */ + $responseBody = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErrno = curl_errno($ch); + $curlError = curl_error($ch); + curl_close($ch); + + if ($responseBody === false || $curlErrno !== 0) { + throw new RuntimeException('cURL error: ' . $curlError, $curlErrno); + } + + if ($httpCode === 401) { + throw new RuntimeException('Radarr API authentication failed (401)'); + } + + if ($httpCode === 404) { + throw new RuntimeException('Radarr API resource not found (404): ' . $path); + } + + if ($httpCode >= 400) { + throw new RuntimeException('Radarr API error: HTTP ' . $httpCode); + } + + if ($responseBody === '') { + return []; + } + + /** @var array */ + $decoded = json_decode($responseBody, true); + if (!is_array($decoded)) { + throw new RuntimeException('Invalid JSON response from Radarr'); + } + + return $decoded; + } + + /** + * Performs a DELETE request. + * + * @param string $path Request path. + * @return array Decoded JSON response. + * @throws RuntimeException On network or HTTP errors. + */ + protected function delete(string $path): array + { + $url = $this->baseUrl . $path; + assert($url !== ''); + + $ch = curl_init(); + if ($ch === false) { + throw new RuntimeException('curl_init() failed'); + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_HTTPHEADER => $this->buildHeaders(), + ]); + + /** @var string|false */ + $responseBody = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErrno = curl_errno($ch); + $curlError = curl_error($ch); + curl_close($ch); + + if ($responseBody === false || $curlErrno !== 0) { + throw new RuntimeException('cURL error: ' . $curlError, $curlErrno); + } + + if ($httpCode === 401) { + throw new RuntimeException('Radarr API authentication failed (401)'); + } + + if ($httpCode === 404) { + throw new RuntimeException('Radarr API resource not found (404): ' . $path); + } + + if ($httpCode >= 400) { + throw new RuntimeException('Radarr API error: HTTP ' . $httpCode); + } + + if ($responseBody === '') { + return []; + } + + /** @var array */ + $decoded = json_decode($responseBody, true); + if (!is_array($decoded)) { + throw new RuntimeException('Invalid JSON response from Radarr'); + } + + return $decoded; + } + + /** + * Builds the HTTP headers for Radarr API requests. + * + * @return array Headers array. + */ + private function buildHeaders(): array + { + return [ + 'Content-Type: application/json', + 'Accept: application/json', + 'X-Api-Key: ' . $this->apiKey, + ]; + } +} diff --git a/src/Arr/SonarrClient.php b/src/Arr/SonarrClient.php new file mode 100644 index 0000000..673a201 --- /dev/null +++ b/src/Arr/SonarrClient.php @@ -0,0 +1,314 @@ +baseUrl = rtrim($baseUrl, '/'); + $this->apiKey = $apiKey; + $this->logger = $logger; + $this->timeout = $timeout; + } + + /** + * {@inheritdoc} + */ + public function getQueue(): array + { + /** @var array $response Paginated response with keys: records, page, pageSize, totalRecords */ + $response = $this->get('/api/v3/queue'); + return $response; + } + + /** + * Returns all tracked series. + * + * @return array> Series list. + */ + public function getSeries(): array + { + /** @var array> */ + return $this->get('/api/v3/series'); + } + + /** + * Returns a specific series by its Sonarr ID. + * + * @param int $sonarrSeriesId The Sonarr series ID. + * @return array Series data. + */ + public function getSeriesById(int $sonarrSeriesId): array + { + return $this->get('/api/v3/series/' . $sonarrSeriesId); + } + + /** + * Returns a specific episode file by its ID. + * + * @param int $episodeId The episode file ID. + * @return array Episode file data. + */ + public function getEpisodeFile(int $episodeId): array + { + return $this->get('/api/v3/episodefile/' . $episodeId); + } + + /** + * Returns missing episodes (wanted). + * + * @param int|null $startSeason Optional season number to filter by. + * @return array> Missing episodes. + */ + public function getWantedMissing(?int $startSeason = null): array + { + $path = '/api/v3/wanted/missing'; + if ($startSeason !== null) { + $path .= '?season=' . $startSeason; + } + + /** @var array> */ + return $this->get($path); + } + + /** + * {@inheritdoc} + */ + public function getQualityProfiles(): array + { + /** @var array> */ + return $this->get('/api/v3/qualityprofile'); + } + + /** + * {@inheritdoc} + */ + public function getTagList(): array + { + /** @var array> */ + return $this->get('/api/v3/tag'); + } + + /** + * Adds a new series to Sonarr. + * + * @param int|array $tvdbId The TVDB ID or array of TVDB IDs. + * @param int $qualityProfileId The quality profile ID to use. + * @param int $rootFolder The root folder path index. + * @param string|null $monitor Monitoring option (default 'all'). + * @return array Created series data. + */ + public function addSeries( + int|array $tvdbId, + int $qualityProfileId, + int $rootFolder, + ?string $monitor = 'all' + ): array { + $payload = [ + 'tvdbId' => $tvdbId, + 'qualityProfileId' => $qualityProfileId, + 'rootFolder' => $rootFolder, + 'monitor' => $monitor ?? 'all', + ]; + + return $this->post('/api/v3/series', $payload); + } + + /** + * Triggers a download for an episode by marking it as wanted and forcing a search. + * + * @param int $episodeId The episode ID to trigger download for. + * @return bool True if successful, false otherwise. + */ + public function triggerDownload(int $episodeId): bool + { + try { + $this->post('/api/v3/release/' . $episodeId, []); + return true; + } catch (RuntimeException $e) { + $this->logger?->warning('Sonarr trigger download failed: ' . $e->getMessage()); + return false; + } + } + + /** + * {@inheritdoc} + */ + public function testConnection(): bool + { + try { + $response = $this->get('/api/v3/system/status'); + return isset($response['version']); + } catch (RuntimeException $e) { + $this->logger?->warning('Sonarr connection test failed: ' . $e->getMessage()); + return false; + } + } + + /** + * Performs a GET request. + * + * @param string $path Request path. + * @return array Decoded JSON response. + * @throws RuntimeException On network or HTTP errors. + */ + protected function get(string $path): array + { + $url = $this->baseUrl . $path; + assert($url !== ''); + + $ch = curl_init(); + if ($ch === false) { + throw new RuntimeException('curl_init() failed'); + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_HTTPHEADER => $this->buildHeaders(), + ]); + + /** @var string|false */ + $responseBody = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErrno = curl_errno($ch); + $curlError = curl_error($ch); + curl_close($ch); + + if ($responseBody === false || $curlErrno !== 0) { + throw new RuntimeException('cURL error: ' . $curlError, $curlErrno); + } + + if ($httpCode === 401) { + throw new RuntimeException('Sonarr API authentication failed (401)'); + } + + if ($httpCode === 404) { + throw new RuntimeException('Sonarr API resource not found (404): ' . $path); + } + + if ($httpCode >= 400) { + throw new RuntimeException('Sonarr API error: HTTP ' . $httpCode); + } + + /** @var array */ + $decoded = json_decode($responseBody, true); + if (!is_array($decoded)) { + throw new RuntimeException('Invalid JSON response from Sonarr'); + } + + return $decoded; + } + + /** + * Performs a POST request with a JSON body. + * + * @param string $path Request path. + * @param array $body JSON-serializable body. + * @return array Decoded JSON response. + * @throws RuntimeException On network or HTTP errors. + */ + protected function post(string $path, array $body): array + { + $url = $this->baseUrl . $path; + assert($url !== ''); + $encodedBody = json_encode($body); + + if ($encodedBody === false) { + throw new RuntimeException('json_encode failed for Sonarr request body'); + } + + $ch = curl_init(); + if ($ch === false) { + throw new RuntimeException('curl_init() failed'); + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $encodedBody, + CURLOPT_HTTPHEADER => $this->buildHeaders(), + ]); + + /** @var string|false */ + $responseBody = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErrno = curl_errno($ch); + $curlError = curl_error($ch); + curl_close($ch); + + if ($responseBody === false || $curlErrno !== 0) { + throw new RuntimeException('cURL error: ' . $curlError, $curlErrno); + } + + if ($httpCode === 401) { + throw new RuntimeException('Sonarr API authentication failed (401)'); + } + + if ($httpCode === 404) { + throw new RuntimeException('Sonarr API resource not found (404): ' . $path); + } + + if ($httpCode >= 400) { + throw new RuntimeException('Sonarr API error: HTTP ' . $httpCode); + } + + if ($responseBody === '') { + return []; + } + + /** @var array */ + $decoded = json_decode($responseBody, true); + if (!is_array($decoded)) { + throw new RuntimeException('Invalid JSON response from Sonarr'); + } + + return $decoded; + } + + /** + * Builds the HTTP headers for Sonarr API requests. + * + * @return array Headers array. + */ + private function buildHeaders(): array + { + return [ + 'Content-Type: application/json', + 'Accept: application/json', + 'X-Api-Key: ' . $this->apiKey, + ]; + } +} diff --git a/src/Arr/SyncResult.php b/src/Arr/SyncResult.php new file mode 100644 index 0000000..db35cc2 --- /dev/null +++ b/src/Arr/SyncResult.php @@ -0,0 +1,94 @@ +customFormatsAdded + $this->customFormatsUpdated; + } + + /** + * Returns the total number of quality profiles changed. + * + * @return int Total quality profiles added + updated. + */ + public function getTotalQualityProfilesChanged(): int + { + return $this->qualityProfilesAdded + $this->qualityProfilesUpdated; + } + + /** + * Returns the total number of all items changed. + * + * @return int Total of all changes. + */ + public function getTotalChanges(): int + { + return $this->getTotalCustomFormatsChanged() + $this->getTotalQualityProfilesChanged(); + } + + /** + * Returns true if no changes were made. + * + * @return bool True if no items were added or updated. + */ + public function isEmpty(): bool + { + return $this->getTotalChanges() === 0; + } + + /** + * Converts the result to an array representation. + * + * @return array Array representation. + */ + public function toArray(): array + { + return [ + 'custom_formats_added' => $this->customFormatsAdded, + 'custom_formats_updated' => $this->customFormatsUpdated, + 'quality_profiles_added' => $this->qualityProfilesAdded, + 'quality_profiles_updated' => $this->qualityProfilesUpdated, + 'version' => $this->version, + 'synced_at' => $this->syncedAt->format('c'), + 'total_changes' => $this->getTotalChanges(), + ]; + } +} diff --git a/src/Arr/TrashGuidesProvider.php b/src/Arr/TrashGuidesProvider.php new file mode 100644 index 0000000..5be8992 --- /dev/null +++ b/src/Arr/TrashGuidesProvider.php @@ -0,0 +1,296 @@ +|null Cached quality profiles data */ + private static ?array $qualityProfilesCache = null; + + /** @var array|null Cached custom formats data */ + private static ?array $customFormatsCache = null; + + /** @var string|null Cached version string */ + private static ?string $versionCache = null; + + /** @var int|null Timestamp when cache was last set */ + private static ?int $cacheTimestamp = null; + + /** @var array|null Parsed quality profiles */ + private ?array $qualityProfiles = null; + + /** @var array|null Parsed custom formats */ + private ?array $customFormats = null; + + /** @var string|null Parsed version SHA */ + private ?string $version = null; + + private ?LoggerInterface $logger; + + /** + * Creates a new TrashGuidesProvider instance. + * + * @param LoggerInterface|null $logger Optional logger instance. + */ + public function __construct(?LoggerInterface $logger = null) + { + $this->logger = $logger; + } + + /** + * Fetches and returns quality profiles from TRaSH-Guides. + * + * @return array Quality profiles data. + * @throws RuntimeException On network or parsing errors. + */ + public function getQualityProfiles(): array + { + if ($this->qualityProfiles !== null) { + return $this->qualityProfiles; + } + + $this->ensureCacheValid(); + + if (self::$qualityProfilesCache !== null) { + $this->qualityProfiles = self::$qualityProfilesCache; + return $this->qualityProfiles; + } + + $config = $this->loadConfig(); + $url = $config['quality_profiles_url'] ?? ''; + + if (!is_string($url) || $url === '') { + throw new RuntimeException('TRaSH-Guides quality profiles URL not configured'); + } + + $this->logger?->info('Fetching TRaSH-Guides quality profiles', ['url' => $url]); + + $json = $this->fetchUrl($url); + $data = $this->parseJson($json); + + self::$qualityProfilesCache = $data; + self::$cacheTimestamp = time(); + + $this->qualityProfiles = $data; + return $this->qualityProfiles; + } + + /** + * Fetches and returns custom formats from TRaSH-Guides. + * + * @return array Custom formats data. + * @throws RuntimeException On network or parsing errors. + */ + public function getCustomFormats(): array + { + if ($this->customFormats !== null) { + return $this->customFormats; + } + + $this->ensureCacheValid(); + + if (self::$customFormatsCache !== null) { + $this->customFormats = self::$customFormatsCache; + return $this->customFormats; + } + + $config = $this->loadConfig(); + $url = $config['custom_formats_url'] ?? ''; + + if (!is_string($url) || $url === '') { + throw new RuntimeException('TRaSH-Guides custom formats URL not configured'); + } + + $this->logger?->info('Fetching TRaSH-Guides custom formats', ['url' => $url]); + + $json = $this->fetchUrl($url); + $data = $this->parseJson($json); + + self::$customFormatsCache = $data; + self::$cacheTimestamp = time(); + + $this->customFormats = $data; + return $this->customFormats; + } + + /** + * Returns the git commit SHA of the imported TRaSH-Guides version. + * + * @return string The git SHA (40 hex characters). + * @throws RuntimeException On network or parsing errors. + */ + public function getVersion(): string + { + if ($this->version !== null) { + return $this->version; + } + + $this->ensureCacheValid(); + + if (self::$versionCache !== null) { + $this->version = self::$versionCache; + return $this->version; + } + + $config = $this->loadConfig(); + $customFormatsUrl = $config['custom_formats_url'] ?? ''; + + if (!is_string($customFormatsUrl) || $customFormatsUrl === '') { + throw new RuntimeException('TRaSH-Guides custom formats URL not configured'); + } + + // The version is typically embedded in the JSON as '鸡' or we get it from headers + $json = $this->fetchUrl($customFormatsUrl); + $data = $this->parseJson($json); + + // TRaSH-Guides JSON often contains a version field + /** @var string */ + $version = $data['鸡'] ?? $data['version'] ?? $this->deriveVersionFromUrl($customFormatsUrl); + + self::$versionCache = $version; + self::$cacheTimestamp = time(); + + $this->version = $version; + return $this->version; + } + + /** + * Clears the internal cache, forcing the next request to fetch fresh data. + * + * @return void + */ + public function clearCache(): void + { + self::$qualityProfilesCache = null; + self::$customFormatsCache = null; + self::$versionCache = null; + self::$cacheTimestamp = null; + $this->qualityProfiles = null; + $this->customFormats = null; + $this->version = null; + } + + /** + * Ensures the cache is still valid (not expired). + * + * @return void + */ + private function ensureCacheValid(): void + { + if (self::$cacheTimestamp === null) { + return; + } + + if (time() - self::$cacheTimestamp > self::CACHE_TTL_SECONDS) { + $this->clearCache(); + } + } + + /** + * Loads the trash_guides configuration. + * + * @return array Configuration array. + */ + private function loadConfig(): array + { + $configPath = dirname(__DIR__, 2) . '/config/trash_guides.php'; + if (!file_exists($configPath)) { + return [ + 'enabled' => false, + 'auto_sync_interval' => 86400, + 'custom_formats_url' => 'https://raw.githubusercontent.com/TRaSH-' + . '/Guides/main/docs/json/radarr/' + . 'radarr-collection-of-custom-formats.json', + 'quality_profiles_url' => 'https://raw.githubusercontent.com/TRaSH-' + . '/Guides/main/docs/json/radarr/' + . 'radarr-setup-quality-profiles-parent.json', + ]; + } + + /** @var array */ + return include $configPath; + } + + /** + * Fetches content from a URL using file_get_contents with stream context. + * + * @param string $url The URL to fetch. + * @return string The response body. + * @throws RuntimeException On network errors. + */ + private function fetchUrl(string $url): string + { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => 30, + 'follow_location' => true, + 'max_redirects' => 5, + 'header' => [ + 'Accept: application/json', + 'User-Agent: Phlex-Media-Server/0.12.0', + ], + ], + ]); + + $body = @file_get_contents($url, false, $context); + + if ($body === false) { + $error = error_get_last(); + throw new RuntimeException( + 'Failed to fetch TRaSH-Guides data: ' . ($error['message'] ?? 'Unknown error') + ); + } + + return $body; + } + + /** + * Parses JSON string into an array. + * + * @param string $json The JSON string to parse. + * @return array Parsed data. + * @throws RuntimeException On invalid JSON. + */ + private function parseJson(string $json): array + { + /** @var array|false $data */ + $data = json_decode($json, true); + if (!is_array($data)) { + throw new RuntimeException('Invalid JSON response from TRaSH-Guides'); + } + + return $data; + } + + /** + * Derives a version string from the GitHub raw URL. + * + * @param string $url The raw URL containing branch/commit info. + * @return string The derived version string. + */ + private function deriveVersionFromUrl(string $url): string + { + // URL format: .../Guides/main/docs/json/radarr/... or .../Guides/{commit}/docs/... + // Try to extract the commit SHA from the URL path + if (preg_match('/Guides\/([a-f0-9]{7,40})/', $url, $matches)) { + return $matches[1]; + } + + // Fallback: use timestamp as version indicator + return 'unknown-' . time(); + } +} diff --git a/src/Version.php b/src/Version.php index 761ad4f..e263982 100644 --- a/src/Version.php +++ b/src/Version.php @@ -24,7 +24,7 @@ final class Version * * @var non-empty-string */ - public const VERSION = '0.3.0'; + public const VERSION = '0.4.0'; /** * Prevent instantiation — static marker only. diff --git a/tests/Arr/ArrClientFactoryTest.php b/tests/Arr/ArrClientFactoryTest.php new file mode 100644 index 0000000..da0f847 --- /dev/null +++ b/tests/Arr/ArrClientFactoryTest.php @@ -0,0 +1,175 @@ + [ + 'url' => 'http://sonarr.local:8989', + 'api_key' => 'sonarr-api-key-123', + 'enabled' => true, + ], + ]; + + $factory = new ArrClientFactory($config); + $client = $factory->createSonarrClient(); + + $this->assertInstanceOf(SonarrClient::class, $client); + } + + public function testCreatesRadarrClientWithConfig(): void + { + $config = [ + 'radarr' => [ + 'url' => 'http://radarr.local:7878', + 'api_key' => 'radarr-api-key-456', + 'enabled' => true, + ], + ]; + + $factory = new ArrClientFactory($config); + $client = $factory->createRadarrClient(); + + $this->assertInstanceOf(RadarrClient::class, $client); + } + + public function testReturnsNullWhenSonarrNotEnabled(): void + { + $config = [ + 'sonarr' => [ + 'url' => 'http://localhost:8989', + 'api_key' => 'some-key', + 'enabled' => false, + ], + ]; + + $factory = new ArrClientFactory($config); + $client = $factory->createSonarrClient(); + + $this->assertNull($client); + } + + public function testReturnsNullWhenRadarrNotEnabled(): void + { + $config = [ + 'radarr' => [ + 'url' => 'http://localhost:7878', + 'api_key' => 'some-key', + 'enabled' => false, + ], + ]; + + $factory = new ArrClientFactory($config); + $client = $factory->createRadarrClient(); + + $this->assertNull($client); + } + + public function testReturnsNullWhenSonarrApiKeyEmpty(): void + { + $config = [ + 'sonarr' => [ + 'url' => 'http://localhost:8989', + 'api_key' => '', + 'enabled' => true, + ], + ]; + + $factory = new ArrClientFactory($config); + $client = $factory->createSonarrClient(); + + $this->assertNull($client); + } + + public function testReturnsNullWhenRadarrApiKeyEmpty(): void + { + $config = [ + 'radarr' => [ + 'url' => 'http://localhost:7878', + 'api_key' => '', + 'enabled' => true, + ], + ]; + + $factory = new ArrClientFactory($config); + $client = $factory->createRadarrClient(); + + $this->assertNull($client); + } + + public function testReturnsNullWhenSonarrConfigMissing(): void + { + $config = []; + + $factory = new ArrClientFactory($config); + $client = $factory->createSonarrClient(); + + $this->assertNull($client); + } + + public function testReturnsNullWhenRadarrConfigMissing(): void + { + $config = []; + + $factory = new ArrClientFactory($config); + $client = $factory->createRadarrClient(); + + $this->assertNull($client); + } + + public function testCreatesBothClientsSimultaneously(): void + { + $config = [ + 'sonarr' => [ + 'url' => 'http://sonarr.local:8989', + 'api_key' => 'sonarr-key', + 'enabled' => true, + ], + 'radarr' => [ + 'url' => 'http://radarr.local:7878', + 'api_key' => 'radarr-key', + 'enabled' => true, + ], + ]; + + $factory = new ArrClientFactory($config); + + $sonarrClient = $factory->createSonarrClient(); + $radarrClient = $factory->createRadarrClient(); + + $this->assertInstanceOf(SonarrClient::class, $sonarrClient); + $this->assertInstanceOf(RadarrClient::class, $radarrClient); + } + + public function testDefaultValuesWhenConfigPartial(): void + { + $config = [ + 'sonarr' => [ + 'enabled' => true, + // url and api_key missing - should use defaults + ], + ]; + + $factory = new ArrClientFactory($config); + $client = $factory->createSonarrClient(); + + // With empty API key, should return null + $this->assertNull($client); + } +} diff --git a/tests/Arr/BazarrClientTest.php b/tests/Arr/BazarrClientTest.php new file mode 100644 index 0000000..d21e54a --- /dev/null +++ b/tests/Arr/BazarrClientTest.php @@ -0,0 +1,198 @@ +mockableClient = new MockableBazarrClient('http://localhost:6767', 'test-api-key'); + } + + public function testGetSubtitlesReturnsArray(): void + { + $expectedResponse = [ + ['id' => 1, 'language' => 'en', 'path' => '/tv/show/S01E01.mkv'], + ['id' => 2, 'language' => 'es', 'path' => '/tv/show/S01E01.mkv'], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getSubtitles('123'); + + $this->assertCount(2, $result); + $this->assertEquals('en', $result[0]['language']); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v1/subtitles?sonarrSeriesId=123', $this->mockableClient->getLastPathCalled()); + } + + public function testGetSubtitlesWithEpisodeFileId(): void + { + $expectedResponse = [ + ['id' => 1, 'language' => 'en', 'episodeFileId' => 456], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getSubtitles('123', 456); + + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v1/subtitles?sonarrSeriesId=123&episodeFileId=456', $this->mockableClient->getLastPathCalled()); + } + + public function testGetSubtitleLanguages(): void + { + $expectedResponse = [ + ['code' => 'en', 'name' => 'English'], + ['code' => 'es', 'name' => 'Spanish'], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getSubtitleLanguages('/tv/show/S01E01.mkv'); + + $this->assertCount(2, $result); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v1/languages?path=%2Ftv%2Fshow%2FS01E01.mkv', $this->mockableClient->getLastPathCalled()); + } + + public function testDownloadSubtitleSendsPost(): void + { + $expectedResponse = ['result' => true, 'message' => 'Subtitle download queued']; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->downloadSubtitle('/tv/show/S01E01.mkv', 'en'); + + $this->assertTrue($result['result']); + $this->assertEquals('post', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v1/subtitles/download', $this->mockableClient->getLastPathCalled()); + } + + public function testGetLanguages(): void + { + $expectedResponse = [ + ['code' => 'en', 'name' => 'English'], + ['code' => 'es', 'name' => 'Spanish'], + ['code' => 'pt-BR', 'name' => 'Portuguese (Brazil)'], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getLanguages(); + + $this->assertCount(3, $result); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v1/languages/list', $this->mockableClient->getLastPathCalled()); + } + + public function testTestConnectionReturnsTrueOnSuccess(): void + { + $this->mockableClient->setMockResponse(['version' => '1.0.0.12345', 'bazarr' => '0.12.0']); + + $result = $this->mockableClient->testConnection(); + + $this->assertTrue($result); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v1/system', $this->mockableClient->getLastPathCalled()); + } + + public function testTestConnectionReturnsTrueWithVersionOnly(): void + { + $this->mockableClient->setMockResponse(['version' => '1.0.0']); + + $result = $this->mockableClient->testConnection(); + + $this->assertTrue($result); + } + + public function testTestConnectionReturnsFalseOnFailure(): void + { + $this->mockableClient->setMockResponse(new \RuntimeException('Connection refused')); + + $result = $this->mockableClient->testConnection(); + + $this->assertFalse($result); + } + + public function testConstructorSetsBaseUrlAndApiKey(): void + { + $client = new BazarrClient('http://bazarr.local:6767', 'my-secret-key'); + + // Use reflection to verify properties + $reflection = new \ReflectionClass($client); + $baseUrlProperty = $reflection->getProperty('baseUrl'); + $baseUrlProperty->setAccessible(true); + $apiKeyProperty = $reflection->getProperty('apiKey'); + $apiKeyProperty->setAccessible(true); + + $this->assertEquals('http://bazarr.local:6767', $baseUrlProperty->getValue($client)); + $this->assertEquals('my-secret-key', $apiKeyProperty->getValue($client)); + } +} + +/** + * Testable version of BazarrClient that allows mocking HTTP responses. + * + * @internal For testing only + */ +class MockableBazarrClient extends BazarrClient +{ + private mixed $mockResponse = null; + private bool $mockThrowsException = false; + private ?string $lastMethodCalled = null; + private ?string $lastPathCalled = null; + + public function setMockResponse(mixed $response): void + { + $this->mockResponse = $response; + $this->mockThrowsException = ($response instanceof \Throwable); + } + + public function getLastMethodCalled(): ?string + { + return $this->lastMethodCalled; + } + + public function getLastPathCalled(): ?string + { + return $this->lastPathCalled; + } + + protected function get(string $path): array + { + $this->lastMethodCalled = 'get'; + $this->lastPathCalled = $path; + + if ($this->mockThrowsException && $this->mockResponse instanceof \Throwable) { + throw $this->mockResponse; + } + + return is_array($this->mockResponse) ? $this->mockResponse : []; + } + + protected function post(string $path, array $body): array + { + $this->lastMethodCalled = 'post'; + $this->lastPathCalled = $path; + + if ($this->mockThrowsException && $this->mockResponse instanceof \Throwable) { + throw $this->mockResponse; + } + + return is_array($this->mockResponse) ? $this->mockResponse : []; + } +} diff --git a/tests/Arr/ProwlarrClientTest.php b/tests/Arr/ProwlarrClientTest.php new file mode 100644 index 0000000..caba7bf --- /dev/null +++ b/tests/Arr/ProwlarrClientTest.php @@ -0,0 +1,183 @@ +mockableClient = new MockableProwlarrClient('http://localhost:9696', 'test-api-key'); + } + + public function testGetIndexersReturnsArray(): void + { + $expectedResponse = [ + ['id' => 1, 'name' => 'Torznab', 'enabled' => true], + ['id' => 2, 'name' => 'Newznab', 'enabled' => false], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getIndexers(); + + $this->assertCount(2, $result); + $this->assertEquals('Torznab', $result[0]['name']); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v1/indexer', $this->mockableClient->getLastPathCalled()); + } + + public function testGetIndexerStats(): void + { + $expectedResponse = [ + 'id' => 1, + 'name' => 'Torznab', + 'status' => 'OK', + 'lastError' => null, + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getIndexerStats(1); + + $this->assertEquals('Torznab', $result['name']); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v1/indexer/1', $this->mockableClient->getLastPathCalled()); + } + + public function testGetHealth(): void + { + $expectedResponse = [ + ['id' => 1, 'type' => 'warning', 'message' => 'Indexer unavailable'], + ['id' => 2, 'type' => 'error', 'message' => 'Connection timeout'], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getHealth(); + + $this->assertCount(2, $result); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v1/health', $this->mockableClient->getLastPathCalled()); + } + + public function testTriggerReindexerSendsPost(): void + { + $this->mockableClient->setMockResponse(['result' => true]); + + $result = $this->mockableClient->triggerReindexerCheck(1); + + $this->assertTrue($result); + $this->assertEquals('post', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v1/indexer/1/recheck', $this->mockableClient->getLastPathCalled()); + } + + public function testTriggerReindexerReturnsFalseOnFailure(): void + { + $this->mockableClient->setMockResponse(new \RuntimeException('Connection refused')); + + $result = $this->mockableClient->triggerReindexerCheck(1); + + $this->assertFalse($result); + } + + public function testTestConnectionReturnsTrueOnSuccess(): void + { + $this->mockableClient->setMockResponse(['version' => '0.8.0.12345']); + + $result = $this->mockableClient->testConnection(); + + $this->assertTrue($result); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v1/system/status', $this->mockableClient->getLastPathCalled()); + } + + public function testTestConnectionReturnsFalseOnFailure(): void + { + $this->mockableClient->setMockResponse(new \RuntimeException('Connection refused')); + + $result = $this->mockableClient->testConnection(); + + $this->assertFalse($result); + } + + public function testConstructorSetsBaseUrlAndApiKey(): void + { + $client = new ProwlarrClient('http://prowlarr.local:9696', 'my-secret-key'); + + // Use reflection to verify properties + $reflection = new \ReflectionClass($client); + $baseUrlProperty = $reflection->getProperty('baseUrl'); + $baseUrlProperty->setAccessible(true); + $apiKeyProperty = $reflection->getProperty('apiKey'); + $apiKeyProperty->setAccessible(true); + + $this->assertEquals('http://prowlarr.local:9696', $baseUrlProperty->getValue($client)); + $this->assertEquals('my-secret-key', $apiKeyProperty->getValue($client)); + } +} + +/** + * Testable version of ProwlarrClient that allows mocking HTTP responses. + * + * @internal For testing only + */ +class MockableProwlarrClient extends ProwlarrClient +{ + private mixed $mockResponse = null; + private bool $mockThrowsException = false; + private ?string $lastMethodCalled = null; + private ?string $lastPathCalled = null; + + public function setMockResponse(mixed $response): void + { + $this->mockResponse = $response; + $this->mockThrowsException = ($response instanceof \Throwable); + } + + public function getLastMethodCalled(): ?string + { + return $this->lastMethodCalled; + } + + public function getLastPathCalled(): ?string + { + return $this->lastPathCalled; + } + + protected function get(string $path): array + { + $this->lastMethodCalled = 'get'; + $this->lastPathCalled = $path; + + if ($this->mockThrowsException && $this->mockResponse instanceof \Throwable) { + throw $this->mockResponse; + } + + return is_array($this->mockResponse) ? $this->mockResponse : []; + } + + protected function post(string $path, array $body): array + { + $this->lastMethodCalled = 'post'; + $this->lastPathCalled = $path; + + if ($this->mockThrowsException && $this->mockResponse instanceof \Throwable) { + throw $this->mockResponse; + } + + return is_array($this->mockResponse) ? $this->mockResponse : []; + } +} diff --git a/tests/Arr/RadarrClientTest.php b/tests/Arr/RadarrClientTest.php new file mode 100644 index 0000000..3d61bd1 --- /dev/null +++ b/tests/Arr/RadarrClientTest.php @@ -0,0 +1,240 @@ +mockableClient = new MockableRadarrClient('http://localhost:7878', 'test-api-key'); + } + + public function testGetMoviesReturnsArray(): void + { + $expectedResponse = [ + ['id' => 1, 'title' => 'Test Movie', 'tmdbId' => 123456], + ['id' => 2, 'title' => 'Another Movie', 'tmdbId' => 654321], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getMovies(); + + $this->assertCount(2, $result); + $this->assertEquals('Test Movie', $result[0]['title']); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/movie', $this->mockableClient->getLastPathCalled()); + } + + public function testGetMovieByIdReturnsSingleMovie(): void + { + $expectedResponse = [ + 'id' => 1, + 'title' => 'Test Movie', + 'tmdbId' => 123456, + 'monitored' => true, + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getMovieById(1); + + $this->assertEquals('Test Movie', $result['title']); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/movie/1', $this->mockableClient->getLastPathCalled()); + } + + public function testGetQueueParsesItems(): void + { + $expectedResponse = [ + 'records' => [ + ['id' => 1, 'status' => 'downloading', 'movie' => ['title' => 'Test Movie']], + ['id' => 2, 'status' => 'pending', 'movie' => ['title' => 'Another Movie']], + ], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getQueue(); + + $this->assertArrayHasKey('records', $result); + $this->assertIsArray($result['records']); + $this->assertCount(2, $result['records']); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/queue', $this->mockableClient->getLastPathCalled()); + } + + public function testGetQualityProfilesReturnsProfiles(): void + { + $expectedResponse = [ + ['id' => 1, 'name' => 'HD-720p'], + ['id' => 2, 'name' => 'HD-1080p'], + ['id' => 3, 'name' => 'Ultra HD 4K'], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getQualityProfiles(); + + $this->assertCount(3, $result); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/qualityprofile', $this->mockableClient->getLastPathCalled()); + } + + public function testGetCustomFormatsReturnsFormats(): void + { + $expectedResponse = [ + ['id' => 1, 'name' => 'BR-Dish'], + ['id' => 2, 'name' => 'HD-Audio'], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getCustomFormats(); + + $this->assertCount(2, $result); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/customformat', $this->mockableClient->getLastPathCalled()); + } + + public function testGetTagListReturnsTags(): void + { + $expectedResponse = [ + ['id' => 1, 'label' => 'anime'], + ['id' => 2, 'label' => 'kids'], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getTagList(); + + $this->assertCount(2, $result); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/tag', $this->mockableClient->getLastPathCalled()); + } + + public function testAddMovieBuildsCorrectPayload(): void + { + $expectedResponse = ['id' => 10, 'tmdbId' => 123456, 'title' => 'New Movie']; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->addMovie(123456, 2, '/movies', true); + + $this->assertEquals(123456, $result['tmdbId']); + $this->assertEquals('post', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/movie', $this->mockableClient->getLastPathCalled()); + } + + public function testTriggerDownloadSendsPost(): void + { + $this->mockableClient->setMockResponse(['result' => true]); + + $result = $this->mockableClient->triggerDownload(100); + + $this->assertTrue($result); + $this->assertEquals('post', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/release/100', $this->mockableClient->getLastPathCalled()); + } + + public function testTestConnectionReturnsTrueOnSuccess(): void + { + $this->mockableClient->setMockResponse(['version' => '3.0.0.12345']); + + $result = $this->mockableClient->testConnection(); + + $this->assertTrue($result); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/system/status', $this->mockableClient->getLastPathCalled()); + } + + public function testTestConnectionReturnsFalseOnFailure(): void + { + $this->mockableClient->setMockResponse(new \RuntimeException('Connection refused')); + + $result = $this->mockableClient->testConnection(); + + $this->assertFalse($result); + } + + public function testConstructorSetsBaseUrlAndApiKey(): void + { + $client = new RadarrClient('http://radarr.local:7878', 'my-secret-key'); + + // Use reflection to verify properties + $reflection = new \ReflectionClass($client); + $baseUrlProperty = $reflection->getProperty('baseUrl'); + $baseUrlProperty->setAccessible(true); + $apiKeyProperty = $reflection->getProperty('apiKey'); + $apiKeyProperty->setAccessible(true); + + $this->assertEquals('http://radarr.local:7878', $baseUrlProperty->getValue($client)); + $this->assertEquals('my-secret-key', $apiKeyProperty->getValue($client)); + } +} + +/** + * Testable version of RadarrClient that allows mocking HTTP responses. + * + * @internal For testing only + */ +class MockableRadarrClient extends RadarrClient +{ + private mixed $mockResponse = null; + private bool $mockThrowsException = false; + private ?string $lastMethodCalled = null; + private ?string $lastPathCalled = null; + + public function setMockResponse(mixed $response): void + { + $this->mockResponse = $response; + $this->mockThrowsException = ($response instanceof \Throwable); + } + + public function getLastMethodCalled(): ?string + { + return $this->lastMethodCalled; + } + + public function getLastPathCalled(): ?string + { + return $this->lastPathCalled; + } + + protected function get(string $path): array + { + $this->lastMethodCalled = 'get'; + $this->lastPathCalled = $path; + + if ($this->mockThrowsException && $this->mockResponse instanceof \Throwable) { + throw $this->mockResponse; + } + + return is_array($this->mockResponse) ? $this->mockResponse : []; + } + + protected function post(string $path, array $body): array + { + $this->lastMethodCalled = 'post'; + $this->lastPathCalled = $path; + + if ($this->mockThrowsException && $this->mockResponse instanceof \Throwable) { + throw $this->mockResponse; + } + + return is_array($this->mockResponse) ? $this->mockResponse : []; + } +} diff --git a/tests/Arr/SonarrClientTest.php b/tests/Arr/SonarrClientTest.php new file mode 100644 index 0000000..5d29690 --- /dev/null +++ b/tests/Arr/SonarrClientTest.php @@ -0,0 +1,270 @@ +mockableClient = new MockableSonarrClient('http://localhost:8989', 'test-api-key'); + } + + public function testGetSeriesReturnsArray(): void + { + $expectedResponse = [ + ['id' => 1, 'title' => 'Test Series', 'tvdbId' => 123456], + ['id' => 2, 'title' => 'Another Series', 'tvdbId' => 654321], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getSeries(); + + $this->assertCount(2, $result); + $this->assertEquals('Test Series', $result[0]['title']); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/series', $this->mockableClient->getLastPathCalled()); + } + + public function testGetSeriesByIdReturnsSingleSeries(): void + { + $expectedResponse = [ + 'id' => 1, + 'title' => 'Test Series', + 'tvdbId' => 123456, + 'seasons' => [], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getSeriesById(1); + + $this->assertEquals('Test Series', $result['title']); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/series/1', $this->mockableClient->getLastPathCalled()); + } + + public function testGetEpisodeFileReturnsEpisodeFile(): void + { + $expectedResponse = [ + 'id' => 100, + 'seriesId' => 1, + 'episodeFileId' => 100, + 'relativePath' => '/tv/show/S01E01.mkv', + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getEpisodeFile(100); + + $this->assertEquals(100, $result['id']); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/episodefile/100', $this->mockableClient->getLastPathCalled()); + } + + public function testGetQueueParsesItems(): void + { + $expectedResponse = [ + 'records' => [ + ['id' => 1, 'status' => 'downloading', 'movie' => ['title' => 'Test Movie']], + ['id' => 2, 'status' => 'pending', 'movie' => ['title' => 'Another Movie']], + ], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getQueue(); + + $this->assertArrayHasKey('records', $result); + $this->assertIsArray($result['records']); + $this->assertCount(2, $result['records']); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/queue', $this->mockableClient->getLastPathCalled()); + } + + public function testGetWantedMissingReturnsMissingEpisodes(): void + { + $expectedResponse = [ + 'records' => [ + ['id' => 1, 'episodeId' => 100, 'seriesId' => 1], + ], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getWantedMissing(); + + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/wanted/missing', $this->mockableClient->getLastPathCalled()); + } + + public function testGetWantedMissingWithSeasonFilter(): void + { + $expectedResponse = ['records' => []]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getWantedMissing(1); + + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/wanted/missing?season=1', $this->mockableClient->getLastPathCalled()); + } + + public function testGetQualityProfilesReturnsProfiles(): void + { + $expectedResponse = [ + ['id' => 1, 'name' => 'HD-720p'], + ['id' => 2, 'name' => 'HD-1080p'], + ['id' => 3, 'name' => 'Ultra HD 4K'], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getQualityProfiles(); + + $this->assertCount(3, $result); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/qualityprofile', $this->mockableClient->getLastPathCalled()); + } + + public function testGetTagListReturnsTags(): void + { + $expectedResponse = [ + ['id' => 1, 'label' => 'anime'], + ['id' => 2, 'label' => 'kids'], + ]; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->getTagList(); + + $this->assertCount(2, $result); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/tag', $this->mockableClient->getLastPathCalled()); + } + + public function testAddSeriesBuildsCorrectPayload(): void + { + $expectedResponse = ['id' => 10, 'tvdbId' => 123456, 'title' => 'New Series']; + + $this->mockableClient->setMockResponse($expectedResponse); + + $result = $this->mockableClient->addSeries(123456, 2, 1, 'future'); + + $this->assertEquals(123456, $result['tvdbId']); + $this->assertEquals('post', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/series', $this->mockableClient->getLastPathCalled()); + } + + public function testTriggerDownloadSendsPost(): void + { + $this->mockableClient->setMockResponse(['result' => true]); + + $result = $this->mockableClient->triggerDownload(100); + + $this->assertTrue($result); + $this->assertEquals('post', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/release/100', $this->mockableClient->getLastPathCalled()); + } + + public function testTestConnectionReturnsTrueOnSuccess(): void + { + $this->mockableClient->setMockResponse(['version' => '3.0.0.12345']); + + $result = $this->mockableClient->testConnection(); + + $this->assertTrue($result); + $this->assertEquals('get', $this->mockableClient->getLastMethodCalled()); + $this->assertEquals('/api/v3/system/status', $this->mockableClient->getLastPathCalled()); + } + + public function testTestConnectionReturnsFalseOnFailure(): void + { + $this->mockableClient->setMockResponse(new \RuntimeException('Connection refused')); + + $result = $this->mockableClient->testConnection(); + + $this->assertFalse($result); + } + + public function testConstructorSetsBaseUrlAndApiKey(): void + { + $client = new SonarrClient('http://sonarr.local:8989', 'my-secret-key'); + + // Use reflection to verify properties + $reflection = new \ReflectionClass($client); + $baseUrlProperty = $reflection->getProperty('baseUrl'); + $baseUrlProperty->setAccessible(true); + $apiKeyProperty = $reflection->getProperty('apiKey'); + $apiKeyProperty->setAccessible(true); + + $this->assertEquals('http://sonarr.local:8989', $baseUrlProperty->getValue($client)); + $this->assertEquals('my-secret-key', $apiKeyProperty->getValue($client)); + } +} + +/** + * Testable version of SonarrClient that allows mocking HTTP responses. + * + * @internal For testing only + */ +class MockableSonarrClient extends SonarrClient +{ + private mixed $mockResponse = null; + private bool $mockThrowsException = false; + private ?string $lastMethodCalled = null; + private ?string $lastPathCalled = null; + + public function setMockResponse(mixed $response): void + { + $this->mockResponse = $response; + $this->mockThrowsException = ($response instanceof \Throwable); + } + + public function getLastMethodCalled(): ?string + { + return $this->lastMethodCalled; + } + + public function getLastPathCalled(): ?string + { + return $this->lastPathCalled; + } + + protected function get(string $path): array + { + $this->lastMethodCalled = 'get'; + $this->lastPathCalled = $path; + + if ($this->mockThrowsException && $this->mockResponse instanceof \Throwable) { + throw $this->mockResponse; + } + + return is_array($this->mockResponse) ? $this->mockResponse : []; + } + + protected function post(string $path, array $body): array + { + $this->lastMethodCalled = 'post'; + $this->lastPathCalled = $path; + + if ($this->mockThrowsException && $this->mockResponse instanceof \Throwable) { + throw $this->mockResponse; + } + + return is_array($this->mockResponse) ? $this->mockResponse : []; + } +} diff --git a/tests/Arr/TrashGuidesProviderTest.php b/tests/Arr/TrashGuidesProviderTest.php new file mode 100644 index 0000000..993f3bf --- /dev/null +++ b/tests/Arr/TrashGuidesProviderTest.php @@ -0,0 +1,146 @@ +createMock(LoggerInterface::class); + $provider = new TrashGuidesProvider($logger); + + $this->assertInstanceOf(TrashGuidesProvider::class, $provider); + } + + public function testConstructorWithoutLogger(): void + { + $provider = new TrashGuidesProvider(); + + $this->assertInstanceOf(TrashGuidesProvider::class, $provider); + } + + public function testSyncResultConstructor(): void + { + $result = new \Phlex\Shared\Arr\SyncResult( + customFormatsAdded: 5, + customFormatsUpdated: 2, + qualityProfilesAdded: 1, + qualityProfilesUpdated: 3, + version: 'abc123', + syncedAt: new \DateTimeImmutable() + ); + + $this->assertEquals(5, $result->customFormatsAdded); + $this->assertEquals(2, $result->customFormatsUpdated); + $this->assertEquals(1, $result->qualityProfilesAdded); + $this->assertEquals(3, $result->qualityProfilesUpdated); + $this->assertEquals('abc123', $result->version); + } + + public function testSyncResultGetTotalChanges(): void + { + $result = new \Phlex\Shared\Arr\SyncResult( + customFormatsAdded: 5, + customFormatsUpdated: 2, + qualityProfilesAdded: 1, + qualityProfilesUpdated: 3, + version: 'abc123', + syncedAt: new \DateTimeImmutable() + ); + + $this->assertEquals(11, $result->getTotalChanges()); + } + + public function testSyncResultGetTotalCustomFormatsChanged(): void + { + $result = new \Phlex\Shared\Arr\SyncResult( + customFormatsAdded: 5, + customFormatsUpdated: 2, + qualityProfilesAdded: 0, + qualityProfilesUpdated: 0, + version: 'abc123', + syncedAt: new \DateTimeImmutable() + ); + + $this->assertEquals(7, $result->getTotalCustomFormatsChanged()); + } + + public function testSyncResultGetTotalQualityProfilesChanged(): void + { + $result = new \Phlex\Shared\Arr\SyncResult( + customFormatsAdded: 0, + customFormatsUpdated: 0, + qualityProfilesAdded: 3, + qualityProfilesUpdated: 2, + version: 'abc123', + syncedAt: new \DateTimeImmutable() + ); + + $this->assertEquals(5, $result->getTotalQualityProfilesChanged()); + } + + public function testSyncResultIsEmptyWhenNoChanges(): void + { + $result = new \Phlex\Shared\Arr\SyncResult( + customFormatsAdded: 0, + customFormatsUpdated: 0, + qualityProfilesAdded: 0, + qualityProfilesUpdated: 0, + version: 'abc123', + syncedAt: new \DateTimeImmutable() + ); + + $this->assertTrue($result->isEmpty()); + } + + public function testSyncResultIsNotEmptyWhenChanges(): void + { + $result = new \Phlex\Shared\Arr\SyncResult( + customFormatsAdded: 1, + customFormatsUpdated: 0, + qualityProfilesAdded: 0, + qualityProfilesUpdated: 0, + version: 'abc123', + syncedAt: new \DateTimeImmutable() + ); + + $this->assertFalse($result->isEmpty()); + } + + public function testSyncResultToArray(): void + { + $syncedAt = new \DateTimeImmutable('2024-01-15 12:00:00'); + $result = new \Phlex\Shared\Arr\SyncResult( + customFormatsAdded: 5, + customFormatsUpdated: 2, + qualityProfilesAdded: 1, + qualityProfilesUpdated: 3, + version: 'abc123', + syncedAt: $syncedAt + ); + + $array = $result->toArray(); + + $this->assertEquals(5, $array['custom_formats_added']); + $this->assertEquals(2, $array['custom_formats_updated']); + $this->assertEquals(1, $array['quality_profiles_added']); + $this->assertEquals(3, $array['quality_profiles_updated']); + $this->assertEquals('abc123', $array['version']); + $this->assertEquals(11, $array['total_changes']); + $this->assertIsString($array['synced_at']); + $this->assertStringContainsString('2024-01-15', $array['synced_at']); + } +}