+{% 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 %}
-
- {% 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,.
{{ 'usenet.empty'|trans }}
+ {# ─── 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 %}
+
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