Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ 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.
- **Plex Activity page — statistics, graphs & a per-user filter.** The activity page's statistics gain Most Popular Movies / Shows tiles (ranked by distinct viewers), a Most Concurrent Streams tile, and a **Play Count ⇄ Play Duration** toggle that reformats every count-based tile and chart into watch time. Four new graphs are added — plays by source resolution, plays by stream resolution, streams by user, and concurrent streams over time (always a count, never reformatted as duration) — alongside a new privacy-safe **Users** overview table (friendly name, relative last-seen, last played, play count, total watch time). A page-wide **per-user filter** dropdown scopes every section — statistics, all graphs, the users table and the history grid — to a single user, or all users. The metric and user-id are clamped/validated server-side (`metric` ∈ `plays|duration`, user filter digits-only), every new client method and endpoint fails open to its neutral shape, and the allow-list keeps email / IP / avatar / Plex login off the wire — the opaque Tautulli user-id is a filter token only and never rendered. All data comes from read-only Tautulli commands (`get_home_stats`, `get_users_table`, `get_user_names`, `get_plays_by_*`, `get_concurrent_streams_by_stream_type`); strings are i18n'd (en/fr).

### 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
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,37 @@ 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 and a Play Count ⇄ Play
Duration switch: most-watched movies and shows, most-popular movies and shows
(ranked by distinct viewers), most-active users, top platforms, and most
concurrent streams
- Graphs: plays over time with a Media-type ⇄ Stream-type toggle, plays by hour
of day and by day of week, a platform × stream-type "problem clients" chart,
plays by source and stream resolution, streams by user, and concurrent
streams over time
- A page-wide **per-user filter** that scopes every section — statistics,
graphs, the users table and history — to a single user, plus a privacy-safe
Users overview table (friendly name, last seen, last played, plays, watch
time); the opaque Tautulli user id is a filter token only, never displayed
- 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 +233,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