diff --git a/CHANGELOG.md b/CHANGELOG.md
index add05859..f95179a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 `
`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.
diff --git a/symfony/public/img/services/ATTRIBUTION.md b/symfony/public/img/services/ATTRIBUTION.md
index 0258e0d1..0b86311e 100644
--- a/symfony/public/img/services/ATTRIBUTION.md
+++ b/symfony/public/img/services/ATTRIBUTION.md
@@ -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
diff --git a/symfony/public/img/services/transmission.svg b/symfony/public/img/services/transmission.svg
new file mode 100644
index 00000000..04560ee0
--- /dev/null
+++ b/symfony/public/img/services/transmission.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/symfony/src/Controller/AdminSettingsController.php b/symfony/src/Controller/AdminSettingsController.php
index 14e88ec7..00b4fbb8 100644
--- a/symfony/src/Controller/AdminSettingsController.php
+++ b/symfony/src/Controller/AdminSettingsController.php
@@ -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'],
@@ -152,6 +157,7 @@ class AdminSettingsController extends AbstractController
'jellyseerr' => 'Seerr',
'qbittorrent' => 'qBittorrent',
'deluge' => 'Deluge',
+ 'transmission' => 'Transmission',
'sabnzbd' => 'SABnzbd',
'nzbget' => 'NZBGet',
'gluetun' => 'Gluetun',
@@ -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',
@@ -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'],
@@ -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);
}
diff --git a/symfony/src/Controller/Concerns/ApiClientErrorTrait.php b/symfony/src/Controller/Concerns/ApiClientErrorTrait.php
index e69ee485..a3e63e46 100644
--- a/symfony/src/Controller/Concerns/ApiClientErrorTrait.php
+++ b/symfony/src/Controller/Concerns/ApiClientErrorTrait.php
@@ -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;
/**
@@ -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) {
@@ -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) {
diff --git a/symfony/src/Controller/DashboardController.php b/symfony/src/Controller/DashboardController.php
index addcde0a..54972904 100644
--- a/symfony/src/Controller/DashboardController.php
+++ b/symfony/src/Controller/DashboardController.php
@@ -865,7 +865,6 @@ private function pendingRequests(): array
return $out;
}
-
private function recommendations(): array
{
$payload = $this->safeFetch(
diff --git a/symfony/src/Controller/HealthController.php b/symfony/src/Controller/HealthController.php
index d3b901ab..97e796c0 100644
--- a/symfony/src/Controller/HealthController.php
+++ b/symfony/src/Controller/HealthController.php
@@ -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
diff --git a/symfony/src/Controller/SetupController.php b/symfony/src/Controller/SetupController.php
index b420624c..9e246333 100644
--- a/symfony/src/Controller/SetupController.php
+++ b/symfony/src/Controller/SetupController.php
@@ -308,11 +308,14 @@ public function downloads(Request $request): Response
}
$fields = [
- 'qbittorrent_url' => 'http://host.docker.internal:8080',
- 'qbittorrent_user' => 'admin',
+ 'qbittorrent_url' => '',
+ 'qbittorrent_user' => '',
'qbittorrent_password' => '',
'deluge_url' => '',
'deluge_password' => '',
+ 'transmission_url' => '',
+ 'transmission_user' => '',
+ 'transmission_password' => '',
'gluetun_url' => '',
'gluetun_api_key' => '',
// Usenet download clients (optional, like qBittorrent above).
@@ -376,7 +379,7 @@ public function downloads(Request $request): Response
#[Route(
'/test/{service}',
name: 'app_setup_test',
- requirements: ['service' => 'tmdb|radarr|sonarr|prowlarr|jellyseerr|qbittorrent|deluge|sabnzbd|nzbget'],
+ requirements: ['service' => 'tmdb|radarr|sonarr|prowlarr|jellyseerr|qbittorrent|deluge|transmission|sabnzbd|nzbget'],
methods: ['POST'],
)]
#[IsGranted('ROLE_USER')]
@@ -440,6 +443,7 @@ private function collectTestFields(string $service, Request $request): array
'jellyseerr' => ['jellyseerr_url', 'jellyseerr_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'],
];
@@ -651,6 +655,7 @@ private function serviceSummary(): array
$this->summaryRow('Seerr', 'jellyseerr_url'),
$this->summaryRow('qBittorrent', 'qbittorrent_url'),
$this->summaryRow('Deluge', 'deluge_url'),
+ $this->summaryRow('Transmission', 'transmission_url'),
$this->summaryRow('SABnzbd', 'sabnzbd_url'),
$this->summaryRow('NZBGet', 'nzbget_url'),
$this->summaryRow('Gluetun', 'gluetun_url'),
diff --git a/symfony/src/Controller/TransmissionController.php b/symfony/src/Controller/TransmissionController.php
new file mode 100644
index 00000000..1ab063b2
--- /dev/null
+++ b/symfony/src/Controller/TransmissionController.php
@@ -0,0 +1,446 @@
+transmission->getTorrents();
+ $active = 0;
+ $items = [];
+ foreach ($torrents as $t) {
+ $state = $t['state'] ?? '';
+ if ($state === 'downloading') $active++;
+ if (in_array($state, ['completed', 'error', 'downloading', 'seeding'], true)) {
+ $items[] = [
+ 'hash' => $t['hash'] ?? '',
+ 'state' => $state,
+ 'name' => $t['name'] ?? '—',
+ 'size' => (int) ($t['size'] ?? 0),
+ ];
+ }
+ }
+ return $this->json(['active' => $active, 'items' => $items]);
+ } catch (\Throwable $e) {
+ $this->logger->warning('Transmission poll-summary failed', ['exception' => $e::class, 'message' => $e->getMessage()]);
+ return $this->json(['error' => $e->getMessage()], 500);
+ }
+ }
+
+ // ══════════════════════════════════════════════════════════════════════════
+ // Main page
+ // ══════════════════════════════════════════════════════════════════════════
+
+ #[Route('', name: 'index')]
+ public function index(): Response
+ {
+ $torrents = [];
+ $stats = [];
+ $categories = [];
+ $error = false;
+
+ try {
+ if ($this->transmission->getVersion() === null) {
+ $error = true;
+ } else {
+ $all = $this->transmission->getTorrents();
+ $torrents = array_slice($all, 0, 50);
+ $stats = $this->transmission->getStats($all);
+ // Transmission has no label-listing RPC — derive the category
+ // sidebar from what's actually on the torrents, qBit-shaped.
+ $labels = array_values(array_unique(array_filter(array_column($all, 'category'))));
+ $categories = array_fill_keys($labels, ['savePath' => '']);
+ }
+ } catch (\Throwable $e) {
+ $error = true;
+ $this->logger->warning('Transmission index failed', ['exception' => $e::class, 'message' => $e->getMessage()]);
+ }
+
+ return $this->render('transmission/index.html.twig', [
+ 'torrents' => $torrents,
+ 'stats' => $stats,
+ 'categories' => $categories,
+ 'error' => $error,
+ 'service_url' => $this->config->get('transmission_url'),
+ ]);
+ }
+
+ // ══════════════════════════════════════════════════════════════════════════
+ // JSON API — real-time refresh
+ // ══════════════════════════════════════════════════════════════════════════
+
+ #[Route('/api/torrents', name: 'api_torrents', methods: ['GET'])]
+ public function apiTorrents(Request $request): JsonResponse
+ {
+ try {
+ $page = max(1, (int) $request->query->get('page', 1));
+ $perPage = max(1, min(500, (int) $request->query->get('perPage', 50)));
+ $filter = (string) $request->query->get('filter', 'all');
+ $search = trim((string) $request->query->get('search', ''));
+ $category = trim((string) $request->query->get('category', ''));
+ $sort = (string) $request->query->get('sort', 'added');
+ $desc = $request->query->get('desc', '1') === '1';
+
+ $all = $this->transmission->getTorrents();
+ $stats = $this->transmission->getStats($all);
+
+ if ($filter === 'active') {
+ $all = array_values(array_filter($all, fn($t) => ($t['dlspeed'] ?? 0) > 0 || ($t['upspeed'] ?? 0) > 0));
+ } elseif ($filter === 'completed') {
+ $all = array_values(array_filter($all, fn($t) => ($t['progress'] ?? 0) >= 100));
+ } elseif ($filter !== 'all') {
+ $all = array_values(array_filter($all, fn($t) => $t['state'] === $filter));
+ }
+ if ($category !== '') {
+ $all = array_values(array_filter($all, fn($t) => ($t['category'] ?? '') === $category));
+ }
+ if ($search !== '') {
+ $needle = mb_strtolower($search);
+ $all = array_values(array_filter($all, fn($t) => str_contains(mb_strtolower($t['name'] ?? ''), $needle)));
+ }
+
+ $sortKey = match ($sort) {
+ 'name' => 'name',
+ 'size' => 'size',
+ 'progress' => 'progress',
+ 'dlspeed' => 'dlspeed',
+ 'upspeed' => 'upspeed',
+ 'ratio' => 'ratio',
+ 'uploaded' => 'uploaded',
+ 'completed' => 'completion_on',
+ 'seeds' => 'num_seeds',
+ 'category' => 'category',
+ default => 'added_on',
+ };
+ usort($all, function ($a, $b) use ($sortKey, $desc) {
+ $va = $a[$sortKey] ?? 0;
+ $vb = $b[$sortKey] ?? 0;
+ if ($sortKey === 'name' || $sortKey === 'category') {
+ return $desc ? strcmp((string) $vb, (string) $va) : strcmp((string) $va, (string) $vb);
+ }
+ return $desc ? ($vb <=> $va) : ($va <=> $vb);
+ });
+
+ $total = count($all);
+ $totalPages = max(1, (int) ceil($total / $perPage));
+ $page = min($page, $totalPages);
+ $slice = array_slice($all, ($page - 1) * $perPage, $perPage);
+
+ return $this->json([
+ 'torrents' => $slice,
+ 'stats' => $stats,
+ 'pagination' => ['page' => $page, 'perPage' => $perPage, 'total' => $total, 'totalPages' => $totalPages],
+ ]);
+ } catch (\Throwable $e) {
+ $this->logger->warning('Transmission torrents listing failed', ['exception' => $e::class, 'message' => $e->getMessage()]);
+ return $this->json(['error' => $e->getMessage()], 500);
+ }
+ }
+
+ /** Resolve a torrent to its Radarr movie / Sonarr series — same service the qBit tab uses. */
+ #[Route('/api/resolve/{pipeline}/{hash}', name: 'api_resolve', methods: ['GET'], requirements: ['pipeline' => 'radarr|sonarr', 'hash' => '[a-fA-F0-9]{40}'])]
+ public function apiResolve(string $pipeline, string $hash): JsonResponse
+ {
+ try {
+ $torrent = null;
+ foreach ($this->transmission->getTorrents() as $t) {
+ if (($t['hash'] ?? '') === $hash) { $torrent = $t; break; }
+ }
+ if (!$torrent) return $this->json(['found' => false, 'error' => $this->translator->trans('transmission.api.torrent_not_found')], 404);
+
+ return $this->json($this->resolver->resolve($pipeline, $torrent['name'] ?? ''));
+ } catch (\Throwable $e) {
+ $this->logger->warning('Transmission torrent resolve failed', ['pipeline' => $pipeline, 'hash' => $hash, 'exception' => $e::class, 'message' => $e->getMessage()]);
+ return $this->json(['error' => $e->getMessage()], 500);
+ }
+ }
+
+ #[Route('/api/torrent/{hash}', name: 'api_torrent_detail', methods: ['GET'], requirements: ['hash' => '[a-fA-F0-9]{40}'])]
+ public function apiTorrentDetail(string $hash): JsonResponse
+ {
+ try {
+ $detail = $this->transmission->getTorrentDetail($hash);
+ if ($detail === null) {
+ return $this->json(['error' => $this->translator->trans('transmission.api.torrent_not_found')], 404);
+ }
+ return $this->json($detail);
+ } catch (\Throwable $e) {
+ $this->logger->warning('Transmission torrent detail failed', ['hash' => $hash, 'exception' => $e::class, 'message' => $e->getMessage()]);
+ return $this->json(['error' => $e->getMessage()], 500);
+ }
+ }
+
+ // ══════════════════════════════════════════════════════════════════════════
+ // Single-item actions
+ // ══════════════════════════════════════════════════════════════════════════
+
+ #[Route('/api/torrent/{hash}/pause', name: 'api_pause', methods: ['POST'], requirements: ['hash' => '[a-fA-F0-9]{40}'])]
+ public function pause(string $hash): JsonResponse
+ {
+ $ok = $this->transmission->pauseTorrents([$hash]);
+ return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Transmission', $this->transmission);
+ }
+
+ #[Route('/api/torrent/{hash}/resume', name: 'api_resume', methods: ['POST'], requirements: ['hash' => '[a-fA-F0-9]{40}'])]
+ public function resume(string $hash): JsonResponse
+ {
+ $ok = $this->transmission->resumeTorrents([$hash]);
+ return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Transmission', $this->transmission);
+ }
+
+ #[Route('/api/torrent/{hash}/delete', name: 'api_delete', methods: ['POST'], requirements: ['hash' => '[a-fA-F0-9]{40}'])]
+ public function delete(Request $request, string $hash): JsonResponse
+ {
+ $deleteFiles = (bool) ($request->toArray()['deleteFiles'] ?? false);
+ $ok = $this->transmission->deleteTorrents([$hash], $deleteFiles);
+ return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Transmission', $this->transmission);
+ }
+
+ #[Route('/api/torrent/{hash}/recheck', name: 'api_recheck', methods: ['POST'], requirements: ['hash' => '[a-fA-F0-9]{40}'])]
+ public function recheck(string $hash): JsonResponse
+ {
+ $ok = $this->transmission->recheckTorrents([$hash]);
+ return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Transmission', $this->transmission);
+ }
+
+ #[Route('/api/torrent/{hash}/reannounce', name: 'api_reannounce', methods: ['POST'], requirements: ['hash' => '[a-fA-F0-9]{40}'])]
+ public function reannounce(string $hash): JsonResponse
+ {
+ $ok = $this->transmission->reannounceTorrents([$hash]);
+ return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Transmission', $this->transmission);
+ }
+
+ #[Route('/api/torrent/{hash}/location', name: 'api_set_location', methods: ['POST'], requirements: ['hash' => '[a-fA-F0-9]{40}'])]
+ public function setLocation(Request $request, string $hash): JsonResponse
+ {
+ $location = trim((string) ($request->toArray()['location'] ?? ''));
+ if ($location === '') return $this->json(['ok' => false, 'error' => $this->translator->trans('transmission.api.empty_location')], 400);
+ $ok = $this->transmission->setTorrentLocation($hash, $location);
+ return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Transmission', $this->transmission);
+ }
+
+ #[Route('/api/torrent/{hash}/limit', name: 'api_set_limit', methods: ['POST'], requirements: ['hash' => '[a-fA-F0-9]{40}'])]
+ public function setLimit(Request $request, string $hash): JsonResponse
+ {
+ $data = $request->toArray();
+ $ok = true;
+ if (isset($data['dl'])) $ok = $ok && $this->transmission->setTorrentDownloadLimit([$hash], (int) $data['dl']);
+ if (isset($data['up'])) $ok = $ok && $this->transmission->setTorrentUploadLimit([$hash], (int) $data['up']);
+ return $this->json(['ok' => $ok]);
+ }
+
+ // ══════════════════════════════════════════════════════════════════════════
+ // Bulk actions
+ // ══════════════════════════════════════════════════════════════════════════
+
+ /** Transmission hashes are exactly 40 hex chars — no 'all' sentinel (unlike qBit). */
+ private static function sanitizeHashes(mixed $raw): array
+ {
+ if (!is_array($raw)) return [];
+ return array_values(array_filter($raw, static fn($h) => is_string($h)
+ && preg_match('/^[a-fA-F0-9]{40}$/', $h) === 1
+ ));
+ }
+
+ #[Route('/api/bulk/pause', name: 'api_bulk_pause', methods: ['POST'])]
+ public function bulkPause(Request $request): JsonResponse
+ {
+ $hashes = self::sanitizeHashes($request->toArray()['hashes'] ?? []);
+ if (empty($hashes)) return $this->json(['ok' => false, 'error' => $this->translator->trans('transmission.api.no_valid_hash')], 400);
+ $ok = $this->transmission->pauseTorrents($hashes);
+ return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Transmission', $this->transmission);
+ }
+
+ #[Route('/api/bulk/resume', name: 'api_bulk_resume', methods: ['POST'])]
+ public function bulkResume(Request $request): JsonResponse
+ {
+ $hashes = self::sanitizeHashes($request->toArray()['hashes'] ?? []);
+ if (empty($hashes)) return $this->json(['ok' => false, 'error' => $this->translator->trans('transmission.api.no_valid_hash')], 400);
+ $ok = $this->transmission->resumeTorrents($hashes);
+ return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Transmission', $this->transmission);
+ }
+
+ #[Route('/api/bulk/delete', name: 'api_bulk_delete', methods: ['POST'])]
+ public function bulkDelete(Request $request): JsonResponse
+ {
+ $data = $request->toArray();
+ $hashes = self::sanitizeHashes($data['hashes'] ?? []);
+ $deleteFiles = (bool) ($data['deleteFiles'] ?? false);
+ if (empty($hashes)) return $this->json(['ok' => false, 'error' => $this->translator->trans('transmission.api.no_valid_hash')], 400);
+ $ok = $this->transmission->deleteTorrents($hashes, $deleteFiles);
+ return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Transmission', $this->transmission);
+ }
+
+ #[Route('/api/bulk/recheck', name: 'api_bulk_recheck', methods: ['POST'])]
+ public function bulkRecheck(Request $request): JsonResponse
+ {
+ $hashes = self::sanitizeHashes($request->toArray()['hashes'] ?? []);
+ if (empty($hashes)) return $this->json(['ok' => false, 'error' => $this->translator->trans('transmission.api.no_valid_hash')], 400);
+ $ok = $this->transmission->recheckTorrents($hashes);
+ return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Transmission', $this->transmission);
+ }
+
+ #[Route('/api/pause-all', name: 'api_pause_all', methods: ['POST'])]
+ public function pauseAll(): JsonResponse
+ {
+ $ok = $this->transmission->pauseAll();
+ return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Transmission', $this->transmission);
+ }
+
+ #[Route('/api/resume-all', name: 'api_resume_all', methods: ['POST'])]
+ public function resumeAll(): JsonResponse
+ {
+ $ok = $this->transmission->resumeAll();
+ return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Transmission', $this->transmission);
+ }
+
+ // ══════════════════════════════════════════════════════════════════════════
+ // Add torrent
+ // ══════════════════════════════════════════════════════════════════════════
+
+ #[Route('/api/add', name: 'api_add', methods: ['POST'])]
+ public function addTorrent(Request $request): JsonResponse
+ {
+ $data = $request->toArray();
+ $urls = (string) ($data['urls'] ?? '');
+ $savepath = isset($data['savepath']) && $data['savepath'] !== '' ? (string) $data['savepath'] : null;
+ $paused = (bool) ($data['paused'] ?? false);
+
+ if ($urls === '') return $this->json(['ok' => false, 'error' => $this->translator->trans('transmission.upload.invalid_url')], 400);
+
+ $violation = self::validateTorrentUrlsStatic($urls);
+ if ($violation !== null) {
+ $key = $violation === 'forbidden_host' ? 'transmission.upload.forbidden_host' : 'transmission.upload.invalid_url';
+ return $this->json(['ok' => false, 'error' => $this->translator->trans($key)], 400);
+ }
+
+ $ok = $this->transmission->addTorrentFromUrl($urls, $savepath, $paused);
+ return $ok ? $this->json(['ok' => true]) : $this->jsonClientError('Transmission', $this->transmission);
+ }
+
+ /**
+ * SSRF guard — same policy as QBittorrentController::validateTorrentUrls
+ * (Transmission fetches whichever URL we hand it via `filename`): http(s)/magnet
+ * only, cloud metadata hosts blocked, LAN allowed. Static + translator-free so
+ * it unit-tests without a container; returns a violation marker
+ * ('invalid_url' | 'forbidden_host'), null when safe.
+ */
+ private static function validateTorrentUrlsStatic(string $raw): ?string
+ {
+ $blockedHosts = [
+ '169.254.169.254',
+ 'fd00:ec2::254',
+ 'metadata.google.internal',
+ 'metadata.goog',
+ 'metadata.azure.com',
+ 'metadata.azure.net',
+ ];
+
+ foreach (preg_split('/[\r\n|]+/', trim($raw)) as $url) {
+ $url = trim($url);
+ if ($url === '') continue;
+ if (stripos($url, 'magnet:') === 0) continue;
+
+ $parts = parse_url($url);
+ $scheme = strtolower($parts['scheme'] ?? '');
+ if (!in_array($scheme, ['http', 'https'], true)) {
+ return 'invalid_url';
+ }
+ $host = strtolower(trim($parts['host'] ?? '', '[]'));
+ if ($host === '') {
+ return 'invalid_url';
+ }
+ if (in_array($host, $blockedHosts, true)) {
+ return 'forbidden_host';
+ }
+ }
+ return null;
+ }
+
+ /** Upload one or more .torrent files (multipart/form-data). */
+ #[Route('/api/add-file', name: 'api_add_file', methods: ['POST'])]
+ public function addTorrentFile(Request $request): JsonResponse
+ {
+ $uploaded = $request->files->all()['torrents'] ?? [];
+ if (!is_array($uploaded)) $uploaded = [$uploaded];
+ $uploaded = array_filter($uploaded);
+
+ if (empty($uploaded)) return $this->json(['ok' => false, 'error' => $this->translator->trans('transmission.upload.no_file')], 400);
+
+ $files = [];
+ foreach ($uploaded as $file) {
+ /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $file */
+ if (!$file->isValid()) continue;
+ if ($file->getSize() > 10 * 1024 * 1024) {
+ return $this->json(['ok' => false, 'error' => $this->translator->trans('transmission.upload.too_large')], 400);
+ }
+ if (strtolower($file->getClientOriginalExtension()) !== 'torrent') {
+ return $this->json(['ok' => false, 'error' => $this->translator->trans('transmission.upload.invalid_format')], 400);
+ }
+ $content = file_get_contents($file->getPathname());
+ if ($content === false || $content === '') {
+ return $this->json(['ok' => false, 'error' => $this->translator->trans('transmission.upload.unreadable')], 400);
+ }
+ // Bencoded torrent: starts with 'd' + contains "info"/"announce" early
+ if (!str_starts_with($content, 'd') || (!str_contains(substr($content, 0, 4096), 'info') && !str_contains(substr($content, 0, 4096), 'announce'))) {
+ return $this->json(['ok' => false, 'error' => $this->translator->trans('transmission.upload.invalid_format')], 400);
+ }
+ $origName = $file->getClientOriginalName() ?: 'upload.torrent';
+ $cleanName = basename(str_replace("\0", '', $origName));
+ $files[] = ['content' => $content, 'name' => $cleanName !== '' ? $cleanName : 'upload.torrent'];
+ }
+
+ if (empty($files)) return $this->json(['ok' => false, 'error' => $this->translator->trans('transmission.api.no_valid_file')], 400);
+
+ $savepath = $request->request->get('savepath') ?: null;
+ $paused = $request->request->get('paused') === 'true';
+
+ $ok = $this->transmission->addTorrentFromFiles($files, $savepath, $paused);
+ return $ok
+ ? $this->json(['ok' => true, 'count' => count($files)])
+ : $this->jsonClientError('Transmission', $this->transmission);
+ }
+
+ // ══════════════════════════════════════════════════════════════════════════
+ // Global speed limits
+ // ══════════════════════════════════════════════════════════════════════════
+
+ #[Route('/api/global-limit', name: 'api_global_limit', methods: ['POST'])]
+ public function setGlobalLimit(Request $request): JsonResponse
+ {
+ $data = $request->toArray();
+ $ok = true;
+ if (isset($data['dl'])) $ok = $ok && $this->transmission->setGlobalDownloadLimit((int) $data['dl']);
+ if (isset($data['up'])) $ok = $ok && $this->transmission->setGlobalUploadLimit((int) $data['up']);
+ return $this->json(['ok' => $ok]);
+ }
+}
diff --git a/symfony/src/EventSubscriber/ServiceRouteGuardSubscriber.php b/symfony/src/EventSubscriber/ServiceRouteGuardSubscriber.php
index 8641cab8..2344edf1 100644
--- a/symfony/src/EventSubscriber/ServiceRouteGuardSubscriber.php
+++ b/symfony/src/EventSubscriber/ServiceRouteGuardSubscriber.php
@@ -45,6 +45,8 @@ class ServiceRouteGuardSubscriber implements EventSubscriberInterface
'app_qbittorrent' => ['service' => 'qBittorrent', 'service_id' => 'qbittorrent', 'keys' => ['qbittorrent_url', 'qbittorrent_user'], 'wizard' => 'app_setup_downloads', 'index' => 'app_qbittorrent_index'],
'deluge_' => ['service' => 'Deluge', 'service_id' => 'deluge', 'keys' => ['deluge_url'], 'wizard' => 'app_setup_downloads', 'index' => 'app_deluge_index'],
'app_deluge' => ['service' => 'Deluge', 'service_id' => 'deluge', 'keys' => ['deluge_url'], 'wizard' => 'app_setup_downloads', 'index' => 'app_deluge_index'],
+ 'transmission_' => ['service' => 'Transmission', 'service_id' => 'transmission', 'keys' => ['transmission_url'], 'wizard' => 'app_setup_downloads', 'index' => 'app_transmission_index'],
+ 'app_transmission' => ['service' => 'Transmission', 'service_id' => 'transmission', 'keys' => ['transmission_url'], 'wizard' => 'app_setup_downloads', 'index' => 'app_transmission_index'],
'tmdb_' => ['service' => 'TMDb', 'service_id' => 'tmdb', 'keys' => ['tmdb_api_key'], 'wizard' => 'app_setup_tmdb', 'index' => 'tmdb_index'],
'app_tautulli_index' => ['service' => 'Tautulli', 'service_id' => 'tautulli', 'keys' => ['tautulli_url', 'tautulli_api_key'], 'wizard' => 'admin_settings_index', 'index' => 'app_tautulli_index'],
];
diff --git a/symfony/src/Service/DisplayPreferencesService.php b/symfony/src/Service/DisplayPreferencesService.php
index 10c9c3f4..a189bf85 100644
--- a/symfony/src/Service/DisplayPreferencesService.php
+++ b/symfony/src/Service/DisplayPreferencesService.php
@@ -67,6 +67,7 @@ public function getTimeFormat(): string { return $this->get('display_tim
public function getThemeColor(): string { return $this->get('display_theme_color'); }
public function getQbitRefreshSeconds(): int { return (int) $this->get('display_qbit_refresh'); }
public function getDelugeRefreshSeconds(): int { return (int) $this->get('display_deluge_refresh'); }
+ public function getTransmissionRefreshSeconds(): int { return (int) $this->get('display_transmission_refresh'); }
public function getUiDensity(): string { return $this->get('display_ui_density'); }
/**
@@ -180,6 +181,7 @@ private function toUserTimezone(\DateTimeInterface $dt): \DateTimeImmutable
* theme_color_rgb: string,
* qbit_refresh_seconds: int,
* deluge_refresh_seconds: int,
+ * transmission_refresh_seconds: int,
* ui_density: string,
* page_size: int,
* language: string,
@@ -199,6 +201,7 @@ public function all(): array
'theme_color_rgb' => $this->getThemeColorRgb(),
'qbit_refresh_seconds' => $this->getQbitRefreshSeconds(),
'deluge_refresh_seconds' => $this->getDelugeRefreshSeconds(),
+ 'transmission_refresh_seconds' => $this->getTransmissionRefreshSeconds(),
'ui_density' => $this->getUiDensity(),
'page_size' => $this->getPageSize(),
'language' => $this->getLanguage(),
diff --git a/symfony/src/Service/HealthService.php b/symfony/src/Service/HealthService.php
index 138898bf..e3fcf013 100644
--- a/symfony/src/Service/HealthService.php
+++ b/symfony/src/Service/HealthService.php
@@ -8,6 +8,7 @@
use App\Service\Media\JellyseerrClient;
use App\Service\Media\ProwlarrClient;
use App\Service\Media\QBittorrentClient;
+use App\Service\Media\TransmissionClient;
use App\Service\Media\RadarrClient;
use App\Service\Media\ServiceHealthCache;
use App\Service\Media\SonarrClient;
@@ -86,6 +87,9 @@ public function __construct(
// UniFi (network monitoring widget) — nullable + last, same
// legacy-positional-constructor stance as Unraid/Houndarr.
private readonly ?UnifiClient $unifi = null,
+ // Transmission — nullable + last for the same legacy-test-constructor
+ // reason as the clients above.
+ private readonly ?TransmissionClient $transmission = null,
) {}
/**
@@ -259,6 +263,7 @@ private function pingFor(string $service, ?string $instanceSlug): ?bool
'unraid' => $this->unraid?->ping() ?? false,
'houndarr' => $this->houndarr?->ping() ?? false,
'unifi' => $this->unifi?->ping() ?? false,
+ 'transmission' => $this->transmission?->ping() ?? false,
default => true,
};
}
@@ -275,7 +280,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', 'deluge', 'tmdb', 'sabnzbd', 'nzbget', 'tautulli', 'unraid', 'houndarr', 'unifi'];
+ public const TOGGLEABLE_SERVICES = ['prowlarr', 'jellyseerr', 'qbittorrent', 'deluge', 'transmission', 'tmdb', 'sabnzbd', 'nzbget', 'tautulli', 'unraid', 'houndarr', 'unifi'];
/** Brand colors for the health chips — single source for dashboard + topbar. */
private const SERVICE_COLORS = [
@@ -285,6 +290,7 @@ private function pingFor(string $service, ?string $instanceSlug): ?bool
'jellyseerr' => '#a259ff',
'qbittorrent' => '#2f67ba',
'deluge' => '#3e7bbf',
+ 'transmission' => '#d7302f',
'sabnzbd' => '#fbc531',
'nzbget' => '#54c754',
'tmdb' => '#01B4E4',
@@ -318,7 +324,7 @@ public function chips(bool $includeUnraid = false): array
}
}
- $labels = ['prowlarr' => 'Prowlarr', 'jellyseerr' => 'Seerr', 'qbittorrent' => 'qBittorrent', 'deluge' => 'Deluge', 'sabnzbd' => 'SABnzbd', 'nzbget' => 'NZBGet', 'tmdb' => 'TMDb', 'tautulli' => 'Tautulli', 'houndarr' => 'Houndarr'];
+ $labels = ['prowlarr' => 'Prowlarr', 'jellyseerr' => 'Seerr', 'qbittorrent' => 'qBittorrent', 'deluge' => 'Deluge', 'transmission' => 'Transmission', 'sabnzbd' => 'SABnzbd', 'nzbget' => 'NZBGet', 'tmdb' => 'TMDb', 'tautulli' => 'Tautulli', 'houndarr' => 'Houndarr'];
if ($includeUnraid) {
$labels['unraid'] = 'Unraid';
$labels['unifi'] = 'UniFi';
@@ -393,6 +399,10 @@ public function isConfigured(string $service): bool
// UniFi needs the console URL and a local API key (Network 9.0+).
'unifi' =>
$this->config->has('unifi_url') && $this->config->has('unifi_api_key'),
+ // Transmission — URL-only, same reverse-proxy-friendly stance as
+ // qBittorrent/Deluge (empty user/password is a legitimate setup).
+ 'transmission' =>
+ $this->config->has('transmission_url'),
default => true,
};
}
@@ -419,7 +429,7 @@ public function invalidate(?string $service = null): void
if ($service === null) {
$this->statusCache = [];
if ($this->serviceHealthCache !== null) {
- foreach (['radarr', 'sonarr', 'prowlarr', 'jellyseerr', 'qbittorrent', 'deluge', 'tmdb', 'sabnzbd', 'nzbget', 'tautulli', 'unraid', 'houndarr', 'unifi'] as $svc) {
+ foreach (['radarr', 'sonarr', 'prowlarr', 'jellyseerr', 'qbittorrent', 'deluge', 'transmission', 'tmdb', 'sabnzbd', 'nzbget', 'tautulli', 'unraid', 'houndarr', 'unifi'] as $svc) {
$this->serviceHealthCache->clear($svc);
}
}
@@ -554,6 +564,13 @@ public function diagnoseFromResponse(array $resp, string $service): array
}
return ['ok' => true, 'category' => 'ok', 'http' => $http];
}
+ // Transmission: a fresh/expired session always answers 409 with the
+ // real X-Transmission-Session-Id in the response header — that IS a
+ // healthy, reachable daemon, not a failure. A bad RPC password is a
+ // separate 401, independent of the session-id handshake.
+ if ($service === 'transmission' && $http === 409) {
+ return ['ok' => true, 'category' => 'ok', 'http' => $http];
+ }
if ($http !== null && $http >= 200 && $http < 300) {
return ['ok' => true, 'category' => 'ok', 'http' => $http];
}
@@ -773,6 +790,26 @@ private function probeFor(string $service, ?array $overrides = null): ?array
'insecure' => $get('unifi_skip_tls_verify') === '1',
];
}
+ case 'transmission': {
+ $url = $get('transmission_url');
+ $user = $get('transmission_user');
+ $pass = $get('transmission_password');
+ if ($url === '') return null;
+ // Deliberately no X-Transmission-Session-Id header: a fresh
+ // client always gets HTTP 409 back with the real token, and
+ // that 409 IS the "reachable" signal diagnoseFromResponse()
+ // is looking for — no retry needed just to test connectivity.
+ $headers = ['Content-Type: application/json'];
+ if ($user !== '') {
+ $headers[] = 'Authorization: Basic ' . base64_encode($user . ':' . $pass);
+ }
+ return [
+ 'url' => rtrim($url, '/') . '/transmission/rpc',
+ 'headers' => $headers,
+ 'method' => 'POST',
+ 'body' => (string) json_encode(['method' => 'session-get', 'arguments' => ['fields' => ['version']], 'tag' => 1]),
+ ];
+ }
default:
return null;
}
diff --git a/symfony/src/Service/Media/TransmissionClient.php b/symfony/src/Service/Media/TransmissionClient.php
new file mode 100644
index 00000000..dd8b8192
--- /dev/null
+++ b/symfony/src/Service/Media/TransmissionClient.php
@@ -0,0 +1,701 @@
+config->get(self::SERVICE_KEY . '_enabled') === '0') {
+ throw new ServiceNotConfiguredException(self::SERVICE, self::SERVICE_KEY . '_enabled');
+ }
+ if ($this->baseUrl === '') {
+ $this->baseUrl = self::normalizeBaseUrl($this->config->require('transmission_url', self::SERVICE));
+ $this->user = (string) ($this->config->get('transmission_user') ?? '');
+ $this->password = (string) ($this->config->get('transmission_password') ?? '');
+ }
+ }
+
+ /**
+ * Strip a redundant trailing `/transmission`, `/transmission/rpc`, or
+ * `/transmission/web` path segment some users add by analogy to
+ * Transmission's own web-UI URL convention — otherwise it doubles up
+ * with the `/transmission/rpc` suffix httpPost() always appends,
+ * producing an invalid `/transmission/transmission/rpc` path.
+ */
+ private static function normalizeBaseUrl(string $url): string
+ {
+ $url = rtrim($url, '/');
+ return preg_replace('#/transmission(?:/(?:rpc|web))?$#i', '', $url) ?? $url;
+ }
+
+ public function reset(): void
+ {
+ $this->baseUrl = '';
+ $this->user = '';
+ $this->password = '';
+ $this->sessionId = null;
+ $this->tag = 0;
+ $this->lastError = null;
+ $this->serviceUnavailable = false;
+ }
+
+ /** @return array{code:int, method:string, path:string, message:string}|null */
+ public function getLastError(): ?array
+ {
+ return $this->lastError;
+ }
+
+ private function recordError(string $method, int $code, string $message): void
+ {
+ $this->lastError = ['code' => $code, 'method' => $method, 'path' => '/transmission/rpc', 'message' => $message];
+ }
+
+ /** Light ping — true if Transmission answers and (if configured) accepts the credentials. */
+ public function ping(): bool
+ {
+ try {
+ return $this->getVersion() !== null;
+ } catch (\Throwable $e) {
+ $this->logger->warning('Transmission ping failed', ['exception' => $e::class, 'message' => $e->getMessage()]);
+ return false;
+ }
+ }
+
+ public function getVersion(): ?string
+ {
+ $r = $this->result('session-get', ['fields' => ['version']]);
+ return is_array($r) && isset($r['version']) ? (string) $r['version'] : null;
+ }
+
+ /** Fields requested for the torrent table. */
+ private const TORRENT_FIELDS = [
+ 'hashString', 'name', 'status', 'error', 'totalSize', 'sizeWhenDone',
+ 'downloadedEver', 'uploadedEver', 'percentDone', 'rateDownload', 'rateUpload',
+ 'eta', 'uploadRatio', 'peersSendingToUs', 'peersGettingFromUs', 'addedDate', 'doneDate',
+ 'downloadDir', 'labels', 'trackers', 'downloadLimit', 'downloadLimited',
+ 'uploadLimit', 'uploadLimited', 'queuePosition', 'secondsSeeding',
+ ];
+
+ /** Extra fields for the detail panel. */
+ private const DETAIL_FIELDS = [
+ 'hashString', 'name', 'status', 'error', 'totalSize', 'downloadDir',
+ 'pieceSize', 'pieceCount', 'comment', 'uploadedEver', 'downloadedEver', 'uploadRatio',
+ 'addedDate', 'doneDate', 'secondsDownloading', 'secondsSeeding', 'eta',
+ 'peersSendingToUs', 'peersGettingFromUs', 'files', 'fileStats', 'trackerStats', 'peers',
+ ];
+
+ // ══════════════════════════════════════════════════════════════════════════
+ // Torrents — Read
+ // ══════════════════════════════════════════════════════════════════════════
+
+ /** All torrents, normalized to the qBit-compatible shape, newest first. */
+ public function getTorrents(): array
+ {
+ $r = $this->result('torrent-get', ['fields' => self::TORRENT_FIELDS]);
+ $list = is_array($r['torrents'] ?? null) ? $r['torrents'] : [];
+ $out = [];
+ foreach ($list as $t) {
+ if (is_array($t) && isset($t['hashString'])) {
+ $out[] = $this->normalizeTorrent($t);
+ }
+ }
+ usort($out, fn($a, $b) => ($b['added_on'] ?? 0) <=> ($a['added_on'] ?? 0));
+ return $out;
+ }
+
+ /** Detail panel payload: {properties, files, trackers, peers} — qBit-detail-compatible field names. */
+ public function getTorrentDetail(string $hash): ?array
+ {
+ $r = $this->result('torrent-get', ['ids' => [$hash], 'fields' => self::DETAIL_FIELDS]);
+ $list = is_array($r['torrents'] ?? null) ? $r['torrents'] : [];
+ $t = $list[0] ?? null;
+ if (!is_array($t)) {
+ return null;
+ }
+
+ $files = [];
+ $fileStats = $t['fileStats'] ?? [];
+ foreach (($t['files'] ?? []) as $i => $f) {
+ $stat = is_array($fileStats[$i] ?? null) ? $fileStats[$i] : [];
+ $length = (int) ($f['length'] ?? 0);
+ $files[] = [
+ 'name' => $f['name'] ?? '—',
+ 'size' => $length,
+ 'progress' => $length > 0 ? round(((int) ($f['bytesCompleted'] ?? 0)) / $length, 3) : 0,
+ 'priority' => (int) ($stat['priority'] ?? 0),
+ ];
+ }
+
+ $trackers = [];
+ $seedsTotal = 0;
+ $peersTotal = 0;
+ foreach (($t['trackerStats'] ?? []) as $tr) {
+ $trackers[] = [
+ 'url' => $tr['announce'] ?? '',
+ 'tier' => (int) ($tr['tier'] ?? 0),
+ 'status' => self::trackerStatusLabel((int) ($tr['announceState'] ?? 0)),
+ 'msg' => (string) ($tr['lastAnnounceResult'] ?? ''),
+ ];
+ $seedsTotal += max(0, (int) ($tr['seederCount'] ?? -1));
+ $peersTotal += max(0, (int) ($tr['leecherCount'] ?? -1));
+ }
+
+ $peers = [];
+ foreach (($t['peers'] ?? []) as $p) {
+ $peers[] = [
+ 'ip' => $p['address'] ?? '',
+ 'client' => $p['clientName'] ?? '',
+ 'country' => '',
+ 'progress' => round((float) ($p['progress'] ?? 0) * 100, 1),
+ 'dl_speed' => (int) ($p['rateToClient'] ?? 0),
+ 'up_speed' => (int) ($p['rateToPeer'] ?? 0),
+ 'flags' => ($p['isUploadingTo'] ?? false) ? 'seed' : '',
+ 'connection' => '',
+ ];
+ }
+
+ $eta = (int) ($t['eta'] ?? -1);
+ return [
+ 'properties' => [
+ 'name' => (string) ($t['name'] ?? ''),
+ 'save_path' => (string) ($t['downloadDir'] ?? ''),
+ 'total_size' => (int) ($t['totalSize'] ?? 0),
+ 'piece_size' => (int) ($t['pieceSize'] ?? 0),
+ 'pieces_num' => (int) ($t['pieceCount'] ?? 0),
+ 'comment' => (string) ($t['comment'] ?? ''),
+ 'total_uploaded' => (int) ($t['uploadedEver'] ?? 0),
+ 'total_downloaded' => (int) ($t['downloadedEver'] ?? 0),
+ 'share_ratio' => round((float) ($t['uploadRatio'] ?? 0), 2),
+ 'addition_date' => (int) ($t['addedDate'] ?? 0),
+ 'completion_date' => (int) ($t['doneDate'] ?? 0),
+ 'time_elapsed' => (int) ($t['secondsDownloading'] ?? 0),
+ 'seeding_time' => (int) ($t['secondsSeeding'] ?? 0),
+ 'next_announce' => 0,
+ 'eta' => $eta > 0 ? $eta : self::ETA_NONE,
+ 'seeds' => (int) ($t['peersSendingToUs'] ?? 0),
+ 'seeds_total' => $seedsTotal,
+ 'peers' => (int) ($t['peersGettingFromUs'] ?? 0),
+ 'peers_total' => $peersTotal,
+ 'dl_speed' => (int) ($t['rateDownload'] ?? 0),
+ 'up_speed' => (int) ($t['rateUpload'] ?? 0),
+ ],
+ 'files' => $files,
+ 'trackers' => $trackers,
+ 'peers' => $peers,
+ ];
+ }
+
+ private static function trackerStatusLabel(int $announceState): string
+ {
+ return match ($announceState) {
+ 1 => 'announcing',
+ 2 => 'queued',
+ 3 => 'active',
+ default => 'idle',
+ };
+ }
+
+ // ══════════════════════════════════════════════════════════════════════════
+ // Torrents — Actions
+ // ══════════════════════════════════════════════════════════════════════════
+
+ public function pauseTorrents(array $hashes): bool
+ {
+ return $this->ok('torrent-stop', ['ids' => $hashes]);
+ }
+
+ public function resumeTorrents(array $hashes): bool
+ {
+ return $this->ok('torrent-start', ['ids' => $hashes]);
+ }
+
+ /** Omitting `ids` applies the action to every torrent — Transmission's native "all" mode. */
+ public function pauseAll(): bool
+ {
+ return $this->ok('torrent-stop');
+ }
+
+ public function resumeAll(): bool
+ {
+ return $this->ok('torrent-start');
+ }
+
+ public function deleteTorrents(array $hashes, bool $deleteFiles = false): bool
+ {
+ return $this->ok('torrent-remove', ['ids' => $hashes, 'delete-local-data' => $deleteFiles]);
+ }
+
+ public function recheckTorrents(array $hashes): bool
+ {
+ return $this->ok('torrent-verify', ['ids' => $hashes]);
+ }
+
+ public function reannounceTorrents(array $hashes): bool
+ {
+ return $this->ok('torrent-reannounce', ['ids' => $hashes]);
+ }
+
+ public function setTorrentLocation(string $hash, string $location): bool
+ {
+ return $this->ok('torrent-set-location', ['ids' => [$hash], 'location' => $location, 'move' => true]);
+ }
+
+ public function setTorrentDownloadLimit(array $hashes, int $bytes): bool
+ {
+ return $this->ok('torrent-set', [
+ 'ids' => $hashes,
+ 'downloadLimit' => max(0, (int) self::bytesToKib($bytes)),
+ 'downloadLimited' => $bytes > 0,
+ ]);
+ }
+
+ public function setTorrentUploadLimit(array $hashes, int $bytes): bool
+ {
+ return $this->ok('torrent-set', [
+ 'ids' => $hashes,
+ 'uploadLimit' => max(0, (int) self::bytesToKib($bytes)),
+ 'uploadLimited' => $bytes > 0,
+ ]);
+ }
+
+ // ══════════════════════════════════════════════════════════════════════════
+ // Add torrent
+ // ══════════════════════════════════════════════════════════════════════════
+
+ /**
+ * Split a multi-line/pipe-separated add box into magnets vs http(s) URLs.
+ *
+ * @return array{magnets: list
, urls: list}
+ */
+ private static function splitAddUrls(string $raw): array
+ {
+ $magnets = [];
+ $urls = [];
+ foreach (preg_split('/[\r\n|]+/', trim($raw)) as $line) {
+ $line = trim($line);
+ if ($line === '') {
+ continue;
+ }
+ if (stripos($line, 'magnet:') === 0) {
+ $magnets[] = $line;
+ } else {
+ $urls[] = $line;
+ }
+ }
+ return ['magnets' => $magnets, 'urls' => $urls];
+ }
+
+ /** torrent-add has no batch mode — one RPC call per URL/magnet. */
+ public function addTorrentFromUrl(string $urls, ?string $savepath = null, bool $paused = false): bool
+ {
+ $split = self::splitAddUrls($urls);
+ $any = false;
+ $allOk = true;
+ foreach (array_merge($split['magnets'], $split['urls']) as $line) {
+ $any = true;
+ $args = ['filename' => $line];
+ if ($savepath !== null && $savepath !== '') {
+ $args['download-dir'] = $savepath;
+ }
+ if ($paused) {
+ $args['paused'] = true;
+ }
+ $allOk = $this->ok('torrent-add', $args) && $allOk;
+ }
+ return $any && $allOk;
+ }
+
+ /**
+ * @param array $files
+ */
+ public function addTorrentFromFiles(array $files, ?string $savepath = null, bool $paused = false): bool
+ {
+ if ($files === []) {
+ return false;
+ }
+ $allOk = true;
+ foreach ($files as $file) {
+ $args = ['metainfo' => base64_encode($file['content'])];
+ if ($savepath !== null && $savepath !== '') {
+ $args['download-dir'] = $savepath;
+ }
+ if ($paused) {
+ $args['paused'] = true;
+ }
+ $allOk = $this->ok('torrent-add', $args) && $allOk;
+ }
+ return $allOk;
+ }
+
+ // ══════════════════════════════════════════════════════════════════════════
+ // Global transfer limits
+ // ══════════════════════════════════════════════════════════════════════════
+
+ public function getGlobalDownloadLimit(): int
+ {
+ $r = $this->result('session-get', ['fields' => ['speed-limit-down', 'speed-limit-down-enabled']]);
+ if (!is_array($r) || !($r['speed-limit-down-enabled'] ?? false)) {
+ return 0;
+ }
+ return (int) round(((float) ($r['speed-limit-down'] ?? 0)) * 1024);
+ }
+
+ public function getGlobalUploadLimit(): int
+ {
+ $r = $this->result('session-get', ['fields' => ['speed-limit-up', 'speed-limit-up-enabled']]);
+ if (!is_array($r) || !($r['speed-limit-up-enabled'] ?? false)) {
+ return 0;
+ }
+ return (int) round(((float) ($r['speed-limit-up'] ?? 0)) * 1024);
+ }
+
+ public function setGlobalDownloadLimit(int $bytes): bool
+ {
+ return $this->ok('session-set', [
+ 'speed-limit-down' => max(0, (int) self::bytesToKib($bytes)),
+ 'speed-limit-down-enabled' => $bytes > 0,
+ ]);
+ }
+
+ public function setGlobalUploadLimit(int $bytes): bool
+ {
+ return $this->ok('session-set', [
+ 'speed-limit-up' => max(0, (int) self::bytesToKib($bytes)),
+ 'speed-limit-up-enabled' => $bytes > 0,
+ ]);
+ }
+
+ // ══════════════════════════════════════════════════════════════════════════
+ // Aggregated statistics
+ // ══════════════════════════════════════════════════════════════════════════
+
+ /** Same output keys as QBittorrentClient::getStats() so the template ports. */
+ public function getStats(?array $torrents = null): array
+ {
+ $torrents = $torrents ?? $this->getTorrents();
+
+ $sess = $this->result('session-stats');
+ $sess = is_array($sess) ? $sess : [];
+ $cumulative = is_array($sess['cumulative-stats'] ?? null) ? $sess['cumulative-stats'] : [];
+ $current = is_array($sess['current-stats'] ?? null) ? $sess['current-stats'] : [];
+
+ $sessionInfo = $this->result('session-get', ['fields' => ['download-dir']]);
+ $downloadDir = is_array($sessionInfo) ? (string) ($sessionInfo['download-dir'] ?? '') : '';
+ $free = $downloadDir !== '' ? $this->result('free-space', ['path' => $downloadDir]) : null;
+ $freeBytes = is_array($free) ? (int) ($free['size-bytes'] ?? 0) : 0;
+
+ $count = fn(string $state) => count(array_filter($torrents, fn($t) => $t['state'] === $state));
+ $completed = count(array_filter($torrents, fn($t) => ($t['progress'] ?? 0) >= 100));
+
+ $upAll = (int) array_sum(array_column($torrents, 'uploaded'));
+ $dlAll = (int) array_sum(array_column($torrents, 'downloaded'));
+
+ return [
+ 'total' => count($torrents),
+ 'downloading' => $count('downloading'),
+ 'seeding' => $count('seeding'),
+ 'paused' => $count('paused'),
+ 'completed' => $completed,
+ 'errored' => $count('error'),
+ 'stalled' => 0,
+ 'dl_speed' => (int) ($sess['downloadSpeed'] ?? 0),
+ 'up_speed' => (int) ($sess['uploadSpeed'] ?? 0),
+ 'connection' => 'connected',
+ 'dht_nodes' => 0,
+ 'dl_session' => (int) ($current['downloadedBytes'] ?? 0),
+ 'up_session' => (int) ($current['uploadedBytes'] ?? 0),
+ 'dl_alltime' => (int) ($cumulative['downloadedBytes'] ?? $dlAll),
+ 'up_alltime' => (int) ($cumulative['uploadedBytes'] ?? $upAll),
+ 'global_ratio' => $dlAll > 0 ? round($upAll / $dlAll, 2) : 0,
+ 'free_space' => $freeBytes,
+ ];
+ }
+
+ // ══════════════════════════════════════════════════════════════════════════
+ // Normalization
+ // ══════════════════════════════════════════════════════════════════════════
+
+ private function normalizeTorrent(array $t): array
+ {
+ $eta = (int) ($t['eta'] ?? -1);
+ $status = (int) ($t['status'] ?? 0);
+ $errorNum = (int) ($t['error'] ?? 0);
+ $totalSize = (int) ($t['totalSize'] ?? 0);
+ $labels = is_array($t['labels'] ?? null) ? array_values(array_map('strval', $t['labels'])) : [];
+
+ $trackerHost = '';
+ $firstTracker = is_array($t['trackers'] ?? null) ? ($t['trackers'][0] ?? null) : null;
+ if (is_array($firstTracker) && isset($firstTracker['announce'])) {
+ $trackerHost = (string) (parse_url((string) $firstTracker['announce'], PHP_URL_HOST) ?? '');
+ }
+
+ return [
+ 'hash' => (string) $t['hashString'],
+ 'name' => (string) ($t['name'] ?? '—'),
+ 'size' => (int) ($t['sizeWhenDone'] ?? $totalSize),
+ 'total_size' => $totalSize,
+ 'downloaded' => (int) ($t['downloadedEver'] ?? 0),
+ 'uploaded' => (int) ($t['uploadedEver'] ?? 0),
+ 'progress' => round(((float) ($t['percentDone'] ?? 0)) * 100, 1),
+ 'dlspeed' => (int) ($t['rateDownload'] ?? 0),
+ 'upspeed' => (int) ($t['rateUpload'] ?? 0),
+ 'eta' => $eta > 0 ? $eta : self::ETA_NONE,
+ 'state' => self::normalizeState($status, $errorNum),
+ 'raw_state' => (string) $status,
+ 'category' => $labels[0] ?? '',
+ 'tags' => implode(',', $labels),
+ 'ratio' => round((float) ($t['uploadRatio'] ?? 0), 2),
+ 'num_seeds' => (int) ($t['peersSendingToUs'] ?? 0),
+ 'num_leechs' => (int) ($t['peersGettingFromUs'] ?? 0),
+ 'num_complete' => 0,
+ 'num_incomplete' => 0,
+ 'added_on' => isset($t['addedDate']) ? (int) $t['addedDate'] : null,
+ 'completion_on' => isset($t['doneDate']) && (int) $t['doneDate'] > 0 ? (int) $t['doneDate'] : null,
+ 'save_path' => (string) ($t['downloadDir'] ?? ''),
+ 'content_path' => '',
+ 'tracker' => $trackerHost,
+ 'dl_limit' => ($t['downloadLimited'] ?? false) ? self::kibToBytes((float) ($t['downloadLimit'] ?? 0)) : -1,
+ 'up_limit' => ($t['uploadLimited'] ?? false) ? self::kibToBytes((float) ($t['uploadLimit'] ?? 0)) : -1,
+ 'seeding_time' => (int) ($t['secondsSeeding'] ?? 0),
+ 'priority' => (int) ($t['queuePosition'] ?? 0),
+ 'availability' => 0.0,
+ ];
+ }
+
+ /** error !== 0 always wins, same precedence Deluge uses. */
+ private static function normalizeState(int $status, int $errorNum): string
+ {
+ if ($errorNum !== 0) {
+ return 'error';
+ }
+ return match ($status) {
+ 0 => 'paused',
+ 1, 2 => 'checking',
+ 3, 5 => 'queued',
+ 4 => 'downloading',
+ 6 => 'seeding',
+ default => 'unknown',
+ };
+ }
+
+ /** Transmission speed limits are KiB/s, 0/disabled = unlimited. Prismarr speaks bytes/s. */
+ private static function kibToBytes(float $kib): int
+ {
+ return $kib <= 0 ? -1 : (int) round($kib * 1024);
+ }
+
+ private static function bytesToKib(int $bytes): float
+ {
+ return $bytes <= 0 ? 0.0 : round($bytes / 1024, 1);
+ }
+
+ // ══════════════════════════════════════════════════════════════════════════
+ // JSON-RPC transport
+ // ══════════════════════════════════════════════════════════════════════════
+
+ /** Convenience: RPC arguments or null on failure (check getLastError()). */
+ private function result(string $method, array $arguments = []): mixed
+ {
+ return $this->call($method, $arguments)['result'];
+ }
+
+ /** Convenience: true when the RPC succeeded. */
+ private function ok(string $method, array $arguments = []): bool
+ {
+ return $this->call($method, $arguments)['ok'];
+ }
+
+ /**
+ * Perform one Transmission RPC call, transparently handling the
+ * session-id handshake.
+ *
+ * @return array{ok: bool, result: mixed}
+ */
+ private function call(string $method, array $arguments = [], bool $retried = false): array
+ {
+ $failure = ['ok' => false, 'result' => null];
+ if ($this->health->isDown(self::SERVICE_KEY)) {
+ $this->serviceUnavailable = true;
+ return $failure;
+ }
+ if ($this->serviceUnavailable) {
+ return $failure;
+ }
+ $this->lastError = null;
+ $this->ensureConfig();
+
+ $payload = json_encode(['method' => $method, 'arguments' => (object) $arguments, 'tag' => ++$this->tag]);
+ $resp = $this->httpPost($payload);
+
+ if ($resp['body'] === false) {
+ $this->logger->warning('TransmissionClient RPC error', ['method' => $method, 'error' => $resp['err']]);
+ $this->recordError($method, $resp['code'], $resp['err'] !== '' ? $resp['err'] : 'connection failed');
+ $this->serviceUnavailable = true;
+ $this->health->markDown(self::SERVICE_KEY);
+ return $failure;
+ }
+
+ // Expected handshake: no/expired session id — store the fresh one and retry once.
+ if ($resp['code'] === 409) {
+ $sessionId = self::extractSessionId($resp['headers']);
+ if ($sessionId !== null) {
+ $this->sessionId = $sessionId;
+ }
+ if (!$retried) {
+ return $this->call($method, $arguments, true);
+ }
+ $this->recordError($method, 409, 'session-id handshake failed');
+ return $failure;
+ }
+
+ // Wrong RPC credentials — host is reachable, so do not trip the breaker.
+ if ($resp['code'] === 401) {
+ $this->recordError($method, 401, 'unauthorized');
+ return $failure;
+ }
+
+ if ($resp['code'] < 200 || $resp['code'] >= 300) {
+ $this->logger->warning('TransmissionClient RPC HTTP error', ['method' => $method, 'code' => $resp['code']]);
+ $this->recordError($method, $resp['code'], 'HTTP ' . $resp['code']);
+ if ($resp['code'] === 0) {
+ $this->serviceUnavailable = true;
+ $this->health->markDown(self::SERVICE_KEY);
+ }
+ return $failure;
+ }
+
+ $decoded = json_decode($resp['body'], true);
+ if (!is_array($decoded)) {
+ $this->recordError($method, $resp['code'], 'malformed RPC response');
+ return $failure;
+ }
+ if (($decoded['result'] ?? null) !== 'success') {
+ $this->recordError($method, $resp['code'], (string) ($decoded['result'] ?? 'RPC error'));
+ return $failure;
+ }
+
+ $this->health->clear(self::SERVICE_KEY);
+ return ['ok' => true, 'result' => $decoded['arguments'] ?? null];
+ }
+
+ /** Extract the fresh `X-Transmission-Session-Id` from raw response headers. */
+ private static function extractSessionId(string $responseHeaders): ?string
+ {
+ if (preg_match('/X-Transmission-Session-Id:\s*([^\r\n]+)/i', $responseHeaders, $m)) {
+ return trim($m[1]);
+ }
+ return null;
+ }
+
+ // ══════════════════════════════════════════════════════════════════════════
+ // HTTP
+ // ══════════════════════════════════════════════════════════════════════════
+
+ /**
+ * POST a JSON payload to /transmission/rpc. Always captures response
+ * headers (the 409 handshake needs them). Timeouts + NOSIGNAL as in
+ * QBittorrentClient — see the SIGALRM note there for why NOSIGNAL is
+ * critical in worker mode.
+ *
+ * @return array{body: string|false, headers: string, code: int, err: string}
+ */
+ private function httpPost(string $json): array
+ {
+ $url = rtrim($this->baseUrl, '/') . '/transmission/rpc';
+
+ $headers = ['Content-Type: application/json', 'Accept: application/json'];
+ if ($this->sessionId !== null) {
+ $headers[] = 'X-Transmission-Session-Id: ' . $this->sessionId;
+ }
+
+ $opts = [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, // SSRF guard
+ CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
+ CURLOPT_CONNECTTIMEOUT => 4,
+ // torrent-get on a large library is easily multiple MB uncompressed
+ // (e.g. ~6.3MB for ~1900 torrents) — transmission-daemon happily
+ // gzips it if asked, shrinking that to a few hundred KB. Empty
+ // string tells curl to negotiate every encoding it supports and
+ // auto-decompress; without it the transfer itself was what timed
+ // out over a slower/remote (e.g. DDNS) link, not the RPC call.
+ CURLOPT_ENCODING => '',
+ CURLOPT_TIMEOUT => 15,
+ CURLOPT_NOSIGNAL => 1,
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $json,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_HEADER => true,
+ ];
+ if ($this->user !== '') {
+ $opts[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC;
+ $opts[CURLOPT_USERPWD] = $this->user . ':' . $this->password;
+ }
+
+ $ch = curl_init($url);
+ curl_setopt_array($ch, $opts);
+
+ $raw = curl_exec($ch);
+ $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $hsize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
+ $err = curl_error($ch);
+ curl_close($ch);
+
+ if ($raw === false) {
+ return ['body' => false, 'headers' => '', 'code' => $code, 'err' => $err];
+ }
+ return ['body' => substr($raw, $hsize), 'headers' => substr($raw, 0, $hsize), 'code' => $code, 'err' => $err];
+ }
+}
diff --git a/symfony/src/Twig/ConfigExtension.php b/symfony/src/Twig/ConfigExtension.php
index 925f67ce..509b0f9c 100644
--- a/symfony/src/Twig/ConfigExtension.php
+++ b/symfony/src/Twig/ConfigExtension.php
@@ -26,6 +26,7 @@ class ConfigExtension extends AbstractExtension
'jellyseerr' => 'jellyseerr_api_key',
'qbittorrent' => 'qbittorrent_url',
'deluge' => 'deluge_url',
+ 'transmission' => 'transmission_url',
'sabnzbd' => 'sabnzbd_url',
'nzbget' => 'nzbget_url',
'gluetun' => 'gluetun_url',
diff --git a/symfony/src/Twig/DisplayPreferencesExtension.php b/symfony/src/Twig/DisplayPreferencesExtension.php
index 1f3fcb02..65e5776a 100644
--- a/symfony/src/Twig/DisplayPreferencesExtension.php
+++ b/symfony/src/Twig/DisplayPreferencesExtension.php
@@ -152,6 +152,7 @@ public function pref(string $key): mixed
'theme_color_rgb' => $this->prefs->getThemeColorRgb(),
'qbit_refresh_seconds' => $this->prefs->getQbitRefreshSeconds(),
'deluge_refresh_seconds' => $this->prefs->getDelugeRefreshSeconds(),
+ 'transmission_refresh_seconds' => $this->prefs->getTransmissionRefreshSeconds(),
'ui_density' => $this->prefs->getUiDensity(),
default => null,
};
diff --git a/symfony/templates/admin/settings.html.twig b/symfony/templates/admin/settings.html.twig
index 8ee2fab5..4e492b42 100644
--- a/symfony/templates/admin/settings.html.twig
+++ b/symfony/templates/admin/settings.html.twig
@@ -434,6 +434,7 @@
'jellyseerr': { icon_bg: '#6366f1', subtitle: 'admin.services.subtitle.jellyseerr'|trans },
'qbittorrent': { icon_bg: '#2f67ba', subtitle: 'admin.services.subtitle.qbittorrent'|trans },
'deluge': { icon_bg: '#3e7bbf', subtitle: 'admin.services.subtitle.deluge'|trans },
+ 'transmission': { icon_bg: '#d7302f', subtitle: 'admin.services.subtitle.transmission'|trans },
'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 },
@@ -447,7 +448,7 @@
('admin.services.group.discovery'|trans): ['tmdb'],
('admin.services.group.managers'|trans): ['radarr', 'sonarr'],
('admin.services.group.indexers'|trans): ['prowlarr', 'jellyseerr'],
- ('admin.services.group.downloads'|trans): ['qbittorrent', 'deluge', 'gluetun', 'sabnzbd', 'nzbget'],
+ ('admin.services.group.downloads'|trans): ['qbittorrent', 'deluge', 'transmission', 'gluetun', 'sabnzbd', 'nzbget'],
('admin.services.group.monitoring'|trans): ['tautulli', 'unraid', 'unifi', 'houndarr'],
} %}
@@ -489,7 +490,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', 'deluge', 'sabnzbd', 'nzbget', 'tautulli', 'unraid', 'unifi', 'houndarr'] %}
+ {% if service_id in ['tmdb', 'prowlarr', 'jellyseerr', 'qbittorrent', 'deluge', 'transmission', 'sabnzbd', 'nzbget', 'tautulli', 'unraid', 'unifi', 'houndarr'] %}
{% endif %}
+ {% if service_visible_in_sidebar('transmission') %}
+
+
+
+
+
+
+
+ {{ 'sidebar.nav.transmission'|trans }}
+
+
+
+
+ {% endif %}
+
{% if service_visible_in_sidebar('sabnzbd') %}
{% endif %}
+ {# ══════════════════════════════════════════════════════════════════════════
+ Global Transmission poll — DL completion / error toasts + sidebar badge
+ Active only on media pages (avoids hitting Transmission everywhere). Also
+ skipped when Transmission is disabled (#15) — otherwise the poll endpoint
+ would chase a route-guard redirect every few seconds and turn `r.json()`
+ into a JS SyntaxError that the circuit breaker then backs off for two
+ minutes.
+ ══════════════════════════════════════════════════════════════════════════ #}
+ {% set route = app.request.attributes.get('_route') ?? '' %}
+ {% if service_configured('transmission') and (
+ route starts with 'app_transmission_'
+ or route starts with 'app_media'
+ or route starts with 'radarr_'
+ or route starts with 'sonarr_'
+ or route starts with 'prowlarr_'
+ or route starts with 'jellyseerr_'
+ or route starts with 'tmdb_') %}
+
+ {% endif %}
+
{# ══════════════════════════════════════════════════════════════════════════
Jellyseerr pending-requests poll — sidebar badge. Unlike the qBit poll
above (media pages only, to avoid hammering the NAS every 15s), this one
diff --git a/symfony/templates/setup/downloads.html.twig b/symfony/templates/setup/downloads.html.twig
index 92de6ca2..ec1c957d 100644
--- a/symfony/templates/setup/downloads.html.twig
+++ b/symfony/templates/setup/downloads.html.twig
@@ -76,6 +76,40 @@
+