Skip to content
Closed
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 @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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).
- **Keyboard access for media, watchlist and calendar cards.** Cards that open the quick-look were clickable `<div>`s unreachable by keyboard; they now carry `tabindex="0"`, `role="button"` and an `aria-label`, a shared Enter/Space handler activates them, and a `:focus-visible` ring makes the focused card visible. Screen-reader/keyboard users can now reach every tile the mouse can.
- **"New" ribbons and stacked Plex-viewer avatars.** Films/Series cards for items added in the last 7 days get a small Tabler `.ribbon` "New" corner flag (mutually exclusive with the quality badge on films), and the dashboard Plex widget shows a Tabler `.avatar-list` of the current distinct viewers (initial-based avatars, since the session payload has no user thumbnail). Both reuse classes already in the shipped Tabler bundle.
- **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.
### Changed
- **Setup wizard consolidated onto stock Tabler primitives.** The first-run wizard's hand-rolled mini design system was replaced with the Tabler components the rest of the app already ships: the step indicator is now a `.steps.steps-counter` stepper (with a small "done = green" accent rule), connection-test results are `.status` pills, buttons/cards/badges/service rows use stock `.btn` variants, `.card`, badges and list-groups, secret fields use an `input-group` reveal toggle (`[data-toggle-for]`), and section headers use `.hr-text`. Net −268 lines of bespoke CSS; the few deliberate flourishes (accent gradient on the primary action, hero styling) live in one commented "Prismarr accent layer" block in `setup/_layout.html.twig`. Behaviour is unchanged — all 7 steps re-verified on a fresh install in dark, light and mobile widths.
- **Plex items open the app-global quick-look modal.** Clicking a title on the Plex Activity page or the dashboard's Plex widget now opens the same rich detail modal used everywhere else (poster, synopsis, ratings, watchlist button, Manage/Discover deep-links) instead of the bespoke Tautulli metadata pop-up. The click resolves the item's TMDb id server-side (`/tautulli/api/quicklook/{ratingKey}`, one `get_metadata` call — episodes/seasons resolve to their show, with a grandparent hop for older Tautulli payloads that lack show-level guids); only the numeric TMDb id reaches the browser, keeping raw Plex guids inside the existing allow-list sanitization. Items with no TMDb match (music, home videos) fall back to the legacy Plex metadata modal, so every click keeps working.
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 @@ -75,6 +75,11 @@ class AdminSettingsController extends AbstractController
['key' => 'deluge_url', 'type' => 'text', 'label' => 'admin.field.url', 'placeholder' => 'http://host.docker.internal:8112'],
['key' => 'deluge_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 @@ -152,6 +157,7 @@ class AdminSettingsController extends AbstractController
'jellyseerr' => 'Seerr',
'qbittorrent' => 'qBittorrent',
'deluge' => 'Deluge',
'transmission' => 'Transmission',
'sabnzbd' => 'SABnzbd',
'nzbget' => 'NZBGet',
'gluetun' => 'Gluetun',
Expand Down Expand Up @@ -298,6 +304,19 @@ class AdminSettingsController extends AbstractController
],
'help' => 'admin.display.deluge_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 @@ -546,6 +565,7 @@ public function test(string $service, Request $request): JsonResponse
'tmdb' => ['tmdb_api_key'],
'qbittorrent' => ['qbittorrent_url', 'qbittorrent_user', 'qbittorrent_password'],
'deluge' => ['deluge_url', 'deluge_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 @@ -600,7 +620,7 @@ public function test(string $service, Request $request): JsonResponse
public function healthInvalidate(string $service): JsonResponse
{
$service = strtolower($service);
$allowed = ['radarr', 'sonarr', 'prowlarr', 'jellyseerr', 'qbittorrent', 'deluge', 'tmdb', 'sabnzbd', 'nzbget', 'tautulli', 'unraid', 'unifi', 'houndarr'];
$allowed = ['radarr', 'sonarr', 'prowlarr', 'jellyseerr', 'qbittorrent', 'deluge', 'transmission', 'tmdb', 'sabnzbd', 'nzbget', 'tautulli', 'unraid', 'unifi', 'houndarr'];
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 @@ -8,6 +8,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 @@ -35,7 +36,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|DelugeClient $client, ?string $fallback = null): string
private function buildClientErrorFlash(string $service, RadarrClient|SonarrClient|ProwlarrClient|JellyseerrClient|QBittorrentClient|DelugeClient|TransmissionClient $client, ?string $fallback = null): string
{
$err = $client->getLastError();
if ($err === null) {
Expand All @@ -59,7 +60,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|DelugeClient $client, ?string $fallback = null, int $statusCode = 500): JsonResponse
private function jsonClientError(string $service, RadarrClient|SonarrClient|ProwlarrClient|JellyseerrClient|QBittorrentClient|DelugeClient|TransmissionClient $client, ?string $fallback = null, int $statusCode = 500): JsonResponse
{
$err = $client->getLastError();
if ($err === null) {
Expand Down
1 change: 0 additions & 1 deletion symfony/src/Controller/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -865,7 +865,6 @@ private function pendingRequests(): array
return $out;
}


private function recommendations(): array
{
$payload = $this->safeFetch(
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