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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Internal
- **CI / release workflows modernised.** Bumped the pinned GitHub Actions to current majors (`checkout@v5`, `setup-qemu@v4`, `setup-buildx@v4`, `login@v4`, `metadata@v6`, `build-push@v7`, `dockerhub-description@v5`, `action-gh-release@v3`) across `ci`, `beta`, `release` and `dockerhub-readme`, added a `workflow_dispatch` trigger to `ci` so the suite can be run on demand, and guarded the Docker Hub README sync with `if: github.repository == 'Shoshuo/Prismarr'` so forks don't fail the job on missing secrets.
### Added
- **Transmission torrent client integration.** A new download-client page (setup wizard step, admin settings entry, sidebar badge/poll, health circuit breaker) alongside qBittorrent, modeled on the same conventions. Transmission's RPC session handshake is handled transparently: the first request without a valid `X-Transmission-Session-Id` gets an expected HTTP 409 carrying the real token, which the client caches and retries with — the setup wizard's Test button and the dashboard health check both treat that 409 as "reachable," while a genuine `401` (bad RPC password) is reported as an auth failure. The user/password fields are optional, matching qBittorrent's reverse-proxy-friendly config shape. The page covers the list/table/compact torrent views, filtering, search, sort, pagination, bulk actions (pause/resume/delete/recheck, plus pause-all/resume-all), a per-torrent detail modal (general/files/trackers/peers) and add-by-URL / add-by-file, with a read-only category filter derived from Transmission's own labels.
- **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.
- **In-place quick-look on the dashboard.** Clicking any media tile — hero spotlight, upcoming, weekly trending, watchlist or latest additions — now opens a read-only detail modal right on the dashboard (poster, year, status, rating, genres, synopsis) instead of navigating away, with a deep-link to manage the item in Radarr/Sonarr or open it in Discover. Library items resolve from the already-cached dashboard aggregate (zero extra upstream calls on a warm page, falling back to a direct fetch on a miss); TMDb items resolve straight from TMDb. The fragment is read-only and fails open to a small graceful body, and the modal is Turbo-safe (declarative trigger, escaped values).
- **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 the statistics, all graphs and the history grid to a single user, or all users (the Users overview table always lists everyone). 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).
Expand Down
4 changes: 2 additions & 2 deletions symfony/public/img/services/ATTRIBUTION.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Service logos

The service logo SVGs in this directory (radarr, sonarr, prowlarr,
jellyseerr, qbittorrent, sabnzbd, nzbget, gluetun, tmdb) come from the
**Dashboard Icons** collection:
jellyseerr, qbittorrent, transmission, sabnzbd, nzbget, gluetun, tmdb) come
from the **Dashboard Icons** collection:

- Source: https://github.com/homarr-labs/dashboard-icons
- License: Apache License 2.0
Expand Down
1 change: 1 addition & 0 deletions symfony/public/img/services/transmission.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 21 additions & 1 deletion symfony/src/Controller/AdminSettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ class AdminSettingsController extends AbstractController
['key' => 'qbittorrent_user', 'type' => 'text', 'label' => 'admin.field.username', 'clearable' => true],
['key' => 'qbittorrent_password', 'type' => 'password', 'label' => 'admin.field.password', 'clearable' => true],
],
'transmission' => [
['key' => 'transmission_url', 'type' => 'text', 'label' => 'admin.field.url', 'placeholder' => 'http://host.docker.internal:9091'],
['key' => 'transmission_user', 'type' => 'text', 'label' => 'admin.field.username', 'clearable' => true],
['key' => 'transmission_password', 'type' => 'password', 'label' => 'admin.field.password', 'clearable' => true],
],
'sabnzbd' => [
['key' => 'sabnzbd_url', 'type' => 'text', 'label' => 'admin.field.url', 'placeholder' => 'http://host.docker.internal:8080'],
['key' => 'sabnzbd_api_key', 'type' => 'password', 'label' => 'admin.field.api_key'],
Expand Down Expand Up @@ -128,6 +133,7 @@ class AdminSettingsController extends AbstractController
'prowlarr' => 'Prowlarr',
'jellyseerr' => 'Seerr',
'qbittorrent' => 'qBittorrent',
'transmission' => 'Transmission',
'sabnzbd' => 'SABnzbd',
'nzbget' => 'NZBGet',
'gluetun' => 'Gluetun',
Expand Down Expand Up @@ -231,6 +237,19 @@ class AdminSettingsController extends AbstractController
],
'help' => 'admin.display.qbit_refresh.help',
],
'display_transmission_refresh' => [
'label' => 'admin.display.transmission_refresh.label',
'type' => 'select',
'default' => '2',
'options' => [
'1' => 'admin.display.transmission_refresh.options.1',
'2' => 'admin.display.transmission_refresh.options.2',
'5' => 'admin.display.transmission_refresh.options.5',
'10' => 'admin.display.transmission_refresh.options.10',
'0' => 'admin.display.transmission_refresh.options.0',
],
'help' => 'admin.display.transmission_refresh.help',
],
'display_ui_density' => [
'label' => 'admin.display.ui_density.label',
'type' => 'select',
Expand Down Expand Up @@ -476,6 +495,7 @@ public function test(string $service, Request $request): JsonResponse
'radarr', 'sonarr', 'prowlarr', 'jellyseerr' => [$service . '_url', $service . '_api_key'],
'tmdb' => ['tmdb_api_key'],
'qbittorrent' => ['qbittorrent_url', 'qbittorrent_user', 'qbittorrent_password'],
'transmission' => ['transmission_url', 'transmission_user', 'transmission_password'],
'sabnzbd' => ['sabnzbd_url', 'sabnzbd_api_key'],
'nzbget' => ['nzbget_url', 'nzbget_user', 'nzbget_password'],
'tautulli' => ['tautulli_url', 'tautulli_api_key'],
Expand Down Expand Up @@ -527,7 +547,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', 'tautulli'];
$allowed = ['radarr', 'sonarr', 'prowlarr', 'jellyseerr', 'qbittorrent', 'transmission', 'tmdb', 'sabnzbd', 'nzbget', 'tautulli'];
if (!in_array($service, $allowed, true)) {
return new JsonResponse(['ok' => false], 400);
}
Expand Down
5 changes: 3 additions & 2 deletions symfony/src/Controller/Concerns/ApiClientErrorTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Service\Media\QBittorrentClient;
use App\Service\Media\RadarrClient;
use App\Service\Media\SonarrClient;
use App\Service\Media\TransmissionClient;
use Symfony\Component\HttpFoundation\JsonResponse;

/**
Expand Down Expand Up @@ -34,7 +35,7 @@ trait ApiClientErrorTrait
* Returns a generic fallback if the client did not record a structured error
* (e.g. exception thrown before any HTTP call).
*/
private function buildClientErrorFlash(string $service, RadarrClient|SonarrClient|ProwlarrClient|JellyseerrClient|QBittorrentClient $client, ?string $fallback = null): string
private function buildClientErrorFlash(string $service, RadarrClient|SonarrClient|ProwlarrClient|JellyseerrClient|QBittorrentClient|TransmissionClient $client, ?string $fallback = null): string
{
$err = $client->getLastError();
if ($err === null) {
Expand All @@ -58,7 +59,7 @@ private function buildClientErrorFlash(string $service, RadarrClient|SonarrClien
* Output shape:
* { ok: false, error: string, http_code: int, service: string, path: string, method: string }
*/
private function jsonClientError(string $service, RadarrClient|SonarrClient|ProwlarrClient|JellyseerrClient|QBittorrentClient $client, ?string $fallback = null, int $statusCode = 500): JsonResponse
private function jsonClientError(string $service, RadarrClient|SonarrClient|ProwlarrClient|JellyseerrClient|QBittorrentClient|TransmissionClient $client, ?string $fallback = null, int $statusCode = 500): JsonResponse
{
$err = $client->getLastError();
if ($err === null) {
Expand Down
2 changes: 1 addition & 1 deletion symfony/src/Controller/HealthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
class HealthController extends AbstractController
{
/** Single-instance services — kept on the legacy flat ping API. */
private const FLAT_SERVICES = ['prowlarr', 'jellyseerr', 'qbittorrent', 'sabnzbd', 'nzbget', 'tmdb'];
private const FLAT_SERVICES = ['prowlarr', 'jellyseerr', 'qbittorrent', 'transmission', 'sabnzbd', 'nzbget', 'tmdb'];

#[Route('/api/health', name: 'api_health', methods: ['GET'])]
public function health(Connection $db): JsonResponse
Expand Down
Loading