diff --git a/symfony/src/Controller/UsenetController.php b/symfony/src/Controller/UsenetController.php index 38f78ca0..a46aa127 100644 --- a/symfony/src/Controller/UsenetController.php +++ b/symfony/src/Controller/UsenetController.php @@ -14,6 +14,8 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Translation\TranslatorInterface; /** @@ -31,6 +33,26 @@ #[Route('/usenet/{client}', name: 'app_usenet_', requirements: ['client' => 'sabnzbd|nzbget'])] class UsenetController extends AbstractController { + /** Rows in the downloads page's "Recent history" preview (#47). */ + private const RECENT_HISTORY_LIMIT = 15; + + /** + * Cache window for that preview. NZBGet's history RPC has no upstream + * paging — getHistoryPage() pulls the whole retained history and slices + * locally — so an uncached preview would drag that payload across the wire + * on every page render. History only grows when a job finishes, so a short + * window needs no invalidation. + * + * @internal Exposed for tests; matches MediaLibraryCache::TTL. + */ + public const RECENT_HISTORY_TTL = 45; // seconds + + /** @internal Exposed for tests — one key per client, never shared. */ + public static function recentHistoryCacheKey(string $client): string + { + return 'usenet.recent_history.' . $client; + } + public function __construct( private readonly SabnzbdClient $sabnzbd, private readonly NzbgetClient $nzbget, @@ -38,6 +60,7 @@ public function __construct( private readonly ConfigService $config, private readonly LoggerInterface $logger, private readonly TranslatorInterface $translator, + private readonly CacheInterface $cache, ) {} private function client(string $kind): UsenetClientInterface @@ -96,13 +119,49 @@ public function index(string $client): Response } catch (\Throwable) { } + // Recent history preview (#47): server-rendered once, at page load — + // the queue keeps its JS poller, history doesn't need one. Skipped when + // the probe already failed: the page shows its unreachable banner + // instead, and a doomed call would just burn the connect timeout. + // + // Behind a short per-client cache: NZBGet's history RPC returns the + // WHOLE retained history (the client slices locally), so an uncached + // preview would pull an unbounded payload on every render. An empty + // result isn't cached, and a throwing fetch caches nothing at all, so + // neither a fresh install nor a transient failure gets pinned for the + // window. + $recentHistory = []; + $historyTotal = 0; + if ($reason === null) { + try { + $hist = $this->cache->get( + self::recentHistoryCacheKey($client), + function (ItemInterface $item) use ($client): array { + $result = $this->client($client)->getHistoryPage(0, self::RECENT_HISTORY_LIMIT); + $item->expiresAfter($result['items'] === [] ? 0 : self::RECENT_HISTORY_TTL); + return $result; + }, + ); + $recentHistory = $hist['items']; + $historyTotal = $hist['total']; + } catch (\Throwable $e) { + $this->logger->warning('Usenet recent history failed', [ + 'client' => $client, + 'exception' => $e::class, + 'message' => $e->getMessage(), + ]); + } + } + return $this->render('usenet/index.html.twig', [ - 'client' => $client, - 'client_label' => $label, - 'error' => $reason !== null, - 'error_reason' => $reason ?? 'unreachable', - 'service_url' => $this->config->get($client . '_url'), - 'categories' => $categories, + 'client' => $client, + 'client_label' => $label, + 'error' => $reason !== null, + 'error_reason' => $reason ?? 'unreachable', + 'service_url' => $this->config->get($client . '_url'), + 'categories' => $categories, + 'recent_history' => $recentHistory, + 'history_total' => $historyTotal, ]); } diff --git a/symfony/src/Service/Media/Usenet/NzbgetClient.php b/symfony/src/Service/Media/Usenet/NzbgetClient.php index eece5d3a..09260fbf 100644 --- a/symfony/src/Service/Media/Usenet/NzbgetClient.php +++ b/symfony/src/Service/Media/Usenet/NzbgetClient.php @@ -285,6 +285,10 @@ private function normalizeHistory(array $h): UsenetDownload speedBytes: 0, failMessage: $status === UsenetStatus::FAILED && $raw !== '' ? $raw : null, isHistory: true, + // NZBGet's history entries carry HistoryTime — the unix epoch the + // job landed in history, i.e. when it finished. Absent on some entry + // kinds (DUP/URL stubs), so 0 / missing stays null. + completedAt: ((int) ($h['HistoryTime'] ?? 0)) ?: null, ); } diff --git a/symfony/src/Service/Media/Usenet/SabnzbdClient.php b/symfony/src/Service/Media/Usenet/SabnzbdClient.php index 933bedec..274f62ea 100644 --- a/symfony/src/Service/Media/Usenet/SabnzbdClient.php +++ b/symfony/src/Service/Media/Usenet/SabnzbdClient.php @@ -280,6 +280,10 @@ private function normalizeHistorySlot(array $s): UsenetDownload speedBytes: 0, failMessage: $fail !== '' ? $fail : null, isHistory: true, + // SABnzbd stamps the finish time as a unix epoch in `completed`; + // 0 / absent means "unknown", which must stay null so the UI never + // renders a 1970 date. + completedAt: ((int) ($s['completed'] ?? 0)) ?: null, ); } diff --git a/symfony/src/Service/Media/Usenet/UsenetDownload.php b/symfony/src/Service/Media/Usenet/UsenetDownload.php index 93ea2168..2907557e 100644 --- a/symfony/src/Service/Media/Usenet/UsenetDownload.php +++ b/symfony/src/Service/Media/Usenet/UsenetDownload.php @@ -32,5 +32,10 @@ public function __construct( public bool $isHistory, /** Retry countdown (seconds) while FETCHING an NZB from a URL, else null. */ public ?int $waitSeconds = null, + /** + * Unix epoch the job finished, for history entries only — null when the + * downloader doesn't report one (and always null for queue slots). + */ + public ?int $completedAt = null, ) {} } diff --git a/symfony/templates/usenet/_history_rows.html.twig b/symfony/templates/usenet/_history_rows.html.twig new file mode 100644 index 00000000..188f7442 --- /dev/null +++ b/symfony/templates/usenet/_history_rows.html.twig @@ -0,0 +1,34 @@ +{# Usenet history rows — shared by the paginated history page and the "Recent + history" preview on the downloads page (#47). + + Context: `items` (UsenetDownload[], history entries) and `client` + (sabnzbd|nzbget — only used to translate NZBGet's raw failure codes). + + Markup only: the .uh-* CSS lives in _history_styles.html.twig, which both + including pages pull into their {% block stylesheets %}. #} +{% import '_icons.html.twig' as ico %} +{% for item in items %} + {% set fail = item.failMessage %} + {% if fail and client == 'nzbget' %} + {% set _k = 'usenet.nzbget_fail.' ~ (item.rawStatus|split('/')|last|lower) %} + {% set _t = _k|trans %} + {% set fail = _t != _k ? _t : fail %} + {% endif %} +
+
+ {% if item.status == 'failed' %}{{ ico.icon('alert-triangle', '', 15) }} + {% elseif item.status == 'completed' %}{{ ico.icon('check', '', 15) }} + {% else %}{{ ico.icon('clock', '', 15) }}{% endif %} +
+
+
{{ item.name }}{% if item.category %}{{ item.category }}{% endif %}
+
{{ item.sizeBytes|prismarr_bytes }}{% if fail %} · {{ fail }}{% endif %}
+
+
+ {{ ('usenet.status.' ~ item.status)|trans }} + {# Downloaders don't always stamp a finish time (older SABnzbd, NZBGet + URL/DUP stubs) — keep the column aligned with an em dash. #} +
{{ item.completedAt ? item.completedAt|relative_date : '—' }}
+
+
+{% endfor %} diff --git a/symfony/templates/usenet/_history_styles.html.twig b/symfony/templates/usenet/_history_styles.html.twig new file mode 100644 index 00000000..bde45c4c --- /dev/null +++ b/symfony/templates/usenet/_history_styles.html.twig @@ -0,0 +1,29 @@ +{# Styling for the shared usenet history rows (_history_rows.html.twig). + Included from the {% block stylesheets %} of BOTH pages that render those + rows — the paginated history page and the downloads page's "Recent history" + preview — so the rules have a single home and neither page duplicates them. + Same idiom as dashboard/_plex_styles.html.twig. #} + diff --git a/symfony/templates/usenet/history.html.twig b/symfony/templates/usenet/history.html.twig index 72a33f02..32a743f5 100644 --- a/symfony/templates/usenet/history.html.twig +++ b/symfony/templates/usenet/history.html.twig @@ -4,28 +4,7 @@ {% block page_title %} {{ client_label }} — {{ 'usenet.history.title'|trans }}{% endblock %} {% block stylesheets %} - +{% include 'usenet/_history_styles.html.twig' %} {% endblock %} {% block body %} @@ -42,26 +21,7 @@
{{ 'usenet.history.empty'|trans }}
{% else %}
- {% for item in items %} - {% set fail = item.failMessage %} - {% if fail and client == 'nzbget' %} - {% set _k = 'usenet.nzbget_fail.' ~ (item.rawStatus|split('/')|last|lower) %} - {% set _t = _k|trans %} - {% set fail = _t != _k ? _t : fail %} - {% endif %} -
-
- {% if item.status == 'failed' %}{{ ico.icon('alert-triangle', '', 15) }} - {% elseif item.status == 'completed' %}{{ ico.icon('check', '', 15) }} - {% else %}{{ ico.icon('clock', '', 15) }}{% endif %} -
-
-
{{ item.name }}{% if item.category %}{{ item.category }}{% endif %}
-
{{ item.sizeBytes|prismarr_bytes }}{% if fail %} · {{ fail }}{% endif %}
-
-
{{ ('usenet.status.' ~ item.status)|trans }}
-
- {% endfor %} + {% include 'usenet/_history_rows.html.twig' with {items: items, client: client} only %}
{% if total_pages > 1 %} diff --git a/symfony/templates/usenet/index.html.twig b/symfony/templates/usenet/index.html.twig index 47dc376e..670fcccc 100644 --- a/symfony/templates/usenet/index.html.twig +++ b/symfony/templates/usenet/index.html.twig @@ -167,6 +167,8 @@ body[data-bs-theme="dark"] .usenet-view-switcher { background:rgba(255,255,255,. .usenet-prop-label { font-size:.66rem; color:var(--tblr-text-secondary); text-transform:uppercase; letter-spacing:.3px; font-weight:600; margin-bottom:3px; } .usenet-prop-value { font-size:.84rem; font-weight:600; color:var(--tblr-body-color); word-break:break-word; } +{# .uh-* rules for the "Recent history" rows — shared with the history page. #} +{% include 'usenet/_history_styles.html.twig' %} {% endblock %} {% block body %} @@ -304,6 +306,28 @@ body[data-bs-theme="dark"] .usenet-view-switcher { background:rgba(255,255,255,.
+ {# ─── Recent history (#47) ────────────────────────────────────── #} + {# Server-rendered at page load (no poller): what just finished is the + question the queue can't answer once a job leaves it. #} + {% if recent_history is not empty %} +
+
+

{{ 'usenet.history.recent_title'|trans }}

+ +
+
+
+ {% include 'usenet/_history_rows.html.twig' with {items: recent_history, client: client} only %} +
+
+
+ {% endif %} + {# ─── Bulk action bar ─────────────────────────────────────────── #}
diff --git a/symfony/tests/Controller/UsenetControllerTest.php b/symfony/tests/Controller/UsenetControllerTest.php index 09be0848..e11299fa 100644 --- a/symfony/tests/Controller/UsenetControllerTest.php +++ b/symfony/tests/Controller/UsenetControllerTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Controller; +use App\Controller\UsenetController; use App\Entity\Setting; use App\Service\HealthService; use App\Service\Media\Usenet\SabnzbdClient; @@ -10,6 +11,7 @@ use App\Tests\AbstractWebTestCase; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; +use Symfony\Contracts\Cache\CacheInterface; /** * #20 — a configured-but-unreachable Usenet client must show an explicit @@ -22,6 +24,19 @@ #[AllowMockObjectsWithoutExpectations] class UsenetControllerTest extends AbstractWebTestCase { + protected function setUp(): void + { + parent::setUp(); + + // The recent-history preview is cached in the app pool (filesystem in + // the test env), which outlives a single test — drop both clients' keys + // so no test inherits another's cached history. + $cache = static::getContainer()->get(CacheInterface::class); + foreach (['sabnzbd', 'nzbget'] as $kind) { + $cache->delete(UsenetController::recentHistoryCacheKey($kind)); + } + } + public function testUnreachableSabnzbdShowsErrorBanner(): void { $em = $this->em(); @@ -98,6 +113,133 @@ public function testUnconfiguredClientRedirectsHome(): void $this->assertTrue($this->client->getResponse()->isRedirect()); } + // ── Recent history on the downloads page (#47) ──────────────────────────── + + public function testDownloadsPageRendersRecentHistory(): void + { + $sab = $this->configureSabnzbd(); + $this->mockHealthy(); + // Pin the fetch window: 15 rows from offset 0. That limit governs the + // per-render cost, especially on NZBGet (no upstream paging). + $sab->expects($this->once())->method('getHistoryPage')->with(0, 15)->willReturn([ + 'items' => [ + $this->historyItem('Recent.One', UsenetStatus::COMPLETED, 1755800000), + $this->historyItem('Recent.Two', UsenetStatus::FAILED), + ], + 'total' => 42, + ]); + + $this->client->request('GET', '/usenet/sabnzbd'); + $html = (string) $this->client->getResponse()->getContent(); + + $this->assertSame(200, $this->client->getResponse()->getStatusCode()); + // The section, its rows and the "view all" link (with the grand total) + // must all be server-rendered — no poller fills this in. + $this->assertStringContainsString('Recent history', $html); + $this->assertStringContainsString('Recent.One', $html); + $this->assertStringContainsString('Recent.Two', $html); + $this->assertStringContainsString('class="uh-row" data-status="completed"', $html); + $this->assertStringContainsString('View all (42)', $html); + $this->assertStringContainsString('/usenet/sabnzbd/history', $html); + // Two age cells; exactly one falls back to the em dash, so the dated + // row really rendered a relative label (locale-independent assertion). + $this->assertSame(2, substr_count($html, 'class="uh-age"')); + $this->assertSame(1, substr_count($html, 'class="uh-age">—
')); + } + + public function testDownloadsPageSurvivesHistoryFailure(): void + { + // A history call that blows up must not take the whole page with it — + // the queue still renders and the section is simply absent. + $sab = $this->configureSabnzbd(); + $this->mockHealthy(); + $sab->method('getHistoryPage')->willThrowException(new \RuntimeException('boom')); + + $this->client->request('GET', '/usenet/sabnzbd'); + $html = (string) $this->client->getResponse()->getContent(); + + $this->assertSame(200, $this->client->getResponse()->getStatusCode()); + $this->assertStringNotContainsString('Recent history', $html); + // Match the markup, not the bare class name — the .uh-* stylesheet is + // included unconditionally, so "uh-row" also appears in the CSS. + $this->assertStringNotContainsString('class="uh-row"', $html); + $this->assertStringContainsString('data-stat="active"', $html); + } + + public function testUnreachableClientSkipsHistoryFetch(): void + { + // The probe already failed → the page shows its banner; a doomed + // history call would only burn the connect timeout. + $sab = $this->configureSabnzbd(); + $sab->expects($this->never())->method('getHistoryPage'); + + $this->client->request('GET', '/usenet/sabnzbd'); + + $this->assertSame(200, $this->client->getResponse()->getStatusCode()); + $this->assertStringNotContainsString('Recent history', (string) $this->client->getResponse()->getContent()); + } + + public function testRecentHistoryIsFetchedOncePerCacheWindow(): void + { + // NZBGet's history RPC has no upstream paging — getHistoryPage() pulls + // the WHOLE retained history and slices locally. Without a short cache + // that unbounded payload would cross the wire on every page render, so + // two renders inside the TTL must cost exactly one client call. + $this->client->disableReboot(); // keep the mocks + cache pool across both renders + $sab = $this->configureSabnzbd(); + $this->mockHealthy(); + $sab->expects($this->once())->method('getHistoryPage')->with(0, 15)->willReturn([ + 'items' => [$this->historyItem('Cached.Release', UsenetStatus::COMPLETED, 1755800000)], + 'total' => 7, + ]); + + $this->client->request('GET', '/usenet/sabnzbd'); + $first = (string) $this->client->getResponse()->getContent(); + $this->client->request('GET', '/usenet/sabnzbd'); + $second = (string) $this->client->getResponse()->getContent(); + + // Both renders show the rows — the second one out of the cache, which + // also proves the UsenetDownload DTOs survive a round-trip through the + // pool. + $this->assertStringContainsString('Cached.Release', $first); + $this->assertStringContainsString('Cached.Release', $second); + $this->assertStringContainsString('View all (7)', $second); + } + + public function testFailedHistoryFetchIsNotCached(): void + { + // A transient failure must not be pinned for the whole TTL: the next + // render retries (mirrors MediaLibraryCache's "empty is not cached"). + $this->client->disableReboot(); + $sab = $this->configureSabnzbd(); + $this->mockHealthy(); + $calls = 0; + $sab->method('getHistoryPage')->willReturnCallback(function () use (&$calls) { + if (++$calls === 1) { + throw new \RuntimeException('boom'); + } + return ['items' => [$this->historyItem('Retried.Release', UsenetStatus::COMPLETED)], 'total' => 1]; + }); + + $this->client->request('GET', '/usenet/sabnzbd'); + $first = (string) $this->client->getResponse()->getContent(); + $this->client->request('GET', '/usenet/sabnzbd'); + $second = (string) $this->client->getResponse()->getContent(); + + $this->assertStringNotContainsString('Recent history', $first); + $this->assertStringContainsString('Retried.Release', $second); + $this->assertSame(2, $calls); + } + + /** Make the render-time probe report a healthy client. */ + private function mockHealthy(): void + { + $health = $this->createMock(HealthService::class); + $health->method('isConfigured')->willReturn(true); + $health->method('diagnose')->willReturn(['ok' => true, 'category' => 'ok', 'http' => 200]); + static::getContainer()->set(HealthService::class, $health); + } + // ── History page ───────────────────────────────────────────────────────── public function testHistoryPageRendersItems(): void @@ -137,12 +279,13 @@ public function testHistoryPageUnconfiguredRedirects(): void $this->assertTrue($this->client->getResponse()->isRedirect()); } - private function historyItem(string $name, string $status): UsenetDownload + private function historyItem(string $name, string $status, ?int $completedAt = null): UsenetDownload { return new UsenetDownload( id: 'x', name: $name, status: $status, rawStatus: 'Completed', sizeBytes: 1048576, remainingBytes: 0, percentage: 100.0, category: 'movies', etaSeconds: null, speedBytes: 0, failMessage: null, isHistory: true, + completedAt: $completedAt, ); } diff --git a/symfony/tests/Service/Media/Usenet/NzbgetClientTest.php b/symfony/tests/Service/Media/Usenet/NzbgetClientTest.php index c68f7991..64bf1034 100644 --- a/symfony/tests/Service/Media/Usenet/NzbgetClientTest.php +++ b/symfony/tests/Service/Media/Usenet/NzbgetClientTest.php @@ -127,6 +127,34 @@ public function testNormalizeHistorySuccessIsHundredPercentNoMessage(): void self::assertNull($d->failMessage); } + public function testNormalizeHistoryExposesHistoryTimeAsCompletedAt(): void + { + // NZBGet stamps each history entry with HistoryTime (unix epoch) — the + // moment it landed in history, i.e. when the job finished (#47). + /** @var UsenetDownload $d */ + $d = $this->call('normalizeHistory', [ + 'NZBID' => 9, + 'Name' => 'Timed.Release', + 'Status' => 'SUCCESS/ALL', + 'FileSizeMB' => 10, + 'HistoryTime' => 1755800000, + ]); + + self::assertSame(1755800000, $d->completedAt); + } + + public function testNormalizeHistoryWithoutHistoryTimeIsNull(): void + { + /** @var UsenetDownload $d */ + $d = $this->call('normalizeHistory', [ + 'NZBID' => 10, + 'Name' => 'Undated.Release', + 'Status' => 'SUCCESS/ALL', + ]); + + self::assertNull($d->completedAt); + } + public function testGetKind(): void { self::assertSame('nzbget', $this->makeClient()->getKind()); diff --git a/symfony/tests/Service/Media/Usenet/SabnzbdClientTest.php b/symfony/tests/Service/Media/Usenet/SabnzbdClientTest.php index c41841f3..84d42d7e 100644 --- a/symfony/tests/Service/Media/Usenet/SabnzbdClientTest.php +++ b/symfony/tests/Service/Media/Usenet/SabnzbdClientTest.php @@ -98,6 +98,38 @@ public function testCompletedHistoryReportsFullPercentageAndNoFailMessage(): voi self::assertNull($d->failMessage); } + public function testHistorySlotExposesCompletionTimestamp(): void + { + // SABnzbd stamps each history slot with `completed` (unix epoch); the + // downloads page renders it as a relative "age" label (#47). + /** @var UsenetDownload $d */ + $d = $this->call('normalizeHistorySlot', [ + 'nzo_id' => 'x', + 'name' => 'n', + 'status' => 'Completed', + 'bytes' => 100, + 'completed' => 1755800000, + ]); + + self::assertSame(1755800000, $d->completedAt); + } + + public function testHistorySlotWithoutCompletionTimestampIsNull(): void + { + // An older SABnzbd (or a partial slot) may omit `completed` — the field + // must read null, never 0, so the UI can fall back instead of printing + // "1970". + /** @var UsenetDownload $d */ + $d = $this->call('normalizeHistorySlot', [ + 'nzo_id' => 'x', + 'name' => 'n', + 'status' => 'Completed', + 'bytes' => 100, + ]); + + self::assertNull($d->completedAt); + } + /** @return array */ public static function clockProvider(): array { diff --git a/symfony/translations/messages+intl-icu.en.yaml b/symfony/translations/messages+intl-icu.en.yaml index 992510a6..01f9446a 100644 --- a/symfony/translations/messages+intl-icu.en.yaml +++ b/symfony/translations/messages+intl-icu.en.yaml @@ -4302,6 +4302,8 @@ usenet: prev: Previous next: Next page_of: 'Page {current} / {total}' + recent_title: 'Recent history' + view_all: 'View all ({count})' empty: 'The queue is empty.' toolbar: pause_all: Pause all diff --git a/symfony/translations/messages+intl-icu.fr.yaml b/symfony/translations/messages+intl-icu.fr.yaml index c4414b27..de7b9b31 100644 --- a/symfony/translations/messages+intl-icu.fr.yaml +++ b/symfony/translations/messages+intl-icu.fr.yaml @@ -4300,6 +4300,8 @@ usenet: prev: Précédent next: Suivant page_of: 'Page {current} / {total}' + recent_title: 'Historique récent' + view_all: 'Tout voir ({count})' empty: 'La file est vide.' toolbar: pause_all: Tout mettre en pause