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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ 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
- **Plex activity via Tautulli (optional).** A new read-only Tautulli integration (URL + API key in `/admin/settings`, behind the per-service health circuit breaker) surfaces current Plex activity. The dashboard gets a "Current Plex activity" widget — active streams, Direct Play / Direct Stream / Transcode counts, total / LAN / WAN bandwidth and a per-session card (quality, HDR/SDR badge, source→target codec when transcoding) — that hydrates on its own and refreshes every 10 s. A dedicated **Plex Activity** page (own sidebar entry) adds a now-playing strip, watch statistics with a 7 / 30 / 90-day toggle (top movies / shows / users / platforms), plays-over-time graphs with a Media-type ⇄ Stream-type toggle plus by-hour and by-day-of-week breakdowns and a platform × stream-type "problem clients" chart, a dense watch-history grid, and per-library item counts. Each title opens an in-app info modal (synopsis, ratings, cast/crew). The API key never leaves the server, every response is sanitised before it reaches the browser, and each section fails open independently so a down/misconfigured Tautulli never breaks the dashboard or page. Chart.js is self-hosted (`public/static/chart/`) for CSP compliance and reused by the existing Radarr stats chart.

### Changed
- **Dashboard service-health chips show latency.** `HealthService::statusFor()` returns a status word plus a round-trip reading (cached 10 s like the existing bool path, which now delegates to it); the dashboard chips render five states — up / slow / very_slow / down / degraded — with a coloured dot, so a reachable-but-slow service is visibly distinct from a healthy one. `isHealthy()` keeps its old contract for every existing caller.

## [1.1.1] - 2026-06-10

### Fixed
Expand Down
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,30 @@ 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

### Plex activity (Tautulli)
- Dedicated **Plex Activity** page (its own sidebar entry) backed by a
read-only Tautulli connection — the API key never leaves the server and
every response is sanitised before it reaches the browser
- Live "Now playing": a stream-summary strip (session count, Direct Play /
Direct Stream / Transcode breakdown, total / LAN / WAN bandwidth) plus a card
per session showing quality, an HDR/SDR badge and, when transcoding, the
source→target codec
- Watch statistics with a 7 / 30 / 90-day toggle: most-watched movies and
shows, most-active users, top platforms
- Graphs: plays over time with a Media-type ⇄ Stream-type toggle, plays by hour
of day and by day of week, and a platform × stream-type "problem clients"
chart
- A dense watch-history grid and a per-library item / episode count
- Every title opens the in-app info modal (synopsis, ratings, cast/crew); each
section fails open independently, so a down Tautulli never breaks the page

### Downloads
- Full qBittorrent dashboard: server-side pagination, sorting and filters
- Drag-and-drop `.torrent` upload (multi-file)
Expand Down Expand Up @@ -206,6 +226,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

Expand Down
4 changes: 4 additions & 0 deletions symfony/public/img/services/ATTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions symfony/public/img/services/tautulli.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions symfony/public/static/chart/chart.umd.min.js

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion symfony/src/Controller/AdminSettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
],
];

/**
Expand Down Expand Up @@ -128,6 +132,7 @@ class AdminSettingsController extends AbstractController
'sabnzbd' => 'SABnzbd',
'nzbget' => 'NZBGet',
'gluetun' => 'Gluetun',
'tautulli' => 'Tautulli',
];

/**
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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);
}
Expand Down
54 changes: 44 additions & 10 deletions symfony/src/Controller/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -61,6 +62,7 @@ public function __construct(
private readonly LoggerInterface $logger,
private readonly TranslatorInterface $translator,
private readonly CacheInterface $cache,
private readonly TautulliClient $tautulli,
) {}

/**
Expand Down Expand Up @@ -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', [
Expand Down Expand Up @@ -251,6 +254,37 @@ 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);

$activity = $this->tautulli->getActivity();

// History changes slowly; cache it ~60s so the 10s now-playing poll
// doesn't hit get_history every time. The relative time label is now
// formatted in the template via the relative_date Twig filter.
$history = $this->cached('plex_history', fn() => $this->tautulli->getHistory(8));

$streaming = ($activity['streamCount'] ?? 0) > 0;

return $this->render('dashboard/_plex_activity.html.twig', [
'plex' => $activity,
'plex_history'=> $history,
'plex_tab' => $streaming ? 'now' : 'recent',
]);
}

/**
* Async fragment (#27) — hero spotlight + library stats. Pulls the three
* heaviest sources (TMDb recommendations, Radarr/Sonarr library counts and
Expand Down Expand Up @@ -502,7 +536,7 @@ private function pendingRequests(): array
}

/**
* @return list<array{id: string, name: string, state: bool}>
* @return list<array{id: string, name: string, status: string, latencyMs: ?int}>
*
* v1.1.0 — radarr/sonarr expand to one chip PER enabled instance, named
* after the instance (Radarr 1080p, Radarr 4K…), matching the topbar
Expand All @@ -517,24 +551,24 @@ private function servicesHealth(): array
foreach ([ServiceInstance::TYPE_RADARR, ServiceInstance::TYPE_SONARR] as $type) {
foreach ($this->instances->getEnabled($type) as $inst) {
try {
$h = $this->health->isHealthy($type, $inst->getSlug());
$s = $this->health->statusFor($type, $inst->getSlug());
} catch (\Throwable) {
$h = false;
$s = ['status' => 'down', 'latencyMs' => null];
}
if ($h === null) continue; // instance has no credentials yet
$chips[] = ['id' => $type, 'name' => $inst->getName(), 'state' => $h];
if ($s['status'] === null) continue; // instance has no credentials yet
$chips[] = ['id' => $type, 'name' => $inst->getName(), 'status' => $s['status'], 'latencyMs' => $s['latencyMs']];
}
}

$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);
$s = $this->health->statusFor($service);
} catch (\Throwable) {
$h = null;
$s = ['status' => null, 'latencyMs' => null];
}
if ($h === null) continue; // not configured / disabled
$chips[] = ['id' => $service, 'name' => $label, 'state' => $h];
if ($s['status'] === null) continue; // not configured / disabled
$chips[] = ['id' => $service, 'name' => $label, 'status' => $s['status'], 'latencyMs' => $s['latencyMs']];
}

return $chips;
Expand Down
Loading