Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 65 additions & 6 deletions symfony/src/Controller/UsenetController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -31,13 +33,34 @@
#[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,
private readonly HealthService $health,
private readonly ConfigService $config,
private readonly LoggerInterface $logger,
private readonly TranslatorInterface $translator,
private readonly CacheInterface $cache,
) {}

private function client(string $kind): UsenetClientInterface
Expand Down Expand Up @@ -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,
]);
}

Expand Down
4 changes: 4 additions & 0 deletions symfony/src/Service/Media/Usenet/NzbgetClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

Expand Down
4 changes: 4 additions & 0 deletions symfony/src/Service/Media/Usenet/SabnzbdClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

Expand Down
5 changes: 5 additions & 0 deletions symfony/src/Service/Media/Usenet/UsenetDownload.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {}
}
34 changes: 34 additions & 0 deletions symfony/templates/usenet/_history_rows.html.twig
Original file line number Diff line number Diff line change
@@ -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 %}
<div class="uh-row" data-status="{{ item.status }}">
<div class="uh-ico">
{% if item.status == 'failed' %}{{ ico.icon('alert-triangle', '', 15) }}
{% elseif item.status == 'completed' %}{{ ico.icon('check', '', 15) }}
{% else %}{{ ico.icon('clock', '', 15) }}{% endif %}
</div>
<div style="min-width:0;">
<div class="uh-name" title="{{ item.name }}">{{ item.name }}{% if item.category %}<span class="uh-tag">{{ item.category }}</span>{% endif %}</div>
<div class="uh-meta">{{ item.sizeBytes|prismarr_bytes }}{% if fail %} · <span class="uh-fail">{{ fail }}</span>{% endif %}</div>
</div>
<div class="uh-side">
<span class="uh-pill">{{ ('usenet.status.' ~ item.status)|trans }}</span>
{# Downloaders don't always stamp a finish time (older SABnzbd, NZBGet
URL/DUP stubs) — keep the column aligned with an em dash. #}
<div class="uh-age">{{ item.completedAt ? item.completedAt|relative_date : '—' }}</div>
</div>
</div>
{% endfor %}
29 changes: 29 additions & 0 deletions symfony/templates/usenet/_history_styles.html.twig
Original file line number Diff line number Diff line change
@@ -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. #}
<style>
.uh-list { display:flex; flex-direction:column; gap:.3rem; }
.uh-row {
display:grid; grid-template-columns:30px 1fr auto; gap:.7rem; align-items:center;
padding:.5rem .8rem; background:var(--tblr-bg-surface);
border:1px solid var(--tblr-border-color); border-left:3px solid var(--tblr-border-color); border-radius:6px;
}
.uh-row[data-status="completed"] { border-left-color:#22c55e; }
.uh-row[data-status="failed"] { border-left-color:#ef4444; }
.uh-ico { width:30px; height:30px; display:flex; align-items:center; justify-content:center; border-radius:7px; background:var(--tblr-bg-surface-secondary); color:var(--tblr-text-secondary); }
.uh-ico svg { width:15px; height:15px; }
.uh-row[data-status="completed"] .uh-ico { color:#22c55e; }
.uh-row[data-status="failed"] .uh-ico { color:#ef4444; }
.uh-name { font-size:.8rem; font-weight:600; color:var(--tblr-body-color); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.uh-meta { font-size:.7rem; color:var(--tblr-text-secondary); margin-top:2px; }
.uh-fail { color:#ef4444; }
.uh-tag { display:inline-block; font-size:.6rem; padding:1px 6px; border-radius:3px; font-weight:600; text-transform:uppercase; letter-spacing:.3px; background:rgba(var(--tblr-primary-rgb),.14); color:var(--tblr-primary); margin-left:.4rem; }
.uh-side { text-align:right; font-size:.72rem; color:var(--tblr-text-secondary); white-space:nowrap; }
.uh-pill { display:inline-block; font-size:.6rem; font-weight:700; padding:1px 7px; border-radius:3px; text-transform:uppercase; letter-spacing:.3px; background:var(--tblr-bg-surface-secondary); }
.uh-row[data-status="completed"] .uh-pill { background:rgba(34,197,94,.15); color:#16a34a; }
.uh-row[data-status="failed"] .uh-pill { background:rgba(239,68,68,.15); color:#dc2626; }
/* .7rem = 11.2px — stays above PRODUCT.md's 11px mobile readability floor. */
.uh-age { font-size:.7rem; color:var(--tblr-text-secondary); margin-top:2px; }
</style>
44 changes: 2 additions & 42 deletions symfony/templates/usenet/history.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,7 @@
{% block page_title %}<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="{{ client == 'nzbget' ? '#1f9c3a' : '#ffa000' }}" stroke-width="2"><path d="M12 8v4l3 3"/><path d="M3.05 11a9 9 0 1 1 .5 4"/><path d="M3 4v5h5"/></svg> {{ client_label }} — {{ 'usenet.history.title'|trans }}{% endblock %}

{% block stylesheets %}
<style>
.uh-list { display:flex; flex-direction:column; gap:.3rem; }
.uh-row {
display:grid; grid-template-columns:30px 1fr auto; gap:.7rem; align-items:center;
padding:.5rem .8rem; background:var(--tblr-bg-surface);
border:1px solid var(--tblr-border-color); border-left:3px solid var(--tblr-border-color); border-radius:6px;
}
.uh-row[data-status="completed"] { border-left-color:#22c55e; }
.uh-row[data-status="failed"] { border-left-color:#ef4444; }
.uh-ico { width:30px; height:30px; display:flex; align-items:center; justify-content:center; border-radius:7px; background:var(--tblr-bg-surface-secondary); color:var(--tblr-text-secondary); }
.uh-ico svg { width:15px; height:15px; }
.uh-row[data-status="completed"] .uh-ico { color:#22c55e; }
.uh-row[data-status="failed"] .uh-ico { color:#ef4444; }
.uh-name { font-size:.8rem; font-weight:600; color:var(--tblr-body-color); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.uh-meta { font-size:.7rem; color:var(--tblr-text-secondary); margin-top:2px; }
.uh-fail { color:#ef4444; }
.uh-tag { display:inline-block; font-size:.6rem; padding:1px 6px; border-radius:3px; font-weight:600; text-transform:uppercase; letter-spacing:.3px; background:rgba(var(--tblr-primary-rgb),.14); color:var(--tblr-primary); margin-left:.4rem; }
.uh-side { text-align:right; font-size:.72rem; color:var(--tblr-text-secondary); white-space:nowrap; }
.uh-pill { display:inline-block; font-size:.6rem; font-weight:700; padding:1px 7px; border-radius:3px; text-transform:uppercase; letter-spacing:.3px; background:var(--tblr-bg-surface-secondary); }
.uh-row[data-status="completed"] .uh-pill { background:rgba(34,197,94,.15); color:#16a34a; }
.uh-row[data-status="failed"] .uh-pill { background:rgba(239,68,68,.15); color:#dc2626; }
</style>
{% include 'usenet/_history_styles.html.twig' %}
{% endblock %}

{% block body %}
Expand All @@ -42,26 +21,7 @@
<div class="card"><div class="card-body text-center text-muted py-5">{{ 'usenet.history.empty'|trans }}</div></div>
{% else %}
<div class="uh-list">
{% 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 %}
<div class="uh-row" data-status="{{ item.status }}">
<div class="uh-ico">
{% if item.status == 'failed' %}{{ ico.icon('alert-triangle', '', 15) }}
{% elseif item.status == 'completed' %}{{ ico.icon('check', '', 15) }}
{% else %}{{ ico.icon('clock', '', 15) }}{% endif %}
</div>
<div style="min-width:0;">
<div class="uh-name" title="{{ item.name }}">{{ item.name }}{% if item.category %}<span class="uh-tag">{{ item.category }}</span>{% endif %}</div>
<div class="uh-meta">{{ item.sizeBytes|prismarr_bytes }}{% if fail %} · <span class="uh-fail">{{ fail }}</span>{% endif %}</div>
</div>
<div class="uh-side"><span class="uh-pill">{{ ('usenet.status.' ~ item.status)|trans }}</span></div>
</div>
{% endfor %}
{% include 'usenet/_history_rows.html.twig' with {items: items, client: client} only %}
</div>

{% if total_pages > 1 %}
Expand Down
24 changes: 24 additions & 0 deletions symfony/templates/usenet/index.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
</style>
{# .uh-* rules for the "Recent history" rows — shared with the history page. #}
{% include 'usenet/_history_styles.html.twig' %}
{% endblock %}

{% block body %}
Expand Down Expand Up @@ -304,6 +306,28 @@ body[data-bs-theme="dark"] .usenet-view-switcher { background:rgba(255,255,255,.
<div class="usenet-list" data-usenet-queue data-view="list"></div>
<div class="usenet-empty text-muted small text-center py-3" data-usenet-queue-empty hidden>{{ 'usenet.empty'|trans }}</div>

{# ─── 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 %}
<div class="card mt-3">
<div class="card-header py-2">
<h3 class="card-title" style="font-size:.82rem;">{{ 'usenet.history.recent_title'|trans }}</h3>
<div class="card-actions">
<a href="{{ path('app_usenet_history', {client: client}) }}" class="btn btn-sm btn-outline-secondary d-inline-flex align-items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 8v4l3 3"/><path d="M3.05 11a9 9 0 1 1 .5 4"/><path d="M3 4v5h5"/></svg>
{{ 'usenet.history.view_all'|trans({count: history_total}) }}
</a>
</div>
</div>
<div class="card-body py-2">
<div class="uh-list">
{% include 'usenet/_history_rows.html.twig' with {items: recent_history, client: client} only %}
</div>
</div>
</div>
{% endif %}

{# ─── Bulk action bar ─────────────────────────────────────────── #}
<div class="usenet-bulk-bar" data-usenet-bulk-bar>
<span class="fw-bold" data-usenet-bulk-count></span>
Expand Down
Loading