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'] %}
+
+
{{ 'setup.step.downloads.transmission.section'|trans }} {{ 'setup.step.downloads.usenet_optional'|trans }}
+

{{ 'setup.step.downloads.transmission.subtitle'|trans }}

+
+
+ + +
+
+ + +
+
+ + {{ forms.secret_input('transmission_password', 'transmission_password', { placeholder: '••••••••', value: values.transmission_password, autocomplete: 'new-password' }) }} +
+
+ + {{ 'setup.step.downloads.transmission.reverse_proxy_hint'|trans }} + +
+
+
+ + +
+
+
{{ 'setup.step.downloads.sabnzbd.section'|trans }} {{ 'setup.step.downloads.usenet_optional'|trans }}

{{ 'setup.step.downloads.sabnzbd.subtitle'|trans }}

diff --git a/symfony/templates/transmission/index.html.twig b/symfony/templates/transmission/index.html.twig new file mode 100644 index 00000000..79c273bd --- /dev/null +++ b/symfony/templates/transmission/index.html.twig @@ -0,0 +1,1967 @@ +{% extends 'base.html.twig' %} +{% import '_icons.html.twig' as ico %} +{% block title %}{{ 'transmission.page_title'|trans }}{% endblock %} +{% block page_title %} {{ 'transmission.page_title'|trans }}{% endblock %} + +{% block stylesheets %} + +{% endblock %} + +{% block body %} +
+ +{% if error %} + {% include 'error/_service_banner.html.twig' with { + service: 'Transmission', + wizard_route: 'app_setup_downloads', + configured_url: service_url, + reason: 'unreachable' + } %} +{% endif %} + +{% if not error %} + +{# ══════════════════════════════════════════════════════════════════════════ + Stats cards — row 1: torrent states + ══════════════════════════════════════════════════════════════════════════ #} +
+
+
+
+ + {{ stats.total ?? 0 }} +
+
{{ 'transmission.stats.total'|trans }}
+ +
+
+
+
+ {{ stats.downloading ?? 0 }} +
{{ 'transmission.stats.downloading'|trans }}
+
 
+ +
+
+
+
+ {{ stats.seeding ?? 0 }} +
{{ 'transmission.stats.seeding'|trans }}
+
 
+ +
+
+
+
+ 0 +
{{ 'transmission.stats.stalled'|trans }}
+ +
+
+
+
+ {{ stats.paused ?? 0 }} +
{{ 'transmission.stats.paused'|trans }}
+ +
+
+
+
+ {{ stats.errored ?? 0 }} +
{{ 'transmission.stats.errored'|trans }}
+ +
+
+
+ +{# ══════════════════════════════════════════════════════════════════════════ + Stats cards — row 2: totals + Session (no VPN card — Transmission has no + Gluetun/port-forwarding integration) + ══════════════════════════════════════════════════════════════════════════ #} +
+
+
+
+
+
{{ 'transmission.stats.total_downloaded'|trans }}
+ {{ (stats.dl_alltime ?? 0)|prismarr_bytes }} +
+
+
{{ 'transmission.stats.session'|trans }}
+ {{ (stats.dl_session ?? 0)|prismarr_bytes }} +
+
+ +
+
+
+
+
+
+
{{ 'transmission.stats.total_uploaded'|trans }}
+ {{ (stats.up_alltime ?? 0)|prismarr_bytes }} +
+
+
{{ 'transmission.stats.session'|trans }}
+ {{ (stats.up_session ?? 0)|prismarr_bytes }} +
+
+ +
+
+ {# Card Session #} +
+
+
+
+
+ {{ 'transmission.session.label'|trans }} + · — +
+ {{ 'transmission.session.ratio'|trans }} {{ (stats.global_ratio ?? 0)|number_format(2) }} +
+
+
{{ 'transmission.stats.peak_dl'|trans }}
+
{{ 'transmission.stats.peak_up'|trans }}
+
{{ 'transmission.stats.free_space'|trans }} {{ (stats.free_space ?? 0)|prismarr_bytes(0) }}
+
+
+ +
+
+
+ +{# ══════════════════════════════════════════════════════════════════════════ + Filter bar + search + actions + ══════════════════════════════════════════════════════════════════════════ #} +
+
+
+
+ + + + +
+
+ + + + + + + + +
+ {{ 'transmission.count_tpl'|trans({count: torrents|length}) }} +
+ + + +
+
+
+ +
+ {{ 'transmission.filters.category_label'|trans }} + +
+
+ {{ 'transmission.filters.sort_label'|trans }} + +
+
+ + + +
+
+ {# 3rd row of the card: pagination #} + +
+
+ +{# ══════════════════════════════════════════════════════════════════════════ + Table header (visible only in table mode) + ══════════════════════════════════════════════════════════════════════════ #} +
+
+
+
{{ 'transmission.table.name'|trans }}
+
{{ 'transmission.table.size'|trans }}
+
{{ 'transmission.table.progress'|trans }}
+
{{ 'transmission.table.dl_speed'|trans }}
+
{{ 'transmission.table.up_speed'|trans }}
+
{{ 'transmission.table.seeds'|trans }}
+
{{ 'transmission.table.ratio'|trans }}
+
{{ 'transmission.table.added'|trans }}
+
{{ 'transmission.table.actions'|trans }}
+
+ +{# ══════════════════════════════════════════════════════════════════════════ + Liste torrents + ══════════════════════════════════════════════════════════════════════════ #} +
+ {% for t in torrents %} + {{ _self.torrent_row(t) }} + {% endfor %} +
+ + + +{# Barre bulk #} +
+ {{ 'transmission.bulk.selected_tpl'|trans({count: 0}) }} + + + + + +
+ +
{# /container-xl #} + +{# ══════════════════════════════════════════════════════════════════════════ + Torrent detail modal + ══════════════════════════════════════════════════════════════════════════ #} +
+ +{# ══════════════════════════════════════════════════════════════════════════ + Modal ajout torrent + ══════════════════════════════════════════════════════════════════════════ #} + + +{# ══════════════════════════════════════════════════════════════════════════ + Delete modal + ══════════════════════════════════════════════════════════════════════════ #} + + +{# Triggers Turbo-safe #} + + + + +{% endif %} {# /if not error #} + +{# ══════════════════════════════════════════════════════════════════════════ + Macro torrent row + ══════════════════════════════════════════════════════════════════════════ #} +{% macro torrent_row(t) %} +{% set state_label = { + 'downloading': 'transmission.state.downloading'|trans, + 'seeding': 'transmission.state.seeding'|trans, + 'paused': 'transmission.state.paused'|trans, + 'queued': 'transmission.state.queued'|trans, + 'checking': 'transmission.state.checking'|trans, + 'error': 'transmission.state.error'|trans, + 'completed': 'transmission.state.completed'|trans, + 'stalled': 'transmission.state.stalled'|trans, + 'moving': 'transmission.state.moving'|trans, + 'unknown': 'transmission.state.unknown'|trans +}[t.state] ?? t.state %} +{% set cat = t.category|lower %} +{% set ico_type = cat in ['films', 'movies', 'radarr'] ? 'movie' : (cat in ['tv', 'tv-sonarr', 'series', 'sonarr'] ? 'tv' : 'generic') %} +
+ + +
+ {% if t.state == 'downloading' %} + + {% elseif t.state == 'seeding' %} + + {% elseif t.state == 'paused' %} + + {% elseif t.state == 'completed' %} + + {% elseif t.state == 'stalled' %} + + {% elseif t.state == 'error' %} + + {% elseif t.state == 'queued' %} + + {% elseif t.state == 'checking' %} + + {% elseif t.state == 'moving' %} + + {% else %} + + {% endif %} +
+ +
+
+
{{ t.name }}
+ {% if t.category %} + {% if cat in ['films', 'movies', 'radarr'] %} + + + {{ t.category }} + + {% elseif cat in ['tv', 'tv-sonarr', 'series', 'sonarr'] %} + + + {{ t.category }} + + {% else %} + {{ t.category }} + {% endif %} + {% endif %} +
+
+
+
+
+ {{ t.progress }}% + + {{ t.size|prismarr_bytes(2) }} + + {{ state_label }} + {% if t.dlspeed > 0 %} + + ↓ {{ t.dlspeed|prismarr_speed }} + {% endif %} + {% if t.upspeed > 0 %} + + ↑ {{ t.upspeed|prismarr_speed }} + {% endif %} + {% if t.state == 'downloading' and t.eta < 8640000 %} + + {{ ico.icon('clock', '', 14) }} {{ (t.eta / 3600)|round(0,'floor') }}h{{ ((t.eta % 3600) / 60)|round }}m + {% endif %} + + {{ ico.icon('seeding', '', 14) }} {{ t.num_seeds }}/{{ t.num_leechs ?? 0 }} + + {{ ico.icon('scale', '', 14) }} {{ (t.ratio ?? 0)|number_format(2) }} + {% if t.added_on %} + + {{ ico.icon('calendar', '', 14) }} {{ t.added_on|date('d/m/y') }} + {% endif %} +
+
+ + {# Cellules mode tableau #} +
{{ t.size|prismarr_bytes(2) }}
+
{{ t.progress }}%
+
{{ t.dlspeed > 0 ? t.dlspeed|prismarr_speed : '—' }}
+
{{ t.upspeed > 0 ? t.upspeed|prismarr_speed : '—' }}
+
{{ t.num_seeds }}
+
{{ (t.ratio ?? 0)|number_format(2) }}
+
{% if t.added_on %}{{ t.added_on|date('d/m/y') }}{% else %}—{% endif %}
+ + {# Cellule mode compact — % seul #} +
{{ t.progress }}%
+ +
+ {% if t.state == 'paused' %} + + {% else %} + + {% endif %} + +
+
+{% endmacro %} +{% endblock %} + +{% block javascripts %} + +{% endblock %} diff --git a/symfony/tests/Controller/ControllersSmokeTest.php b/symfony/tests/Controller/ControllersSmokeTest.php index 1d5d2a6b..b954acfa 100644 --- a/symfony/tests/Controller/ControllersSmokeTest.php +++ b/symfony/tests/Controller/ControllersSmokeTest.php @@ -44,6 +44,7 @@ public static function routesProvider(): array 'jellyseerr index' => ['/jellyseerr', 'JellyseerrController::index'], 'qbittorrent index' => ['/qbittorrent', 'QBittorrentController::index'], 'deluge index' => ['/deluge', 'DelugeController::index'], + 'transmission index' => ['/transmission', 'TransmissionController::index'], // Usenet pages (#20) — unconfigured in the test env, so they // redirect home with a flash rather than crash. 'usenet sabnzbd' => ['/usenet/sabnzbd', 'UsenetController::index'], diff --git a/symfony/tests/Controller/TransmissionControllerTest.php b/symfony/tests/Controller/TransmissionControllerTest.php new file mode 100644 index 00000000..32a87517 --- /dev/null +++ b/symfony/tests/Controller/TransmissionControllerTest.php @@ -0,0 +1,78 @@ +getMethod($method); + $m->setAccessible(true); + + return $m->invokeArgs(null, $args); + } + + /** + * Transmission hashes are exactly 40 hex chars — unlike qBit, there is + * no 'all' sentinel (pause-all/resume-all are separate explicit routes). + * + * @return iterable}> + */ + public static function hashInputs(): iterable + { + $valid1 = str_repeat('a', 40); + $valid2 = str_repeat('B', 40); + + yield 'not an array' => ['not-an-array', []]; + yield 'empty array' => [[], []]; + yield 'single valid hash' => [[$valid1], [$valid1]]; + yield 'mixed case valid hash' => [[$valid2], [$valid2]]; + yield 'too short' => [[str_repeat('a', 39)], []]; + yield 'too long' => [[str_repeat('a', 41)], []]; + yield 'non-hex characters' => [['g' . str_repeat('a', 39)], []]; + yield "'all' sentinel rejected" => [['all'], []]; + yield 'non-string entries dropped' => [[123, null, true, $valid1], [$valid1]]; + yield 'valid + invalid mixed' => [[$valid1, 'nope', $valid2], [$valid1, $valid2]]; + } + + #[DataProvider('hashInputs')] + public function testSanitizeHashesAcceptsOnly40HexCharsNoAllSentinel(mixed $raw, array $expected): void + { + $this->assertSame($expected, $this->invokeStatic('sanitizeHashes', [$raw])); + } + + /** + * SSRF guard for `/api/add` (Transmission fetches whatever URL we hand + * it via `filename`): http(s)/magnet only, cloud-metadata hosts blocked, + * everything else (including LAN) allowed. Static + translator-free so + * it unit-tests without booting the container. + * + * @return iterable + */ + public static function urlValidationCases(): iterable + { + yield 'https url' => ['https://example.com/a.torrent', null]; + yield 'http url' => ['http://example.com/a.torrent', null]; + yield 'magnet link' => ['magnet:?xt=urn:btih:' . str_repeat('a', 40), null]; + yield 'lan http url' => ['http://192.168.1.10:8080/a.torrent', null]; + yield 'multiple valid lines' => ["https://a.example/1.torrent\nmagnet:?xt=urn:btih:" . str_repeat('a', 40), null]; + yield 'file scheme blocked' => ['file:///etc/passwd', 'invalid_url']; + yield 'gopher scheme blocked' => ['gopher://example.com', 'invalid_url']; + yield 'missing host' => ['https:///a.torrent', 'invalid_url']; + yield 'aws metadata blocked' => ['http://169.254.169.254/latest/meta-data/', 'forbidden_host']; + yield 'gcp metadata blocked' => ['http://metadata.google.internal/', 'forbidden_host']; + yield 'azure metadata blocked' => ['http://metadata.azure.com/', 'forbidden_host']; + yield 'metadata blocked case-insensitive' => ['http://METADATA.GOOGLE.INTERNAL/', 'forbidden_host']; + yield 'blank lines ignored' => ["\n\nhttps://example.com/a.torrent\n\n", null]; + } + + #[DataProvider('urlValidationCases')] + public function testValidateTorrentUrlsStaticBlocksForbiddenSchemesAndHosts(string $raw, ?string $expectedViolation): void + { + $this->assertSame($expectedViolation, $this->invokeStatic('validateTorrentUrlsStatic', [$raw])); + } +} diff --git a/symfony/tests/EventSubscriber/ServiceRouteGuardSubscriberTest.php b/symfony/tests/EventSubscriber/ServiceRouteGuardSubscriberTest.php index 3ffc3ed5..ba52e41b 100644 --- a/symfony/tests/EventSubscriber/ServiceRouteGuardSubscriberTest.php +++ b/symfony/tests/EventSubscriber/ServiceRouteGuardSubscriberTest.php @@ -223,6 +223,16 @@ public function testDelugeUnconfiguredRedirectsToDownloadsWizard(): void $this->assertStringContainsString('app_setup_downloads', $response->getTargetUrl()); } + public function testTransmissionUnconfiguredRedirectsToDownloadsWizard(): void + { + $event = $this->event('app_transmission_add'); + ($this->subscriber())->onKernelRequest($event); + + $response = $event->getResponse(); + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertStringContainsString('app_setup_downloads', $response->getTargetUrl()); + } + public function testDelugeConfiguredLetsThrough(): void { $event = $this->event('app_deluge_index'); @@ -234,4 +244,16 @@ public function testDelugeConfiguredLetsThrough(): void $this->assertFalse($event->hasResponse()); } + + public function testTransmissionConfiguredAndHealthyLetsThrough(): void + { + $event = $this->event('app_transmission_index'); + $sub = $this->subscriber( + configuredKeys: ['transmission_url'], + healthy: ['transmission'], + ); + $sub->onKernelRequest($event); + + $this->assertFalse($event->hasResponse()); + } } diff --git a/symfony/tests/Service/HealthServiceDiagnoseTest.php b/symfony/tests/Service/HealthServiceDiagnoseTest.php index 3401b30a..d6e2bad2 100644 --- a/symfony/tests/Service/HealthServiceDiagnoseTest.php +++ b/symfony/tests/Service/HealthServiceDiagnoseTest.php @@ -64,6 +64,30 @@ public function testNzbgetNetworkError(): void self::assertSame('network', $this->makeService()->diagnoseFromResponse($resp, 'nzbget')['category']); } + /** + * Transmission's session-id handshake is the one case in this codebase + * where a non-2xx status means "reachable": the probe is deliberately + * sent WITHOUT a session id, so a healthy daemon always answers 409 + * with the real token in a response header — that is 'ok', not a failure. + */ + public function testTransmission409HandshakeIsOk(): void + { + $resp = ['http' => 409, 'body' => '{"result":"Conflict"}', 'err' => '']; + self::assertSame('ok', $this->makeService()->diagnoseFromResponse($resp, 'transmission')['category']); + } + + public function testTransmission401BadRpcCredentialsIsAuth(): void + { + $resp = ['http' => 401, 'body' => '', 'err' => '']; + self::assertSame('auth', $this->makeService()->diagnoseFromResponse($resp, 'transmission')['category']); + } + + public function testTransmission200SuccessIsOk(): void + { + $resp = ['http' => 200, 'body' => '{"result":"success","arguments":{"version":"4.0.5"}}', 'err' => '']; + self::assertSame('ok', $this->makeService()->diagnoseFromResponse($resp, 'transmission')['category']); + } + public function testPassiveDiagnoseShortCircuitsWhenBreakerDown(): void { // #20 perf: a passive diagnosis (no overrides) must honour the circuit diff --git a/symfony/tests/Service/Media/TransmissionClientTest.php b/symfony/tests/Service/Media/TransmissionClientTest.php new file mode 100644 index 00000000..28515ba8 --- /dev/null +++ b/symfony/tests/Service/Media/TransmissionClientTest.php @@ -0,0 +1,169 @@ +createMock(ConfigService::class); + + return new TransmissionClient($config, new NullLogger(), new ServiceHealthCache(new ArrayAdapter())); + } + + /** + * The 409 handshake is the expected first round trip, not a failure — + * the fresh session id must be parsed out of the raw response headers + * regardless of casing or surrounding headers. + * + * @return iterable + */ + public static function sessionIdHeaders(): iterable + { + yield 'canonical casing' => [ + "HTTP/1.1 409 Conflict\r\nX-Transmission-Session-Id: abc123XYZ==\r\n\r\n", + 'abc123XYZ==', + ]; + yield 'lowercase header name' => [ + "HTTP/1.1 409 Conflict\r\nx-transmission-session-id: lower-case-id\r\n\r\n", + 'lower-case-id', + ]; + yield 'surrounded by other headers' => [ + "HTTP/1.1 409 Conflict\r\nServer: Transmission\r\nX-Transmission-Session-Id: mid-id\r\nContent-Length: 0\r\n\r\n", + 'mid-id', + ]; + yield 'missing header' => [ + "HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\n", + null, + ]; + } + + #[DataProvider('sessionIdHeaders')] + public function testExtractSessionIdParsesThe409Handshake(string $rawHeaders, ?string $expected): void + { + $client = $this->makeClient(); + + $m = (new \ReflectionClass($client))->getMethod('extractSessionId'); + $m->setAccessible(true); + + $this->assertSame($expected, $m->invoke(null, $rawHeaders)); + } + + /** + * A non-zero `error` field always wins over `status`, matching Deluge's + * precedence; otherwise Transmission's numeric status maps onto the + * same state vocabulary qBit/Deluge normalize to. + * + * @return iterable + */ + public static function statusMappings(): iterable + { + yield 'stopped (0)' => [0, 0, 'paused']; + yield 'verify queued (1)' => [1, 0, 'checking']; + yield 'verifying (2)' => [2, 0, 'checking']; + yield 'download queued (3)' => [3, 0, 'queued']; + yield 'downloading (4)' => [4, 0, 'downloading']; + yield 'seed queued (5)' => [5, 0, 'queued']; + yield 'seeding (6)' => [6, 0, 'seeding']; + yield 'unknown status code' => [42, 0, 'unknown']; + yield 'error overrides seeding' => [6, 1, 'error']; + yield 'error overrides downloading' => [4, 2, 'error']; + } + + #[DataProvider('statusMappings')] + public function testNormalizeStateAppliesErrorPrecedence(int $status, int $errorNum, string $expected): void + { + $client = $this->makeClient(); + + $m = (new \ReflectionClass($client))->getMethod('normalizeState'); + $m->setAccessible(true); + + $this->assertSame($expected, $m->invoke(null, $status, $errorNum)); + } + + /** + * Transmission speed limits are KiB/s with 0/disabled meaning unlimited; + * Prismarr's shared UI speaks bytes/s with -1 meaning unlimited. + * + * @return iterable + */ + public static function kibToBytesCases(): iterable + { + yield 'disabled (0)' => [0.0, -1]; + yield 'negative' => [-5.0, -1]; + yield '1 MiB/s' => [1024.0, 1024 * 1024]; + yield 'fractional KiB' => [10.5, (int) round(10.5 * 1024)]; + } + + #[DataProvider('kibToBytesCases')] + public function testKibToBytesConvertsAndTreatsZeroAsUnlimited(float $kib, int $expected): void + { + $client = $this->makeClient(); + + $m = (new \ReflectionClass($client))->getMethod('kibToBytes'); + $m->setAccessible(true); + + $this->assertSame($expected, $m->invoke(null, $kib)); + } + + /** + * A user-entered URL with a redundant `/transmission`, `/transmission/rpc`, + * or `/transmission/web` suffix must be stripped, otherwise it doubles up + * with the `/transmission/rpc` suffix httpPost() always appends. + * + * @return iterable + */ + public static function baseUrlCases(): iterable + { + yield 'plain host:port' => ['http://192.168.86.10:9091', 'http://192.168.86.10:9091']; + yield 'trailing slash' => ['http://192.168.86.10:9091/', 'http://192.168.86.10:9091']; + yield 'redundant /transmission suffix' => ['http://192.168.86.10:9091/transmission', 'http://192.168.86.10:9091']; + yield 'redundant /transmission/ suffix with trailing slash' => ['http://192.168.86.10:9091/transmission/', 'http://192.168.86.10:9091']; + yield 'redundant /transmission/rpc suffix' => ['http://192.168.86.10:9091/transmission/rpc', 'http://192.168.86.10:9091']; + yield 'redundant /transmission/web suffix' => ['http://192.168.86.10:9091/transmission/web', 'http://192.168.86.10:9091']; + yield 'case-insensitive suffix' => ['http://192.168.86.10:9091/Transmission', 'http://192.168.86.10:9091']; + yield 'reverse-proxy path preserved' => ['http://host.docker.internal:8080/transmission-daemon', 'http://host.docker.internal:8080/transmission-daemon']; + } + + #[DataProvider('baseUrlCases')] + public function testNormalizeBaseUrlStripsRedundantTransmissionSuffix(string $input, string $expected): void + { + $client = $this->makeClient(); + + $m = (new \ReflectionClass($client))->getMethod('normalizeBaseUrl'); + $m->setAccessible(true); + + $this->assertSame($expected, $m->invoke(null, $input)); + } + + /** + * @return iterable + */ + public static function bytesToKibCases(): iterable + { + yield 'unlimited (-1)' => [-1, 0.0]; + yield 'zero' => [0, 0.0]; + yield '1 MiB/s' => [1024 * 1024, 1024.0]; + } + + #[DataProvider('bytesToKibCases')] + public function testBytesToKibRoundTripsWithKibToBytes(int $bytes, float $expected): void + { + $client = $this->makeClient(); + + $m = (new \ReflectionClass($client))->getMethod('bytesToKib'); + $m->setAccessible(true); + + $this->assertSame($expected, $m->invoke(null, $bytes)); + } +} diff --git a/symfony/translations/messages+intl-icu.en.yaml b/symfony/translations/messages+intl-icu.en.yaml index 87828966..e17734f2 100644 --- a/symfony/translations/messages+intl-icu.en.yaml +++ b/symfony/translations/messages+intl-icu.en.yaml @@ -67,6 +67,7 @@ sidebar: dashboard: Dashboard qbittorrent: qBittorrent deluge: Deluge + transmission: Transmission discovery: Discover calendar: Calendar radarr: Radarr @@ -297,6 +298,12 @@ setup: subtitle: 'Torrent client (Web UI) — used for the Deluge tab.' password: Web UI password reverse_proxy_hint: 'Leave the password empty if Deluge sits behind an authenticating reverse proxy that injects the session for you.' + transmission: + section: '⬇️ Transmission' + subtitle: Authentication through the Transmission RPC interface. + user: Username + password: Password + reverse_proxy_hint: 'Leave the username and password empty if Transmission sits behind a reverse proxy that injects authentication itself, or if RPC authentication is disabled.' usenet_optional: 'Usenet — optional' sabnzbd: section: 'SABnzbd' @@ -734,6 +741,7 @@ admin: jellyseerr: Request platform qbittorrent: Torrent client deluge: Torrent client (Web UI) + transmission: Torrent client sabnzbd: Usenet downloader nzbget: Usenet downloader gluetun: 'VPN / port forwarding' @@ -897,6 +905,15 @@ admin: deluge_refresh: label: Deluge refresh interval help: How often the Deluge page and sidebar badge poll for changes. 0 disables polling. + transmission_refresh: + label: Transmission refresh + help: Auto-polling frequency on the Downloads page. + options: + '1': '1 second (real-time)' + '2': '2 seconds (default)' + '5': '5 seconds' + '10': '10 seconds (low load)' + '0': Disabled ui_density: label: UI density options: @@ -3823,6 +3840,241 @@ deluge: api: empty_location: Destination path is required +transmission: + page_title: Downloads + stats: + total: Total + downloading: Downloading + seeding: Seeding + stalled: Stalled + paused: Paused + errored: Errors + total_downloaded: Total downloaded + total_uploaded: Total uploaded + session: Session + free_space: Free + peak_dl: 'Peak ↓' + peak_up: 'Peak ↑' + session: + label: Session + ratio: Ratio + filters: + search_placeholder: 'Search...' + all: All + active: '⚡ Active' + active_title: 'Torrents with speed > 0' + downloading: Downloading + seeding: Seeding + stalled: Stalled + paused: Paused + completed: Completed + error: Errors + category_label: 'Category:' + category_all: All + sort_label: 'Sort:' + select_all: Select all + count_tpl: '{count, plural, one {# torrent} other {# torrents}}' + sort: + added_desc: 'Added (newest → oldest)' + added_asc: 'Added (oldest → newest)' + name_asc: 'Name (A → Z)' + name_desc: 'Name (Z → A)' + size_desc: 'Size (↓)' + size_asc: 'Size (↑)' + progress_desc: 'Progress (↓)' + dlspeed_desc: 'DL speed (↓)' + upspeed_desc: 'UP speed (↓)' + ratio_desc: 'Ratio (↓)' + seeds_desc: 'Seeds (↓)' + view: + aria_label: "Display mode" + list: List + list_title: List view + table: Table + table_title: Table view + compact: Compact + compact_title: Compact view + table: + name: Name + size: Size + progress: '%' + dl_speed: DL/s + up_speed: UP/s + seeds: Seeds + ratio: Ratio + added: Added + actions: Actions + pagination: + per_page: 'Per page:' + info_tpl: '__FROM__–__TO__ / __TOTAL__' + toolbar: + add: Add + add_title: Add a torrent + pause_all_title: Pause all + resume_all_title: Resume all + state: + downloading: Downloading + seeding: Seeding + paused: Paused + queued: Queued + checking: Checking + error: Error + completed: Completed + stalled: Stalled + moving: Moving + unknown: Unknown state + row: + title_seeds_leechers: 'Seeds / Leechers' + title_ratio: Ratio + title_added_tpl: 'Added on __DATE__' + title_radarr: View in Radarr + title_sonarr: View in Sonarr + pause_title: Pause + resume_title: Resume + delete_title: Delete + bulk: + selected_tpl: '{count, plural, one {# selected} other {# selected}}' + resume: Resume + pause: Pause + delete: Delete + recheck: Recheck + cancel: Cancel + empty: + no_match: 'No torrent matches these criteria.' + modal_detail: + loading: 'Loading...' + tab_general: General + tab_files: Files + tab_trackers: Trackers + tab_peers: Peers + files: + col_name: Name + col_size: Size + col_progress: Progress + col_priority: Priority + empty: No files + prio_low: Low + prio_normal: Normal + prio_high: High + trackers: + col_url: URL + col_status: Status + col_seeds: Seeds + col_leechs: Leechers + empty: No trackers + status_announcing: Announcing + status_queued: Queued + status_active: Active + status_idle: Idle + peers: + col_ip: IP + col_client: Client + col_progress: '%' + col_dl: DL + col_up: UP + empty: No peers connected + props: + downloaded: Downloaded + uploaded: Uploaded + ratio: Ratio + dl_speed: DL speed + up_speed: UP speed + eta: Time left + seeds: Seeds + peers: Peers + total_size: Total size + added: Added + completed: Completed + save_path: Save folder + actions: + pause: Pause + resume: Resume + recheck: Recheck + reannounce: Reannounce + delete: Delete + error_load: Loading error + modal_add: + title: Add a torrent + urls_label: 'Magnet link or .torrent URL' + urls_placeholder: 'magnet:?xt=urn:btih:... or https://...' + urls_hint: 'One link per line to add multiple torrents.' + files_label: 'Or .torrent files' + drop_text: 'Drag your .torrent files here' + drop_browse: 'or click to browse' + file_remove_title: Remove + start_label: Start + start_now: Start immediately + start_paused: Add paused + cancel: Cancel + submit: Add + modal_delete: + title: Delete + msg_single: 'Delete this torrent?' + msg_named_tpl: 'Delete "__NAME__"?' + msg_bulk_tpl: '{count, plural, one {Delete # torrent?} other {Delete # torrents?}}' + delete_files: 'Also delete files' + cancel: Cancel + confirm: Delete + toast: + action_sent: 'Action sent' + network_error: 'Network error' + failed_tpl: 'Failed: __ERROR__' + error_generic: error + paused: 'Torrent paused' + resumed: 'Torrent resumed' + recheck_started: 'Recheck started' + reannounce_sent: 'Reannounce sent' + bulk_resumed_tpl: '{count, plural, one {# torrent resumed} other {# torrents resumed}}' + bulk_paused_tpl: '{count, plural, one {# torrent paused} other {# torrents paused}}' + bulk_recheck_tpl: '{count, plural, one {Rechecking # torrent} other {Rechecking # torrents}}' + bulk_deleted_tpl: '{count, plural, one {# torrent deleted} other {# torrents deleted}}' + bulk_deleted_with_files_suffix: ' + files' + pause_all: 'All torrents paused' + resume_all: 'All torrents resumed' + add_files_ok_tpl: '{count, plural, one {# file added} other {# files added}}' + add_files_ko: 'File upload failed' + add_url_ok: 'Torrent(s) added' + add_url_ko: 'URL add failed' + add_required: 'Add a magnet/URL or a .torrent file' + file_ignored_tpl: 'Ignored (not .torrent): __NAME__' + cat_resolve_search: 'Searching...' + cat_resolve_no_match_tpl: 'No match found for "__NAME__"' + cat_resolve_error: 'Resolution error' + date: + just_now: 'just now' + minutes_ago_tpl: '__N__min ago' + hours_ago_tpl: '__N__h ago' + days_ago_tpl: '__N__d ago' + months_ago_tpl: '__N__ months ago' + years_ago_tpl: '{count, plural, one {# year ago} other {# years ago}}' + units: + gb: GB + mb: MB + kb: KB + bytes: B + mb_per_s: MB/s + kb_per_s: KB/s + bytes_per_s: B/s + eta_tpl: '__H__h__M__m' + duration_days_tpl: '__D__d __H__h' + duration_hours_tpl: '__H__h __M__m' + duration_minutes_tpl: '__M__m' + uptime_hours_tpl: '__H__h __M__m' + uptime_minutes_tpl: '__M__ min' + uptime_seconds_tpl: '__S__s' + upload: + invalid_url: 'Only http(s) and magnet: links are accepted' + forbidden_host: 'This URL points to a forbidden host (cloud metadata)' + no_file: 'No file received' + invalid_format: 'Only .torrent files are accepted' + too_large: 'File too large (>10 MB)' + unreadable: 'Unreadable file' + api: + no_valid_hash: No valid hash + no_valid_file: No valid file + torrent_not_found: Torrent not found + empty_location: 'Location cannot be empty' + usenet: page_title: 'Usenet downloads' disabled_notice: '{client} is disabled.' diff --git a/symfony/translations/messages+intl-icu.fr.yaml b/symfony/translations/messages+intl-icu.fr.yaml index 3008f0ff..0dd2aee3 100644 --- a/symfony/translations/messages+intl-icu.fr.yaml +++ b/symfony/translations/messages+intl-icu.fr.yaml @@ -66,6 +66,7 @@ sidebar: dashboard: Tableau de bord qbittorrent: qBittorrent deluge: Deluge + transmission: Transmission discovery: Découverte calendar: Calendrier radarr: Radarr @@ -296,6 +297,12 @@ setup: subtitle: "Client torrent (Web UI) — utilisé pour l'onglet Deluge." password: Mot de passe Web UI reverse_proxy_hint: 'Laissez le mot de passe vide si Deluge est derrière un reverse proxy authentifiant qui injecte la session.' + transmission: + section: '⬇️ Transmission' + subtitle: Authentification via l'interface RPC de Transmission. + user: Utilisateur + password: Mot de passe + reverse_proxy_hint: 'Laissez l''utilisateur et le mot de passe vides si Transmission est derrière un reverse proxy qui injecte lui-même l''authentification, ou si l''authentification RPC est désactivée.' usenet_optional: 'Usenet — optionnel' sabnzbd: section: 'SABnzbd' @@ -733,6 +740,7 @@ admin: jellyseerr: Plateforme de requêtes qbittorrent: Client torrent deluge: Client torrent (Web UI) + transmission: Client torrent sabnzbd: Téléchargeur Usenet nzbget: Téléchargeur Usenet gluetun: 'VPN / port forwarding' @@ -896,6 +904,15 @@ admin: deluge_refresh: label: Intervalle de rafraîchissement Deluge help: Fréquence de mise à jour de la page Deluge et du badge. 0 désactive le polling. + transmission_refresh: + label: Rafraîchissement Transmission + help: Fréquence du polling automatique sur la page Téléchargements. + options: + '1': '1 seconde (temps réel)' + '2': '2 secondes (défaut)' + '5': '5 secondes' + '10': '10 secondes (économe)' + '0': Désactivé ui_density: label: Densité de l'interface options: @@ -3822,6 +3839,241 @@ deluge: api: empty_location: Le chemin de destination est requis +transmission: + page_title: Téléchargements + stats: + total: Total + downloading: En cours + seeding: Partage + stalled: En attente + paused: Pause + errored: Erreurs + total_downloaded: Téléchargé total + total_uploaded: Uploadé total + session: Session + free_space: Dispo + peak_dl: 'Pic ↓' + peak_up: 'Pic ↑' + session: + label: Session + ratio: Ratio + filters: + search_placeholder: 'Rechercher...' + all: Tous + active: '⚡ Actifs' + active_title: 'Torrents avec vitesse > 0' + downloading: En cours + seeding: Partage + stalled: En attente + paused: Pause + completed: Terminés + error: Erreurs + category_label: 'Catégorie :' + category_all: Toutes + sort_label: 'Tri :' + select_all: Tout sélectionner + count_tpl: '{count, plural, one {# torrent} other {# torrents}}' + sort: + added_desc: 'Ajout (récent → ancien)' + added_asc: 'Ajout (ancien → récent)' + name_asc: 'Nom (A → Z)' + name_desc: 'Nom (Z → A)' + size_desc: 'Taille (↓)' + size_asc: 'Taille (↑)' + progress_desc: 'Progression (↓)' + dlspeed_desc: 'Vitesse DL (↓)' + upspeed_desc: 'Vitesse UP (↓)' + ratio_desc: 'Ratio (↓)' + seeds_desc: 'Seeds (↓)' + view: + aria_label: "Mode d'affichage" + list: Liste + list_title: Vue liste + table: Tableau + table_title: Vue tableau + compact: Compact + compact_title: Vue compacte + table: + name: Nom + size: Taille + progress: '%' + dl_speed: DL/s + up_speed: UP/s + seeds: Seeds + ratio: Ratio + added: Ajouté + actions: Actions + pagination: + per_page: 'Par page :' + info_tpl: '__FROM__–__TO__ / __TOTAL__' + toolbar: + add: Ajouter + add_title: Ajouter un torrent + pause_all_title: Tout mettre en pause + resume_all_title: Tout reprendre + state: + downloading: Téléchargement + seeding: Partage + paused: Pause + queued: File + checking: Vérification + error: Erreur + completed: Terminé + stalled: En attente + moving: Déplacement + unknown: État inconnu + row: + title_seeds_leechers: 'Seeds / Leechers' + title_ratio: Ratio + title_added_tpl: 'Ajouté le __DATE__' + title_radarr: Voir dans Radarr + title_sonarr: Voir dans Sonarr + pause_title: Pause + resume_title: Reprendre + delete_title: Supprimer + bulk: + selected_tpl: '{count, plural, one {# sélectionné} other {# sélectionnés}}' + resume: Reprendre + pause: Pause + delete: Supprimer + recheck: Vérifier + cancel: Annuler + empty: + no_match: 'Aucun torrent ne correspond à ces critères.' + modal_detail: + loading: 'Chargement...' + tab_general: Général + tab_files: Fichiers + tab_trackers: Trackers + tab_peers: Peers + files: + col_name: Nom + col_size: Taille + col_progress: Progression + col_priority: Priorité + empty: Aucun fichier + prio_low: Basse + prio_normal: Normal + prio_high: Haute + trackers: + col_url: URL + col_status: Statut + col_seeds: Seeds + col_leechs: Leechs + empty: Aucun tracker + status_announcing: Annonce + status_queued: En file + status_active: Actif + status_idle: Inactif + peers: + col_ip: IP + col_client: Client + col_progress: '%' + col_dl: DL + col_up: UP + empty: Aucun peer connecté + props: + downloaded: Téléchargé + uploaded: Envoyé + ratio: Ratio + dl_speed: Vitesse DL + up_speed: Vitesse UP + eta: Temps restant + seeds: Seeds + peers: Peers + total_size: Taille totale + added: Ajouté + completed: Complété + save_path: Dossier de sauvegarde + actions: + pause: Pause + resume: Reprendre + recheck: Vérifier + reannounce: Réannoncer + delete: Supprimer + error_load: Erreur de chargement + modal_add: + title: Ajouter un torrent + urls_label: 'Lien magnet ou URL .torrent' + urls_placeholder: 'magnet:?xt=urn:btih:... ou https://...' + urls_hint: 'Un lien par ligne pour ajouter plusieurs torrents.' + files_label: 'Ou fichiers .torrent' + drop_text: 'Glissez vos fichiers .torrent ici' + drop_browse: 'ou cliquez pour parcourir' + file_remove_title: Retirer + start_label: Démarrage + start_now: Démarrer immédiatement + start_paused: Ajouter en pause + cancel: Annuler + submit: Ajouter + modal_delete: + title: Supprimer + msg_single: 'Supprimer ce torrent ?' + msg_named_tpl: 'Supprimer "__NAME__" ?' + msg_bulk_tpl: '{count, plural, one {Supprimer # torrent ?} other {Supprimer # torrents ?}}' + delete_files: 'Supprimer aussi les fichiers' + cancel: Annuler + confirm: Supprimer + toast: + action_sent: 'Action envoyée' + network_error: 'Erreur réseau' + failed_tpl: 'Échec : __ERROR__' + error_generic: erreur + paused: 'Torrent mis en pause' + resumed: 'Torrent repris' + recheck_started: 'Vérification lancée' + reannounce_sent: 'Réannonce envoyée' + bulk_resumed_tpl: '{count, plural, one {# torrent repris} other {# torrents repris}}' + bulk_paused_tpl: '{count, plural, one {# torrent en pause} other {# torrents en pause}}' + bulk_recheck_tpl: '{count, plural, one {Vérification de # torrent} other {Vérification de # torrents}}' + bulk_deleted_tpl: '{count, plural, one {# torrent supprimé} other {# torrents supprimés}}' + bulk_deleted_with_files_suffix: ' + fichiers' + pause_all: 'Tous les torrents en pause' + resume_all: 'Tous les torrents repris' + add_files_ok_tpl: '{count, plural, one {# fichier ajouté} other {# fichiers ajoutés}}' + add_files_ko: 'Échec upload fichier' + add_url_ok: 'Torrent(s) ajouté(s)' + add_url_ko: 'Échec ajout URL' + add_required: 'Ajoute un magnet/URL ou un fichier .torrent' + file_ignored_tpl: 'Ignoré (pas .torrent) : __NAME__' + cat_resolve_search: 'Recherche...' + cat_resolve_no_match_tpl: 'Aucun match trouvé pour "__NAME__"' + cat_resolve_error: 'Erreur de résolution' + date: + just_now: "à l'instant" + minutes_ago_tpl: 'il y a __N__min' + hours_ago_tpl: 'il y a __N__h' + days_ago_tpl: 'il y a __N__j' + months_ago_tpl: 'il y a __N__ mois' + years_ago_tpl: '{count, plural, one {il y a # an} other {il y a # ans}}' + units: + gb: Go + mb: Mo + kb: Ko + bytes: o + mb_per_s: Mo/s + kb_per_s: Ko/s + bytes_per_s: o/s + eta_tpl: '__H__h__M__m' + duration_days_tpl: '__D__j __H__h' + duration_hours_tpl: '__H__h __M__m' + duration_minutes_tpl: '__M__m' + uptime_hours_tpl: '__H__h __M__m' + uptime_minutes_tpl: '__M__ min' + uptime_seconds_tpl: '__S__s' + upload: + invalid_url: 'Seuls les liens http(s) et magnet: sont acceptés' + forbidden_host: 'Cette URL pointe vers un hôte interdit (métadonnées cloud)' + no_file: 'Aucun fichier reçu' + invalid_format: 'Seuls les fichiers .torrent sont acceptés' + too_large: 'Fichier trop volumineux (>10 Mo)' + unreadable: 'Fichier illisible' + api: + no_valid_hash: Aucun hash valide + no_valid_file: Aucun fichier valide + torrent_not_found: Torrent introuvable + empty_location: 'L''emplacement ne peut pas être vide' + usenet: page_title: 'Téléchargements Usenet' disabled_notice: '{client} est désactivé.'