From 10cf32dfc899a0cf55ff07e741a4b52507ee2d27 Mon Sep 17 00:00:00 2001 From: ndandan Date: Sat, 13 Jun 2026 13:19:05 -0500 Subject: [PATCH 001/284] feat(tautulli): add optional Plex activity widget to the dashboard Read-only Tautulli integration: a server-side client (sanitized, SSRF- guarded, circuit-breaker cached, fails open), an internal JSON endpoint, HealthService + admin-settings wiring, and an async dashboard widget that refreshes every 10s. The API key never reaches the browser and private fields (IPs, tokens, machine_id, file paths, Plex username/email) are dropped by allow-list before the data leaves the server. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 + README.md | 6 +- symfony/public/img/services/ATTRIBUTION.md | 4 + symfony/public/img/services/tautulli.svg | 1 + .../Controller/AdminSettingsController.php | 8 +- .../src/Controller/DashboardController.php | 25 +- symfony/src/Controller/TautulliController.php | 63 +++ symfony/src/Service/HealthService.php | 40 +- symfony/src/Service/Media/TautulliClient.php | 369 ++++++++++++++++++ symfony/templates/_icons.html.twig | 1 + symfony/templates/admin/settings.html.twig | 4 +- symfony/templates/dashboard/_health.html.twig | 1 + .../dashboard/_plex_activity.html.twig | 78 ++++ symfony/templates/dashboard/index.html.twig | 65 +++ .../Service/Media/TautulliClientTest.php | 153 ++++++++ .../translations/messages+intl-icu.en.yaml | 33 ++ .../translations/messages+intl-icu.fr.yaml | 33 ++ 17 files changed, 883 insertions(+), 6 deletions(-) create mode 100644 symfony/public/img/services/tautulli.svg create mode 100644 symfony/src/Controller/TautulliController.php create mode 100644 symfony/src/Service/Media/TautulliClient.php create mode 100644 symfony/templates/dashboard/_plex_activity.html.twig create mode 100644 symfony/tests/Service/Media/TautulliClientTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e923b9fc..dc8273ff 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] + +### Added +- **Tautulli API integration for current Plex activity.** A new optional integration consumes an existing Tautulli instance's API (`get_activity`) and surfaces current Plex streams in a "Current Plex activity" dashboard widget: active stream count, Direct Play / Direct Stream / Transcode breakdown, total/LAN/WAN bandwidth in Mbps, and one card per session (title, user, player/device, quality, decision badges, location, bandwidth, progress and play state). Configure it under **Settings → Services → Monitoring** (URL + API key, with an enable toggle and a Test-connection button) and it also gets a health chip on the dashboard. Read-only by design — no mutating Tautulli commands are implemented. Every call is server-side, the API key never reaches the browser, and the response is sanitized to a normalized shape (no IPs, tokens, machine ids, file paths or raw payload) before it leaves Prismarr. The widget polls every 10 s and fails open: a disabled, unconfigured, unreachable or wrong-key Tautulli shows a clean message instead of breaking the dashboard. + ## [1.1.1] - 2026-06-10 ### Fixed diff --git a/README.md b/README.md index adc232e6..52a694b7 100644 --- a/README.md +++ b/README.md @@ -165,8 +165,11 @@ a request UI (Seerr). - Hero spotlight with a random pick from your library - Upcoming releases (seven-day mini-calendar) - Pending Seerr requests enriched with TMDb metadata -- Live health of all six services +- Live health of all configured services - Personal watchlist, weekly TMDb trending, latest library additions +- Optional **Current Plex activity** widget via the Tautulli API: active + streams, Direct Play / Direct Stream / Transcode counts, LAN/WAN + bandwidth and a per-session card (read-only, refreshes every 10s) - Near-instant load: the page paints first, then each widget hydrates on its own ### Downloads @@ -206,6 +209,7 @@ a request UI (Seerr). - At least one of: qBittorrent, Radarr, Sonarr, Prowlarr, Seerr - Optional: Gluetun if qBittorrent runs behind a VPN - Optional: a TMDb API key (free) to enable the Discovery page +- Optional: a Tautulli instance (URL + API key) for the Current Plex activity widget ### Install diff --git a/symfony/public/img/services/ATTRIBUTION.md b/symfony/public/img/services/ATTRIBUTION.md index f4f6defe..c1d333fb 100644 --- a/symfony/public/img/services/ATTRIBUTION.md +++ b/symfony/public/img/services/ATTRIBUTION.md @@ -7,6 +7,10 @@ jellyseerr, qbittorrent, sabnzbd, nzbget, gluetun, tmdb) come from the - Source: https://github.com/homarr-labs/dashboard-icons - License: Apache License 2.0 +`tautulli.svg` is an original, simplified mark drawn for Prismarr in +Tautulli's brand amber (`#e5a00d`); it is not taken from the Dashboard +Icons set. + Each logo remains a trademark of its respective project. They are used here nominatively, to identify the third-party services Prismarr can connect to, never to imply endorsement. diff --git a/symfony/public/img/services/tautulli.svg b/symfony/public/img/services/tautulli.svg new file mode 100644 index 00000000..c9d60e6b --- /dev/null +++ b/symfony/public/img/services/tautulli.svg @@ -0,0 +1 @@ + diff --git a/symfony/src/Controller/AdminSettingsController.php b/symfony/src/Controller/AdminSettingsController.php index cda45db7..99f1d051 100644 --- a/symfony/src/Controller/AdminSettingsController.php +++ b/symfony/src/Controller/AdminSettingsController.php @@ -83,6 +83,10 @@ class AdminSettingsController extends AbstractController ['key' => 'gluetun_api_key', 'type' => 'password', 'label' => 'admin.field.api_key_if_protected'], ['key' => 'gluetun_protocol', 'type' => 'text', 'label' => 'admin.field.protocol'], ], + 'tautulli' => [ + ['key' => 'tautulli_url', 'type' => 'text', 'label' => 'admin.field.url', 'placeholder' => 'http://host.docker.internal:8181'], + ['key' => 'tautulli_api_key', 'type' => 'password', 'label' => 'admin.field.api_key'], + ], ]; /** @@ -128,6 +132,7 @@ class AdminSettingsController extends AbstractController 'sabnzbd' => 'SABnzbd', 'nzbget' => 'NZBGet', 'gluetun' => 'Gluetun', + 'tautulli' => 'Tautulli', ]; /** @@ -474,6 +479,7 @@ public function test(string $service, Request $request): JsonResponse 'qbittorrent' => ['qbittorrent_url', 'qbittorrent_user', 'qbittorrent_password'], 'sabnzbd' => ['sabnzbd_url', 'sabnzbd_api_key'], 'nzbget' => ['nzbget_url', 'nzbget_user', 'nzbget_password'], + 'tautulli' => ['tautulli_url', 'tautulli_api_key'], default => [], }; $overrides = []; @@ -522,7 +528,7 @@ public function test(string $service, Request $request): JsonResponse public function healthInvalidate(string $service): JsonResponse { $service = strtolower($service); - $allowed = ['radarr', 'sonarr', 'prowlarr', 'jellyseerr', 'qbittorrent', 'tmdb', 'sabnzbd', 'nzbget']; + $allowed = ['radarr', 'sonarr', 'prowlarr', 'jellyseerr', 'qbittorrent', 'tmdb', 'sabnzbd', 'nzbget', 'tautulli']; if (!in_array($service, $allowed, true)) { return new JsonResponse(['ok' => false], 400); } diff --git a/symfony/src/Controller/DashboardController.php b/symfony/src/Controller/DashboardController.php index ddb15ce2..3a49b7c8 100644 --- a/symfony/src/Controller/DashboardController.php +++ b/symfony/src/Controller/DashboardController.php @@ -8,6 +8,7 @@ use App\Service\Media\JellyseerrClient; use App\Service\Media\RadarrClient; use App\Service\Media\SonarrClient; +use App\Service\Media\TautulliClient; use App\Service\Media\TmdbClient; use App\Service\ServiceInstanceProvider; use Psr\Log\LoggerInterface; @@ -61,6 +62,7 @@ public function __construct( private readonly LoggerInterface $logger, private readonly TranslatorInterface $translator, private readonly CacheInterface $cache, + private readonly TautulliClient $tautulli, ) {} /** @@ -153,6 +155,7 @@ public function index(): Response 'sonarr' => $this->health->isConfigured('sonarr'), 'jellyseerr' => $this->health->isConfigured('jellyseerr'), 'tmdb' => $this->health->isConfigured('tmdb'), + 'tautulli' => $this->health->isConfigured('tautulli'), ]; return $this->render('dashboard/index.html.twig', [ @@ -251,6 +254,26 @@ public function widgetHealth(): Response ]); } + /** + * Async fragment — current Plex activity from Tautulli. Skipped entirely + * (empty body → hidden client-side) when Tautulli isn't configured / + * enabled. Otherwise renders the widget body; the fragment re-fetches on a + * 10 s interval (see index.html.twig) and fails open to an error state, so + * a down/misconfigured Tautulli never breaks the dashboard. + */ + #[Route('/tableau-de-bord/widget/plex', name: 'app_dashboard_widget_plex')] + public function widgetPlex(): Response + { + if (!$this->health->isConfigured('tautulli')) { + return new Response(''); + } + set_time_limit(60); + + return $this->render('dashboard/_plex_activity.html.twig', [ + 'plex' => $this->tautulli->getActivity(), + ]); + } + /** * Async fragment (#27) — hero spotlight + library stats. Pulls the three * heaviest sources (TMDb recommendations, Radarr/Sonarr library counts and @@ -526,7 +549,7 @@ private function servicesHealth(): array } } - $labels = ['prowlarr' => 'Prowlarr', 'jellyseerr' => 'Seerr', 'qbittorrent' => 'qBittorrent', 'tmdb' => 'TMDb']; + $labels = ['prowlarr' => 'Prowlarr', 'jellyseerr' => 'Seerr', 'qbittorrent' => 'qBittorrent', 'tmdb' => 'TMDb', 'tautulli' => 'Tautulli']; foreach ($labels as $service => $label) { try { $h = $this->health->isHealthy($service); diff --git a/symfony/src/Controller/TautulliController.php b/symfony/src/Controller/TautulliController.php new file mode 100644 index 00000000..b81c7f73 --- /dev/null +++ b/symfony/src/Controller/TautulliController.php @@ -0,0 +1,63 @@ +json($this->tautulli->getActivity()); + } catch (\Throwable) { + // Defensive: getActivity() already fails open, but never let an + // unexpected throwable leak a message/secret to the browser. + return $this->json([ + 'enabled' => true, + 'configured' => true, + 'connected' => false, + 'error' => 'unreachable', + 'streamCount' => 0, + 'directPlayCount' => 0, + 'directStreamCount' => 0, + 'transcodeCount' => 0, + 'bandwidth' => [ + 'totalKbps' => 0, 'lanKbps' => 0, 'wanKbps' => 0, + 'totalMbps' => 0.0, 'lanMbps' => 0.0, 'wanMbps' => 0.0, + ], + 'sessions' => [], + ]); + } + } +} diff --git a/symfony/src/Service/HealthService.php b/symfony/src/Service/HealthService.php index 0dfa48b6..44dfec63 100644 --- a/symfony/src/Service/HealthService.php +++ b/symfony/src/Service/HealthService.php @@ -9,6 +9,7 @@ use App\Service\Media\RadarrClient; use App\Service\Media\ServiceHealthCache; use App\Service\Media\SonarrClient; +use App\Service\Media\TautulliClient; use App\Service\Media\TmdbClient; use App\Service\Media\Usenet\NzbgetClient; use App\Service\Media\Usenet\SabnzbdClient; @@ -47,6 +48,9 @@ public function __construct( // working without each having to provide a fake SAB/NZBGet. private readonly ?SabnzbdClient $sabnzbd = null, private readonly ?NzbgetClient $nzbget = null, + // Tautulli (current Plex activity) — nullable + last for the same + // legacy-test-constructor reason as the Usenet clients above. + private readonly ?TautulliClient $tautulli = null, ) {} /** @@ -119,6 +123,7 @@ private function pingFor(string $service, ?string $instanceSlug): ?bool // banner still uses diagnose() to tell auth vs host_whitelist apart. 'sabnzbd' => $this->sabnzbd?->ping() ?? false, 'nzbget' => $this->nzbget?->ping() ?? false, + 'tautulli' => $this->tautulli?->ping() ?? false, default => true, }; } @@ -135,7 +140,7 @@ private function pingFor(string $service, ?string $instanceSlug): ?bool * (issue #15). Radarr/Sonarr are absent on purpose — they enable/disable * per instance via the `enabled` flag on `service_instance`. */ - public const TOGGLEABLE_SERVICES = ['prowlarr', 'jellyseerr', 'qbittorrent', 'tmdb', 'sabnzbd', 'nzbget']; + public const TOGGLEABLE_SERVICES = ['prowlarr', 'jellyseerr', 'qbittorrent', 'tmdb', 'sabnzbd', 'nzbget', 'tautulli']; public function isConfigured(string $service): bool { @@ -176,6 +181,10 @@ public function isConfigured(string $service): bool $this->config->has('sabnzbd_url') && $this->config->has('sabnzbd_api_key'), 'nzbget' => $this->config->has('nzbget_url'), + // Tautulli needs both the URL and the API key (every command, + // including get_activity, is apikey-authenticated). + 'tautulli' => + $this->config->has('tautulli_url') && $this->config->has('tautulli_api_key'), default => true, }; } @@ -192,7 +201,7 @@ public function invalidate(?string $service = null): void if ($service === null) { $this->cache = []; if ($this->serviceHealthCache !== null) { - foreach (['radarr', 'sonarr', 'prowlarr', 'jellyseerr', 'qbittorrent', 'tmdb', 'sabnzbd', 'nzbget'] as $svc) { + foreach (['radarr', 'sonarr', 'prowlarr', 'jellyseerr', 'qbittorrent', 'tmdb', 'sabnzbd', 'nzbget', 'tautulli'] as $svc) { $this->serviceHealthCache->clear($svc); } } @@ -278,6 +287,21 @@ public function diagnoseFromResponse(array $resp, string $service): array return ['ok' => false, 'category' => 'auth', 'http' => $http]; } } + // Tautulli always answers HTTP 200, even on a bad apikey — the real + // status lives in the JSON envelope ({"response":{"result":"error", + // "message":"Invalid apikey"}}). Treat any non-"success" result as an + // auth failure so the admin gets an actionable hint instead of a + // misleading green check. + if ($service === 'tautulli' && $http === 200 && is_string($body)) { + $decoded = json_decode($body, true); + $result = is_array($decoded) && is_array($decoded['response'] ?? null) + ? ($decoded['response']['result'] ?? null) + : null; + if ($result !== 'success') { + return ['ok' => false, 'category' => 'auth', 'http' => $http]; + } + return ['ok' => true, 'category' => 'ok', 'http' => $http]; + } if ($http !== null && $http >= 200 && $http < 300) { return ['ok' => true, 'category' => 'ok', 'http' => $http]; } @@ -409,6 +433,18 @@ private function probeFor(string $service, ?array $overrides = null): ?array 'url' => rtrim($url, '/') . '/api?mode=queue&output=json&apikey=' . urlencode($key), ]; } + case 'tautulli': { + $url = $get('tautulli_url'); + $key = $get('tautulli_api_key'); + if ($url === '' || $key === '') return null; + // get_activity is the same read-only command the widget uses. + // It also validates the key: a bad apikey returns HTTP 200 with + // result:"error", which diagnoseFromResponse() maps to `auth`. + return [ + 'url' => rtrim($url, '/') . '/api/v2?' . http_build_query(['apikey' => $key, 'cmd' => 'get_activity']), + 'headers' => ['Accept: application/json'], + ]; + } case 'nzbget': { $url = $get('nzbget_url'); $user = $get('nzbget_user'); diff --git a/symfony/src/Service/Media/TautulliClient.php b/symfony/src/Service/Media/TautulliClient.php new file mode 100644 index 00000000..5804bc2c --- /dev/null +++ b/symfony/src/Service/Media/TautulliClient.php @@ -0,0 +1,369 @@ +configLoaded = false; + $this->enabled = true; + $this->baseUrl = ''; + $this->apiKey = ''; + $this->lastError = null; + } + + /** @return array{code:int, method:string, path:string, message:string}|null */ + public function getLastError(): ?array + { + return $this->lastError; + } + + private function ensureConfig(): void + { + if ($this->configLoaded) { + return; + } + // Explicit kill switch (issue #15 pattern): only '0' disables; a + // missing row means the toggle was never touched → stays enabled. + $this->enabled = $this->config->get('tautulli_enabled') !== '0'; + $this->baseUrl = (string) ($this->config->get('tautulli_url') ?? ''); + $this->apiKey = (string) ($this->config->get('tautulli_api_key') ?? ''); + $this->configLoaded = true; + } + + /** + * Lightweight reachability probe for HealthService. True when a fresh + * get_activity call returns a successful Tautulli envelope. + */ + public function ping(): bool + { + $this->ensureConfig(); + if (!$this->enabled || $this->baseUrl === '' || $this->apiKey === '') { + return false; + } + $resp = $this->request(); + return $resp !== null && $resp['ok'] === true; + } + + /** + * Current Plex activity, normalized + sanitized for the frontend. + * + * Always returns the full shape — `enabled`, `configured`, `connected` + * flags plus an `error` code (null | 'unconfigured' | 'unreachable' | + * 'auth') so the widget can render the right empty/error state without + * ever seeing a stack trace or a secret. + * + * @return array{ + * enabled: bool, configured: bool, connected: bool, error: ?string, + * streamCount: int, directPlayCount: int, directStreamCount: int, + * transcodeCount: int, + * bandwidth: array{totalKbps:int, lanKbps:int, wanKbps:int, totalMbps:float, lanMbps:float, wanMbps:float}, + * sessions: list> + * } + */ + public function getActivity(): array + { + $this->ensureConfig(); + + $configured = $this->baseUrl !== '' && $this->apiKey !== ''; + $base = self::emptyShape($this->enabled, $configured); + + if (!$this->enabled) { + return $base; // error stays null — the widget is hidden upstream anyway + } + if (!$configured) { + $base['error'] = 'unconfigured'; + return $base; + } + + $resp = $this->request(); + if ($resp === null) { + $base['error'] = 'unreachable'; + return $base; + } + if ($resp['ok'] !== true) { + // Tautulli answers HTTP 200 with result:"error" on a bad apikey. + $base['error'] = 'auth'; + return $base; + } + + return [ + 'enabled' => true, + 'configured' => true, + 'connected' => true, + 'error' => null, + ] + self::normalizeActivity($resp['data']); + } + + /** + * Issue the get_activity call. Returns the decoded Tautulli envelope split + * into a tiny result tuple, or null when the host is unreachable / the + * response isn't valid JSON. Honors + feeds the cross-request circuit + * breaker so a downed Tautulli doesn't cost an 8 s timeout on every poll. + * + * @return array{ok: bool, data: array}|null + */ + private function request(): ?array + { + // Circuit breaker: skip the call entirely if Tautulli was just seen + // down — the 10 s widget poll would otherwise stack connect timeouts. + if ($this->health?->isDown(self::SERVICE)) { + return null; + } + + // SSRF guard #1 — reuse the shared validator (blocks non-http(s) + // schemes + link-local / cloud-metadata IPs) before opening a socket. + $endpoint = rtrim($this->baseUrl, '/') . '/api/v2'; + if (($reason = HealthService::urlBlockedReason($endpoint)) !== null) { + $this->recordError(0, 'blocked: ' . $reason); + $this->logger->warning('Tautulli URL blocked', ['reason' => $reason]); + return null; + } + + $url = $endpoint . '?' . http_build_query([ + 'apikey' => $this->apiKey, + 'cmd' => 'get_activity', + ]); + + $ch = curl_init($url); + if ($ch === false) { + return null; + } + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => 8, + CURLOPT_NOSIGNAL => true, // critical under FrankenPHP/Alpine + CURLOPT_FOLLOWLOCATION => false, + // SSRF guard #2 — lock the protocol even across any redirect. + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($body === false || $err !== '' || $code === 0) { + $this->recordError($code, $err !== '' ? $err : 'connection failed'); + $this->health?->markDown(self::SERVICE); + return null; + } + + $json = json_decode((string) $body, true); + if (!is_array($json)) { + $this->recordError($code, 'invalid JSON response'); + $this->health?->markDown(self::SERVICE); + return null; + } + + // A reachable host clears the breaker even on an auth error — the box + // is up, only the key is wrong. + $this->health?->clear(self::SERVICE); + + $resp = is_array($json['response'] ?? null) ? $json['response'] : []; + $result = $resp['result'] ?? null; + if ($result !== 'success') { + $this->recordError($code, 'tautulli result: ' . (is_string($result) ? $result : 'error')); + return ['ok' => false, 'data' => []]; + } + + $this->lastError = null; + $data = is_array($resp['data'] ?? null) ? $resp['data'] : []; + return ['ok' => true, 'data' => $data]; + } + + /** + * Pure transform: Tautulli `get_activity` `data` object → sanitized shape. + * Public + static so it can be unit-tested against a captured fixture + * without any network. Only allow-listed fields are copied out — anything + * sensitive (ip_address[_public], machine_id, *_token, file, …) is dropped + * by construction because it is never read here. + * + * @param array $data + * @return array{ + * streamCount:int, directPlayCount:int, directStreamCount:int, transcodeCount:int, + * bandwidth: array{totalKbps:int, lanKbps:int, wanKbps:int, totalMbps:float, lanMbps:float, wanMbps:float}, + * sessions: list> + * } + */ + public static function normalizeActivity(array $data): array + { + $sessions = []; + $rawSessions = $data['sessions'] ?? []; + if (is_array($rawSessions)) { + foreach ($rawSessions as $s) { + if (is_array($s)) { + $sessions[] = self::normalizeSession($s); + } + } + } + + $total = (int) ($data['total_bandwidth'] ?? 0); + $lan = (int) ($data['lan_bandwidth'] ?? 0); + $wan = (int) ($data['wan_bandwidth'] ?? 0); + + return [ + 'streamCount' => (int) ($data['stream_count'] ?? 0), + 'directPlayCount' => (int) ($data['stream_count_direct_play'] ?? 0), + 'directStreamCount' => (int) ($data['stream_count_direct_stream'] ?? 0), + 'transcodeCount' => (int) ($data['stream_count_transcode'] ?? 0), + 'bandwidth' => [ + 'totalKbps' => $total, + 'lanKbps' => $lan, + 'wanKbps' => $wan, + 'totalMbps' => self::toMbps($total), + 'lanMbps' => self::toMbps($lan), + 'wanMbps' => self::toMbps($wan), + ], + 'sessions' => $sessions, + ]; + } + + /** + * @param array $s + * @return array + */ + private static function normalizeSession(array $s): array + { + $bw = (int) ($s['bandwidth'] ?? 0); + + return [ + 'sessionKey' => self::str($s['session_key'] ?? null), + 'sessionId' => self::str($s['session_id'] ?? null), + 'state' => self::str($s['state'] ?? null), + // full_title is the human label Tautulli builds ("Show - SxxEyy" / + // "Movie (year)"); fall back to the bare title if it's missing. + 'title' => self::str($s['full_title'] ?? ($s['title'] ?? null)), + 'grandparentTitle' => self::str($s['grandparent_title'] ?? null), + 'year' => self::str($s['year'] ?? null), + 'mediaType' => self::str($s['media_type'] ?? null), + // Plex metadata path (e.g. /library/metadata/123/thumb/456) — NOT a + // server filesystem path. Kept for a future server-side image proxy; + // the MVP widget renders a placeholder and never requests it. + 'posterPath' => self::str($s['thumb'] ?? null), + // Display name only. We deliberately never expose `username` (the + // Plex login) or any email/IP. + 'userDisplayName' => self::str($s['friendly_name'] ?? ($s['user'] ?? null)), + 'product' => self::str($s['product'] ?? null), + 'player' => self::str($s['player'] ?? null), + 'device' => self::str($s['device'] ?? null), + 'platform' => self::str($s['platform'] ?? null), + 'quality' => self::str($s['quality_profile'] ?? null), + 'containerDecision'=> self::str($s['container_decision'] ?? null), + 'videoDecision' => self::str($s['video_decision'] ?? null), + 'audioDecision' => self::str($s['audio_decision'] ?? null), + 'subtitleDecision' => self::str($s['subtitle_decision'] ?? null), + 'transcodeDecision'=> self::str($s['transcode_decision'] ?? null), + 'location' => self::str($s['location'] ?? null), + 'bandwidthKbps' => $bw, + 'bandwidthMbps' => self::toMbps($bw), + 'progressPercent' => self::pct($s['progress_percent'] ?? null), + ]; + } + + /** + * @return array{ + * enabled: bool, configured: bool, connected: bool, error: ?string, + * streamCount: int, directPlayCount: int, directStreamCount: int, transcodeCount: int, + * bandwidth: array{totalKbps:int, lanKbps:int, wanKbps:int, totalMbps:float, lanMbps:float, wanMbps:float}, + * sessions: list> + * } + */ + private static function emptyShape(bool $enabled, bool $configured): array + { + return [ + 'enabled' => $enabled, + 'configured' => $configured, + 'connected' => false, + 'error' => null, + 'streamCount' => 0, + 'directPlayCount' => 0, + 'directStreamCount' => 0, + 'transcodeCount' => 0, + 'bandwidth' => [ + 'totalKbps' => 0, 'lanKbps' => 0, 'wanKbps' => 0, + 'totalMbps' => 0.0, 'lanMbps' => 0.0, 'wanMbps' => 0.0, + ], + 'sessions' => [], + ]; + } + + /** Tautulli reports bandwidth in kbps; the UI shows Mbps (1 decimal). */ + private static function toMbps(int $kbps): float + { + return $kbps > 0 ? round($kbps / 1000, 1) : 0.0; + } + + /** Coerce a Tautulli scalar to a trimmed string, or null when absent/empty. */ + private static function str(mixed $v): ?string + { + if ($v === null || is_array($v)) { + return null; + } + $s = trim((string) $v); + return $s === '' ? null : $s; + } + + /** progress_percent comes back as a numeric string; clamp to 0-100. */ + private static function pct(mixed $v): float + { + if (!is_numeric($v)) { + return 0.0; + } + return max(0.0, min(100.0, round((float) $v, 1))); + } + + private function recordError(int $code, string $message): void + { + $this->lastError = [ + 'code' => $code, + 'method' => 'GET', + 'path' => '/api/v2?cmd=get_activity', + 'message' => $message, + ]; + } +} diff --git a/symfony/templates/_icons.html.twig b/symfony/templates/_icons.html.twig index 9f27f49a..2e03725e 100644 --- a/symfony/templates/_icons.html.twig +++ b/symfony/templates/_icons.html.twig @@ -34,6 +34,7 @@ 'clipboard-check': ' ', 'package': ' ', 'bolt': '', + 'activity': '', 'file': ' ', 'crystal-ball': ' ', 'shield': '', diff --git a/symfony/templates/admin/settings.html.twig b/symfony/templates/admin/settings.html.twig index 3794185a..f0b2ca21 100644 --- a/symfony/templates/admin/settings.html.twig +++ b/symfony/templates/admin/settings.html.twig @@ -435,6 +435,7 @@ 'sabnzbd': { icon_bg: '#ffa000', subtitle: 'admin.services.subtitle.sabnzbd'|trans }, 'nzbget': { icon_bg: '#1f9c3a', subtitle: 'admin.services.subtitle.nzbget'|trans }, 'gluetun': { icon_bg: '#10b981', subtitle: 'admin.services.subtitle.gluetun'|trans }, + 'tautulli': { icon_bg: '#e5a00d', subtitle: 'admin.services.subtitle.tautulli'|trans }, } %} {% set groupings = { @@ -442,6 +443,7 @@ ('admin.services.group.managers'|trans): ['radarr', 'sonarr'], ('admin.services.group.indexers'|trans): ['prowlarr', 'jellyseerr'], ('admin.services.group.downloads'|trans): ['qbittorrent', 'gluetun', 'sabnzbd', 'nzbget'], + ('admin.services.group.monitoring'|trans): ['tautulli'], } %} {# ─── Section 1 : Services externes ─────────────────────── #} @@ -482,7 +484,7 @@ {# Issue #15 — per-service kill switch. Only the flat-config services have it; radarr/sonarr enable/disable per instance. Unchecked box isn't POSTed → disabled. #} - {% if service_id in ['tmdb', 'prowlarr', 'jellyseerr', 'qbittorrent', 'sabnzbd', 'nzbget'] %} + {% if service_id in ['tmdb', 'prowlarr', 'jellyseerr', 'qbittorrent', 'sabnzbd', 'nzbget', 'tautulli'] %}