diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..08a11599 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Container control files are read inside a Linux image. A CRLF here makes +# s6-rc-compile reject the service ("type must be oneshot, longrun, or bundle") +# and breaks shell shebang parsing, so a `docker build` from a Windows checkout +# (core.autocrlf=true) produces an unbootable image. Pin them to LF regardless +# of the contributor's git settings. +docker/** text eol=lf +*.sh text eol=lf diff --git a/.github/workflows/ghcr.yml b/.github/workflows/ghcr.yml new file mode 100644 index 00000000..54fc7129 --- /dev/null +++ b/.github/workflows/ghcr.yml @@ -0,0 +1,78 @@ +name: GHCR + +# Build the FrankenPHP image and publish it to the GitHub Container Registry +# (ghcr.io//prismarr) for self-hosted testing — e.g. on Unraid. Uses +# the built-in GITHUB_TOKEN, so no external registry credentials are needed +# (unlike the Docker Hub release/beta workflows). +# +# amd64 only: Unraid is x86_64 and a single-arch build keeps this fast. Add +# linux/arm64 back to `platforms` (with setup-qemu-action) if you need it. +# +# After the first successful run the package is created PRIVATE — make it +# public once at: +# https://github.com/users//packages/container/prismarr/settings +# so Unraid can pull ghcr.io//prismarr:latest without a login. + +on: + workflow_dispatch: + push: + branches: [main] + +jobs: + publish: + name: Build and push to GHCR (amd64) + runs-on: ubuntu-latest + timeout-minutes: 60 + + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Resolve lowercase image name + id: image + # GHCR requires the namespace to be lowercase. + run: echo "name=ghcr.io/$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')/prismarr" >> "$GITHUB_OUTPUT" + + - name: Resolve tags + version + id: meta + # A manual dispatch from a NON-main branch publishes only the :beta tag + # for self-hosted testing (e.g. Unraid), leaving the stable :latest + # untouched. Pushes to main (or a dispatch on main) keep publishing + # :latest + the commit-sha tag as before. + run: | + IMG="${{ steps.image.outputs.name }}" + if [ "${{ github.ref_name }}" = "main" ]; then + echo "tags=${IMG}:latest,${IMG}:${{ github.sha }}" >> "$GITHUB_OUTPUT" + echo "version=1.1.0-tautulli" >> "$GITHUB_OUTPUT" + else + echo "tags=${IMG}:beta" >> "$GITHUB_OUTPUT" + echo "version=beta-${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + file: docker/frankenphp/Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + build-args: | + PRISMARR_VERSION=${{ steps.meta.outputs.version }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false diff --git a/.gitignore b/.gitignore index 566f56a5..c8dc9fbe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Brainstorming companion scratch (superpowers visual companion) +.superpowers/ + # Symfony symfony/vendor/ symfony/var/ @@ -37,3 +40,8 @@ CLAUDE.md AGENTS.md REDDIT_POST.md REDDIT_POSTS.md +# Superpowers brainstorming specs + implementation plans (internal scaffolding) +docs/superpowers/ + +# Local screenshots dropped in the repo root (not part of the project) +/*.png diff --git a/CHANGELOG.md b/CHANGELOG.md index e385d916..6e276b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,20 +8,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **A torrent page kept polling — and overwriting — after you navigated to a different torrent client.** Each of the qBittorrent, Deluge and Transmission pages refreshes on a 3-second `setInterval`, but the timer handle lived in the page script's own closure. Turbo Drive re-executes the incoming page's ` diff --git a/symfony/templates/_stat_tiles.html.twig b/symfony/templates/_stat_tiles.html.twig new file mode 100644 index 00000000..8acc3189 --- /dev/null +++ b/symfony/templates/_stat_tiles.html.twig @@ -0,0 +1,32 @@ +{# Shared header stat-tiles. Params: + tiles : list of { value, label, hue?, unit?, id?, data_stat? } + hue = a Tabler token ('success','warning','blue','green','cyan', + 'orange','secondary','danger') applied as .text-, OR a raw + color ('#hex' / 'var(--…)' / 'rgb(…)') applied inline. value + + label are already resolved/translated by the caller. + layout : 'strip' (one card, inline value+label row) | 'grid' (one card per tile) + wrap : bool (default true) — wrap in the outer .card (strip) / .row (grid) + cols : grid column classes (default 'col-6 col-sm-4 col-lg') [grid only] + row_class : outer row classes (default 'row g-2 mb-3') [grid only] #} +{% macro _val(t) %} + {%- set raw = t.hue is defined and t.hue is not null and (t.hue starts with '#' or t.hue starts with 'var(' or t.hue starts with 'rgb') -%} +
{{ t.value|raw }}{% if t.unit is defined and t.unit %} {{ t.unit }}{% endif %}
+{% endmacro %} +{% import _self as st %} +{% if layout|default('grid') == 'strip' %} +
+ {% for t in tiles %} +
{{ st._val(t) }}{{ t.label }}
+ {% endfor %} +
+{% else %} +
+ {% for t in tiles %} +
+
+ {{ st._val(t) }}
{{ t.label }}
+
+
+ {% endfor %} +
+{% endif %} diff --git a/symfony/templates/_view_switcher.html.twig b/symfony/templates/_view_switcher.html.twig new file mode 100644 index 00000000..722756dd --- /dev/null +++ b/symfony/templates/_view_switcher.html.twig @@ -0,0 +1,14 @@ +{# Shared segmented view-switcher for the download-client list pages + (deluge/qbittorrent/usenet). Params: + modes : list of { view: string, label: string, icon: string(raw SVG), title: string(optional) } + active : the currently-active view key + aria_label : group aria-label (already translated) + The page's own JS wires the clicks (delegated on .view-btn) and persists + the choice; this partial only renders consistent markup. #} +
+ {% for m in modes %} + + {% endfor %} +
diff --git a/symfony/templates/admin/_instance_card.html.twig b/symfony/templates/admin/_instance_card.html.twig index b193c331..3ef26e18 100644 --- a/symfony/templates/admin/_instance_card.html.twig +++ b/symfony/templates/admin/_instance_card.html.twig @@ -13,6 +13,7 @@ fetch() — see admin-instances.js — to bypass the nested-form CSRF issue and to keep the user on the same page (no full reload). #} +{% import '_icons.html.twig' as ico %}
@@ -36,23 +37,18 @@ {% set enabled_count = instances|filter(i => i.enabled)|length %} {% if instances is empty %} -
- - - - -

{{ 'admin.instances.empty.title'|trans({ '%service%': service_label }) }}

-

{{ 'admin.instances.empty.subtitle'|trans({ '%service%': service_label }) }}

- +
+
{{ ico.icon('folder-plus', '', 36) }}
+

{{ 'admin.instances.empty.title'|trans({ '%service%': service_label }) }}

+

{{ 'admin.instances.empty.subtitle'|trans({ '%service%': service_label }) }}

+
+ +
{% else %}
diff --git a/symfony/templates/admin/_instance_modals.html.twig b/symfony/templates/admin/_instance_modals.html.twig index ebcf6343..19061469 100644 --- a/symfony/templates/admin/_instance_modals.html.twig +++ b/symfony/templates/admin/_instance_modals.html.twig @@ -1,4 +1,5 @@ {% import '_icons.html.twig' as ico %} +{% import '_form.html.twig' as forms %} {# v1.1.0 Phase B1 — modales (add/edit) for the multi-instance card. MUST be rendered OUTSIDE the admin_settings_index main form because HTML doesn't allow nested forms (CSRF gets confused otherwise). @@ -36,19 +37,7 @@
-
- - -
+ {{ forms.secret_input('instance-add-key-' ~ service_id, 'api_key', { autocomplete: 'new-password' }) }}
@@ -94,20 +83,7 @@
-
- - -
+ {{ forms.secret_input('instance-edit-key-' ~ instance.id, 'api_key', { value: instance.apiKey ?? '', autocomplete: 'new-password' }) }}
diff --git a/symfony/templates/admin/settings.html.twig b/symfony/templates/admin/settings.html.twig index f0b2ca21..4e492b42 100644 --- a/symfony/templates/admin/settings.html.twig +++ b/symfony/templates/admin/settings.html.twig @@ -1,6 +1,7 @@ {% extends 'base.html.twig' %} {% import '_icons.html.twig' as ico %} +{% import '_form.html.twig' as forms %} {% block title %}{{ 'admin.page.title'|trans }}{% endblock %} @@ -123,21 +124,27 @@ .service-card + .service-card { margin-top: 1rem; } .service-card:focus-within { border-color: rgba(var(--tblr-primary-rgb),.55); } - /* Two-column layout for the service cards so the list doesn't grow into a - very tall single column. Collapses to one column under ~700px; the grid - gap replaces the stacked margin, and align-items:start keeps a short card - from stretching to match a tall neighbour. */ + /* Two-column layout for the service cards. CSS multi-column (not grid) so + cards pack tightly by height: grid allocates every row track to its + tallest card, leaving dead space beside/below a short card (Deluge, + Houndarr) and an empty column when the count is odd (NZBGet). Columns + flow cards into balanced stacks with no row tracks, so no gaps. + `columns: 2 320px` = at most 2 columns, each ≥320px → drops to one column + under ~660px, matching the old auto-fit responsive behaviour. */ .service-grid-2col { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - gap: 1rem; - align-items: start; + columns: 2 320px; + column-gap: 1rem; + } + .service-grid-2col .service-card { + margin-top: 0; + margin-bottom: 1rem; + /* Keep a card whole — never split one across a column break. */ + break-inside: avoid; } - .service-grid-2col .service-card { margin-top: 0; } /* Multi-instance cards (Radarr/Sonarr) hold a 6-column table that can't fit - a half-width column, so they span the full row; only the simple URL+key + a half-width column, so they span both columns; only the simple URL+key cards share the two columns. */ - .service-grid-2col .service-card--wide { grid-column: 1 / -1; } + .service-grid-2col .service-card--wide { column-span: all; } /* The 6-column instance table can't shrink to phone width, so let it scroll horizontally inside the card instead of overflowing the layout. */ .instance-list { overflow-x: auto; } @@ -194,22 +201,6 @@ margin-bottom: .3rem; } - .secret-wrapper { position: relative; } - .secret-wrapper input { - padding-right: 2.4rem; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; - font-size: .85rem; - } - .secret-toggle { - position: absolute; right: .55rem; top: 50%; transform: translateY(-50%); - background: transparent; border: 0; padding: 0; - cursor: pointer; color: inherit; opacity: .5; - display: inline-flex; align-items: center; - transition: opacity .15s ease; - } - .secret-toggle:hover { opacity: .95; } - .secret-toggle svg { width: 16px; height: 16px; } - /* "Clear" button next to qBit user/password (issue #10 — reverse-proxy setups need to deliberately wipe these credentials; the default empty-value guard would otherwise silently restore the previous value). */ @@ -324,6 +315,16 @@ border-color: currentColor; box-shadow: 0 0 0 2px var(--tblr-body-bg) inset, 0 2px 6px rgba(0,0,0,.25); } + .color-swatch-auto { + background: conic-gradient(from 0deg, #ef4444, #f59e0b, #22c55e, #3b82f6, #6366f1, #ec4899, #ef4444); + } + .dashboard-layout-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .4rem; } + .dashboard-layout-row { display: flex; align-items: center; gap: .65rem; padding: .55rem .7rem; + background: var(--tblr-bg-surface-secondary); border: 1px solid var(--tblr-border-color); + border-radius: 8px; cursor: grab; } + .dashboard-layout-row.dragging { opacity: .5; } + .dashboard-layout-handle { color: var(--tblr-secondary); display: inline-flex; } + .dashboard-layout-toggle { flex: 1; } {% endblock %} @@ -432,18 +433,23 @@ 'prowlarr': { icon_bg: '#e86100', subtitle: 'admin.services.subtitle.prowlarr'|trans }, '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 }, 'tautulli': { icon_bg: '#e5a00d', subtitle: 'admin.services.subtitle.tautulli'|trans }, + 'unraid': { icon_bg: '#f15a2c', subtitle: 'admin.services.subtitle.unraid'|trans }, + 'unifi': { icon_bg: '#006fff', subtitle: 'admin.services.subtitle.unifi'|trans }, + 'houndarr': { icon_bg: '#c2703d', subtitle: 'admin.services.subtitle.houndarr'|trans }, } %} {% set groupings = { ('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', 'gluetun', 'sabnzbd', 'nzbget'], - ('admin.services.group.monitoring'|trans): ['tautulli'], + ('admin.services.group.downloads'|trans): ['qbittorrent', 'deluge', 'transmission', 'gluetun', 'sabnzbd', 'nzbget'], + ('admin.services.group.monitoring'|trans): ['tautulli', 'unraid', 'unifi', 'houndarr'], } %} {# ─── Section 1 : Services externes ─────────────────────── #} @@ -484,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', 'sabnzbd', 'nzbget', 'tautulli'] %} + {% if service_id in ['tmdb', 'prowlarr', 'jellyseerr', 'qbittorrent', 'deluge', 'transmission', 'sabnzbd', 'nzbget', 'tautulli', 'unraid', 'unifi', 'houndarr'] %}
- {# Theme #} - - - - - - - {# Profile #} + {% include '_quicklook.html.twig' with {} only %} + {# Strings translated server-side and exposed to inline JS. Keep this block @@ -1391,16 +1509,18 @@ 'search.forget': 'topbar.search.forget'|trans, 'search.in_library_badge': 'topbar.search.in_library_badge'|trans, 'search.not_added_badge': 'topbar.search.not_added_badge'|trans, - 'theme.label_light': 'topbar.theme.label_light'|trans, - 'theme.label_dark': 'topbar.theme.label_dark'|trans, + 'search.add': 'topbar.search.add'|trans, + 'dashboard.quicklook.manage': 'dashboard.quicklook.manage'|trans, 'health.all_ok': 'topbar.health.summary_ok'|trans, 'health.loading': 'topbar.health.loading'|trans, 'health.unknown': 'topbar.health.summary_unknown'|trans, 'health.no_service': 'topbar.health.summary_no_service'|trans, 'health.summary_tpl': 'topbar.health.summary_running'|trans({'{ok}': '__OK__', '{total}': '__TOTAL__'}), - 'health.status_ok': 'topbar.health.status.ok'|trans, - 'health.status_down': 'topbar.health.status.down'|trans, - 'health.status_unk': 'topbar.health.status.not_configured'|trans, + 'health.status_up': 'dashboard.health.status_up'|trans, + 'health.status_slow': 'dashboard.health.status_slow'|trans, + 'health.status_very_slow': 'dashboard.health.status_very_slow'|trans, + 'health.status_degraded': 'dashboard.health.status_degraded'|trans, + 'health.status_down': 'dashboard.health.status_down'|trans, 'common.loading': 'common.status.loading'|trans, 'qa.add_radarr': 'quickadd.cta.add_radarr'|trans, 'qa.add_sonarr': 'quickadd.cta.add_sonarr'|trans, @@ -1423,6 +1543,7 @@ {% block javascripts %}{% endblock %} {% endif %} + {# ══════════════════════════════════════════════════════════════════════════ + Global Deluge poll — DL completion / error toasts + sidebar badge + Active only on media pages (avoids hitting Deluge everywhere). Also skipped + when Deluge is disabled (#15) — otherwise the poll endpoint would chase a + route-guard redirect every few seconds and turn `r.json()` into a JS + SyntaxError that the circuit breaker then backs off for two minutes. + ══════════════════════════════════════════════════════════════════════════ #} + {% set route = app.request.attributes.get('_route') ?? '' %} + {% if service_configured('deluge') and ( + route starts with 'app_deluge_' + or route starts with 'app_media' + or route starts with 'radarr_' + or route starts with 'sonarr_' + or route starts with 'prowlarr_' + or route starts with 'jellyseerr_' + or route starts with 'tmdb_') %} + + {% endif %} + + {# ══════════════════════════════════════════════════════════════════════════ + Global Transmission poll — DL completion / error toasts + sidebar badge + Active only on media pages (avoids hitting Transmission everywhere). Also + skipped when Transmission is disabled (#15) — otherwise the poll endpoint + would chase a route-guard redirect every few seconds and turn `r.json()` + into a JS SyntaxError that the circuit breaker then backs off for two + minutes. + ══════════════════════════════════════════════════════════════════════════ #} + {% set route = app.request.attributes.get('_route') ?? '' %} + {% if service_configured('transmission') and ( + route starts with 'app_transmission_' + or route starts with 'app_media' + or route starts with 'radarr_' + or route starts with 'sonarr_' + or route starts with 'prowlarr_' + or route starts with 'jellyseerr_' + or route starts with 'tmdb_') %} + + {% endif %} + {# ══════════════════════════════════════════════════════════════════════════ Jellyseerr pending-requests poll — sidebar badge. Unlike the qBit poll above (media pages only, to avoid hammering the NAS every 15s), this one @@ -2421,14 +2919,7 @@ window._prismarrSeerrFailCount = 0; function updateSeerrBadge(pending) { - var badge = document.getElementById('sidebar-seerr-badge'); - if (!badge) return; - if (pending > 0) { - badge.textContent = pending > 99 ? '99+' : String(pending); - badge.style.display = ''; - } else { - badge.style.display = 'none'; - } + window.prismarrDlBadge(document.getElementById('sidebar-seerr-badge'), pending); } function seerrNextDelay() { @@ -2543,10 +3034,7 @@ var fails = 0, timer = null; function update(active) { - var badge = document.getElementById(c.id); - if (!badge) return; - if (active > 0) { badge.textContent = active > 99 ? '99+' : String(active); badge.style.display = ''; } - else { badge.style.display = 'none'; } + window.prismarrDlBadge(document.getElementById(c.id), active); } function schedule() { if (timer) clearTimeout(timer); @@ -2571,77 +3059,52 @@ diff --git a/symfony/templates/dashboard/_plex_styles.html.twig b/symfony/templates/dashboard/_plex_styles.html.twig index 990a0d30..3fa47c03 100644 --- a/symfony/templates/dashboard/_plex_styles.html.twig +++ b/symfony/templates/dashboard/_plex_styles.html.twig @@ -16,9 +16,22 @@ .plex-session-title.plex-clickable:hover { color: var(--tblr-primary); } .plex-session-main { flex: 1 1 auto; min-width: 0; } .plex-session-title { font-size: .88rem; font-weight: 600; } - .plex-session-year { font-weight: 400; color: var(--tblr-secondary); } + /* The year span could overhang the session card edge by ~13px on narrow + cards (responsive audit 2026-06-29): cap it to the title's width so it + truncates with the title instead of overflowing. */ + .plex-session-year { + font-weight: 400; color: var(--tblr-secondary); + display: inline-block; max-width: 100%; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + vertical-align: bottom; + } .plex-session-meta { font-size: .75rem; color: var(--tblr-secondary); margin-top: .1rem; } .plex-badges { display: flex; flex-wrap: wrap; gap: .3rem; margin: .4rem 0 .35rem; } .plex-decisions { font-size: .68rem; margin-bottom: .35rem; } .progress.plex-progress { height: 5px; background: var(--tblr-bg-surface-secondary); } + /* Mobile/tablet readability floor (responsive audit 2026-06-29) — see + base.html.twig; floors sub-11px text at 11px below the lg breakpoint. */ + @media (max-width: 991.98px) { + .plex-decisions { font-size: .6875rem; } + } diff --git a/symfony/templates/dashboard/_plex_summary.html.twig b/symfony/templates/dashboard/_plex_summary.html.twig index fe33fccd..8fe5dc48 100644 --- a/symfony/templates/dashboard/_plex_summary.html.twig +++ b/symfony/templates/dashboard/_plex_summary.html.twig @@ -3,11 +3,27 @@ dashboard widget (_plex_activity) and the Tautulli page (_now_playing). The .plex-summary CSS lives in dashboard/_plex_styles.html.twig (loaded on both). #} {% import '_icons.html.twig' as ico %} +{# Distinct current viewers, for the stacked avatar-list glance below. #} +{% set viewers = [] %} +{% for s in plex.sessions|default([]) %} + {% if s.userDisplayName and s.userDisplayName not in viewers %} + {% set viewers = viewers|merge([s.userDisplayName]) %} + {% endif %} +{% endfor %}
{{ plex.streamCount }}
{{ 'dashboard.plex.streams'|trans }}
+ {% if viewers is not empty %} +
+ {% for v in viewers|slice(0, 5) %} + {% set initials = v|split(' ')|map(w => w|first)|slice(0, 2)|join|upper %} + {{ initials }} + {% endfor %} + {% if viewers|length > 5 %}+{{ viewers|length - 5 }}{% endif %} +
+ {% endif %}
{% if plex.directPlayCount > 0 %}{{ 'dashboard.plex.summary.direct_play'|trans({count: plex.directPlayCount}) }}{% endif %} {% if plex.directStreamCount > 0 %}{{ 'dashboard.plex.summary.direct_stream'|trans({count: plex.directStreamCount}) }}{% endif %} diff --git a/symfony/templates/dashboard/_quicklook_body.html.twig b/symfony/templates/dashboard/_quicklook_body.html.twig index a72e6376..767738bc 100644 --- a/symfony/templates/dashboard/_quicklook_body.html.twig +++ b/symfony/templates/dashboard/_quicklook_body.html.twig @@ -22,8 +22,76 @@ {% if ql.metaLine %}{{ ql.metaLine }}{% endif %} {% for g in ql.genres %}{{ g }}{% endfor %}
+ {% if ql.airStatus|default(null) or ql.releaseDates|default([])|length %} +
+ {% if ql.airStatus|default(null) %} + {{ ('dashboard.quicklook.airstatus.' ~ ql.airStatus)|trans }} + {% endif %} + {% for d in ql.releaseDates|default([]) %} + + {{ d.label }} + {{ d.date|date('M j, Y') }} + + {% endfor %} +
+ {% endif %} {% if ql.overview %}

{{ ql.overview }}

{% endif %} - {{ ql.actionLabel }} → + + {% if ql.providers|default([])|length %} +
+ {% for p in ql.providers %} + {% if p.logo %}{{ p.name }}{% endif %} + {% endfor %} +
+ {% endif %} + + {% if ql.cast|default([])|length %} +
+ {% for c in ql.cast %} +
+ {% if c.profile %}{{ c.name }} + {% else %}
?
{% endif %} + {{ c.name }} +
+ {% endfor %} +
+ {% endif %} + + {% if ql.trailerKey|default(null) %} +
+
+ +
+
+ {% endif %} + +
+ {% if ql.inLibrary|default(false) or not ql.tmdbId|default(null) %} + {{ ql.actionLabel }} → + {% else %} + + {% endif %} + {% if ql.tmdbId|default(null) %} + + TMDb + {% endif %} + {% if ql.imdbId|default(null) %} + IMDb + {% endif %} +
diff --git a/symfony/templates/dashboard/_requests.html.twig b/symfony/templates/dashboard/_requests.html.twig index a50596de..493f4112 100644 --- a/symfony/templates/dashboard/_requests.html.twig +++ b/symfony/templates/dashboard/_requests.html.twig @@ -3,8 +3,9 @@ Returns the empty-state message when there are no pending requests. #} {% import '_icons.html.twig' as ico %} {% if jellyseerr_requests is empty %} -
- {{ 'dashboard.requests.empty'|trans }} +
+
{{ ico.icon('hourglass', '', 32) }}
+

{{ 'dashboard.requests.empty'|trans }}

{% else %} {% for req in jellyseerr_requests %} diff --git a/symfony/templates/dashboard/_server.html.twig b/symfony/templates/dashboard/_server.html.twig new file mode 100644 index 00000000..f8b0d796 --- /dev/null +++ b/symfony/templates/dashboard/_server.html.twig @@ -0,0 +1,166 @@ +{# Async-hydrated fragment — Unraid server overview (admin-only), rendered by + DashboardController::widgetServer into the card's [data-dash-body]. + `server` = UnraidClient::overview(): null → unreachable state; each group + renders only when the API returned it (partial schema/scope tolerated). + Layout: top row Array · System · Parity · UPS, then full-width Disks, + then full-width Docker container chip row. #} +{% macro dur(s) %}{% apply spaceless %} + {% set h = (s / 3600)|round(0, 'floor') %} + {% set m = ((s - h * 3600) / 60)|round(0, 'floor') %} + {% if h > 0 %}{{ h }}h {{ m }}m{% else %}{{ m }}m{% endif %} +{% endapply %}{% endmacro %} +{% if server is null %} +
{{ 'dashboard.server.unreachable'|trans }}
+{% else %} +
+ + {% if server.array is not null %} +
+
+ {{ 'dashboard.server.array'|trans }} + {# STARTED = green; STOPPED = neutral grey (a deliberately-stopped array + isn't an error); anything else (unmountable, etc.) = red. #} + {% set state_up = server.array.state|default('')|upper %} + {% set state_class = state_up starts with 'STARTED' ? 'is-ok' : (state_up starts with 'STOPPED' ? 'is-idle' : 'is-ko') %} + {{ server.array.state|default('—') }} +
+ {% if server.array.capacity.total %} + {% set pct = (server.array.capacity.used / server.array.capacity.total * 100)|round %} +
+
+
+
+ {{ (server.array.capacity.used / 1073741824)|round(1) }} TB / {{ (server.array.capacity.total / 1073741824)|round(1) }} TB · {{ pct }}% +
+ {% endif %} +
+ {% endif %} + + {% if server.system is not null %} +
+
+ {{ 'dashboard.server.system'|trans }} + {% if server.system.cpuBrand %}{{ server.system.cpuBrand }}{% endif %} +
+ {% if server.system.cpuPercent is not null %} +
+ {{ 'dashboard.server.cpu'|trans }} +
+ {{ server.system.cpuPercent|round }}% +
+ {% endif %} + {% if server.system.memPercent is not null %} +
+ {{ 'dashboard.server.ram'|trans }} +
+ {{ server.system.memPercent|round }}% +
+ {% endif %} + {% if server.system.uptimeEpoch %} + {# Boot time parsed to epoch by UnraidClient so the locale-aware + relative_date filter applies ("17 days ago" / "il y a 17 jours"). #} +
{{ 'dashboard.server.uptime'|trans }} · {{ server.system.uptimeEpoch|relative_date }}
+ {% elseif server.system.uptime %} + {# Unparseable boot timestamp — fall back to the raw string. #} +
{{ 'dashboard.server.uptime'|trans }} · {{ server.system.uptime }}
+ {% endif %} +
+ {% endif %} + + {% if server.array is not null or server.parity is not null %} +
+
+ {{ 'dashboard.server.parity'|trans }} + {% if server.parity is not null and server.parity.running %} + {{ 'dashboard.server.parity_checking'|trans }} + {% endif %} +
+ {% if server.parity is not null and server.parity.running %} + {% if server.parity.progress is not null %} +
+
+ {{ server.parity.progress }}% + {% if server.parity.elapsed is not null %} · {{ 'dashboard.server.elapsed'|trans }} {{ _self.dur(server.parity.elapsed) }}{% endif %} + {% if server.parity.etaSeconds is not null %} · {{ 'dashboard.server.eta'|trans }} {{ _self.dur(server.parity.etaSeconds) }}{% endif %} +
+ {% elseif server.parity.elapsed is not null %} +
{{ 'dashboard.server.elapsed'|trans }} {{ _self.dur(server.parity.elapsed) }}
+ {% endif %} + {% if server.parity.errors is not null %} +
{{ server.parity.errors }} {{ 'dashboard.server.errors'|trans }}
+ {% endif %} + {% elseif server.parity is not null and server.parity.last is not null %} +
+ {{ 'dashboard.server.last_check'|trans }}{% if server.parity.last.dateEpoch is not null %} · {{ server.parity.last.dateEpoch|relative_date }}{% endif %} +
+
+ {% if server.parity.last.duration is not null %}{{ _self.dur(server.parity.last.duration) }} · {% endif %}{{ server.parity.last.errors ?? 0 }} {{ 'dashboard.server.errors'|trans }} +
+ {% endif %} + {% if server.array is not null and server.array.parities is not empty %} +
+ {% for p in server.array.parities %}{{ p.status|default('—') }}{% if p.temp is not null %} · {{ p.temp }}°C{% endif %}{% if not loop.last %}, {% endif %}{% endfor %} +
+ {% endif %} +
+ {% endif %} + + {% if server.ups is not null %} +
+
+ {{ 'dashboard.server.ups'|trans }} + {% if server.ups.name %}{{ server.ups.name }}{% endif %} +
+ {% if server.ups.battery is not null %} +
+ {{ 'dashboard.server.battery'|trans }} +
+ {{ server.ups.battery }}% +
+ {% endif %} +
+ {% if server.ups.load is not null %}{{ 'dashboard.server.load'|trans }} {{ server.ups.load|round }}%{% endif %} + {% if server.ups.runtime is not null %} · {{ server.ups.runtime }} min{% endif %} +
+
+ {% endif %} + + {% if server.array is not null and server.array.disks is not empty %} +
+
+ {{ 'dashboard.server.disks'|trans }} +
+
+ {% for disk in server.array.disks|merge(server.array.caches) %} +
+
+ {{ disk.name|default('—') }} + {% if disk.temp is not null %}{{ disk.temp }}°C{% endif %} +
+ {% if disk.size %} + {% set dpct = (disk.used / disk.size * 100)|round %} +
+
{{ dpct }}%
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + {% if server.docker is not null %} +
+
+ {{ 'dashboard.server.docker'|trans }} + {{ server.docker.running }}/{{ server.docker.total }} {{ 'dashboard.server.running'|trans }} +
+
+ {% for c in server.docker.containers|default([]) %} + {{ c.name }} + {% endfor %} +
+
+ {% endif %} + +
+{% endif %} diff --git a/symfony/templates/dashboard/_upcoming.html.twig b/symfony/templates/dashboard/_upcoming.html.twig index b9ec8f5b..f82d2a74 100644 --- a/symfony/templates/dashboard/_upcoming.html.twig +++ b/symfony/templates/dashboard/_upcoming.html.twig @@ -3,8 +3,9 @@ [data-dash-body]. Returns the empty-state message when there's nothing. #} {% import '_icons.html.twig' as ico %} {% if upcoming is empty %} -
- {{ 'dashboard.upcoming.empty'|trans }} +
+
{{ ico.icon('calendar', '', 32) }}
+

{{ 'dashboard.upcoming.empty'|trans }}

{% else %}
diff --git a/symfony/templates/dashboard/index.html.twig b/symfony/templates/dashboard/index.html.twig index 3eb5d0fd..4e2f0b38 100644 --- a/symfony/templates/dashboard/index.html.twig +++ b/symfony/templates/dashboard/index.html.twig @@ -448,6 +448,52 @@ .service-chip-dot.is-down { background: #ef4444; } .service-chip-name { font-size: .82rem; font-weight: 500; line-height: 1; } .service-chip-state { font-size: .68rem; color: var(--tblr-secondary); margin-top: .15rem; } + /* ── Unraid server widget ──────────────────────────────────────── */ + .server-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: .6rem; + } + .server-tile { + padding: .7rem .8rem; border-radius: 8px; + background: var(--tblr-bg-surface-secondary); + border: 1px solid var(--tblr-border-color); + min-width: 0; + } + .server-tile--wide { grid-column: 1 / -1; } + .server-tile-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-bottom: .45rem; } + .server-tile-label { font-size: .82rem; font-weight: 600; } + .server-tile-sub { font-size: .68rem; color: var(--tblr-secondary); margin-top: .3rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .server-state-badge { + font-size: .62rem; font-weight: 700; padding: .12rem .45rem; border-radius: 999px; + text-transform: uppercase; letter-spacing: .04em; + } + .server-state-badge.is-ok { background: rgba(34,197,94,.15); color: #22c55e; } + .server-state-badge.is-idle { background: rgba(148,163,184,.18); color: var(--tblr-secondary); } + .server-state-badge.is-ko { background: rgba(239,68,68,.15); color: #ef4444; } + .server-bar { flex: 1; height: 6px; border-radius: 3px; background: var(--tblr-border-color); overflow: hidden; } + .server-bar-fill { height: 100%; border-radius: 3px; background: var(--tblr-primary); transition: width .3s ease; } + .server-bar-fill.is-hot { background: #ef4444; } + .server-gauge-row { display: flex; align-items: center; gap: .5rem; margin-top: .35rem; } + .server-gauge-label { font-size: .66rem; color: var(--tblr-secondary); min-width: 2.2rem; white-space: nowrap; flex-shrink: 0; } + .server-gauge-value { font-size: .68rem; font-weight: 600; width: 2.4rem; text-align: right; flex-shrink: 0; } + .server-chips { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .35rem; } + .server-chip-muted { + font-size: .62rem; padding: .1rem .4rem; border-radius: 4px; + background: var(--tblr-bg-surface); border: 1px solid var(--tblr-border-color); + color: var(--tblr-secondary); + } + .server-chip-ok { + font-size: .62rem; padding: .1rem .4rem; border-radius: 4px; + background: rgba(34,197,94,.12); border: 1px solid rgba(34,197,94,.35); + color: #22c55e; + } + .server-disk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: .5rem; } + .server-disk { min-width: 0; } + .server-disk-name { font-size: .68rem; font-weight: 600; display: flex; justify-content: space-between; gap: .3rem; margin-bottom: .25rem; } + .server-disk-temp { color: var(--tblr-secondary); font-weight: 500; } + .server-disk-temp.is-hot { color: #ef4444; font-weight: 700; } + .server-disk-sub { font-size: .62rem; color: var(--tblr-secondary); margin-top: .2rem; } /* Async widget hydration skeletons (#27) */ .dash-skel { pointer-events: none; } .dash-skel .poster-tile-wrap { aspect-ratio: 2/3; } @@ -461,43 +507,92 @@ .dash-skel-line { height: .7rem; width: 80%; margin-top: .45rem; border-radius: .25rem; } @keyframes dash-shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } } - /* ── Quick-look modal ───────────────────────────────────────────── */ - .ql-modal-content { background: var(--tblr-bg-surface); overflow: hidden; border-radius: 14px; } - .ql-loading { padding: 4rem; text-align: center; } - .ql-backdrop { - position: relative; min-height: 180px; - background-size: cover; background-position: center 25%; - background-color: var(--tblr-bg-surface-secondary); - } - .ql-backdrop-fade { - position: absolute; inset: 0; - background: linear-gradient(to top, var(--tblr-bg-surface) 2%, rgba(0,0,0,.15) 60%, rgba(0,0,0,.35) 100%); - } - .ql-close { position: absolute; top: .75rem; right: .75rem; z-index: 2; filter: drop-shadow(0 0 2px rgba(0,0,0,.8)); } - .ql-body { display: flex; gap: 1.25rem; padding: 0 1.5rem 1.5rem; margin-top: -60px; position: relative; z-index: 1; } - @media (max-width: 576px) { .ql-body { flex-direction: column; align-items: center; text-align: center; } } - .ql-poster { width: 120px; aspect-ratio: 2/3; object-fit: cover; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,.45); flex-shrink: 0; } - .ql-poster-empty { display: flex; align-items: center; justify-content: center; background: var(--tblr-bg-surface-secondary); color: var(--tblr-secondary); } - .ql-main { flex: 1; min-width: 0; padding-top: 64px; } - @media (max-width: 576px) { .ql-main { padding-top: .5rem; } } - .ql-title { font-size: 1.4rem; font-weight: 700; margin: 0 0 .5rem; color: var(--tblr-body-color); } - .ql-year { font-weight: 400; color: var(--tblr-secondary); } - .ql-meta { display: flex; flex-wrap: wrap; gap: .5rem .7rem; align-items: center; margin-bottom: .85rem; font-size: .8rem; color: var(--tblr-secondary); } - .ql-meta .ql-rating { color: #f59e0b; font-weight: 600; } - .ql-chip { padding: .12rem .5rem; background: rgba(148,163,184,.18); border-radius: 4px; font-size: .7rem; } - .ql-badge { padding: .15rem .55rem; border-radius: 999px; font-size: .68rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } - .ql-badge.is-downloaded { background: rgba(34,197,94,.18); color: #22c55e; } - .ql-badge.is-monitored { background: rgba(99,102,241,.18); color: #6366f1; } - .ql-badge.is-missing { background: rgba(245,158,11,.18); color: #f59e0b; } - .ql-overview { font-size: .88rem; line-height: 1.55; color: var(--tblr-body-color); margin-bottom: 1rem; } - .ql-action { - display: inline-flex; align-items: center; gap: .4rem; - padding: .5rem 1rem; border-radius: 8px; - background: var(--tblr-primary); color: #fff; font-weight: 600; font-size: .85rem; - text-decoration: none !important; - } - .ql-action:hover { filter: brightness(1.08); color: #fff; } - .ql-error { padding: 3rem 1.5rem; text-align: center; color: var(--tblr-secondary); } + /* ── Houndarr stat strip ───────────────────────────────────────── */ + .houndarr-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: .75rem; } + .houndarr-stat { background: var(--tblr-bg-surface-secondary, rgba(128,128,128,.06)); border-radius: .5rem; padding: .6rem .75rem; text-align: center; } + .houndarr-stat-value { font-size: 1.4rem; font-weight: 700; line-height: 1.2; } + .houndarr-stat--eligible .houndarr-stat-value { color: var(--tblr-primary); } + .houndarr-stat-label { font-size: .72rem; color: var(--tblr-secondary); text-transform: uppercase; letter-spacing: .04em; } + .houndarr-updated { margin-top: .5rem; font-size: .72rem; color: var(--tblr-secondary); text-align: right; } + .houndarr-bar { display: flex; height: .5rem; border-radius: .25rem; overflow: hidden; margin-top: .75rem; background: var(--tblr-bg-surface-secondary, rgba(128,128,128,.06)); } + .houndarr-bar-seg { height: 100%; } + .houndarr-legend { display: flex; flex-wrap: wrap; gap: .35rem 1rem; margin-top: .4rem; font-size: .72rem; color: var(--tblr-secondary); } + .houndarr-legend-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: .3rem; } + .houndarr-insts { margin-top: .75rem; } + .houndarr-inst { display: flex; align-items: center; gap: .5rem; font-size: .8rem; padding: .35rem 0; border-top: 1px solid var(--tblr-border-color-translucent, rgba(128,128,128,.15)); } + .houndarr-inst-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; } + .houndarr-inst-name { font-weight: 600; } + .houndarr-inst-counts { margin-left: auto; display: flex; gap: .75rem; color: var(--tblr-secondary); text-align: right; } + .houndarr-note { margin-top: .35rem; font-size: .7rem; color: var(--tblr-secondary); } + + /* ── UniFi network widget ──────────────────────────────────────── */ + .net-rate { font-size: 1.35rem; font-weight: 700; line-height: 1.2; margin-top: .1rem; } + .net-chart { display: block; width: 100%; height: 120px; margin-top: .35rem; } + .net-chart-fill { fill: rgba(var(--tblr-primary-rgb, 99,102,241), .22); stroke: none; } + .net-chart-down { fill: none; stroke: var(--tblr-primary); stroke-width: 1.5; } + /* Upload is a fixed sky blue (not the theme primary): green-primary themes + otherwise render both series in near-identical greens (live-verified). */ + .net-chart-up { fill: none; stroke: #38bdf8; stroke-width: 1.5; } + .net-chart-axis { display: flex; justify-content: space-between; font-size: .62rem; color: var(--tblr-secondary); margin-top: .2rem; } + .net-legend { display: flex; gap: 1rem; font-size: .66rem; color: var(--tblr-secondary); margin-top: .3rem; } + .net-legend-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: .3rem; } + .server-chip-ko { + font-size: .62rem; padding: .1rem .4rem; border-radius: 4px; + background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.35); + color: #ef4444; + } + + /* ── Edit-mode layout toolbar ──────────────────────────────────── */ + .dash-layout-toolbar { display: flex; gap: .5rem; justify-content: flex-end; margin-bottom: 1rem; } + .dashboard-scope.is-editing [data-section-key] { position: relative; outline: 2px dashed rgba(var(--tblr-primary-rgb), .5); outline-offset: 4px; border-radius: 12px; cursor: grab; } + .dashboard-scope.is-editing [data-section-key].dragging { opacity: .5; } + .section-edit-overlay { position: absolute; top: .5rem; right: .5rem; z-index: 5; display: flex; gap: .35rem; } + .section-edit-overlay button { background: rgba(0,0,0,.7); color: #fff; border: 0; border-radius: 6px; padding: .25rem .5rem; font-size: .72rem; cursor: pointer; } + .dashboard-scope:not(.is-editing) .section-edit-overlay { display: none; } + + /* ── Mobile/tablet readability floor (responsive audit 2026-06-29) ──── + Floor sub-11px dashboard text at 11px (.6875rem) below the lg + breakpoint. Same selectors as above so specificity matches; desktop + (≥992px) keeps the original sizes. */ + @media (max-width: 991.98px) { + .mini-cal-dow, + .mini-cal-more, + .mini-cal-item-type, + .mini-cal-item-meta, + .dash-date-time, + .dash-badge-inline, + .poster-tile-sub, + .poster-tile-badge, + .service-chip-state, + .server-tile-sub, + .server-state-badge, + .server-gauge-label, + .server-gauge-value, + .server-chip-muted, + .server-chip-ok, + .server-chip-ko, + .server-disk-name, + .server-disk-sub, + .net-chart-axis, + .net-legend { font-size: .6875rem; } + } + + /* ── Tap targets (responsive audit 2026-06-29) ──────────────────────── + The small header/section arrow links measure ~26px tall; on touch + devices give them a ≥44px hit area. Coarse-pointer only, desktop + rendering unchanged. */ + @media (pointer: coarse) { + .dash-action-btn, + .row-section-link { min-height: 44px; padding-top: .3rem; padding-bottom: .3rem; } + } + + /* Tablet 2-column dashboard was tried (responsive audit 2026-06-29) but + reverted after live verification: dashboard section order is + admin-configurable, so a half-width section (health / houndarr / network) + with no adjacent half-width neighbour left a large empty column beside it + — e.g. Services health next to an empty cell — which looked worse than + the single-column stack. Sections stay full-width at every breakpoint. */ + {% include 'dashboard/_plex_styles.html.twig' %} {% endblock %} @@ -514,6 +609,17 @@
+ {% if is_granted('ROLE_ADMIN') %} +
+ + + +
+ {% endif %} + {# ── Hero: welcome + spotlight/stats (async-hydrated, #27) ───────── Greeting + clock paint instantly; the spotlight and library stats load from app_dashboard_widget_hero after first paint. The fanart background @@ -541,197 +647,19 @@
- {# ── Prochaines sorties (mini-calendrier, async-hydraté #27) ────── - Issue #9 — masqué quand ni Radarr ni Sonarr ne sont configurés. Le - corps (mini-cal ou message vide) est chargé via - app_dashboard_widget_upcoming après le first paint. #} - {% if services_configured.radarr or services_configured.sonarr %} -
-
-
-
-

- - - - {{ 'dashboard.upcoming.title'|trans }} -

- -
-
-
-
-
-
-
- {% endif %} - - {# ── Requests + Services health ─────────────────────────────────── #} - {# Seerr requests masqué quand le service n'est pas configuré. - La grid se réduit alors à la seule carte "Services health". #} -
- {% if services_configured.jellyseerr %} -
-
-
-

- - - - {{ 'dashboard.requests.title'|trans }} -

- -
-
-
-
-
-
+ {# ── Reorderable content sections (order + visibility resolved + server-side by DashboardLayoutService). Each partial self-gates on + service configuration; we only skip user-hidden ones here. ───────── #} + {% for section in dashboard_layout %} + {% if section.visible %} + {% include 'dashboard/sections/_' ~ section.key ~ '.html.twig' %} {% endif %} - - {# Services health card spans the full width when the requests card is - hidden (no Seerr), otherwise sits next to it on the right. #} -
-
-
-

- - - - {{ 'dashboard.health.title'|trans }} -

- -
-
-
-
-
-
-
- - {# ── Current Plex activity (Tautulli, async-hydrated) ──────────── - Hidden entirely when Tautulli isn't configured/enabled (issue #9 - pattern). The body loads from app_dashboard_widget_plex after first - paint and refreshes every 10s (see the javascripts block); an empty - fragment hides the whole card client-side. #} - {% if services_configured.tautulli %} -
-
-
-
-

- - - - {{ 'dashboard.plex.title'|trans }} -

- -
-
-
-
-
-
-
- {% endif %} - - {# ── Ma watchlist (perso, DB locale) ────────────────────────────── #} - {% if watchlist is not empty %} - - {% endif %} - - {# ── TMDb trending (async-hydrated, #27) ───────────────────────── - Issue #9 — still hidden entirely when TMDb isn't configured. The - tiles load from app_dashboard_widget_recommendations after first paint; - an empty fragment hides the section. #} - {% if services_configured.tmdb %} -
-
-

- - - - {{ 'dashboard.trending.title'|trans }} -

- {{ 'dashboard.trending.discover_cta'|trans }} -
-
- {% for i in 1..7 %}{% endfor %} -
-
- {% endif %} - - {# ── Recent library additions (async-hydrated, #27) ────────────── - The section shell + skeleton paint instantly; the poster tiles are - fetched from app_dashboard_widget_recent after first paint and - injected into [data-dash-body]. An empty fragment hides the section. - Issue #9 — still skipped entirely when neither service is configured. #} - {% if services_configured.radarr or services_configured.sonarr %} -
-
-

- - - - {{ 'dashboard.recent.title'|trans }} -

-
-
- {% for i in 1..7 %}{% endfor %} -
-
- {% endif %} + {% endfor %}
{% include 'dashboard/_plex_info_modal.html.twig' with {} only %} -{% include 'dashboard/_quicklook_modal.html.twig' with {} only %} {% endblock %} {% block javascripts %} @@ -755,55 +683,117 @@ } } + // Failure ≠ empty: an empty 200 body means "section not applicable" and + // hides the node (contract above), but a non-OK response or network error + // must stay visible — a monitoring dashboard may never answer "is + // everything OK?" by removing the evidence. Render a retryable state + // in the card body instead. + function applyFailure(node) { + var body = node.querySelector('[data-dash-body]') || node; + body.innerHTML = + '
' + + '
{{ 'dashboard.widget_error.unreachable'|trans|e('js') }}
' + + '' + + '
'; + } + + function hydrateNode(node) { + if (node.dataset.dashLoaded === '1') return; + node.dataset.dashLoaded = '1'; + fetch(node.dataset.dashUrl, { headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' }) + .then(function (r) { + if (!r.ok) { applyFailure(node); return; } + return r.text().then(function (html) { applyFragment(node, html); }); + }) + .catch(function () { applyFailure(node); }); + } + function hydrateDashWidgets() { var nodes = document.querySelectorAll('[data-dash-widget][data-dash-url]'); - for (var i = 0; i < nodes.length; i++) { - (function (node) { - if (node.dataset.dashLoaded === '1') return; - node.dataset.dashLoaded = '1'; - fetch(node.dataset.dashUrl, { headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' }) - .then(function (r) { return r.ok ? r.text() : ''; }) - .then(function (html) { applyFragment(node, html); }) - .catch(function () { node.style.display = 'none'; }); - })(nodes[i]); - } + for (var i = 0; i < nodes.length; i++) { hydrateNode(nodes[i]); } } document.addEventListener('turbo:load', hydrateDashWidgets); if (document.readyState !== 'loading') { hydrateDashWidgets(); } - // Keep the services-health widget live instead of frozen at first paint: - // re-fetch just that fragment on an interval. It's cheap — isHealthy() is - // 10s-cached server-side. Turbo-safe singleton (window._dashHealthTimer) so - // navigating away and back doesn't stack timers; skipped while the tab is - // hidden so a background dashboard isn't polling. - function refreshHealth() { - if (document.hidden) return; - var node = document.querySelector('[data-dash-widget="health"][data-dash-url]'); - if (!node) return; - fetch(node.dataset.dashUrl, { headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' }) - .then(function (r) { return r.ok ? r.text() : ''; }) - .then(function (html) { if ((html || '').trim()) applyFragment(node, html); }) - .catch(function () {}); + // Retry: delegated + bound once per session (window guard) — this block + // re-executes on every Turbo visit, so a bare addEventListener would stack. + if (!window._dashRetryBound) { + window._dashRetryBound = true; + document.addEventListener('click', function (e) { + var btn = e.target.closest('[data-dash-retry]'); + if (!btn) return; + var node = btn.closest('[data-dash-widget]'); + if (!node) return; + node.dataset.dashLoaded = ''; + var body = node.querySelector('[data-dash-body]') || node; + body.innerHTML = '
'; + hydrateNode(node); + }); + } + + // Live-widget polling — one coalescing scheduler for every widget that + // declares a data-dash-poll cadence (plex 10s, health/server/network 30s, + // houndarr 60s). Previously each ran its own setInterval + its own fragment + // request, which at the 30s alignment fired up to four HTTP requests at + // once. Instead we tick once at the GCD of the cadences and batch every + // widget due on that tick into a single /widgets?w=… request, applying each + // returned fragment to its node. Preserves the per-widget behaviour exactly: + // hidden-tab guarded, fails open (a widget missing from the JSON map keeps + // its last value), Turbo-safe singleton timer (window._dashLiveTimer), and + // the plex post-apply hook that re-asserts the active tab after a swap. + var DASH_WIDGETS_URL = '{{ path('app_dashboard_widgets') }}'; + var dashPostApply = { + plex: function (node) { applyPlexTab(node.querySelector('[data-dash-body]') || node); } + }; + function dashGcd(a, b) { return b ? dashGcd(b, a % b) : a; } + function dashLiveNodes() { + return document.querySelectorAll('[data-dash-widget][data-dash-poll][data-dash-url]'); } - if (window._dashHealthTimer) { clearInterval(window._dashHealthTimer); } - window._dashHealthTimer = setInterval(refreshHealth, 30000); - - // Same live-refresh pattern for the Plex activity widget, but on a tighter - // 10s cadence (stream progress/state move fast). Cheap server-side: - // TautulliClient is 10s-circuit-breaker-cached and the call is read-only. - // Skipped while the tab is hidden; re-fetched fragment fails open so a - // down Tautulli can't break the loop. - function refreshPlex() { + // First poll for each widget is scheduled one full interval out (matching + // the old setInterval), and re-armed to now+interval whenever it fires. + var dashNextDue = {}; + var dashBaseTick = 0; + (function planDashCadences() { + var nodes = dashLiveNodes(); + var now = Date.now(); + for (var i = 0; i < nodes.length; i++) { + var name = nodes[i].dataset.dashWidget; + var ms = parseInt(nodes[i].dataset.dashPoll, 10) || 30000; + if (!(name in dashNextDue)) { dashNextDue[name] = now + ms; } + dashBaseTick = dashBaseTick ? dashGcd(dashBaseTick, ms) : ms; + } + if (!dashBaseTick) { dashBaseTick = 30000; } + })(); + function dashTick() { if (document.hidden) return; - var node = document.querySelector('[data-dash-widget="plex"][data-dash-url]'); - if (!node) return; - fetch(node.dataset.dashUrl, { headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' }) - .then(function (r) { return r.ok ? r.text() : ''; }) - .then(function (html) { if ((html || '').trim()) { applyFragment(node, html); applyPlexTab(node.querySelector('[data-dash-body]') || node); } }) + var nodes = dashLiveNodes(); + if (!nodes.length) return; + var now = Date.now(); + var byName = {}; + var due = []; + for (var i = 0; i < nodes.length; i++) { + var name = nodes[i].dataset.dashWidget; + var ms = parseInt(nodes[i].dataset.dashPoll, 10) || 30000; + byName[name] = nodes[i]; + if (now >= (dashNextDue[name] || 0)) { due.push(name); dashNextDue[name] = now + ms; } + } + if (!due.length) return; + fetch(DASH_WIDGETS_URL + '?w=' + encodeURIComponent(due.join(',')), + { headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (map) { + if (!map) return; + Object.keys(map).forEach(function (name) { + var node = byName[name]; + if (!node || !(map[name] || '').trim()) return; + applyFragment(node, map[name]); + if (dashPostApply[name]) { dashPostApply[name](node); } + }); + }) .catch(function () {}); } - if (window._dashPlexTimer) { clearInterval(window._dashPlexTimer); } - window._dashPlexTimer = setInterval(refreshPlex, 10000); + if (window._dashLiveTimer) { clearInterval(window._dashLiveTimer); } + window._dashLiveTimer = setInterval(dashTick, dashBaseTick); // Plex widget tabs: client-side pane toggle, remembers the active tab so the // 10s fragment refresh doesn't snap the user back to "Now playing". @@ -831,95 +821,86 @@ } document.addEventListener('turbo:before-render', function () { - if (window._dashHealthTimer) { clearInterval(window._dashHealthTimer); window._dashHealthTimer = null; } - if (window._dashPlexTimer) { clearInterval(window._dashPlexTimer); window._dashPlexTimer = null; } + if (window._dashLiveTimer) { clearInterval(window._dashLiveTimer); window._dashLiveTimer = null; } }); - // ── Quick-look modal: delegated open + fetch + manual show/hide. - // Tiles carry [data-ql-trigger] + data-ql-source/type/slug/id and keep - // their href as a no-JS fallback. We intercept the click, open the shell, - // fetch the matching fragment, and swap it into [data-ql-body]. - // Re-query the modal on every use: it lives in the content block, so Turbo - // replaces the node on each render. Caching it once would leave the - // document-level delegated listeners pointing at a detached node after a - // navigation, and the quick-look would silently stop opening. - function qlEl() { return document.getElementById('ql-modal'); } - function qlBody() { var m = qlEl(); return m ? m.querySelector('[data-ql-body]') : null; } - - function qlOpen() { - var m = qlEl(); - if (!m) return; - m.classList.add('show'); - m.style.display = 'block'; - m.removeAttribute('aria-hidden'); - m.setAttribute('aria-modal', 'true'); - document.body.classList.add('modal-open'); - if (!document.querySelector('.modal-backdrop.ql-backdrop-el')) { - var bd = document.createElement('div'); - bd.className = 'modal-backdrop fade show ql-backdrop-el'; - document.body.appendChild(bd); - } - } - function qlClose() { - var m = qlEl(); - if (!m) return; - m.classList.remove('show'); - m.style.display = 'none'; - m.setAttribute('aria-hidden', 'true'); - m.removeAttribute('aria-modal'); - var bd = document.querySelector('.modal-backdrop.ql-backdrop-el'); - if (bd) bd.remove(); - if (!document.querySelector('.modal.show')) { document.body.classList.remove('modal-open'); } - var b = qlBody(); - if (b) { b.innerHTML = '
'; } - } - - function qlUrl(t) { - var src = t.getAttribute('data-ql-source'); - var type = t.getAttribute('data-ql-type'); - var id = t.getAttribute('data-ql-id'); - if (!type || !id) return null; - if (src === 'tmdb') { return '/tableau-de-bord/quicklook/tmdb/' + type + '/' + id; } - var slug = t.getAttribute('data-ql-slug'); - if (!slug) return null; - return '/tableau-de-bord/quicklook/' + type + '/' + slug + '/' + id; + })(); + + {% endblock %} diff --git a/symfony/templates/dashboard/sections/_health.html.twig b/symfony/templates/dashboard/sections/_health.html.twig new file mode 100644 index 00000000..bd112e48 --- /dev/null +++ b/symfony/templates/dashboard/sections/_health.html.twig @@ -0,0 +1,23 @@ +{# Services health card — always renders, no config gate. Full-width card. #} +
+
+
+
+
+

+ + + + {{ 'dashboard.health.title'|trans }} +

+ +
+
+
+
+
+
+
+
diff --git a/symfony/templates/dashboard/sections/_houndarr.html.twig b/symfony/templates/dashboard/sections/_houndarr.html.twig new file mode 100644 index 00000000..9bd84ce4 --- /dev/null +++ b/symfony/templates/dashboard/sections/_houndarr.html.twig @@ -0,0 +1,25 @@ +{# Houndarr backlog-search totals — self-gates on houndarr config. #} +
+{% if services_configured.houndarr %} +
+
+
+
+

+ + + + {{ 'dashboard.houndarr.title'|trans }} +

+ +
+
+
+
+
+
+
+{% endif %} +
diff --git a/symfony/templates/dashboard/sections/_network.html.twig b/symfony/templates/dashboard/sections/_network.html.twig new file mode 100644 index 00000000..16d439a9 --- /dev/null +++ b/symfony/templates/dashboard/sections/_network.html.twig @@ -0,0 +1,31 @@ +{# UniFi network monitoring — admin-only, self-gates on unifi config. + The fragment endpoint re-checks ROLE_ADMIN server-side; this gate just + avoids rendering a card shell non-admins would see spin forever. #} +
+{% if is_granted('ROLE_ADMIN') and services_configured.unifi %} +
+
+
+
+

+ + + + {{ 'dashboard.network.title'|trans }} +

+
+ {# The widget is the glance; /unifi is the detail. Same admin gate as + the card itself, so this link is only ever rendered to someone who + can follow it. #} + {{ 'common.action.view_all'|trans }} + {{ 'dashboard.network.settings_cta'|trans }} +
+
+
+
+
+
+
+
+{% endif %} +
diff --git a/symfony/templates/dashboard/sections/_plex.html.twig b/symfony/templates/dashboard/sections/_plex.html.twig new file mode 100644 index 00000000..6f9a3b78 --- /dev/null +++ b/symfony/templates/dashboard/sections/_plex.html.twig @@ -0,0 +1,25 @@ +{# Current Plex activity (Tautulli) — self-gates on tautulli config. #} +
+{% if services_configured.tautulli %} +
+
+
+
+

+ + + + {{ 'dashboard.plex.title'|trans }} +

+ +
+
+
+
+
+
+
+{% endif %} +
diff --git a/symfony/templates/dashboard/sections/_recent.html.twig b/symfony/templates/dashboard/sections/_recent.html.twig new file mode 100644 index 00000000..2973308e --- /dev/null +++ b/symfony/templates/dashboard/sections/_recent.html.twig @@ -0,0 +1,18 @@ +{# Recent library additions (async-hydrated) — self-gates on radarr/sonarr config. #} +
+{% if services_configured.radarr or services_configured.sonarr %} +
+
+

+ + + + {{ 'dashboard.recent.title'|trans }} +

+
+
+ {% for i in 1..7 %}{% endfor %} +
+
+{% endif %} +
diff --git a/symfony/templates/dashboard/sections/_requests.html.twig b/symfony/templates/dashboard/sections/_requests.html.twig new file mode 100644 index 00000000..9ceb51a1 --- /dev/null +++ b/symfony/templates/dashboard/sections/_requests.html.twig @@ -0,0 +1,25 @@ +{# Jellyseerr requests — self-gates on jellyseerr config. Full-width card. #} +
+{% if services_configured.jellyseerr %} +
+
+
+
+

+ + + + {{ 'dashboard.requests.title'|trans }} +

+ +
+
+
+
+
+
+
+{% endif %} +
diff --git a/symfony/templates/dashboard/sections/_server.html.twig b/symfony/templates/dashboard/sections/_server.html.twig new file mode 100644 index 00000000..e90b6e4c --- /dev/null +++ b/symfony/templates/dashboard/sections/_server.html.twig @@ -0,0 +1,27 @@ +{# Unraid server monitoring — admin-only, self-gates on unraid config. + The fragment endpoint re-checks ROLE_ADMIN server-side; this gate just + avoids rendering a card shell non-admins would see spin forever. #} +
+{% if is_granted('ROLE_ADMIN') and services_configured.unraid %} +
+
+
+
+

+ + + + {{ 'dashboard.server.title'|trans }} +

+ +
+
+
+
+
+
+
+{% endif %} +
diff --git a/symfony/templates/dashboard/sections/_trending.html.twig b/symfony/templates/dashboard/sections/_trending.html.twig new file mode 100644 index 00000000..962eb9f8 --- /dev/null +++ b/symfony/templates/dashboard/sections/_trending.html.twig @@ -0,0 +1,19 @@ +{# TMDb trending poster row (async-hydrated) — self-gates on tmdb config. #} +
+{% if services_configured.tmdb %} +
+
+

+ + + + {{ 'dashboard.trending.title'|trans }} +

+ {{ 'dashboard.trending.discover_cta'|trans }} +
+
+ {% for i in 1..7 %}{% endfor %} +
+
+{% endif %} +
diff --git a/symfony/templates/dashboard/sections/_upcoming.html.twig b/symfony/templates/dashboard/sections/_upcoming.html.twig new file mode 100644 index 00000000..43120f71 --- /dev/null +++ b/symfony/templates/dashboard/sections/_upcoming.html.twig @@ -0,0 +1,25 @@ +{# Upcoming releases mini-calendar — self-gates on radarr/sonarr config. #} +
+{% if services_configured.radarr or services_configured.sonarr %} +
+
+
+
+

+ + + + {{ 'dashboard.upcoming.title'|trans }} +

+ +
+
+
+
+
+
+
+{% endif %} +
diff --git a/symfony/templates/dashboard/sections/_watchlist.html.twig b/symfony/templates/dashboard/sections/_watchlist.html.twig new file mode 100644 index 00000000..632b4df8 --- /dev/null +++ b/symfony/templates/dashboard/sections/_watchlist.html.twig @@ -0,0 +1,43 @@ +{# Personal watchlist (local DB) — self-gates when watchlist is empty. #} + diff --git a/symfony/templates/decouverte/explorer.html.twig b/symfony/templates/decouverte/explorer.html.twig index 4e32bbfb..8c02f536 100644 --- a/symfony/templates/decouverte/explorer.html.twig +++ b/symfony/templates/decouverte/explorer.html.twig @@ -136,6 +136,24 @@ .explorer-layout { flex-direction: column !important; } .explorer-sidebar { width: 100% !important; } } + + /* ── Touch devices: the watchlist star is hover-only and unreachable on + touch (responsive audit 2026-06-29) — keep it always visible with a + bigger hit area. Desktop hover behaviour unchanged. */ + @media (hover: none) { + .ex-card .wl-star { opacity: 1; width: 32px; height: 32px; } + .ex-card .wl-star svg { width: 16px; height: 16px; } + } + + /* ── Mobile/tablet readability floor (responsive audit 2026-06-29) ── + Floor sub-11px text at 11px (.6875rem) below the lg breakpoint; + desktop (≥992px) keeps the original sizes. */ + @media (max-width: 991.98px) { + .ex-card .overlay .sub, + .ex-card .badge-type, + .ex-card .badge-lib, + .ex-card .badge-vote { font-size: .6875rem; } + }
@@ -251,50 +269,9 @@
- - - - - +{% endblock %} diff --git a/symfony/templates/jellyseerr/index.html.twig b/symfony/templates/jellyseerr/index.html.twig index 377ad24f..f9bb6e05 100644 --- a/symfony/templates/jellyseerr/index.html.twig +++ b/symfony/templates/jellyseerr/index.html.twig @@ -165,7 +165,7 @@
-
{{ counts.pending ?? 0 }}
+
{{ counts.pending ?? 0 }}
{{ 'jellyseerr.index.stat_pending'|trans }}
@@ -183,7 +183,7 @@
-
{{ counts.declined ?? 0 }}
+
{{ counts.declined ?? 0 }}
{{ 'jellyseerr.index.stat_declined'|trans }}
@@ -379,6 +379,7 @@ {% block javascripts %} +{% endblock %} diff --git a/symfony/templates/unifi/_history.html.twig b/symfony/templates/unifi/_history.html.twig new file mode 100644 index 00000000..91f52ba0 --- /dev/null +++ b/symfony/templates/unifi/_history.html.twig @@ -0,0 +1,105 @@ +{# 7-day WAN traffic + 30-day speedtest history. Both charts are server-rendered + inline SVG built by the controller; this file only lays them out. Each card + degrades on its own — a missing speedtest archive must not blank the traffic + chart. + + NOTE on styling: the shipped widget's `.net-chart*` classes (net-chart, + net-chart-fill, net-chart-down, net-chart-up, net-chart-axis) are defined in + an inline +{% endblock %} + +{% block body %} + + +
+
+ + {# Three regions, three cadences. Each carries the same data-* contract the + dashboard widgets use, so the scheduler below is a near-copy of the + dashboard's — deliberately, because that one is shipped and debugged. #} + {% for region in [ + { name: 'live', poll: 10000 }, + { name: 'infra', poll: 60000 }, + { name: 'history', poll: 300000 } + ] %} +
+
+
+ +
+
+
+ {% endfor %} + +
+
+{% endblock %} + +{% block javascripts %} +{{ parent() }} + +{% endblock %} diff --git a/symfony/templates/usenet/index.html.twig b/symfony/templates/usenet/index.html.twig index 47dc376e..fb3d9a45 100644 --- a/symfony/templates/usenet/index.html.twig +++ b/symfony/templates/usenet/index.html.twig @@ -13,8 +13,6 @@ trimmed to Usenet semantics (no seeding / ratio / trackers / peers). */ .usenet-stat-card { position:relative; overflow:hidden; height:100%; } .usenet-stat-card .card-body { min-height:80px; display:flex; flex-direction:column; justify-content:center; } -.usenet-stat-card .stat-val { font-size:1.4rem; font-weight:700; line-height:1; letter-spacing:-.5px; } -.usenet-stat-card .stat-lbl { font-size:.7rem; text-transform:uppercase; letter-spacing:.4px; color:var(--tblr-text-secondary); font-weight:600; } /* ── List / row base ─────────────────────────────────────────────────────── */ .usenet-list { display:flex; flex-direction:column; gap:.3rem; } @@ -139,12 +137,6 @@ body[data-bs-theme="dark"] .usenet-tmini { background:rgba(255,255,255,.08); } /* ── Filters / view switcher / bulk bar ──────────────────────────────────── */ -.usenet-view-switcher { display:inline-flex; background:rgba(0,0,0,.04); border-radius:6px; padding:2px; gap:1px; } -body[data-bs-theme="dark"] .usenet-view-switcher { background:rgba(255,255,255,.05); } -.usenet-view-btn { border:none; background:transparent; padding:3px 9px; border-radius:4px; font-size:.72rem; font-weight:500; color:var(--tblr-text-secondary); display:inline-flex; align-items:center; gap:4px; cursor:pointer; } -.usenet-view-btn:hover { background:rgba(var(--tblr-primary-rgb),.1); color:var(--tblr-primary); } -.usenet-view-btn.active { background:var(--tblr-primary); color:#fff; } -.usenet-view-btn svg { width:13px; height:13px; } .usenet-bulk-bar { position:fixed; bottom:0; left:15rem; right:0; z-index:1050; background:var(--tblr-bg-surface); border-top:2px solid var(--tblr-primary); padding:.7rem 1.25rem; display:none; align-items:center; gap:.6rem; box-shadow:0 -6px 18px rgba(0,0,0,.12); } .usenet-bulk-bar.show { display:flex; } @@ -185,39 +177,14 @@ body[data-bs-theme="dark"] .usenet-view-switcher { background:rgba(255,255,255,. data-base="{{ path('app_usenet_index', {client: client}) }}" data-refresh="{{ display_pref('qbit_refresh_seconds') }}"> - {# ─── Stats strip ──────────────────────────────────────────────── #} -
-
-
-
-
{{ 'usenet.stats.active'|trans }}
-
-
-
-
-
-
{{ 'usenet.stats.queued'|trans }}
-
-
-
-
-
-
{{ 'usenet.stats.speed'|trans }}
-
-
-
-
-
-
{{ 'usenet.stats.eta'|trans }}
-
-
-
-
-
-
{{ 'usenet.stats.free_space'|trans }}
-
-
-
+ {# ─── Stats strip (shared partial — JS fills [data-stat]) ──────────── #} + {% include '_stat_tiles.html.twig' with { layout:'grid', cols:'col-6 col-sm-4 col-lg', tiles:[ + { value:'—', data_stat:'active', label:'usenet.stats.active'|trans, hue:'blue' }, + { value:'—', data_stat:'queued', label:'usenet.stats.queued'|trans, hue:'secondary' }, + { value:'—', data_stat:'speed', label:'usenet.stats.speed'|trans, hue:'green' }, + { value:'—', data_stat:'eta', label:'usenet.stats.eta'|trans, hue:'cyan' }, + { value:'—', data_stat:'free_space', label:'usenet.stats.free_space'|trans, hue:'orange' } + ] } only %} {# ─── Toolbar ──────────────────────────────────────────────────── #}
@@ -257,6 +224,9 @@ body[data-bs-theme="dark"] .usenet-view-switcher { background:rgba(255,255,255,. {{ ico.icon('bolt', '', 14) }} + {# Visible unit so a filled value never reads as a naked number + (the unit used to live only in the placeholder). #} + MB/s
{{ 'usenet.filters.sort_label'|trans }} @@ -270,20 +240,18 @@ body[data-bs-theme="dark"] .usenet-view-switcher { background:rgba(255,255,255,.
-
- - - -
+ {% set vs_icon_list = '' %} + {% set vs_icon_table = '' %} + {% set vs_icon_compact = '' %} + {% include '_view_switcher.html.twig' with { + active: 'list', + aria_label: 'usenet.view.aria_label'|trans, + modes: [ + { view: 'list', label: 'usenet.view.list'|trans, title: 'usenet.view.list_title'|trans, icon: vs_icon_list }, + { view: 'table', label: 'usenet.view.table'|trans, title: 'usenet.view.table_title'|trans, icon: vs_icon_table }, + { view: 'compact', label: 'usenet.view.compact'|trans, title: 'usenet.view.compact_title'|trans, icon: vs_icon_compact } + ] + } only %}
@@ -706,10 +674,10 @@ body[data-bs-theme="dark"] .usenet-view-switcher { background:rgba(255,255,255,. function setView(v) { view = v; localStorage.setItem(VKEY, v); - root.querySelectorAll('.usenet-view-btn').forEach(function (b) { b.classList.toggle('active', b.dataset.view === v); }); + root.querySelectorAll('.view-btn').forEach(function (b) { b.classList.toggle('active', b.dataset.view === v); }); renderQueue(); } - root.querySelectorAll('.usenet-view-btn').forEach(function (b) { + root.querySelectorAll('.view-btn').forEach(function (b) { b.addEventListener('click', function () { setView(b.dataset.view); }); }); setView(view); diff --git a/symfony/tests/Controller/AdminSettingsControllerTest.php b/symfony/tests/Controller/AdminSettingsControllerTest.php index 6cf731dc..a4c73202 100644 --- a/symfony/tests/Controller/AdminSettingsControllerTest.php +++ b/symfony/tests/Controller/AdminSettingsControllerTest.php @@ -24,6 +24,7 @@ private function controller( HealthService $health, ?ServiceInstanceProvider $instances = null, array $services = [], + ?\App\Service\DashboardLayoutService $layout = null, ): AdminSettingsController { $appVersion = $this->createMock(\App\Service\AppVersion::class); $appVersion->method('current')->willReturn('test'); @@ -39,6 +40,7 @@ private function controller( $this->createMock(LoggerInterface::class), $this->createMock(\Symfony\Component\Cache\Adapter\AdapterInterface::class), $appVersion, + $layout ?? new \App\Service\DashboardLayoutService($config), projectDir: sys_get_temp_dir(), environment: 'test', ); @@ -663,4 +665,67 @@ public function testImportV1StaysSupportedForBackwardsCompat(): void $response = $this->controller($settings, $config, $health, $instances)->import($request); $this->assertSame(302, $response->getStatusCode(), 'v1 imports must still redirect cleanly'); } + + public function testDashboardLayoutEndpointPersistsOrderAndHidden(): void + { + $saved = []; + $settings = $this->createMock(SettingRepository::class); + $settings->method('setMany')->willReturnCallback(function (array $p) use (&$saved) { $saved = $p; }); + $config = $this->createMock(ConfigService::class); + $config->expects(self::once())->method('invalidate'); + $health = $this->createMock(HealthService::class); + $controller = $this->controller($settings, $config, $health); + + $request = Request::create('/admin/settings/dashboard-layout', 'POST', [ + '_csrf_token' => 'x', + 'order' => 'recent,plex', + 'hidden' => 'health,trending', + ]); + $request->setSession(new \Symfony\Component\HttpFoundation\Session\Session( + new \Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage() + )); + + $response = $controller->dashboardLayout($request); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertSame(200, $response->getStatusCode()); + self::assertSame(['ok' => true], json_decode($response->getContent(), true)); + self::assertSame('recent,plex', $saved['dashboard_section_order']); + self::assertSame('1', $saved['dashboard_hide_health']); + self::assertSame('1', $saved['dashboard_hide_trending']); + self::assertNull($saved['dashboard_hide_plex']); + } + + public function testSavePersistsDashboardOrderAndHiddenFlags(): void + { + $saved = []; + $settings = $this->createMock(SettingRepository::class); + $settings->method('setMany')->willReturnCallback(function (array $p) use (&$saved) { $saved = $p; }); + $config = $this->createMock(ConfigService::class); + $health = $this->createMock(HealthService::class); + + $controller = $this->controller($settings, $config, $health); + + $request = Request::create('/admin/settings', 'POST', [ + '_csrf_token' => 'x', + 'dashboard_section_order' => 'recent,plex,upcoming,bogus', + // health checkbox omitted => hidden; others present => visible + 'dashboard_visible_upcoming' => '1', + 'dashboard_visible_requests' => '1', + 'dashboard_visible_plex' => '1', + 'dashboard_visible_watchlist' => '1', + 'dashboard_visible_trending' => '1', + 'dashboard_visible_recent' => '1', + ]); + $request->setSession(new \Symfony\Component\HttpFoundation\Session\Session( + new \Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage() + )); + + $controller->index($request); + + // Unknown 'bogus' dropped; order preserved; missing keys NOT appended on save. + self::assertSame('recent,plex,upcoming', $saved['dashboard_section_order']); + self::assertSame('1', $saved['dashboard_hide_health']); // unchecked => hidden + self::assertNull($saved['dashboard_hide_plex']); // checked => visible + } } diff --git a/symfony/tests/Controller/AdminSettingsThemeTest.php b/symfony/tests/Controller/AdminSettingsThemeTest.php new file mode 100644 index 00000000..af83b332 --- /dev/null +++ b/symfony/tests/Controller/AdminSettingsThemeTest.php @@ -0,0 +1,31 @@ + ['/prowlarr', 'ProwlarrController::index'], '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/DashboardControllerTest.php b/symfony/tests/Controller/DashboardControllerTest.php index 19650a2e..8728ff91 100644 --- a/symfony/tests/Controller/DashboardControllerTest.php +++ b/symfony/tests/Controller/DashboardControllerTest.php @@ -90,6 +90,7 @@ public function testQuickLookLibraryBuildsMovieViewModel(): void $this->createMock(SonarrClient::class), $this->createMock(JellyseerrClient::class), $this->createMock(TmdbClient::class), $this->createMock(WatchlistItemRepository::class), $instances, new NullLogger(), $translator, $cache, $this->createMock(TautulliClient::class), + new \App\Service\DashboardLayoutService($this->createMock(\App\Service\ConfigService::class)), ); $this->attachRouter($controller); @@ -135,6 +136,7 @@ public function testQuickLookLibrarySeriesAndUnknownId(): void $sonarr, $this->createMock(JellyseerrClient::class), $this->createMock(TmdbClient::class), $this->createMock(WatchlistItemRepository::class), $instances, new NullLogger(), $translator, $cache, $this->createMock(TautulliClient::class), + new \App\Service\DashboardLayoutService($this->createMock(\App\Service\ConfigService::class)), ); $this->attachRouter($controller); $m = new ReflectionMethod(DashboardController::class, 'quickLookLibrary'); @@ -151,56 +153,6 @@ public function testQuickLookLibrarySeriesAndUnknownId(): void self::assertNull($m->invoke($controller, 'series', 'sonarr-1', 999)); } - public function testServicesHealthExpandsOneChipPerInstance(): void - { - $health = $this->createMock(HealthService::class); - $health->method('statusFor')->willReturnCallback( - fn(string $service, ?string $slug = null): array => match (true) { - $service === 'radarr' && $slug === 'radarr-1' => ['status' => 'up', 'latencyMs' => 120], - $service === 'radarr' && $slug === 'radarr-4k' => ['status' => 'down', 'latencyMs' => null], - $service === 'sonarr' && $slug === 'sonarr-1' => ['status' => 'slow', 'latencyMs' => 1500], - $service === 'qbittorrent' => ['status' => 'up', 'latencyMs' => 40], - default => ['status' => null, 'latencyMs' => null], // prowlarr/jellyseerr/tmdb not configured - } - ); - - $instances = $this->createMock(ServiceInstanceProvider::class); - $instances->method('getEnabled')->willReturnCallback( - fn(string $type): array => match ($type) { - ServiceInstance::TYPE_RADARR => [$this->instance('radarr-1', 'Radarr 1080p'), $this->instance('radarr-4k', 'Radarr 4K')], - ServiceInstance::TYPE_SONARR => [$this->instance('sonarr-1', 'Sonarr')], - default => [], - } - ); - - $controller = new DashboardController( - $health, - $this->createMock(RadarrClient::class), - $this->createMock(SonarrClient::class), - $this->createMock(JellyseerrClient::class), - $this->createMock(TmdbClient::class), - $this->createMock(WatchlistItemRepository::class), - $instances, - new NullLogger(), - $this->createMock(TranslatorInterface::class), - $this->createMock(CacheInterface::class), - $this->createMock(TautulliClient::class), - ); - - $m = new ReflectionMethod(DashboardController::class, 'servicesHealth'); - $m->setAccessible(true); - /** @var list $chips */ - $chips = $m->invoke($controller); - - // Two Radarr instances + one Sonarr + qBittorrent = 4 chips; the - // unconfigured single services (prowlarr/jellyseerr/tmdb) drop out. - self::assertCount(4, $chips); - self::assertSame(['id' => 'radarr', 'name' => 'Radarr 1080p', 'status' => 'up', 'latencyMs' => 120], $chips[0]); - self::assertSame(['id' => 'radarr', 'name' => 'Radarr 4K', 'status' => 'down', 'latencyMs' => null], $chips[1]); - self::assertSame(['id' => 'sonarr', 'name' => 'Sonarr', 'status' => 'slow', 'latencyMs' => 1500], $chips[2]); - self::assertSame(['id' => 'qbittorrent', 'name' => 'qBittorrent', 'status' => 'up', 'latencyMs' => 40], $chips[3]); - } - public function testQuickLookTmdbMovieAndTv(): void { $tmdb = $this->createMock(TmdbClient::class); @@ -229,6 +181,7 @@ public function testQuickLookTmdbMovieAndTv(): void $tmdb, $this->createMock(WatchlistItemRepository::class), $this->createMock(ServiceInstanceProvider::class), new NullLogger(), $translator, $this->createMock(CacheInterface::class), $this->createMock(TautulliClient::class), + new \App\Service\DashboardLayoutService($this->createMock(\App\Service\ConfigService::class)), ); $this->attachRouter($controller); // quickLookTmdb calls generateUrl('tmdb_index') $m = new ReflectionMethod(DashboardController::class, 'quickLookTmdb'); @@ -250,6 +203,118 @@ public function testQuickLookTmdbMovieAndTv(): void self::assertStringContainsString('detail=tv/95396', $tv['actionUrl']); } + public function testQuickLookTmdbIncludesCastProvidersTrailerAndExternalIds(): void + { + $tmdb = $this->createMock(TmdbClient::class); + $tmdb->method('getMovie')->willReturn([ + 'id' => 693134, 'title' => 'Dune: Part Two', 'release_date' => '2024-02-27', + 'overview' => 'Paul unites with the Fremen.', 'runtime' => 167, + 'vote_average' => 8.2, 'poster_path' => '/p.jpg', 'backdrop_path' => '/b.jpg', + 'genres' => [['id' => 1, 'name' => 'Science Fiction']], + 'imdb_id' => 'tt15239678', + 'credits' => ['cast' => [ + ['name' => 'Timothée Chalamet', 'character' => 'Paul', 'profile_path' => '/tc.jpg'], + ['name' => 'Zendaya', 'character' => 'Chani', 'profile_path' => null], + ]], + 'videos' => ['results' => [ + ['site' => 'YouTube', 'type' => 'Teaser', 'official' => false, 'iso_639_1' => 'en', 'key' => 'TEASER'], + ['site' => 'YouTube', 'type' => 'Trailer', 'official' => true, 'iso_639_1' => 'en', 'key' => 'TRAILER'], + ['site' => 'Vimeo', 'type' => 'Trailer', 'official' => true, 'iso_639_1' => 'en', 'key' => 'VIMEO'], + ]], + 'watch/providers' => ['results' => [ + 'US' => ['flatrate' => [['provider_name' => 'Max', 'logo_path' => '/max.jpg']]], + 'FR' => ['flatrate' => [['provider_name' => 'Canal+', 'logo_path' => '/canal.jpg']]], + ]], + ]); + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback( + fn(string $k, array $p = []) => $k === 'dashboard.quicklook.runtime' ? $p['min'] . ' min' : $k + ); + + $controller = new DashboardController( + $this->createMock(HealthService::class), $this->createMock(RadarrClient::class), + $this->createMock(SonarrClient::class), $this->createMock(JellyseerrClient::class), + $tmdb, $this->createMock(WatchlistItemRepository::class), + $this->createMock(ServiceInstanceProvider::class), new NullLogger(), + $translator, $this->createMock(CacheInterface::class), $this->createMock(TautulliClient::class), + new \App\Service\DashboardLayoutService($this->createMock(\App\Service\ConfigService::class)), + ); + $this->attachRouter($controller); + $m = new ReflectionMethod(DashboardController::class, 'quickLookTmdb'); + $m->setAccessible(true); + + $movie = $m->invoke($controller, 'movie', 693134); + + // Cast: top entries, profile paths expanded to full URLs (null stays null). + self::assertCount(2, $movie['cast']); + self::assertSame('Timothée Chalamet', $movie['cast'][0]['name']); + self::assertSame('https://image.tmdb.org/t/p/w185/tc.jpg', $movie['cast'][0]['profile']); + self::assertNull($movie['cast'][1]['profile']); + + // Providers: FR preferred over US (country priority), flatrate only. + self::assertSame('Canal+', $movie['providers'][0]['name']); + self::assertSame('https://image.tmdb.org/t/p/w92/canal.jpg', $movie['providers'][0]['logo']); + + // Trailer: official YouTube Trailer beats the teaser; Vimeo ignored. + self::assertSame('TRAILER', $movie['trailerKey']); + + // External ids + identity for the modal's links/watchlist. + self::assertSame('tt15239678', $movie['imdbId']); + self::assertSame(693134, $movie['tmdbId']); + self::assertSame('movie', $movie['tmdbType']); + self::assertSame('/p.jpg', $movie['posterPath']); + + // Library lookup fails open (bare cache mock) → treated as not added, + // so the body renders the Add affordance. + self::assertFalse($movie['inLibrary']); + self::assertNull($movie['statusBadge']); + } + + public function testQuickLookTmdbInLibraryShowsManageDeepLink(): void + { + $tmdb = $this->createMock(TmdbClient::class); + $tmdb->method('getMovie')->willReturn([ + 'id' => 693134, 'title' => 'Dune: Part Two', 'release_date' => '2024-02-27', + 'overview' => '...', 'runtime' => 167, 'vote_average' => 8.2, + 'poster_path' => '/p.jpg', 'genres' => [['id' => 1, 'name' => 'Science Fiction']], + ]); + + // Populated Radarr library so the tmdbId resolves to a Manage deep-link. + $cache = $this->createMock(CacheInterface::class); + $cache->method('get')->willReturnCallback(fn(string $k, callable $cb) => $cb($this->cacheItem())); + $instances = $this->createMock(ServiceInstanceProvider::class); + $instances->method('getEnabled')->willReturnCallback( + fn(string $type): array => $type === ServiceInstance::TYPE_RADARR + ? [$this->instance('radarr-1', 'Radarr')] : [] + ); + $radarr = $this->createMock(RadarrClient::class); + $radarr->method('withInstance')->willReturnSelf(); + $radarr->method('getMovies')->willReturn([ + ['tmdbId' => 693134, 'id' => 42, 'hasFile' => true, 'monitored' => true], + ]); + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback(fn(string $k) => $k); + + $controller = new DashboardController( + $this->createMock(HealthService::class), $radarr, + $this->createMock(SonarrClient::class), $this->createMock(JellyseerrClient::class), + $tmdb, $this->createMock(WatchlistItemRepository::class), + $instances, new NullLogger(), $translator, $cache, $this->createMock(TautulliClient::class), + new \App\Service\DashboardLayoutService($this->createMock(\App\Service\ConfigService::class)), + ); + $this->attachRouter($controller); + $m = new ReflectionMethod(DashboardController::class, 'quickLookTmdb'); + $m->setAccessible(true); + + $movie = $m->invoke($controller, 'movie', 693134); + + self::assertTrue($movie['inLibrary']); + self::assertSame('downloaded', $movie['statusBadge']['kind']); + self::assertStringContainsString('open=42', $movie['actionUrl']); + self::assertStringContainsString('app_media_films', $movie['actionUrl']); + self::assertSame('dashboard.quicklook.manage', $movie['actionLabel']); + } + public function testHeroSpotlightCarriesQuickLookFields(): void { $cache = $this->createMock(CacheInterface::class); @@ -264,6 +329,7 @@ public function testHeroSpotlightCarriesQuickLookFields(): void $this->createMock(SonarrClient::class), $this->createMock(JellyseerrClient::class), $this->createMock(TmdbClient::class), $this->createMock(WatchlistItemRepository::class), $instances, new NullLogger(), $translator, $cache, $this->createMock(TautulliClient::class), + new \App\Service\DashboardLayoutService($this->createMock(\App\Service\ConfigService::class)), ); $this->attachRouter($controller); // pickHeroSpotlight calls generateUrl $m = new ReflectionMethod(DashboardController::class, 'pickHeroSpotlight'); @@ -279,6 +345,89 @@ public function testHeroSpotlightCarriesQuickLookFields(): void self::assertNull($vm['qlSlug']); } + public function testQuickLookLibraryMovieIncludesReleaseChips(): void + { + $movieRow = [ + 'id' => 42, 'title' => 'Dune', 'year' => 2021, 'overview' => 'x', + 'genres' => [], 'ratings' => 7.8, 'runtime' => 155, + 'poster' => 'p', 'fanart' => 'f', 'hasFile' => true, 'monitored' => true, + 'status' => 'released', '_instanceSlug' => 'radarr-1', '_instanceName' => 'Radarr', + 'inCinemasAt' => new \DateTimeImmutable('-2 years'), + 'digitalAt' => new \DateTimeImmutable('+30 days'), + 'physicalAt' => null, + ]; + $cache = $this->createMock(CacheInterface::class); + $cache->method('get')->willReturnCallback(fn(string $k, callable $cb) => $cb($this->cacheItem())); + $instances = $this->createMock(ServiceInstanceProvider::class); + $instances->method('getEnabled')->willReturnCallback( + fn(string $type): array => $type === ServiceInstance::TYPE_RADARR + ? [$this->instance('radarr-1', 'Radarr')] : [] + ); + $radarr = $this->createMock(RadarrClient::class); + $radarr->method('withInstance')->willReturnSelf(); + $radarr->method('getMovies')->willReturn([$movieRow]); + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback(fn(string $k, array $p = []) => $k); + + $controller = new DashboardController( + $this->createMock(HealthService::class), $radarr, + $this->createMock(SonarrClient::class), $this->createMock(JellyseerrClient::class), + $this->createMock(TmdbClient::class), $this->createMock(WatchlistItemRepository::class), + $instances, new NullLogger(), $translator, $cache, $this->createMock(TautulliClient::class), + new \App\Service\DashboardLayoutService($this->createMock(\App\Service\ConfigService::class)), + ); + $this->attachRouter($controller); + + $m = new ReflectionMethod(DashboardController::class, 'quickLookLibrary'); + $m->setAccessible(true); + $vm = $m->invoke($controller, 'movie', 'radarr-1', 42); + + $kinds = array_column($vm['releaseDates'], 'kind'); + self::assertSame(['cinema', 'digital'], $kinds); // physical null → skipped, fixed order + self::assertFalse($vm['releaseDates'][0]['upcoming']); // cinema 2y ago + self::assertTrue($vm['releaseDates'][1]['upcoming']); // digital +30d + } + + public function testQuickLookTmdbMovieParsesReleaseDates(): void + { + $tmdb = $this->createMock(TmdbClient::class); + $tmdb->method('getMovie')->willReturn([ + 'id' => 603, 'title' => 'The Matrix', 'release_date' => '1999-03-31', + 'genres' => [], 'overview' => 'x', 'vote_average' => 8.2, + 'release_dates' => ['results' => [ + ['iso_3166_1' => 'US', 'release_dates' => [ + ['type' => 3, 'release_date' => '1999-03-31T00:00:00.000Z'], + ['type' => 4, 'release_date' => '2020-01-01T00:00:00.000Z'], + ]], + ['iso_3166_1' => 'FR', 'release_dates' => [ + ['type' => 5, 'release_date' => '2099-01-01T00:00:00.000Z'], + ]], + ]], + ]); + $cache = $this->createMock(CacheInterface::class); + $cache->method('get')->willReturnCallback(fn(string $k, callable $cb) => $cb($this->cacheItem())); + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback(fn(string $k, array $p = []) => $k); + + $controller = new DashboardController( + $this->createMock(HealthService::class), $this->createMock(RadarrClient::class), + $this->createMock(SonarrClient::class), $this->createMock(JellyseerrClient::class), + $tmdb, $this->createMock(WatchlistItemRepository::class), + $this->createMock(ServiceInstanceProvider::class), new NullLogger(), + $translator, $cache, $this->createMock(TautulliClient::class), + new \App\Service\DashboardLayoutService($this->createMock(\App\Service\ConfigService::class)), + ); + $this->attachRouter($controller); + + $m = new ReflectionMethod(DashboardController::class, 'quickLookTmdb'); + $m->setAccessible(true); + $vm = $m->invoke($controller, 'movie', 603); + + $kinds = array_column($vm['releaseDates'], 'kind'); + self::assertSame(['cinema', 'digital', 'physical'], $kinds); + self::assertTrue($vm['releaseDates'][2]['upcoming']); // FR physical year 2099 + } + public function testQuickLookTmdbTvZeroSeasonsRenders(): void { $tmdb = $this->createMock(TmdbClient::class); @@ -300,6 +449,7 @@ public function testQuickLookTmdbTvZeroSeasonsRenders(): void $tmdb, $this->createMock(WatchlistItemRepository::class), $this->createMock(ServiceInstanceProvider::class), new NullLogger(), $translator, $this->createMock(CacheInterface::class), $this->createMock(TautulliClient::class), + new \App\Service\DashboardLayoutService($this->createMock(\App\Service\ConfigService::class)), ); $this->attachRouter($controller); $m = new ReflectionMethod(DashboardController::class, 'quickLookTmdb'); @@ -308,4 +458,127 @@ public function testQuickLookTmdbTvZeroSeasonsRenders(): void $tv = $m->invoke($controller, 'tv', 12345); self::assertStringContainsString('0 saisons', $tv['metaLine']); } + + public function testQuickLookLibrarySeriesIncludesAirInfo(): void + { + $seriesRow = [ + 'id' => 7, 'title' => 'Severance', 'year' => 2022, 'overview' => 'x', + 'genres' => [], 'ratings' => 8.4, 'network' => 'Apple TV+', + 'poster' => 's', 'fanart' => null, 'monitored' => true, 'hasFile' => false, + 'status' => 'continuing', 'ended' => false, + 'firstAired' => new \DateTimeImmutable('-3 years'), + 'nextAiring' => new \DateTimeImmutable('+10 days'), + 'previousAiring' => new \DateTimeImmutable('-20 days'), + '_instanceSlug' => 'sonarr-1', '_instanceName' => 'Sonarr', + ]; + $cache = $this->createMock(CacheInterface::class); + $cache->method('get')->willReturnCallback(fn(string $k, callable $cb) => $cb($this->cacheItem())); + $instances = $this->createMock(ServiceInstanceProvider::class); + $instances->method('getEnabled')->willReturnCallback( + fn(string $type): array => $type === ServiceInstance::TYPE_SONARR + ? [$this->instance('sonarr-1', 'Sonarr')] : [] + ); + $sonarr = $this->createMock(SonarrClient::class); + $sonarr->method('withInstance')->willReturnSelf(); + $sonarr->method('getSeries')->willReturn([$seriesRow]); + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback(fn(string $k, array $p = []) => $k); + + $controller = new DashboardController( + $this->createMock(HealthService::class), $this->createMock(RadarrClient::class), + $sonarr, $this->createMock(JellyseerrClient::class), $this->createMock(TmdbClient::class), + $this->createMock(WatchlistItemRepository::class), $instances, new NullLogger(), + $translator, $cache, $this->createMock(TautulliClient::class), + new \App\Service\DashboardLayoutService($this->createMock(\App\Service\ConfigService::class)), + ); + $this->attachRouter($controller); + $m = new ReflectionMethod(DashboardController::class, 'quickLookLibrary'); + $m->setAccessible(true); + $vm = $m->invoke($controller, 'series', 'sonarr-1', 7); + + self::assertSame('continuing', $vm['airStatus']); + $kinds = array_column($vm['releaseDates'], 'kind'); + self::assertContains('first_aired', $kinds); + self::assertContains('next_episode', $kinds); + self::assertNotContains('ended', $kinds); + } + + public function testQuickLookLibrarySeriesEndedShowsEndDate(): void + { + $seriesRow = [ + 'id' => 99, 'title' => 'The Wire', 'year' => 2002, 'overview' => 'Baltimore crime.', + 'genres' => [], 'ratings' => 9.3, 'network' => 'HBO', + 'poster' => 'w', 'fanart' => null, 'monitored' => false, 'hasFile' => false, + 'status' => 'ended', 'ended' => true, + 'firstAired' => new \DateTimeImmutable('-5 years'), + 'nextAiring' => null, + 'previousAiring' => new \DateTimeImmutable('-1 year'), + '_instanceSlug' => 'sonarr-1', '_instanceName' => 'Sonarr', + ]; + $cache = $this->createMock(CacheInterface::class); + $cache->method('get')->willReturnCallback(fn(string $k, callable $cb) => $cb($this->cacheItem())); + $instances = $this->createMock(ServiceInstanceProvider::class); + $instances->method('getEnabled')->willReturnCallback( + fn(string $type): array => $type === ServiceInstance::TYPE_SONARR + ? [$this->instance('sonarr-1', 'Sonarr')] : [] + ); + $sonarr = $this->createMock(SonarrClient::class); + $sonarr->method('withInstance')->willReturnSelf(); + $sonarr->method('getSeries')->willReturn([$seriesRow]); + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback(fn(string $k, array $p = []) => $k); + + $controller = new DashboardController( + $this->createMock(HealthService::class), $this->createMock(RadarrClient::class), + $sonarr, $this->createMock(JellyseerrClient::class), $this->createMock(TmdbClient::class), + $this->createMock(WatchlistItemRepository::class), $instances, new NullLogger(), + $translator, $cache, $this->createMock(TautulliClient::class), + new \App\Service\DashboardLayoutService($this->createMock(\App\Service\ConfigService::class)), + ); + $this->attachRouter($controller); + $m = new ReflectionMethod(DashboardController::class, 'quickLookLibrary'); + $m->setAccessible(true); + $vm = $m->invoke($controller, 'series', 'sonarr-1', 99); + + self::assertSame('ended', $vm['airStatus']); + $kinds = array_column($vm['releaseDates'], 'kind'); + self::assertContains('first_aired', $kinds); + self::assertContains('ended', $kinds); + self::assertNotContains('next_episode', $kinds); + } + + public function testQuickLookTmdbTvIncludesAirInfo(): void + { + $tmdb = $this->createMock(TmdbClient::class); + $tmdb->method('getTv')->willReturn([ + 'id' => 95396, 'name' => 'Severance', 'first_air_date' => '2022-02-18', + 'genres' => [], 'overview' => 'x', 'vote_average' => 8.4, + 'status' => 'Returning Series', 'number_of_seasons' => 2, + 'networks' => [['name' => 'Apple TV+']], + 'next_episode_to_air' => ['air_date' => '2099-01-15'], + 'last_episode_to_air' => ['air_date' => '2022-04-08'], + ]); + $cache = $this->createMock(CacheInterface::class); + $cache->method('get')->willReturnCallback(fn(string $k, callable $cb) => $cb($this->cacheItem())); + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback(fn(string $k, array $p = []) => $k); + + $controller = new DashboardController( + $this->createMock(HealthService::class), $this->createMock(RadarrClient::class), + $this->createMock(SonarrClient::class), $this->createMock(JellyseerrClient::class), + $tmdb, $this->createMock(WatchlistItemRepository::class), + $this->createMock(ServiceInstanceProvider::class), new NullLogger(), + $translator, $cache, $this->createMock(TautulliClient::class), + new \App\Service\DashboardLayoutService($this->createMock(\App\Service\ConfigService::class)), + ); + $this->attachRouter($controller); + $m = new ReflectionMethod(DashboardController::class, 'quickLookTmdb'); + $m->setAccessible(true); + $vm = $m->invoke($controller, 'tv', 95396); + + self::assertSame('continuing', $vm['airStatus']); + $kinds = array_column($vm['releaseDates'], 'kind'); + self::assertContains('first_aired', $kinds); + self::assertContains('next_episode', $kinds); + } } diff --git a/symfony/tests/Controller/DashboardWidgetHoundarrTest.php b/symfony/tests/Controller/DashboardWidgetHoundarrTest.php new file mode 100644 index 00000000..eb25ee76 --- /dev/null +++ b/symfony/tests/Controller/DashboardWidgetHoundarrTest.php @@ -0,0 +1,76 @@ +createMock(HealthService::class); + $health->method('isConfigured')->willReturnCallback( + fn(string $s) => $s === 'houndarr' ? $configured : false + ); + + return new DashboardController( + $health, + $this->createMock(RadarrClient::class), + $this->createMock(SonarrClient::class), + $this->createMock(JellyseerrClient::class), + $this->createMock(TmdbClient::class), + $this->createMock(WatchlistItemRepository::class), + $this->createMock(ServiceInstanceProvider::class), + new NullLogger(), + $this->createMock(TranslatorInterface::class), + $this->createMock(CacheInterface::class), + $this->createMock(TautulliClient::class), + new DashboardLayoutService($this->createMock(\App\Service\ConfigService::class)), + $this->createMock(UnraidClient::class), + $houndarr, + ); + } + + public function testUnconfiguredGetsEmptyBodyAndNoHoundarrCall(): void + { + $houndarr = $this->createMock(HoundarrClient::class); + $houndarr->expects($this->never())->method('widget'); + + $resp = $this->makeController(configured: false, houndarr: $houndarr)->widgetHoundarr(); + $this->assertSame('', $resp->getContent()); + } + + public function testMissingClientGetsEmptyBody(): void + { + $resp = $this->makeController(configured: true, houndarr: null)->widgetHoundarr(); + $this->assertSame('', $resp->getContent()); + } + + public function testSectionRegistered(): void + { + $this->assertContains('houndarr', \App\Dashboard\DashboardSections::DEFAULT_ORDER); + $this->assertArrayHasKey('houndarr', \App\Dashboard\DashboardSections::META); + } +} diff --git a/symfony/tests/Controller/DashboardWidgetNetworkTest.php b/symfony/tests/Controller/DashboardWidgetNetworkTest.php new file mode 100644 index 00000000..cf7dfa6d --- /dev/null +++ b/symfony/tests/Controller/DashboardWidgetNetworkTest.php @@ -0,0 +1,89 @@ +createMock(HealthService::class); + $health->method('isConfigured')->willReturnCallback( + fn(string $s) => $s === 'unifi' ? $configured : false + ); + + $controller = new DashboardController( + $health, + $this->createMock(RadarrClient::class), + $this->createMock(SonarrClient::class), + $this->createMock(JellyseerrClient::class), + $this->createMock(TmdbClient::class), + $this->createMock(WatchlistItemRepository::class), + $this->createMock(ServiceInstanceProvider::class), + new NullLogger(), + $this->createMock(TranslatorInterface::class), + $this->createMock(CacheInterface::class), + $this->createMock(TautulliClient::class), + new DashboardLayoutService($this->createMock(\App\Service\ConfigService::class)), + unifi: $unifi, + ); + + $checker = $this->createMock(AuthorizationCheckerInterface::class); + $checker->method('isGranted')->willReturn($isAdmin); + $container = $this->createMock(\Psr\Container\ContainerInterface::class); + $container->method('has')->willReturn(true); + $container->method('get')->willReturnCallback( + fn(string $id) => $id === 'security.authorization_checker' ? $checker : null + ); + $controller->setContainer($container); + + return $controller; + } + + public function testNonAdminGetsEmptyBodyAndNoUnifiCall(): void + { + $unifi = $this->createMock(UnifiClient::class); + $unifi->expects($this->never())->method('overview'); + + $resp = $this->makeController(isAdmin: false, configured: true, unifi: $unifi)->widgetNetwork(); + $this->assertSame('', $resp->getContent()); + } + + public function testUnconfiguredGetsEmptyBodyAndNoUnifiCall(): void + { + $unifi = $this->createMock(UnifiClient::class); + $unifi->expects($this->never())->method('overview'); + + $resp = $this->makeController(isAdmin: true, configured: false, unifi: $unifi)->widgetNetwork(); + $this->assertSame('', $resp->getContent()); + } + + public function testNetworkSectionRegistered(): void + { + $this->assertContains('network', \App\Dashboard\DashboardSections::DEFAULT_ORDER); + $this->assertArrayHasKey('network', \App\Dashboard\DashboardSections::META); + $this->assertTrue(\App\Dashboard\DashboardSections::isValid('network')); + } +} diff --git a/symfony/tests/Controller/DashboardWidgetServerTest.php b/symfony/tests/Controller/DashboardWidgetServerTest.php new file mode 100644 index 00000000..0cc699d6 --- /dev/null +++ b/symfony/tests/Controller/DashboardWidgetServerTest.php @@ -0,0 +1,82 @@ +createMock(HealthService::class); + $health->method('isConfigured')->willReturnCallback( + fn(string $s) => $s === 'unraid' ? $configured : false + ); + + $controller = new DashboardController( + $health, + $this->createMock(RadarrClient::class), + $this->createMock(SonarrClient::class), + $this->createMock(JellyseerrClient::class), + $this->createMock(TmdbClient::class), + $this->createMock(WatchlistItemRepository::class), + $this->createMock(ServiceInstanceProvider::class), + new NullLogger(), + $this->createMock(TranslatorInterface::class), + $this->createMock(CacheInterface::class), + $this->createMock(TautulliClient::class), + new DashboardLayoutService($this->createMock(\App\Service\ConfigService::class)), + $unraid, + ); + + $checker = $this->createMock(AuthorizationCheckerInterface::class); + $checker->method('isGranted')->willReturn($isAdmin); + $container = $this->createMock(\Psr\Container\ContainerInterface::class); + $container->method('has')->willReturn(true); + $container->method('get')->willReturnCallback( + fn(string $id) => $id === 'security.authorization_checker' ? $checker : null + ); + $controller->setContainer($container); + + return $controller; + } + + public function testNonAdminGetsEmptyBodyAndNoUnraidCall(): void + { + $unraid = $this->createMock(UnraidClient::class); + $unraid->expects($this->never())->method('overview'); + + $resp = $this->makeController(isAdmin: false, configured: true, unraid: $unraid)->widgetServer(); + $this->assertSame('', $resp->getContent()); + } + + public function testUnconfiguredGetsEmptyBodyAndNoUnraidCall(): void + { + $unraid = $this->createMock(UnraidClient::class); + $unraid->expects($this->never())->method('overview'); + + $resp = $this->makeController(isAdmin: true, configured: false, unraid: $unraid)->widgetServer(); + $this->assertSame('', $resp->getContent()); + } +} diff --git a/symfony/tests/Controller/DelugeControllerTest.php b/symfony/tests/Controller/DelugeControllerTest.php new file mode 100644 index 00000000..c6b329cd --- /dev/null +++ b/symfony/tests/Controller/DelugeControllerTest.php @@ -0,0 +1,57 @@ +getMethod($method); + $m->setAccessible(true); + return $m->invoke(null, ...$args); + } + + /** + * Deluge hashes are exactly 40 hex chars. No 'all' sentinel (unlike qBit). + */ + public function testSanitizeHashesKeepsOnly40CharHex(): void + { + $valid = str_repeat('a1', 20); // 40 chars + $out = $this->invokeStatic('sanitizeHashes', [ + $valid, + 'all', // qBit sentinel — NOT valid for Deluge + str_repeat('a', 32), // qBit-length v1 hash prefix — reject + 'zz' . substr($valid, 2), // non-hex + 42, // not a string + ]); + $this->assertSame([$valid], $out); + } + + /** + * Same SSRF policy as the qBit add box: http(s)/magnet only, cloud + * metadata hosts blocked, LAN allowed (private trackers are legitimate). + * + * @return iterable + */ + public static function urlCases(): iterable + { + yield 'magnet ok' => ['magnet:?xt=urn:btih:abc', true]; + yield 'https ok' => ['https://tracker.example/file.torrent', true]; + yield 'lan ok' => ['http://192.168.1.10:8112/file.torrent', true]; + yield 'file scheme' => ['file:///etc/passwd', false]; + yield 'gopher scheme' => ['gopher://evil/x', false]; + yield 'aws metadata' => ['http://169.254.169.254/latest/meta-data/', false]; + yield 'gcp metadata' => ['http://metadata.google.internal/computeMetadata/', false]; + } + + #[DataProvider('urlCases')] + public function testValidateTorrentUrls(string $url, bool $ok): void + { + $error = $this->invokeStatic('validateTorrentUrlsStatic', $url); + $ok ? $this->assertNull($error) : $this->assertNotNull($error); + } +} diff --git a/symfony/tests/Controller/HealthControllerTest.php b/symfony/tests/Controller/HealthControllerTest.php index 72851d2a..9cd9b7e1 100644 --- a/symfony/tests/Controller/HealthControllerTest.php +++ b/symfony/tests/Controller/HealthControllerTest.php @@ -8,18 +8,22 @@ use Doctrine\DBAL\Connection; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; -use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\JsonResponse; #[AllowMockObjectsWithoutExpectations] class HealthControllerTest extends TestCase { - private function newController(): HealthController + private function newController(bool $isAdmin = false): HealthController { $controller = new HealthController(); - // AbstractController constructor initializes nothing, but some helpers - // require a container. Minimal stub. - $controller->setContainer($this->createMock(ContainerInterface::class)); + $checker = $this->createMock(\Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface::class); + $checker->method('isGranted')->willReturn($isAdmin); + $container = $this->createMock(\Psr\Container\ContainerInterface::class); + $container->method('has')->willReturn(true); + $container->method('get')->willReturnCallback( + fn(string $id) => $id === 'security.authorization_checker' ? $checker : null + ); + $controller->setContainer($container); return $controller; } @@ -69,6 +73,12 @@ public function testServicesHealthExposesUsenetClients(): void default => null, // prowlarr, jellyseerr, tmdb not configured }); + $health->method('chips')->willReturn([ + ['id' => 'qbittorrent', 'name' => 'qBittorrent', 'status' => 'up', 'latencyMs' => 40, 'color' => '#2f67ba'], + ['id' => 'sabnzbd', 'name' => 'SABnzbd', 'status' => 'up', 'latencyMs' => 30, 'color' => '#fbc531'], + ['id' => 'tautulli', 'name' => 'Tautulli', 'status' => 'down', 'latencyMs' => null, 'color' => '#e5a00d'], + ]); + $instances = $this->createMock(ServiceInstanceProvider::class); $instances->method('getEnabled')->willReturn([]); @@ -81,9 +91,25 @@ public function testServicesHealthExposesUsenetClients(): void $this->assertTrue($payload['services']['sabnzbd']); $this->assertNull($payload['services']['nzbget']); - // sabnzbd + qbittorrent are up → counted; nzbget (null) is not. + // ok/total now mirror the chip list (2 of 3 up); legacy keys still present. $this->assertSame(2, $payload['ok']); - $this->assertSame(2, $payload['total']); + $this->assertSame(3, $payload['total']); + $this->assertCount(3, $payload['chips']); + $this->assertSame('qBittorrent', $payload['chips'][0]['name']); + } + + public function testUnraidChipIsAdminGated(): void + { + $instances = $this->createMock(ServiceInstanceProvider::class); + $instances->method('getEnabled')->willReturn([]); + + $health = $this->createMock(HealthService::class); + $health->expects($this->once())->method('chips')->with(true)->willReturn([]); + $this->newController(isAdmin: true)->servicesHealth($health, $instances); + + $health2 = $this->createMock(HealthService::class); + $health2->expects($this->once())->method('chips')->with(false)->willReturn([]); + $this->newController(isAdmin: false)->servicesHealth($health2, $instances); } public function testErrorResponseDoesNotLeakDetails(): void diff --git a/symfony/tests/Controller/TautulliControllerTest.php b/symfony/tests/Controller/TautulliControllerTest.php index c983bbf2..95b894bd 100644 --- a/symfony/tests/Controller/TautulliControllerTest.php +++ b/symfony/tests/Controller/TautulliControllerTest.php @@ -170,4 +170,26 @@ public function testHistoryEndpointAcceptsUserFilter(): void self::assertResponseIsSuccessful(); self::assertStringNotContainsString('Exception', (string) $this->client->getResponse()->getContent()); } + + /** + * /tautulli/api/quicklook/{ratingKey} fails open to {type:null, id:null} + * when Tautulli is not configured — the frontend then falls back to the + * legacy Plex metadata modal instead of a broken quick-look. + */ + public function testQuickLookResolveReturnsNullShapeWhenUnconfigured(): void + { + $this->client->request('GET', '/tautulli/api/quicklook/12345'); + + self::assertResponseIsSuccessful(); + $content = (string) $this->client->getResponse()->getContent(); + self::assertJson($content); + self::assertSame(['type' => null, 'id' => null], json_decode($content, true)); + } + + /** Non-numeric rating keys never reach the client — 404 by route requirement. */ + public function testQuickLookResolveRejectsNonNumericKeys(): void + { + $this->client->request('GET', '/tautulli/api/quicklook/abc'); + self::assertResponseStatusCodeSame(404); + } } diff --git a/symfony/tests/Controller/ThemeRenderTest.php b/symfony/tests/Controller/ThemeRenderTest.php new file mode 100644 index 00000000..462939c8 --- /dev/null +++ b/symfony/tests/Controller/ThemeRenderTest.php @@ -0,0 +1,18 @@ +client->request('GET', '/tableau-de-bord'); + $html = $this->client->getResponse()->getContent(); + + self::assertStringContainsString('--tblr-body-bg: hsl(0, 0%, 6.5%)', $html); + self::assertStringContainsString('data-bs-theme="dark"', $html); + self::assertStringNotContainsString('id="theme-toggle"', $html); + } +} 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/Controller/TransmissionRenderTest.php b/symfony/tests/Controller/TransmissionRenderTest.php new file mode 100644 index 00000000..de950bc8 --- /dev/null +++ b/symfony/tests/Controller/TransmissionRenderTest.php @@ -0,0 +1,139 @@ + */ + public static function locales(): iterable + { + yield 'english' => ['en']; + yield 'french' => ['fr']; + } + + #[DataProvider('locales')] + public function testIndexRendersCleanlyWithNoLeakedTranslationKeys(string $locale): void + { + $this->configureTransmission(); + + $this->client->request('GET', '/transmission?_locale=' . $locale); + $html = (string) $this->client->getResponse()->getContent(); + + $this->assertSame(200, $this->client->getResponse()->getStatusCode()); + + if (preg_match('/\b(qbittorrent|transmission|deluge)\.[a-z_.]+/', $html, $m)) { + $this->fail(sprintf( + 'Locale "%s": rendered page contains an unresolved translation key "%s" — it has no entry in either messages YAML and rendered as the raw id instead of translated text.', + $locale, + $m[0] + )); + } + } + + /** + * Seed transmission_url (so ServiceRouteGuardSubscriber's "not configured" + * check passes) and swap the autowired TransmissionClient for a mock + * populated with one torrent per state TransmissionClient::normalizeState() + * can emit, so every state-dependent template branch actually executes. + */ + private function configureTransmission(): void + { + $em = $this->em(); + $em->persist(new Setting('transmission_url', 'http://transmission.test:9091')); + $em->flush(); + + $mock = $this->createMock(TransmissionClient::class); + $mock->method('getVersion')->willReturn('4.0.5'); + + $states = ['error', 'paused', 'checking', 'queued', 'downloading', 'seeding', 'unknown']; + $torrents = []; + foreach ($states as $i => $state) { + $torrents[] = $this->torrent($state, $i); + } + $mock->method('getTorrents')->willReturn($torrents); + + $mock->method('getStats')->willReturn([ + 'total' => count($torrents), + 'downloading' => 1, + 'seeding' => 1, + 'paused' => 1, + 'completed' => 0, + 'errored' => 1, + 'stalled' => 0, + 'dl_speed' => 1024, + 'up_speed' => 512, + 'connection' => 'connected', + 'dht_nodes' => 0, + 'dl_session' => 10_000, + 'up_session' => 5_000, + 'dl_alltime' => 100_000, + 'up_alltime' => 50_000, + 'global_ratio' => 0.5, + 'free_space' => 1_000_000_000, + ]); + + static::getContainer()->set(TransmissionClient::class, $mock); + } + + /** @return array */ + private function torrent(string $state, int $i): array + { + return [ + 'hash' => str_pad((string) $i, 40, 'a'), + 'name' => 'Torrent.' . $state, + 'size' => 1_000_000_000, + 'total_size' => 1_000_000_000, + 'downloaded' => 500_000_000, + 'uploaded' => 250_000_000, + 'progress' => 50.0, + 'dlspeed' => 1024, + 'upspeed' => 512, + 'eta' => 3600, + 'state' => $state, + 'raw_state' => '0', + 'category' => $i % 2 === 0 ? 'radarr' : 'sonarr', + 'tags' => '', + 'ratio' => 1.5, + 'num_seeds' => 3, + 'num_leechs' => 2, + 'num_complete' => 0, + 'num_incomplete' => 0, + 'added_on' => 1_700_000_000, + 'completion_on' => 1_700_001_000, + 'save_path' => '/downloads', + 'content_path' => '', + 'tracker' => 'tracker.example', + 'dl_limit' => -1, + 'up_limit' => -1, + 'seeding_time' => 3600, + 'priority' => 0, + 'availability' => 1.0, + ]; + } +} diff --git a/symfony/tests/Controller/TransmissionRouteParityTest.php b/symfony/tests/Controller/TransmissionRouteParityTest.php new file mode 100644 index 00000000..c027f291 --- /dev/null +++ b/symfony/tests/Controller/TransmissionRouteParityTest.php @@ -0,0 +1,50 @@ +get('router'); + $all = array_keys($router->getRouteCollection()->all()); + + $deluge = array_filter($all, fn($n) => str_starts_with($n, 'app_deluge_')); + self::assertNotEmpty($deluge, 'sanity: Deluge routes must exist'); + + foreach ($deluge as $name) { + $twin = str_replace('app_deluge_', 'app_transmission_', $name); + self::assertContains($twin, $all, "missing Transmission twin for {$name}"); + } + } + + /** + * Réciproque de testEveryDelugeRouteHasATransmissionTwin(). Les deux + * contrôleurs déclarent le même nombre de routes (21 chacun, vérifié + * manuellement) : la relation est donc une bijection, pas seulement + * une inclusion à sens unique. Une route Transmission ajoutée plus + * tard sans son pendant Deluge romprait cette garantie silencieusement + * si on ne testait que le sens Deluge → Transmission. + */ + public function testNoTransmissionRouteIsOrphaned(): void + { + self::bootKernel(); + $router = self::getContainer()->get('router'); + $all = array_keys($router->getRouteCollection()->all()); + + $transmission = array_filter($all, fn($n) => str_starts_with($n, 'app_transmission_')); + self::assertNotEmpty($transmission, 'sanity: Transmission routes must exist'); + + foreach ($transmission as $name) { + $twin = str_replace('app_transmission_', 'app_deluge_', $name); + self::assertContains($twin, $all, "orphaned Transmission route with no Deluge counterpart: {$name}"); + } + } +} diff --git a/symfony/tests/Controller/UnifiControllerTest.php b/symfony/tests/Controller/UnifiControllerTest.php new file mode 100644 index 00000000..105a7cb6 --- /dev/null +++ b/symfony/tests/Controller/UnifiControllerTest.php @@ -0,0 +1,167 @@ + $throwOn group names whose reader throws + */ + private function makeController( + bool $configured = true, + array $throwOn = [], + ): UnifiController { + $fetcher = function (string $group) use ($throwOn): UnifiFetcher { + if (!in_array($group, $throwOn, true)) { + return new StubUnifiFetcher(); + } + $mock = $this->createMock(UnifiFetcher::class); + $mock->method('fetch')->willThrowException(new \RuntimeException('upstream exploded')); + return $mock; + }; + + $health = $this->createMock(HealthService::class); + $health->method('isConfigured')->willReturnCallback( + fn(string $s) => $s === 'unifi' ? $configured : false + ); + + // Render the template name back out instead of running real Twig: this + // test covers gating, dispatch and error containment, not markup. Tasks + // 8-11 own the templates and Task 12 renders them for real on :beta. + $twig = $this->createMock(Environment::class); + $twig->method('render')->willReturnCallback( + static fn(string $tpl, array $ctx = []): string => 'RENDERED:' . $tpl + ); + + $controller = new UnifiController( + new UnifiLiveReader($fetcher('live'), new NullLogger()), + new UnifiInfraReader($fetcher('infra'), new NullLogger()), + new UnifiHistoryReader($fetcher('history'), new NullLogger()), + $health, + new NullLogger(), + ); + + // has() answers truthfully per id rather than blanket-true: json() + // takes its serializer branch on has('serializer') and would then call + // serialize() on the null get() returns. Only 'twig' is provided, so + // only 'twig' exists. + $container = $this->createMock(\Psr\Container\ContainerInterface::class); + $container->method('has')->willReturnCallback( + static fn(string $id): bool => $id === 'twig' + ); + $container->method('get')->willReturnCallback( + fn(string $id) => $id === 'twig' ? $twig : null + ); + $controller->setContainer($container); + + return $controller; + } + + /** + * #[IsGranted] is enforced by Symfony's attribute listener during HTTP + * dispatch, not by the method body, so a directly-invoked method cannot + * demonstrate the 403. Assert the guard is declared instead; the live 403 + * is verified end-to-end in Task 12. + */ + public function testControllerIsGuardedByRoleAdmin(): void + { + $attrs = (new \ReflectionClass(UnifiController::class)) + ->getAttributes(\Symfony\Component\Security\Http\Attribute\IsGranted::class); + + $this->assertCount(1, $attrs, 'UnifiController must carry a class-level #[IsGranted]'); + $this->assertSame('ROLE_ADMIN', $attrs[0]->newInstance()->attribute); + } + + public function testUnconfiguredConsoleAnswersEmptyNotAnError(): void + { + // The route guard normally redirects first; this is the belt-and-braces + // path for a fragment fetched from an already-open tab after the admin + // deletes the config. Empty body = "not applicable", per the poller's + // documented contract. + $c = $this->makeController(configured: false); + $r = $c->panel('live'); + + $this->assertSame(200, $r->getStatusCode()); + $this->assertSame('', $r->getContent()); + } + + public function testUnknownPanelNameIsNotFoundNotAFatal(): void + { + $r = $this->makeController()->panel('wat'); + + $this->assertSame(404, $r->getStatusCode()); + } + + public function testEachPanelRendersItsOwnTemplate(): void + { + $c = $this->makeController(); + + $this->assertSame('RENDERED:unifi/_live.html.twig', $c->panel('live')->getContent()); + $this->assertSame('RENDERED:unifi/_infra.html.twig', $c->panel('infra')->getContent()); + $this->assertSame('RENDERED:unifi/_history.html.twig', $c->panel('history')->getContent()); + } + + public function testAThrowingReaderStillAnswersTwoHundred(): void + { + // The whole point: a monitoring page must not 500 because the thing it + // monitors misbehaved. The template renders its empty state from null. + $r = $this->makeController(throwOn: ['live'])->panel('live'); + + $this->assertSame(200, $r->getStatusCode()); + $this->assertSame('RENDERED:unifi/_live.html.twig', $r->getContent()); + } + + public function testBatchReturnsAMapAndIgnoresUnknownNames(): void + { + $c = $this->makeController(); + $r = $c->panels(new Request(['p' => 'live,wat,infra'])); + + $map = json_decode((string) $r->getContent(), true); + $this->assertSame(['live', 'infra'], array_keys($map)); + $this->assertSame('RENDERED:unifi/_live.html.twig', $map['live']); + } + + public function testBatchDeduplicatesAndToleratesAnEmptyQuery(): void + { + $c = $this->makeController(); + + $map = json_decode((string) $c->panels(new Request(['p' => 'live, live ']))->getContent(), true); + $this->assertSame(['live'], array_keys($map)); + + $this->assertSame([], json_decode((string) $c->panels(new Request())->getContent(), true)); + } + + public function testIndexRendersTheShellWithoutTouchingAnyReader(): void + { + // First paint must not block on cURL. Readers that throw would surface + // here if the shell called them; the shell must render regardless. + $r = $this->makeController(throwOn: ['live', 'infra', 'history'])->index(); + + $this->assertSame(200, $r->getStatusCode()); + $this->assertSame('RENDERED:unifi/index.html.twig', $r->getContent()); + } +} diff --git a/symfony/tests/Dashboard/DashboardSectionsTest.php b/symfony/tests/Dashboard/DashboardSectionsTest.php new file mode 100644 index 00000000..5347c9c4 --- /dev/null +++ b/symfony/tests/Dashboard/DashboardSectionsTest.php @@ -0,0 +1,35 @@ +assertNull(NetworkUsageChart::build(null)); + $this->assertNull(NetworkUsageChart::build([])); + $this->assertNull(NetworkUsageChart::build([['ts' => 1, 'downBytes' => 5.0, 'upBytes' => 1.0]])); + } + + public function testGeometrySpansViewboxAndScalesToPeak(): void + { + $c = NetworkUsageChart::build([ + ['ts' => 1000, 'downBytes' => 0.0, 'upBytes' => 0.0], + ['ts' => 2000, 'downBytes' => 100.0, 'upBytes' => 50.0], // peak = 100 + ['ts' => 3000, 'downBytes' => 50.0, 'upBytes' => 25.0], + ]); + + // x spans 0 → WIDTH; y: value 0 → HEIGHT (baseline), peak → PAD_TOP (8). + $this->assertSame('0,120 300,8 600,64', $c['downLine']); + $this->assertSame('0,120 300,64 600,92', $c['upLine']); + // Area = baseline-closed polygon around the down line. + $this->assertSame('0,120 0,120 300,8 600,64 600,120', $c['downArea']); + $this->assertCount(3, $c['points']); + } + + public function testUnsortedInputIsSorted(): void + { + $c = NetworkUsageChart::build([ + ['ts' => 3000, 'downBytes' => 50.0, 'upBytes' => 0.0], + ['ts' => 1000, 'downBytes' => 100.0, 'upBytes' => 0.0], + ]); + // First x=0 must belong to ts=1000 (the peak) → y = 8. + $this->assertStringStartsWith('0,8 ', $c['downLine']); + } + + public function testTotalsAndLabels(): void + { + $c = NetworkUsageChart::build([ + ['ts' => 1751738400, 'downBytes' => 1.0e9, 'upBytes' => 5.0e7], + ['ts' => 1751742000, 'downBytes' => 2.0e9, 'upBytes' => 5.0e7], + ]); + $this->assertSame('3 GB', $c['downTotal']); + $this->assertSame('100 MB', $c['upTotal']); + $this->assertMatchesRegularExpression('/^\d{2}:\d{2}$/', $c['startLabel']); + $this->assertMatchesRegularExpression('/^\d{2}:\d{2}$/', $c['endLabel']); + $this->assertStringContainsString('↓ 1 GB', $c['points'][0]['label']); + $this->assertStringContainsString('↑ 50 MB', $c['points'][0]['label']); + } + + public function testMalformedRowsSkipped(): void + { + $this->assertNull(NetworkUsageChart::build([ + ['ts' => 'nope', 'downBytes' => 1.0, 'upBytes' => 1.0], + 'garbage', + ['ts' => 1000, 'downBytes' => 1.0, 'upBytes' => 1.0], + ])); // only 1 usable point survives → null + } + + public function testBytesFormatter(): void + { + $this->assertSame('0 B', NetworkUsageChart::bytes(0.0)); + $this->assertSame('999 B', NetworkUsageChart::bytes(999.0)); + $this->assertSame('2.5 MB', NetworkUsageChart::bytes(2500000.0)); + $this->assertSame('1.9 GB', NetworkUsageChart::bytes(1.85e9)); + } + + /** Two hourly buckets one hour apart. */ + private function series(): array + { + return [ + ['ts' => 1751738400, 'downBytes' => 1.0e9, 'upBytes' => 5.0e7], + ['ts' => 1751742000, 'downBytes' => 2.0e9, 'upBytes' => 1.0e8], + ]; + } + + public function testDefaultFormatOutputIsUnchanged(): void + { + $c = NetworkUsageChart::build($this->series()); + + // Geometry: 2 points across WIDTH=600, peak = 2e9 → the peak sits at + // PAD_TOP=8 and an idle value sits at HEIGHT=120. + $this->assertSame('0,64 600,8', $c['downLine']); + $this->assertSame('0,120 0,64 600,8 600,120', $c['downArea']); + $this->assertSame('0,117.2 600,114.4', $c['upLine']); + $this->assertSame('3 GB', $c['downTotal']); + $this->assertSame('150 MB', $c['upTotal']); + // Default labels are hour-of-day in the server timezone. + $this->assertSame(date('H:i', 1751738400), $c['startLabel']); + $this->assertSame(date('H:i', 1751742000), $c['endLabel']); + $this->assertStringContainsString(date('H:i', 1751738400), $c['points'][0]['label']); + } + + public function testLabelFormatAppliesToPointsAndAxis(): void + { + $c = NetworkUsageChart::build($this->series(), 'D'); + + $this->assertSame(date('D', 1751738400), $c['startLabel']); + $this->assertSame(date('D', 1751742000), $c['endLabel']); + $this->assertStringStartsWith(date('D', 1751738400) . ' — ', $c['points'][0]['label']); + } + + public function testGeometryDoesNotDependOnLabelFormat(): void + { + $a = NetworkUsageChart::build($this->series()); + $b = NetworkUsageChart::build($this->series(), 'D'); + + foreach (['downArea', 'downLine', 'upLine', 'downTotal', 'upTotal'] as $k) { + $this->assertSame($a[$k], $b[$k], "$k must not depend on the label format"); + } + } +} diff --git a/symfony/tests/Dashboard/SpeedtestChartTest.php b/symfony/tests/Dashboard/SpeedtestChartTest.php new file mode 100644 index 00000000..603bf594 --- /dev/null +++ b/symfony/tests/Dashboard/SpeedtestChartTest.php @@ -0,0 +1,103 @@ + 1751900000, 'downMbps' => 900.0, 'upMbps' => 800.0, 'latencyMs' => 4], + ['ts' => 1751700000, 'downMbps' => 450.0, 'upMbps' => 400.0, 'latencyMs' => 8], + ['ts' => 1751800000, 'downMbps' => 300.0, 'upMbps' => 200.0, 'latencyMs' => 12], + ]; + } + + public function testNullWhenFewerThanTwoUsableRuns(): void + { + $this->assertNull(SpeedtestChart::build(null)); + $this->assertNull(SpeedtestChart::build([])); + $this->assertNull(SpeedtestChart::build([['ts' => 1, 'downMbps' => 100.0, 'upMbps' => 90.0, 'latencyMs' => 5]])); + // Rows with no usable throughput at all do not count toward the two. + $this->assertNull(SpeedtestChart::build([ + ['ts' => 1, 'downMbps' => null, 'upMbps' => null, 'latencyMs' => 5], + ['ts' => 2, 'downMbps' => null, 'upMbps' => null, 'latencyMs' => 6], + ])); + } + + public function testSortsAscendingAndScalesThroughputToPeak(): void + { + $c = SpeedtestChart::build($this->runs()); + + // Sorted: 450 (x=0), 300 (x=300), 900 (x=600). Peak = 900 → y=PAD_TOP=8. + // Idle baseline is HEIGHT=120; y = 120 - (v/900) * (120-8). + $this->assertSame('0,64 300,82.7 600,8', $c['downLine']); + $this->assertSame('900 Mbps', $c['peakMbps']); + $this->assertSame(date('M j', 1751700000), $c['startLabel']); + $this->assertSame(date('M j', 1751900000), $c['endLabel']); + } + + public function testLatencyUsesItsOwnScale(): void + { + $c = SpeedtestChart::build($this->runs()); + + // Latency peak = 12 ms → that run sits at PAD_TOP; 4 ms is near baseline. + // Sorted order is 8, 12, 4. + $this->assertSame('0,45.3 300,8 600,82.7', $c['latencyLine']); + $this->assertSame(4, $c['latencyMin']); + $this->assertSame(8, $c['latencyAvg']); + $this->assertSame(12, $c['latencyMax']); + } + + public function testLatestRunIsTheNewestNotTheLast(): void + { + $c = SpeedtestChart::build($this->runs()); + + $this->assertSame('900 Mbps', $c['latestDown']); + $this->assertSame('800 Mbps', $c['latestUp']); + $this->assertSame('4 ms', $c['latestLatency']); + } + + public function testRunMissingLatencyStillPlotsThroughput(): void + { + $c = SpeedtestChart::build([ + ['ts' => 100, 'downMbps' => 100.0, 'upMbps' => 50.0, 'latencyMs' => null], + ['ts' => 200, 'downMbps' => 200.0, 'upMbps' => 100.0, 'latencyMs' => null], + ]); + + $this->assertSame('0,64 600,8', $c['downLine']); + $this->assertSame('', $c['latencyLine']); // nothing to draw + $this->assertNull($c['latencyMin']); + $this->assertNull($c['latencyAvg']); + $this->assertNull($c['latencyMax']); + $this->assertNull($c['latestLatency']); + } + + public function testGarbageRowsAreSkippedNotFatal(): void + { + $c = SpeedtestChart::build([ + ['ts' => 'not-a-number', 'downMbps' => 100.0, 'upMbps' => 1.0, 'latencyMs' => 1], + ['ts' => 100, 'downMbps' => 'x', 'upMbps' => null, 'latencyMs' => 'y'], + ['ts' => 200, 'downMbps' => 100.0, 'upMbps' => 50.0, 'latencyMs' => 5], + ['ts' => 300, 'downMbps' => 200.0, 'upMbps' => 100.0, 'latencyMs' => 7], + 'not-an-array', + ]); + + $this->assertNotNull($c); + $this->assertCount(2, $c['points']); // only the two fully usable runs + } + + public function testHoverLabelCarriesDateAndBothDirections(): void + { + $c = SpeedtestChart::build($this->runs()); + + $this->assertStringContainsString(date('M j, H:i', 1751700000), $c['points'][0]['label']); + $this->assertStringContainsString('450 Mbps', $c['points'][0]['label']); + $this->assertStringContainsString('400 Mbps', $c['points'][0]['label']); + $this->assertStringContainsString('8 ms', $c['points'][0]['label']); + } +} diff --git a/symfony/tests/EventSubscriber/ServiceRouteGuardSubscriberTest.php b/symfony/tests/EventSubscriber/ServiceRouteGuardSubscriberTest.php index 48d00c30..fbb5dea0 100644 --- a/symfony/tests/EventSubscriber/ServiceRouteGuardSubscriberTest.php +++ b/symfony/tests/EventSubscriber/ServiceRouteGuardSubscriberTest.php @@ -212,4 +212,73 @@ public function testQbittorrentPrefixMatches(): void $this->assertInstanceOf(RedirectResponse::class, $response); $this->assertStringContainsString('app_setup_downloads', $response->getTargetUrl()); } + + public function testDelugeUnconfiguredRedirectsToDownloadsWizard(): void + { + $event = $this->event('app_deluge_index'); + ($this->subscriber())->onKernelRequest($event); + + $response = $event->getResponse(); + $this->assertInstanceOf(RedirectResponse::class, $response); + $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'); + $sub = $this->subscriber( + configuredKeys: ['deluge_url'], + healthy: ['deluge'], + ); + $sub->onKernelRequest($event); + + $this->assertFalse($event->hasResponse()); + } + + public function testUnifiRouteRedirectsToSettingsWhenUnconfigured(): void + { + // unifi_url present, unifi_api_key missing → half configured, must bounce. + // UniFi has no wizard step, so the target is admin settings. + $event = $this->event('app_unifi_index'); + $sub = $this->subscriber(configuredKeys: ['unifi_url']); + $sub->onKernelRequest($event); + + $response = $event->getResponse(); + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertStringContainsString('admin_settings_index', $response->getTargetUrl()); + } + + public function testUnifiRoutePassesWhenFullyConfiguredAndHealthy(): void + { + $event = $this->event('app_unifi_index'); + $sub = $this->subscriber( + configuredKeys: ['unifi_url', 'unifi_api_key'], + healthy: ['unifi'], + ); + $sub->onKernelRequest($event); + + $this->assertNull($event->getResponse()); + } + + 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/DashboardLayoutServiceTest.php b/symfony/tests/Service/DashboardLayoutServiceTest.php new file mode 100644 index 00000000..607abbb8 --- /dev/null +++ b/symfony/tests/Service/DashboardLayoutServiceTest.php @@ -0,0 +1,86 @@ + $stored */ + private function serviceFor(array $stored): DashboardLayoutService + { + $config = $this->createMock(ConfigService::class); + $config->method('get')->willReturnCallback(fn(string $k) => $stored[$k] ?? null); + return new DashboardLayoutService($config); + } + + public function testEmptyConfigGivesDefaultOrderAllVisible(): void + { + $r = $this->serviceFor([])->resolve(); + self::assertSame( + ['upcoming', 'requests', 'health', 'houndarr', 'plex', 'watchlist', 'trending', 'recent', 'server', 'network'], + array_column($r, 'key'), + ); + foreach ($r as $row) { + self::assertTrue($row['visible']); + } + } + + public function testStoredOrderIsHonored(): void + { + $r = $this->serviceFor(['dashboard_section_order' => 'recent,plex,upcoming'])->resolve(); + // Stored keys first, in stored order; the rest appended in default order. + self::assertSame( + ['recent', 'plex', 'upcoming', 'requests', 'health', 'houndarr', 'watchlist', 'trending', 'server', 'network'], + array_column($r, 'key'), + ); + } + + public function testUnknownKeysAreDroppedAndMissingAppended(): void + { + $r = $this->serviceFor(['dashboard_section_order' => 'trending,bogus,recent'])->resolve(); + $keys = array_column($r, 'key'); + self::assertNotContains('bogus', $keys); + self::assertSame(['trending', 'recent', 'upcoming', 'requests', 'health', 'houndarr', 'plex', 'watchlist', 'server', 'network'], $keys); + } + + public function testDuplicateKeysAreCollapsed(): void + { + $r = $this->serviceFor(['dashboard_section_order' => 'plex,plex,recent'])->resolve(); + $keys = array_column($r, 'key'); + self::assertSame(1, count(array_keys($keys, 'plex', true))); + } + + public function testHiddenFlagMarksSectionNotVisible(): void + { + $r = $this->serviceFor(['dashboard_hide_health' => '1'])->resolve(); + $byKey = array_column($r, 'visible', 'key'); + self::assertFalse($byKey['health']); + self::assertTrue($byKey['plex']); + } + + public function testResolutionIsCachedUntilReset(): void + { + $calls = 0; + $config = $this->createMock(ConfigService::class); + $config->method('get')->willReturnCallback(function (string $k) use (&$calls) { + $calls++; + return null; // all defaults (empty order, all visible) + }); + $svc = new DashboardLayoutService($config); + + // First resolve: uncached, should call get() once for order + 10 times for per-section visibility = 11 total. + $svc->resolve(); + self::assertSame(11, $calls, 'First resolve should call ConfigService::get() 11 times (1 order + 10 sections)'); + + // Second resolve: cached, should not call get() again. + $svc->resolve(); + self::assertSame(11, $calls, 'Second resolve should use cache and not call ConfigService::get()'); + + // After reset: cache cleared, next resolve should call get() again. + $svc->reset(); + $svc->resolve(); + self::assertSame(22, $calls, 'After reset, resolve should call ConfigService::get() another 11 times'); + } +} diff --git a/symfony/tests/Service/DisplayPreferencesServiceTest.php b/symfony/tests/Service/DisplayPreferencesServiceTest.php index 4bb9a0af..ea248faf 100644 --- a/symfony/tests/Service/DisplayPreferencesServiceTest.php +++ b/symfony/tests/Service/DisplayPreferencesServiceTest.php @@ -20,7 +20,7 @@ private function serviceWith(array $stored): DisplayPreferencesService $config->method('get') ->willReturnCallback(fn(string $key) => $stored[$key] ?? null); - return new DisplayPreferencesService($config); + return new DisplayPreferencesService($config, new \App\Service\ThemeService($config)); } public function testImplementsResetInterface(): void @@ -40,7 +40,7 @@ public function testDefaultsWhenNothingStored(): void $this->assertSame(date_default_timezone_get(), $prefs->getTimezone()); $this->assertSame('fr', $prefs->getDateFormat()); $this->assertSame('24h', $prefs->getTimeFormat()); - $this->assertSame('indigo', $prefs->getThemeColor()); + $this->assertSame('theme_default', $prefs->getThemeColor()); $this->assertSame('#6366f1', $prefs->getThemeColorHex()); $this->assertSame(2, $prefs->getQbitRefreshSeconds()); $this->assertSame('comfortable', $prefs->getUiDensity()); @@ -96,7 +96,7 @@ public function testResetClearsInRequestCache(): void $config = $this->createMock(ConfigService::class); $config->method('get')->willReturnOnConsecutiveCalls('films', 'series'); - $prefs = new DisplayPreferencesService($config); + $prefs = new DisplayPreferencesService($config, new \App\Service\ThemeService($config)); $this->assertSame('films', $prefs->getHomePage()); // Without reset the cached 'films' would be returned. diff --git a/symfony/tests/Service/HealthServiceDiagnoseTest.php b/symfony/tests/Service/HealthServiceDiagnoseTest.php index d170e6e4..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 @@ -193,4 +217,40 @@ private function probeFor(string $service, array $overrides): ?array $m->setAccessible(true); return $m->invoke($this->makeService(), $service, $overrides); } + + // Deluge (#deluge-tab): deluge-web answers HTTP 200 for everything — the + // real outcome lives in the JSON-RPC envelope, same shape problem as + // Tautulli above but with a different success/failure encoding. + public function testDelugeWrongPasswordIsAuthNotOk(): void + { + $health = $this->makeService(); + $r = $health->diagnoseFromResponse( + ['http' => 200, 'body' => '{"result": false, "error": null, "id": 1}', 'err' => ''], + 'deluge' + ); + $this->assertFalse($r['ok']); + $this->assertSame('auth', $r['category']); + } + + public function testDelugeRpcErrorEnvelopeIsAuth(): void + { + $health = $this->makeService(); + $r = $health->diagnoseFromResponse( + ['http' => 200, 'body' => '{"result": null, "error": {"message": "Not authenticated", "code": 1}, "id": 1}', 'err' => ''], + 'deluge' + ); + $this->assertFalse($r['ok']); + $this->assertSame('auth', $r['category']); + } + + public function testDelugeSuccessEnvelopeIsOk(): void + { + $health = $this->makeService(); + $r = $health->diagnoseFromResponse( + ['http' => 200, 'body' => '{"result": true, "error": null, "id": 1}', 'err' => ''], + 'deluge' + ); + $this->assertTrue($r['ok']); + $this->assertSame('ok', $r['category']); + } } diff --git a/symfony/tests/Service/HealthServiceSharedCacheTest.php b/symfony/tests/Service/HealthServiceSharedCacheTest.php new file mode 100644 index 00000000..5323bc3b --- /dev/null +++ b/symfony/tests/Service/HealthServiceSharedCacheTest.php @@ -0,0 +1,94 @@ +createMock(ProwlarrClient::class); + $pingedOnce->expects(self::once())->method('ping')->willReturn(true); + $first = $this->make($pingedOnce, $pool); + self::assertSame('up', $first->statusFor('prowlarr')['status']); + + // Second instance = second request. Must read the pooled result, not + // re-ping (a live ping here would fail the ::never expectation). + $neverPinged = $this->createMock(ProwlarrClient::class); + $neverPinged->expects(self::never())->method('ping'); + $second = $this->make($neverPinged, $pool); + self::assertSame('up', $second->statusFor('prowlarr')['status']); + } + + public function testInvalidateDropsThePooledStatus(): void + { + $pool = new ArrayAdapter(); + + $before = $this->createMock(ProwlarrClient::class); + $before->expects(self::once())->method('ping')->willReturn(true); + $a = $this->make($before, $pool); + self::assertSame('up', $a->statusFor('prowlarr')['status']); + + $a->invalidate('prowlarr'); + + // A fresh instance after invalidate() must re-probe — "Test + // connection" recovery may not serve a pre-invalidation verdict. + $after = $this->createMock(ProwlarrClient::class); + $after->expects(self::once())->method('ping')->willReturn(false); + $b = $this->make($after, $pool); + self::assertSame('down', $b->statusFor('prowlarr')['status']); + } + + public function testGlobalInvalidateDropsThePooledStatus(): void + { + $pool = new ArrayAdapter(); + + $before = $this->createMock(ProwlarrClient::class); + $before->expects(self::once())->method('ping')->willReturn(true); + $a = $this->make($before, $pool); + self::assertSame('up', $a->statusFor('prowlarr')['status']); + + $a->invalidate(); + + $after = $this->createMock(ProwlarrClient::class); + $after->expects(self::once())->method('ping')->willReturn(true); + $b = $this->make($after, $pool); + self::assertSame('up', $b->statusFor('prowlarr')['status']); + } + + private function make(ProwlarrClient $prowlarr, CacheInterface $pool): HealthService + { + // config/serviceHealthCache stay null: isConfigured() and the breaker + // are skipped, so the tests drive the probe + pool paths directly. + return new HealthService( + $this->createMock(RadarrClient::class), + $this->createMock(SonarrClient::class), + $prowlarr, + $this->createMock(JellyseerrClient::class), + $this->createMock(QBittorrentClient::class), + $this->createMock(TmdbClient::class), + statusPool: $pool, + ); + } +} diff --git a/symfony/tests/Service/HealthServiceTest.php b/symfony/tests/Service/HealthServiceTest.php index eed5affb..9a7e6547 100644 --- a/symfony/tests/Service/HealthServiceTest.php +++ b/symfony/tests/Service/HealthServiceTest.php @@ -430,4 +430,58 @@ public function testUrlBlockedReasonAllowsPublicHttps(): void { $this->assertNull(HealthService::urlBlockedReason('https://api.themoviedb.org/3/configuration')); } + + // ─── chips() — shared chip builder (dashboard + topbar single source) ─── + + public function testChipsExpandsInstancesAddsColorsAndFiltersUnconfigured(): void + { + $svc = $this->chipsService(); // helper below + + $chips = $svc->chips(); + + self::assertSame([ + ['id' => 'radarr', 'name' => 'Radarr 1080p', 'status' => 'up', 'latencyMs' => 12, 'color' => '#FFC230'], + ['id' => 'sabnzbd', 'name' => 'SABnzbd', 'status' => 'up', 'latencyMs' => 30, 'color' => '#fbc531'], + ], $chips); + } + + public function testChipsIncludesUnraidOnlyWhenAsked(): void + { + $svc = $this->chipsService(); + + $ids = array_column($svc->chips(true), 'id'); + self::assertContains('unraid', $ids); + self::assertSame('#f15a2c', $svc->chips(true)[array_search('unraid', $ids, true)]['color']); + + self::assertNotContains('unraid', array_column($svc->chips(), 'id')); + } + + /** HealthService with statusFor stubbed: radarr-1/sabnzbd/unraid up, rest unconfigured. */ + private function chipsService(): HealthService + { + $inst = $this->createMock(ServiceInstance::class); + $inst->method('getSlug')->willReturn('radarr-1'); + $inst->method('getName')->willReturn('Radarr 1080p'); + $instances = $this->createMock(ServiceInstanceProvider::class); + $instances->method('getEnabled')->willReturnCallback( + fn(string $t): array => $t === ServiceInstance::TYPE_RADARR ? [$inst] : [] + ); + + return new class( + $this->createMock(RadarrClient::class), $this->createMock(SonarrClient::class), + $this->createMock(ProwlarrClient::class), $this->createMock(JellyseerrClient::class), + $this->createMock(QBittorrentClient::class), $this->createMock(TmdbClient::class), + null, null, $instances, + ) extends HealthService { + public function statusFor(string $service, ?string $instanceSlug = null): array + { + return match ($service) { + 'radarr' => ['status' => 'up', 'latencyMs' => 12], + 'sabnzbd' => ['status' => 'up', 'latencyMs' => 30], + 'unraid' => ['status' => 'up', 'latencyMs' => 13], + default => ['status' => null, 'latencyMs' => null], + }; + } + }; + } } diff --git a/symfony/tests/Service/HoundarrHealthTest.php b/symfony/tests/Service/HoundarrHealthTest.php new file mode 100644 index 00000000..033f61df --- /dev/null +++ b/symfony/tests/Service/HoundarrHealthTest.php @@ -0,0 +1,100 @@ + $settings */ + private function makeService(array $settings, ?HoundarrClient $houndarr = null): HealthService + { + $config = $this->createMock(ConfigService::class); + $config->method('get')->willReturnCallback(fn(string $k) => $settings[$k] ?? null); + $config->method('has')->willReturnCallback( + fn(string $k) => ($settings[$k] ?? null) !== null && $settings[$k] !== '' + ); + + return new HealthService( + $this->createMock(RadarrClient::class), + $this->createMock(SonarrClient::class), + $this->createMock(ProwlarrClient::class), + $this->createMock(JellyseerrClient::class), + $this->createMock(QBittorrentClient::class), + $this->createMock(TmdbClient::class), + $config, + houndarr: $houndarr, + ); + } + + public function testHoundarrIsToggleable(): void + { + $this->assertContains('houndarr', HealthService::TOGGLEABLE_SERVICES); + } + + public function testConfiguredNeedsUrlAndKey(): void + { + $this->assertFalse($this->makeService([])->isConfigured('houndarr')); + $this->assertFalse($this->makeService(['houndarr_url' => 'http://houndarr:8877'])->isConfigured('houndarr')); + $this->assertTrue($this->makeService([ + 'houndarr_url' => 'http://houndarr:8877', 'houndarr_api_key' => 'hndarr_k', + ])->isConfigured('houndarr')); + } + + public function testKillSwitchDisables(): void + { + $this->assertFalse($this->makeService([ + 'houndarr_url' => 'http://houndarr:8877', 'houndarr_api_key' => 'hndarr_k', 'houndarr_enabled' => '0', + ])->isConfigured('houndarr')); + } + + public function testStatusForPingsTheHoundarrClient(): void + { + $houndarr = $this->createMock(HoundarrClient::class); + $houndarr->expects($this->once())->method('ping')->willReturn(true); + + $svc = $this->makeService( + ['houndarr_url' => 'http://houndarr:8877', 'houndarr_api_key' => 'hndarr_k'], + $houndarr, + ); + $this->assertTrue($svc->isHealthy('houndarr')); + } + + public function testStatusForIsDownWhenPingFails(): void + { + $houndarr = $this->createMock(HoundarrClient::class); + $houndarr->method('ping')->willReturn(false); + + $svc = $this->makeService( + ['houndarr_url' => 'http://houndarr:8877', 'houndarr_api_key' => 'hndarr_k'], + $houndarr, + ); + $this->assertFalse($svc->isHealthy('houndarr')); + } + + public function testChipAppearsWhenConfigured(): void + { + $houndarr = $this->createMock(HoundarrClient::class); + $houndarr->method('ping')->willReturn(true); + + $chips = $this->makeService( + ['houndarr_url' => 'http://houndarr:8877', 'houndarr_api_key' => 'hndarr_k'], + $houndarr, + )->chips(); + $ids = array_column($chips, 'id'); + $this->assertContains('houndarr', $ids); + + $this->assertNotContains('houndarr', array_column($this->makeService([])->chips(), 'id')); + } +} diff --git a/symfony/tests/Service/Media/DelugeClientTest.php b/symfony/tests/Service/Media/DelugeClientTest.php new file mode 100644 index 00000000..81e32e9c --- /dev/null +++ b/symfony/tests/Service/Media/DelugeClientTest.php @@ -0,0 +1,180 @@ +createMock(ConfigService::class); + return new DelugeClient($config, new NullLogger(), new ServiceHealthCache(new ArrayAdapter())); + } + + private function invokeStatic(string $method, mixed ...$args): mixed + { + $m = (new \ReflectionClass(DelugeClient::class))->getMethod($method); + $m->setAccessible(true); + return $m->invoke(null, ...$args); + } + + /** + * deluge-web answers HTTP 200 for EVERYTHING — the real outcome lives in + * the JSON-RPC envelope. parseRpcBody() must return the result on + * success and a structured error otherwise (including malformed JSON, + * which happens when a reverse proxy serves an HTML error page). + * + * @return iterable + */ + public static function rpcBodies(): iterable + { + yield 'success with result' => ['{"result": "2.1.1", "error": null, "id": 1}', '2.1.1', null]; + yield 'success null result' => ['{"result": null, "error": null, "id": 2}', null, null]; + yield 'auth error (code 1)' => ['{"result": null, "error": {"message": "Not authenticated", "code": 1}, "id": 3}', null, 'Not authenticated']; + yield 'plugin missing' => ['{"result": null, "error": {"message": "Unknown method", "code": 2}, "id": 4}', null, 'Unknown method']; + yield 'malformed body' => ['proxy error', null, 'malformed JSON-RPC response']; + } + + #[DataProvider('rpcBodies')] + public function testParseRpcBody(string $body, mixed $expectedResult, ?string $expectedError): void + { + $parsed = $this->invokeStatic('parseRpcBody', $body); + $this->assertSame($expectedResult, $parsed['result']); + if ($expectedError === null) { + $this->assertNull($parsed['error']); + } else { + $this->assertSame($expectedError, $parsed['error']['message']); + } + } + + public function testParseRpcBodyExposesErrorCodeForReloginDetection(): void + { + $parsed = $this->invokeStatic('parseRpcBody', '{"result": null, "error": {"message": "Not authenticated", "code": 1}, "id": 9}'); + $this->assertSame(1, $parsed['error']['code']); + } + + /** + * deluge-web sets `_session_id` on auth.login. We must echo it back as a + * ready-to-send "name=value" pair, and return null when login failed + * (wrong password → result false, no cookie). + * + * @return iterable + */ + public static function setCookieHeaders(): iterable + { + yield 'session cookie' => [ + "HTTP/1.1 200 OK\r\nSet-Cookie: _session_id=abcDEF123; Expires=...; Path=/json\r\n\r\n", + '_session_id=abcDEF123', + ]; + yield 'no cookie (bad password)' => [ + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n", + null, + ]; + } + + #[DataProvider('setCookieHeaders')] + public function testExtractSessionCookie(string $rawHeaders, ?string $expected): void + { + $this->assertSame($expected, $this->invokeStatic('extractSessionCookie', $rawHeaders)); + } + + /** + * Deluge state names → the normalized vocabulary the (copied) qBit + * template understands. `Paused` stays paused even when finished — + * Deluge has no "completed" state. + * + * @return iterable + */ + public static function states(): iterable + { + yield 'Downloading' => ['Downloading', false, 'downloading']; + yield 'Seeding' => ['Seeding', true, 'seeding']; + yield 'Paused unfinished' => ['Paused', false, 'paused']; + yield 'Paused finished' => ['Paused', true, 'paused']; + yield 'Queued' => ['Queued', false, 'queued']; + yield 'Checking' => ['Checking', false, 'checking']; + yield 'Error' => ['Error', false, 'error']; + yield 'Moving' => ['Moving', true, 'moving']; + yield 'Allocating' => ['Allocating', false, 'downloading']; + yield 'unknown unfinished' => ['Bogus', false, 'unknown']; + yield 'unknown finished' => ['Bogus', true, 'seeding']; + } + + #[DataProvider('states')] + public function testNormalizeState(string $state, bool $finished, string $expected): void + { + $this->assertSame($expected, $this->invokeStatic('normalizeState', $state, $finished)); + } + + /** + * Deluge speaks KiB/s for speed limits (-1 = unlimited); the rest of + * Prismarr (and the copied template) speaks bytes/s. + */ + public function testSpeedLimitUnitConversions(): void + { + $this->assertSame(-1, $this->invokeStatic('kibToBytes', -1.0)); + $this->assertSame(1024, $this->invokeStatic('kibToBytes', 1.0)); + $this->assertSame(512000, $this->invokeStatic('kibToBytes', 500.0)); + $this->assertSame(-1.0, $this->invokeStatic('bytesToKib', 0)); + $this->assertSame(-1.0, $this->invokeStatic('bytesToKib', -1)); + $this->assertSame(500.0, $this->invokeStatic('bytesToKib', 512000)); + } + + public function testNormalizeTorrentMapsDelugeStatusToQbitShape(): void + { + $client = $this->makeClient(); + $m = (new \ReflectionClass($client))->getMethod('normalizeTorrent'); + $m->setAccessible(true); + + $t = $m->invoke($client, 'a1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d0', [ + 'name' => 'Example.2026.1080p.WEB.h264-GRP', + 'total_wanted' => 4000000000, 'total_size' => 4000000000, + 'total_done' => 4000000000, 'all_time_download' => 4100000000, + 'total_uploaded' => 900000000, + 'progress' => 100.0, 'download_payload_rate' => 0, 'upload_payload_rate' => 12345, + 'eta' => 0, 'state' => 'Seeding', 'is_finished' => true, + 'label' => 'tv-sonarr', 'ratio' => 0.219512, + 'num_seeds' => 1, 'total_seeds' => 14, 'num_peers' => 2, 'total_peers' => 3, + 'time_added' => 1751000000, 'completed_time' => 1751100000, + 'save_path' => '/downloads', 'tracker_host' => 'xspeeds.eu', + 'seeding_time' => 86400, 'max_download_speed' => -1.0, 'max_upload_speed' => 500.0, + 'distributed_copies' => 14.97, + ]); + + $this->assertSame('a1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d0', $t['hash']); + $this->assertSame('seeding', $t['state']); + $this->assertSame('Seeding', $t['raw_state']); + $this->assertSame(100.0, $t['progress']); + $this->assertSame('tv-sonarr', $t['category']); // Deluge label rides the category field + $this->assertSame('', $t['tags']); + $this->assertSame(0.22, $t['ratio']); + $this->assertSame(8640000, $t['eta']); // 0 → qBit "no ETA" sentinel + $this->assertSame(1751000000, $t['added_on']); + $this->assertSame(1751100000, $t['completion_on']); + $this->assertSame('xspeeds.eu', $t['tracker']); + $this->assertSame(-1, $t['dl_limit']); + $this->assertSame(512000, $t['up_limit']); // 500 KiB/s → bytes + $this->assertSame(86400, $t['seeding_time']); + } + + /** + * splitAddUrls() feeds addTorrentFromUrl(): magnet lines go to + * core.add_torrent_magnet, http(s) lines to core.add_torrent_url. + * Split on newlines and pipes like the qBit add box. + */ + public function testSplitAddUrlsSeparatesMagnetsFromHttp(): void + { + $split = $this->invokeStatic('splitAddUrls', "magnet:?xt=urn:btih:aaa\nhttps://tracker.example/x.torrent | magnet:?xt=urn:btih:bbb"); + $this->assertSame(['magnet:?xt=urn:btih:aaa', 'magnet:?xt=urn:btih:bbb'], $split['magnets']); + $this->assertSame(['https://tracker.example/x.torrent'], $split['urls']); + } +} diff --git a/symfony/tests/Service/Media/HoundarrClientTest.php b/symfony/tests/Service/Media/HoundarrClientTest.php new file mode 100644 index 00000000..fd0395c4 --- /dev/null +++ b/symfony/tests/Service/Media/HoundarrClientTest.php @@ -0,0 +1,170 @@ + 1, + 'generated_at' => '2026-05-22T18:00:00Z', + 'totals' => [ + 'tracked' => 11, + 'eligible' => 7, + 'gated' => 2, + 'unreleased' => 1, + 'searches_7d' => 1, + ], + ]; + } + + public function testNormalizesDocumentedPayload(): void + { + $out = HoundarrClient::normalizeWidget($this->fixture()); + + self::assertSame( + ['tracked' => 11, 'eligible' => 7, 'gated' => 2, 'unreleased' => 1, 'searches7d' => 1], + $out['totals'], + ); + self::assertSame(strtotime('2026-05-22T18:00:00Z'), $out['generatedAtEpoch']); + self::assertNull($out['error']); + } + + public function testClampsNegativesAndCastsStrings(): void + { + $out = HoundarrClient::normalizeWidget([ + 'totals' => ['tracked' => '-3', 'eligible' => '5', 'gated' => -1, 'unreleased' => 2.9, 'searches_7d' => 'abc'], + ]); + + self::assertSame( + ['tracked' => 0, 'eligible' => 5, 'gated' => 0, 'unreleased' => 2, 'searches7d' => 0], + $out['totals'], + ); + } + + public function testMissingFieldsDefaultToZeroAndUnknownKeysAreDropped(): void + { + $out = HoundarrClient::normalizeWidget(['totals' => ['tracked' => 4, 'secret_path' => '/mnt/x'], 'api_key' => 'leak']); + + self::assertSame( + ['tracked' => 4, 'eligible' => 0, 'gated' => 0, 'unreleased' => 0, 'searches7d' => 0], + $out['totals'], + ); + self::assertSame(['totals', 'generatedAtEpoch', 'error'], array_keys($out)); + } + + public function testBadOrMissingGeneratedAtIsNull(): void + { + self::assertNull(HoundarrClient::normalizeWidget(['totals' => [], 'generated_at' => 'not-a-date'])['generatedAtEpoch']); + self::assertNull(HoundarrClient::normalizeWidget(['totals' => []])['generatedAtEpoch']); + } + + public function testTotalsNotAnArrayIsTreatedAsEmpty(): void + { + $out = HoundarrClient::normalizeWidget(['totals' => 'nope']); + self::assertSame(0, $out['totals']['tracked']); + } + + /** + * Test double: stubs the protected transport seam, counts calls, and lets + * tests drive config via a real ConfigService mock. + */ + private function makeClient(array $settings, ?array $response, int &$calls): HoundarrClient + { + $config = $this->createMock(\App\Service\ConfigService::class); + $config->method('get')->willReturnCallback(fn(string $k) => $settings[$k] ?? null); + + return new class($config, new \Psr\Log\NullLogger(), $response, $calls) extends HoundarrClient { + public function __construct($config, $logger, private readonly ?array $response, private int &$calls) + { + parent::__construct($config, $logger); + } + protected function request(): ?array + { + $this->calls++; + return $this->response; + } + }; + } + + private const CONFIGURED = ['houndarr_url' => 'http://houndarr:8877', 'houndarr_api_key' => 'hndarr_k']; + + public function testWidgetReturnsNullAndNeverCallsUpstreamWhenUnconfigured(): void + { + $calls = 0; + self::assertNull($this->makeClient([], ['http' => 200, 'body' => '{}'], $calls)->widget()); + self::assertSame(0, $calls); + } + + public function testWidgetReturnsNullWhenKillSwitchedOff(): void + { + $calls = 0; + $client = $this->makeClient(self::CONFIGURED + ['houndarr_enabled' => '0'], ['http' => 200, 'body' => '{}'], $calls); + self::assertNull($client->widget()); + self::assertSame(0, $calls); + } + + public function testWidgetNormalizesSuccessAndCachesWithinTtl(): void + { + $calls = 0; + $body = json_encode(['schema' => 1, 'generated_at' => '2026-05-22T18:00:00Z', + 'totals' => ['tracked' => 11, 'eligible' => 7, 'gated' => 2, 'unreleased' => 1, 'searches_7d' => 1]]); + $client = $this->makeClient(self::CONFIGURED, ['http' => 200, 'body' => $body], $calls); + + $out = $client->widget(); + self::assertSame(7, $out['totals']['eligible']); + self::assertNull($out['error']); + + $client->widget(); + self::assertSame(1, $calls); // second read served from the 45 s cache + } + + public function testWidgetMapsAuthAndCachesTheVerdict(): void + { + $calls = 0; + $client = $this->makeClient(self::CONFIGURED, ['http' => 401, 'body' => ''], $calls); + + $out = $client->widget(); + self::assertSame('auth', $out['error']); + self::assertNull($out['totals']); + + $client->widget(); + self::assertSame(1, $calls); // auth verdict cached too — no 429-tripping re-probes + } + + public function testWidgetReturnsNullUncachedOnServerErrorAndTransportFailure(): void + { + $calls = 0; + $client = $this->makeClient(self::CONFIGURED, ['http' => 500, 'body' => ''], $calls); + self::assertNull($client->widget()); + self::assertNull($client->widget()); + self::assertSame(2, $calls); // not cached — retry next poll + + $calls = 0; + self::assertNull($this->makeClient(self::CONFIGURED, null, $calls)->widget()); + self::assertSame(1, $calls); + } + + public function testWidgetReturnsNullOnMalformedJson(): void + { + $calls = 0; + self::assertNull($this->makeClient(self::CONFIGURED, ['http' => 200, 'body' => 'not-json'], $calls)->widget()); + } + + public function testPingTrueOnlyOnCleanFetch(): void + { + $calls = 0; + $ok = json_encode(['totals' => ['tracked' => 0, 'eligible' => 0, 'gated' => 0, 'unreleased' => 0, 'searches_7d' => 0]]); + self::assertTrue($this->makeClient(self::CONFIGURED, ['http' => 200, 'body' => $ok], $calls)->ping()); + self::assertFalse($this->makeClient(self::CONFIGURED, ['http' => 401, 'body' => ''], $calls)->ping()); + self::assertFalse($this->makeClient(self::CONFIGURED, null, $calls)->ping()); + self::assertFalse($this->makeClient([], ['http' => 200, 'body' => $ok], $calls)->ping()); + } +} diff --git a/symfony/tests/Service/Media/TautulliQuickLookResolveTest.php b/symfony/tests/Service/Media/TautulliQuickLookResolveTest.php new file mode 100644 index 00000000..4a638355 --- /dev/null +++ b/symfony/tests/Service/Media/TautulliQuickLookResolveTest.php @@ -0,0 +1,109 @@ + 'movie', + 'guids' => ['imdb://tt0133093', 'tmdb://603'], + ]); + + self::assertSame(['type' => 'movie', 'id' => 603], $r); + } + + public function testShowResolvesAsTvFromItsOwnGuids(): void + { + $r = TautulliClient::tmdbIdFromMetadata([ + 'media_type' => 'show', + 'guids' => ['tvdb://121361', 'tmdb://1399'], + ]); + + self::assertSame(['type' => 'tv', 'id' => 1399], $r); + } + + public function testEpisodeResolvesFromGrandparentGuidsOnly(): void + { + $r = TautulliClient::tmdbIdFromMetadata([ + 'media_type' => 'episode', + // Episode-level tmdb guid = the EPISODE's id — must be ignored. + 'guids' => ['tmdb://999999'], + 'grandparent_guids' => ['imdb://tt0903747', 'tmdb://1396'], + ]); + + self::assertSame(['type' => 'tv', 'id' => 1396], $r); + } + + public function testSeasonResolvesFromParentGuids(): void + { + $r = TautulliClient::tmdbIdFromMetadata([ + 'media_type' => 'season', + 'parent_guids' => ['tmdb://1396'], + ]); + + self::assertSame(['type' => 'tv', 'id' => 1396], $r); + } + + public function testEpisodeWithoutShowLevelGuidsIsNull(): void + { + // The pure transform can't hop to the grandparent — that's + // resolveTmdbId()'s orchestration job. It must signal "unresolved". + $r = TautulliClient::tmdbIdFromMetadata([ + 'media_type' => 'episode', + 'guids' => ['tmdb://999999'], + ]); + + self::assertNull($r); + } + + public function testMusicAndUnknownTypesAreNull(): void + { + self::assertNull(TautulliClient::tmdbIdFromMetadata([ + 'media_type' => 'track', + 'guids' => ['tmdb://603'], + ])); + self::assertNull(TautulliClient::tmdbIdFromMetadata([])); + } + + public function testNoTmdbGuidIsNull(): void + { + self::assertNull(TautulliClient::tmdbIdFromMetadata([ + 'media_type' => 'movie', + 'guids' => ['imdb://tt0133093', 'tvdb://81189'], + ])); + } + + public function testMalformedGuidShapesAreNullNotFatal(): void + { + self::assertNull(TautulliClient::tmdbIdFromMetadata([ + 'media_type' => 'movie', + 'guids' => 'tmdb://603', // string, not list + ])); + self::assertNull(TautulliClient::tmdbIdFromMetadata([ + 'media_type' => 'movie', + 'guids' => [42, null, ['tmdb://603']], + ])); + self::assertNull(TautulliClient::tmdbIdFromMetadata([ + 'media_type' => 'movie', + 'guids' => ['tmdb://not-a-number'], + ])); + } +} diff --git a/symfony/tests/Service/Media/TmdbClientRegionPriorityTest.php b/symfony/tests/Service/Media/TmdbClientRegionPriorityTest.php new file mode 100644 index 00000000..0860a681 --- /dev/null +++ b/symfony/tests/Service/Media/TmdbClientRegionPriorityTest.php @@ -0,0 +1,64 @@ +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)); + } + + /** + * Transmission's torrent-get only returns the fields it was asked for, so + * every key getTorrentDetail() reads off the top-level torrent object must + * be present in DETAIL_FIELDS — otherwise it silently coalesces to a + * default (0/''/[]) instead of the real value. Regression for + * rateDownload/rateUpload being read (properties.dl_speed/up_speed) but + * not requested, which zeroed both speeds on the detail panel. + * + * @return iterable + */ + public static function detailFieldsReadByGetTorrentDetail(): iterable + { + // hashString/status/error are requested but not directly read inside + // getTorrentDetail() (deliberately not asserted here — this test is + // about reads without a matching request, not the reverse). + yield 'name' => ['name']; + yield 'downloadDir' => ['downloadDir']; + yield 'totalSize' => ['totalSize']; + yield 'pieceSize' => ['pieceSize']; + yield 'pieceCount' => ['pieceCount']; + yield 'comment' => ['comment']; + yield 'uploadedEver' => ['uploadedEver']; + yield 'downloadedEver' => ['downloadedEver']; + yield 'uploadRatio' => ['uploadRatio']; + yield 'addedDate' => ['addedDate']; + yield 'doneDate' => ['doneDate']; + yield 'secondsDownloading' => ['secondsDownloading']; + yield 'secondsSeeding' => ['secondsSeeding']; + yield 'eta' => ['eta']; + yield 'peersSendingToUs' => ['peersSendingToUs']; + yield 'peersGettingFromUs' => ['peersGettingFromUs']; + yield 'files' => ['files']; + yield 'fileStats' => ['fileStats']; + yield 'trackerStats' => ['trackerStats']; + yield 'peers' => ['peers']; + yield 'rateDownload (properties.dl_speed)' => ['rateDownload']; + yield 'rateUpload (properties.up_speed)' => ['rateUpload']; + } + + #[DataProvider('detailFieldsReadByGetTorrentDetail')] + public function testDetailFieldsCoversEveryKeyGetTorrentDetailReads(string $field): void + { + $r = new \ReflectionClassConstant(TransmissionClient::class, 'DETAIL_FIELDS'); + $detailFields = $r->getValue(); + + $this->assertContains( + $field, + $detailFields, + sprintf('DETAIL_FIELDS is missing "%s", which getTorrentDetail() reads off the torrent object — Transmission will silently default it to 0/empty.', $field) + ); + } +} diff --git a/symfony/tests/Service/Media/UnifiClientTest.php b/symfony/tests/Service/Media/UnifiClientTest.php new file mode 100644 index 00000000..0c10cda3 --- /dev/null +++ b/symfony/tests/Service/Media/UnifiClientTest.php @@ -0,0 +1,239 @@ + $responses keyed by a substring of the path */ + private function makeClient(array $responses, array $settings = [], bool $failTransport = false): UnifiClient + { + $settings += [ + 'unifi_url' => 'https://192.168.1.1', + 'unifi_api_key' => 'k3y', + 'unifi_site' => null, + 'unifi_enabled' => null, + 'unifi_skip_tls_verify' => null, + ]; + $config = $this->createMock(ConfigService::class); + $config->method('get')->willReturnCallback(fn(string $k) => $settings[$k] ?? null); + + return new class($config, $this->createMock(LoggerInterface::class), $responses, $failTransport) extends UnifiClient { + public array $pathsRequested = []; + public array $bodiesSent = []; + public ?int $nowOverride = null; + public function __construct($config, $logger, private array $responses, private bool $failTransport) + { + parent::__construct($config, $logger); + } + protected function request(string $path, ?array $jsonBody = null): ?array + { + $this->pathsRequested[] = $path; + $this->bodiesSent[] = $jsonBody; + if ($this->failTransport) { + $this->transportDown = true; + return null; + } + foreach ($this->responses as $needle => $payload) { + if (str_contains($path, $needle)) { + return $payload; + } + } + return null; + } + protected function now(): int { return $this->nowOverride ?? parent::now(); } + }; + } + + private const HEALTH_DATA = [ + ['subsystem' => 'wan', 'status' => 'ok', 'wan_ip' => '203.0.113.7', + 'gw_system-stats' => ['cpu' => '12.3', 'mem' => '38.1', 'uptime' => '123456']], + ['subsystem' => 'www', 'status' => 'ok', 'tx_bytes-r' => '125000', 'rx_bytes-r' => '2500000', + 'latency' => '12', 'uptime' => '864000'], + ['subsystem' => 'wlan', 'status' => 'ok', 'num_user' => 18, 'num_guest' => 2, 'num_iot' => 7], + ['subsystem' => 'lan', 'status' => 'ok', 'num_user' => 9, 'num_guest' => 0, 'num_iot' => 3], + ]; + private const REPORT_DATA = [ + // Deliberately unsorted: mapUsage must sort ascending. time is epoch MS. + ['time' => 1751742000000, 'wan-tx_bytes' => 1.0e8, 'wan-rx_bytes' => 2.0e9], + ['time' => 1751738400000, 'wan-tx_bytes' => 5.0e7, 'wan-rx_bytes' => 1.0e9], + ]; + private const DEVICE_DATA = [ + ['name' => 'Dream Machine', 'type' => 'udm', 'model' => 'UDM-PRO', 'state' => 1, 'uptime' => 999], + ['name' => 'Office AP', 'type' => 'uap', 'model' => 'U6-Lite', 'state' => 1, 'uptime' => 500], + ['name' => 'Garage Switch', 'type' => 'usw', 'model' => 'USW-8', 'state' => 0], + ]; + + private function allEndpoints(): array + { + return [ + 'stat/health' => self::HEALTH_DATA, + 'stat/report/hourly.site' => self::REPORT_DATA, + 'stat/device' => self::DEVICE_DATA, + ]; + } + + public function testUnconfiguredReturnsNullWithoutRequest(): void + { + $client = $this->makeClient($this->allEndpoints(), ['unifi_url' => null]); + $this->assertNull($client->overview()); + $this->assertSame([], $client->pathsRequested); + + $client = $this->makeClient($this->allEndpoints(), ['unifi_api_key' => null]); + $this->assertNull($client->overview()); + $this->assertSame([], $client->pathsRequested); + } + + public function testKillSwitchDisables(): void + { + $client = $this->makeClient($this->allEndpoints(), ['unifi_enabled' => '0']); + $this->assertNull($client->overview()); + $this->assertSame([], $client->pathsRequested); + } + + public function testOverviewMapsWanClientsGateway(): void + { + $o = $this->makeClient($this->allEndpoints())->overview(); + + $this->assertSame('ok', $o['wan']['status']); + $this->assertSame('203.0.113.7', $o['wan']['ip']); + $this->assertSame(864000, $o['wan']['uptimeSeconds']); + $this->assertSame(2500000.0, $o['wan']['downBps']); // rx = download + $this->assertSame(125000.0, $o['wan']['upBps']); + $this->assertSame(12, $o['wan']['latencyMs']); + + $this->assertSame(27, $o['clients']['wireless']); // 18 + 2 + 7 + $this->assertSame(12, $o['clients']['wired']); // 9 + 0 + 3 + $this->assertSame(39, $o['clients']['total']); + $this->assertSame(2, $o['clients']['guest']); // wlan 2 + lan 0 + + $this->assertSame(12.3, $o['gateway']['cpuPercent']); + $this->assertSame(38.1, $o['gateway']['memPercent']); + } + + public function testMissingSubsystemsTolerated(): void + { + $health = [['subsystem' => 'lan', 'status' => 'ok', 'num_user' => 5]]; + $o = $this->makeClient(['stat/health' => $health, 'stat/report' => null, 'stat/device' => null])->overview(); + + $this->assertNull($o['wan']); // no wan/www subsystem + $this->assertNull($o['gateway']); + $this->assertSame(5, $o['clients']['wired']); + $this->assertNull($o['clients']['wireless']); + $this->assertSame(5, $o['clients']['total']); + $this->assertNull($o['usage24h']); + $this->assertNull($o['devices']); + } + + public function testUsageMappingSortsAndConvertsMsToSeconds(): void + { + $o = $this->makeClient($this->allEndpoints())->overview(); + + $this->assertCount(2, $o['usage24h']); + $this->assertSame(1751738400, $o['usage24h'][0]['ts']); // sorted ascending, ms → s + $this->assertSame(1.0e9, $o['usage24h'][0]['downBytes']); // rx = download + $this->assertSame(5.0e7, $o['usage24h'][0]['upBytes']); + $this->assertSame(1751742000, $o['usage24h'][1]['ts']); + } + + public function testReportRequestUses24hMsWindow(): void + { + $client = $this->makeClient($this->allEndpoints()); + $client->nowOverride = 1751800000; + $client->overview(); + + $reportBody = null; + foreach ($client->pathsRequested as $i => $p) { + if (str_contains($p, 'report')) { $reportBody = $client->bodiesSent[$i]; } + } + $this->assertSame(['time', 'wan-tx_bytes', 'wan-rx_bytes'], $reportBody['attrs']); + $this->assertSame((1751800000 - 86400) * 1000, $reportBody['start']); + $this->assertSame(1751800000 * 1000, $reportBody['end']); + } + + public function testDevicesMappedAndSortedOfflineFirst(): void + { + $o = $this->makeClient($this->allEndpoints())->overview(); + + $this->assertSame('Garage Switch', $o['devices'][0]['name']); // offline first + $this->assertFalse($o['devices'][0]['online']); + $this->assertSame('switch', $o['devices'][0]['kind']); + $this->assertSame('Dream Machine', $o['devices'][1]['name']); // then gateway + $this->assertSame('gateway', $o['devices'][1]['kind']); + $this->assertSame('Office AP', $o['devices'][2]['name']); // then AP + $this->assertSame('ap', $o['devices'][2]['kind']); + $this->assertSame(999, $o['devices'][1]['uptimeSeconds']); + } + + public function testTransportDownShortCircuits(): void + { + $client = $this->makeClient([], failTransport: true); + $this->assertNull($client->overview()); + $this->assertCount(1, $client->pathsRequested); // health only, no report/device + } + + public function testOverviewTtlCachesWithinWindow(): void + { + $client = $this->makeClient($this->allEndpoints()); + $first = $client->overview(); + $second = $client->overview(); + $this->assertSame($first, $second); + $this->assertCount(3, $client->pathsRequested); // not 6 + } + + public function testAllNullEndpointsMeansNullAndNoCache(): void + { + $client = $this->makeClient(['stat/health' => null, 'stat/report' => null, 'stat/device' => null]); + $this->assertNull($client->overview()); + $client->overview(); + $this->assertCount(6, $client->pathsRequested); // second call retried + } + + public function testPing(): void + { + $this->assertTrue($this->makeClient($this->allEndpoints())->ping()); + $this->assertFalse($this->makeClient(['stat/health' => null])->ping()); + } + + public function testFetchDelegatesToRequestAndReturnsData(): void + { + $client = $this->makeClient($this->allEndpoints()); + + $this->assertSame(self::DEVICE_DATA, $client->fetch('/stat/device')); + $this->assertContains('/stat/device', $client->pathsRequested); + $this->assertFalse($client->transportFailed()); + } + + public function testFetchReportsTransportFailureButNotApplicationMiss(): void + { + $failing = $this->makeClient([], failTransport: true); + $this->assertNull($failing->fetch('/stat/device')); + $this->assertTrue($failing->transportFailed()); + + // An application-level miss (no such payload) is NOT a transport + // failure — readers must keep trying their remaining endpoints. + $ok = $this->makeClient($this->allEndpoints()); + $this->assertNull($ok->fetch('/stat/nonexistent')); + $this->assertFalse($ok->transportFailed()); + } + + public function testFetchPassesJsonBodyThrough(): void + { + $client = $this->makeClient($this->allEndpoints()); + $client->fetch('/stat/report/hourly.site', ['attrs' => ['time']]); + + $i = array_search('/stat/report/hourly.site', $client->pathsRequested, true); + $this->assertSame(['attrs' => ['time']], $client->bodiesSent[$i]); + } +} diff --git a/symfony/tests/Service/Media/UnraidClientTest.php b/symfony/tests/Service/Media/UnraidClientTest.php new file mode 100644 index 00000000..418bc867 --- /dev/null +++ b/symfony/tests/Service/Media/UnraidClientTest.php @@ -0,0 +1,449 @@ + $responses keyed by a substring of the query */ + private function makeClient(array $responses, array $settings = []): UnraidClient + { + $settings += [ + 'unraid_url' => 'https://tower.local', + 'unraid_api_key' => 'k3y', + 'unraid_enabled' => null, + 'unraid_skip_tls_verify' => null, + ]; + $config = $this->createMock(ConfigService::class); + $config->method('get')->willReturnCallback(fn(string $k) => $settings[$k] ?? null); + + return new class($config, $this->createMock(LoggerInterface::class), $responses) extends UnraidClient { + public array $queriesSent = []; + public ?int $nowOverride = null; + public function __construct($config, $logger, private array $responses) + { + parent::__construct($config, $logger); + } + protected function gql(string $query): ?array + { + $this->queriesSent[] = $query; + foreach ($this->responses as $needle => $payload) { + if (str_contains($query, $needle)) { + return $payload; + } + } + return null; + } + protected function now(): int { return $this->nowOverride ?? parent::now(); } + }; + } + + private const ARRAY_DATA = ['array' => [ + 'state' => 'STARTED', + 'capacity' => ['kilobytes' => ['free' => '1000', 'used' => '3000', 'total' => '4000']], + 'disks' => [ + ['name' => 'disk1', 'temp' => 38, 'status' => 'DISK_OK', 'fsSize' => '4000', 'fsFree' => '1000', 'fsUsed' => '3000'], + ], + 'parities' => [['name' => 'parity', 'temp' => 41, 'status' => 'DISK_OK', 'size' => '10000']], + 'caches' => [['name' => 'cache', 'temp' => 35, 'fsSize' => '500', 'fsFree' => '200', 'fsUsed' => '300']], + ]]; + private const INFO_DATA = ['info' => [ + 'os' => ['uptime' => '2026-06-20T04:05:06Z'], + 'cpu' => ['brand' => 'AMD Ryzen 7', 'cores' => 8, 'threads' => 16], + ]]; + private const METRICS_DATA = ['metrics' => [ + 'cpu' => ['percentTotal' => 12.5], + 'memory' => ['percentTotal' => 40.0, 'total' => '32000000000', 'used' => '12800000000'], + ]]; + private const DOCKER_DATA = ['docker' => ['containers' => [ + ['names' => ['/plex'], 'state' => 'RUNNING'], + ['names' => ['/radarr'], 'state' => 'RUNNING'], + ['names' => ['/old-app'], 'state' => 'EXITED'], + ]]]; + private const UPS_DATA = ['upsDevices' => [[ + 'name' => 'APC', + 'battery' => ['chargeLevel' => 100, 'estimatedRuntime' => 4302], // seconds (≈72 min) + 'power' => ['loadPercentage' => 18.0], + ]]]; + private const PARITY_STATUS_RUNNING = ['vars' => [ + 'mdResyncPos' => '5000', 'mdResyncSize' => '10000', + 'sbSynced' => '1750000000', 'sbSyncErrs' => '3', + ]]; + private const PARITY_STATUS_IDLE = ['vars' => [ + 'mdResyncPos' => '0', 'mdResyncSize' => '10000', + 'sbSynced' => '1750000000', 'sbSyncErrs' => '0', + ]]; + private const PARITY_STATUS_RUNNING_NULLSIZE = ['vars' => [ + 'mdResyncPos' => '5000', 'mdResyncSize' => null, + 'sbSynced' => '1750000000', 'sbSyncErrs' => '0', + ]]; + private const PARITY_HISTORY = ['parityHistory' => [ + // Deliberately oldest-first: mapParity must pick the newest by date. + ['date' => '2026-05-01 06:00:00', 'duration' => 80000, 'errors' => 2, 'status' => 'OK'], + ['date' => '2026-06-01 06:00:00', 'duration' => 76440, 'errors' => 0, 'status' => 'OK'], + ]]; + + private function allGroups(): array + { + return [ + 'array {' => self::ARRAY_DATA, + 'info {' => self::INFO_DATA, + 'metrics {' => self::METRICS_DATA, + 'docker {' => self::DOCKER_DATA, + 'upsDevices' => self::UPS_DATA, + 'vars {' => self::PARITY_STATUS_IDLE, + 'parityHistory {' => self::PARITY_HISTORY, + ]; + } + + public function testOverviewHappyPathMapsAllGroups(): void + { + $o = $this->makeClient($this->allGroups())->overview(); + + $this->assertNotNull($o); + $this->assertSame('STARTED', $o['array']['state']); + $this->assertSame(3000.0, $o['array']['capacity']['used']); + $this->assertCount(1, $o['array']['disks']); + $this->assertSame('disk1', $o['array']['disks'][0]['name']); + $this->assertSame(38, $o['array']['disks'][0]['temp']); + $this->assertCount(1, $o['array']['parities']); + $this->assertCount(1, $o['array']['caches']); + + $this->assertSame('AMD Ryzen 7', $o['system']['cpuBrand']); + $this->assertSame(12.5, $o['system']['cpuPercent']); + $this->assertSame(40.0, $o['system']['memPercent']); + $this->assertSame('2026-06-20T04:05:06Z', $o['system']['uptime']); + $this->assertSame(strtotime('2026-06-20T04:05:06Z'), $o['system']['uptimeEpoch']); + + $this->assertSame(2, $o['docker']['running']); + $this->assertSame(3, $o['docker']['total']); + $this->assertSame(['old-app'], $o['docker']['stopped']); + + $this->assertSame(100, $o['ups']['battery']); + $this->assertSame(72, $o['ups']['runtime'], 'estimatedRuntime seconds must convert to whole minutes'); + $this->assertSame(18.0, $o['ups']['load']); + } + + public function testMissingGroupsDegradeToNullWithoutKillingOthers(): void + { + // UPS query fails (no UPS / scope missing) — the other groups survive. + $responses = $this->allGroups(); + unset($responses['upsDevices']); + $o = $this->makeClient($responses)->overview(); + + $this->assertNotNull($o); + $this->assertNull($o['ups']); + $this->assertNotNull($o['array']); + $this->assertNotNull($o['docker']); + } + + public function testOverviewIsNullWhenEveryGroupFails(): void + { + $this->assertNull($this->makeClient([])->overview()); + } + + public function testDeadHostShortCircuitsAfterFirstQuery(): void + { + // Simulate a transport-level failure (connect refused/timeout) on the + // first (array) query: gql() sets transportDown and returns null. + // overview() must bail immediately without issuing the other 4 queries. + $config = $this->createMock(ConfigService::class); + $config->method('get')->willReturnCallback(fn(string $k) => [ + 'unraid_url' => 'https://tower.local', + 'unraid_api_key' => 'k3y', + ][$k] ?? null); + + $client = new class($config, $this->createMock(LoggerInterface::class)) extends UnraidClient { + public int $calls = 0; + protected function gql(string $query): ?array + { + $this->calls++; + $this->transportDown = true; // first (and only) call: host down + return null; + } + }; + + $this->assertNull($client->overview()); + $this->assertSame(1, $client->calls, 'must stop after the first transport-failed query'); + } + + public function testAppLevelGraphqlErrorDoesNotShortCircuit(): void + { + // A missing array group WITHOUT a transport failure (e.g. scope error, + // HTTP 200 {errors}) must NOT stop the remaining group queries. + $responses = $this->allGroups(); + unset($responses['array {']); // array group returns null, transportDown stays false + $client = $this->makeClient($responses); + $o = $client->overview(); + + $this->assertNotNull($o); + $this->assertNull($o['array']); + $this->assertNotNull($o['docker']); + $this->assertGreaterThan(1, count($client->queriesSent), 'app-level error must not short-circuit'); + } + + public function testOverviewIsNullAndSendsNothingWhenUnconfigured(): void + { + $client = $this->makeClient($this->allGroups(), ['unraid_url' => null]); + $this->assertNull($client->overview()); + $this->assertSame([], $client->queriesSent); + } + + public function testOverviewIsNullAndSendsNothingWhenKillSwitchedOff(): void + { + $client = $this->makeClient($this->allGroups(), ['unraid_enabled' => '0']); + $this->assertNull($client->overview()); + $this->assertSame([], $client->queriesSent); + } + + public function testOverviewIsCachedWithinTtl(): void + { + $client = $this->makeClient($this->allGroups()); + $client->overview(); + $sent = count($client->queriesSent); + $client->overview(); + $this->assertSame($sent, count($client->queriesSent), 'second overview() within TTL must not re-query'); + } + + public function testSystemSurvivesMetricsBeingUnavailable(): void + { + // Older API without `metrics` — uptime/brand still come from `info`. + $responses = $this->allGroups(); + unset($responses['metrics {']); + $o = $this->makeClient($responses)->overview(); + + $this->assertNotNull($o['system']); + $this->assertNull($o['system']['cpuPercent']); + $this->assertSame('2026-06-20T04:05:06Z', $o['system']['uptime']); + } + + public function testAuthHeaderUsesXApiKey(): void + { + $client = $this->makeClient([]); + $headers = (new \ReflectionMethod($client, 'authHeaders'))->invoke($client); + $this->assertContains('x-api-key: k3y', $headers); + } + + public function testTlsVerificationTogglesWithSetting(): void + { + $on = $this->makeClient([]); + $opts = (new \ReflectionMethod($on, 'curlOptions'))->invoke($on); + $this->assertTrue($opts[CURLOPT_SSL_VERIFYPEER]); + + $off = $this->makeClient([], ['unraid_skip_tls_verify' => '1']); + $opts = (new \ReflectionMethod($off, 'curlOptions'))->invoke($off); + $this->assertFalse($opts[CURLOPT_SSL_VERIFYPEER]); + $this->assertSame(0, $opts[CURLOPT_SSL_VERIFYHOST]); + } + + public function testDockerContainersListIsCompleteAndAlphabetical(): void + { + $client = $this->makeClient(['docker {' => self::DOCKER_DATA]); + $docker = $client->overview()['docker']; + + // Full list, case-insensitive alphabetical, running flag per container. + self::assertSame([ + ['name' => 'old-app', 'running' => false], + ['name' => 'plex', 'running' => true], + ['name' => 'radarr', 'running' => true], + ], $docker['containers']); + // Legacy keys untouched. + self::assertSame(2, $docker['running']); + self::assertSame(3, $docker['total']); + self::assertSame(['old-app'], $docker['stopped']); + } + + public function testParityRunningCheckComputesProgressElapsedAndEta(): void + { + $client = $this->makeClient([ + 'vars {' => self::PARITY_STATUS_RUNNING, + 'parityHistory {' => self::PARITY_HISTORY, + ]); + $client->nowOverride = 1750050000; // 50 000 s after sbSynced + + $parity = $client->overview()['parity']; + self::assertTrue($parity['running']); + self::assertSame(50.0, $parity['progress']); + self::assertSame(50000, $parity['elapsed']); + self::assertSame(50000, $parity['etaSeconds']); // 50% done → same again + self::assertSame(3, $parity['errors']); + self::assertSame(strtotime('2026-06-01 06:00:00'), $parity['last']['dateEpoch']); + self::assertSame(76440, $parity['last']['duration']); + self::assertSame(0, $parity['last']['errors']); + } + + public function testParityIdleShowsLastCheckOnly(): void + { + $client = $this->makeClient([ + 'vars {' => self::PARITY_STATUS_IDLE, + 'parityHistory {' => self::PARITY_HISTORY, + ]); + $parity = $client->overview()['parity']; + self::assertFalse($parity['running']); + self::assertNull($parity['progress']); + self::assertNull($parity['elapsed']); + self::assertNull($parity['errors']); // sbSyncErrs only meaningful while running + self::assertSame(0, $parity['last']['errors']); + } + + public function testParityRunningWithNullSizeFallsBackToParityDiskSize(): void + { + // Live-verified: mdResyncSize nulls (32-bit Int overflow) on big arrays, + // while array.parities[].size returns the same value big-safe. + $client = $this->makeClient([ + 'array {' => self::ARRAY_DATA, // parity size 10000 + 'vars {' => self::PARITY_STATUS_RUNNING_NULLSIZE, + 'parityHistory {' => self::PARITY_HISTORY, + ]); + $client->nowOverride = 1750050000; + + $parity = $client->overview()['parity']; + self::assertTrue($parity['running']); + self::assertSame(50.0, $parity['progress']); // 5000 / 10000 via fallback + self::assertSame(50000, $parity['elapsed']); + self::assertSame(50000, $parity['etaSeconds']); + } + + public function testParityRunningWithNoDenominatorStillReportsRunning(): void + { + $client = $this->makeClient([ + 'vars {' => self::PARITY_STATUS_RUNNING_NULLSIZE, // no array group + 'parityHistory {' => self::PARITY_HISTORY, + ]); + $client->nowOverride = 1750050000; + + $parity = $client->overview()['parity']; + self::assertTrue($parity['running']); // no more idle-lie + self::assertNull($parity['progress']); + self::assertNull($parity['etaSeconds']); + self::assertSame(50000, $parity['elapsed']); + self::assertSame(0, $parity['errors']); + } + + public function testParityEtaPrefersCurrentThroughput(): void + { + // mdResyncDt/mdResyncDb = live sampling window; ETA = (denom − pos) ÷ + // (db/dt), independent of sbSynced — which drifts to the resume time + // on paused checks (live-verified ~8h off the true start). + $client = $this->makeClient([ + 'vars {' => ['vars' => [ + 'mdResyncPos' => '5000', 'mdResyncSize' => '10000', + 'mdResyncDt' => '10', 'mdResyncDb' => '100', + 'sbSynced' => '1750000000', 'sbSyncErrs' => '0', + ]], + 'parityHistory {' => self::PARITY_HISTORY, + ]); + $client->nowOverride = 1750050000; + + $parity = $client->overview()['parity']; + self::assertSame(50.0, $parity['progress']); + self::assertSame(50000, $parity['elapsed']); // still sbSynced-based + self::assertSame(500, $parity['etaSeconds']); // (10000-5000)/(100/10) + } + + public function testParityGroupAbsentWhenBothQueriesFail(): void + { + $client = $this->makeClient(['docker {' => self::DOCKER_DATA]); // no parity payloads + self::assertNull($client->overview()['parity']); + self::assertNotNull($client->overview()['docker']); // rest of widget unaffected + } + + public function testParityHistoryAloneStillMapsLastCheck(): void + { + $client = $this->makeClient(['parityHistory {' => self::PARITY_HISTORY]); + $parity = $client->overview()['parity']; + self::assertFalse($parity['running']); + self::assertSame(76440, $parity['last']['duration']); + } + + public function testParityIdleSynthesizesLastCheckFromVarsWhenNewerThanHistory(): void + { + // Live-verified gap: parity-checks.log (the parityHistory source) is + // appended by a webGui nchan daemon that only runs while the Unraid + // Main page is open in a browser — history can lag a finished check + // by days. vars.sbSynced2 is the kernel's completion stamp and is + // always current, so it must win when newer. + $client = $this->makeClient([ + 'vars {' => ['vars' => [ + 'mdResyncPos' => '0', 'mdResyncSize' => '10000', + 'sbSynced' => '1783088534', 'sbSynced2' => '1783132190', + 'sbSyncErrs' => '5', 'sbSyncExit' => '0', + ]], + 'parityHistory {' => self::PARITY_HISTORY, // newest entry 2026-06-01 + ]); + $parity = $client->overview()['parity']; + self::assertFalse($parity['running']); + self::assertSame(1783132190, $parity['last']['dateEpoch']); + self::assertNull($parity['last']['duration']); // true duration unknowable from vars on paused/resumed checks + self::assertSame(5, $parity['last']['errors']); + self::assertSame('COMPLETED', $parity['last']['status']); + } + + public function testParityIdleKeepsHistoryLastWhenHistoryIsNewer(): void + { + $client = $this->makeClient([ + 'vars {' => ['vars' => [ + 'mdResyncPos' => '0', + 'sbSynced' => '1700000000', 'sbSynced2' => '1700003600', // 2023 — older than history + 'sbSyncErrs' => '0', 'sbSyncExit' => '0', + ]], + 'parityHistory {' => self::PARITY_HISTORY, + ]); + $last = $client->overview()['parity']['last']; + self::assertSame(strtotime('2026-06-01 06:00:00'), $last['dateEpoch']); + self::assertSame(76440, $last['duration']); + } + + public function testParityVarsAloneSynthesizesLastCheckWhenIdle(): void + { + // No parityHistory scope at all — sbSynced2 still yields a last check. + $client = $this->makeClient(['vars {' => ['vars' => [ + 'mdResyncPos' => '0', + 'sbSynced' => '1783088534', 'sbSynced2' => '1783132190', + 'sbSyncErrs' => '0', 'sbSyncExit' => '-4', + ]]]); + $parity = $client->overview()['parity']; + self::assertFalse($parity['running']); + self::assertSame(1783132190, $parity['last']['dateEpoch']); + self::assertSame(0, $parity['last']['errors']); + self::assertSame('CANCELLED', $parity['last']['status']); // -4 = cancelled, mirroring Unraid's own mapping + } + + public function testParityRunningDoesNotSynthesizeLastFromVars(): void + { + // While a check runs, vars reflect the in-flight check — the history + // entry stays the richer record for the previous one. + $client = $this->makeClient([ + 'vars {' => ['vars' => [ + 'mdResyncPos' => '5000', 'mdResyncSize' => '10000', + 'sbSynced' => '1750000000', 'sbSynced2' => '1783132190', + 'sbSyncErrs' => '3', + ]], + 'parityHistory {' => self::PARITY_HISTORY, + ]); + $client->nowOverride = 1750050000; + $last = $client->overview()['parity']['last']; + self::assertSame(strtotime('2026-06-01 06:00:00'), $last['dateEpoch']); + self::assertSame(76440, $last['duration']); + } + + public function testParityStatusQueryRequestsCompletionFields(): void + { + $client = $this->makeClient($this->allGroups()); + $client->overview(); + $varsQuery = implode("\n", array_filter($client->queriesSent, fn($q) => str_contains($q, 'vars {'))); + self::assertStringContainsString('sbSynced2', $varsQuery); + self::assertStringContainsString('sbSyncExit', $varsQuery); + } +} diff --git a/symfony/tests/Service/ThemeServiceTest.php b/symfony/tests/Service/ThemeServiceTest.php new file mode 100644 index 00000000..72d13e02 --- /dev/null +++ b/symfony/tests/Service/ThemeServiceTest.php @@ -0,0 +1,59 @@ +createMock(ConfigService::class); + $config->method('get')->with('display_theme')->willReturn($stored); + return new ThemeService($config); + } + + public function testUnknownKeyFallsBackToDefault(): void + { + $r = $this->serviceFor('does-not-exist')->resolve(); + self::assertSame(ThemePresets::DEFAULT_KEY, $r['key']); + } + + public function testNullStoredFallsBackToDefault(): void + { + $r = $this->serviceFor(null)->resolve(); + self::assertSame('midnight', $r['key']); + } + + public function testMidnightResolvesExpectedPrimaryAndBackground(): void + { + $r = $this->serviceFor('midnight')->resolve(); + self::assertFalse($r['light']); + self::assertSame('#6366f1', $r['primary_hex']); + self::assertSame('99, 102, 241', $r['primary_rgb']); + self::assertSame('hsl(0, 0%, 6.5%)', $r['css']['--tblr-body-bg']); + self::assertSame('hsl(0, 0%, 11%)', $r['css']['--prismarr-surface']); + self::assertSame('hsl(0, 0%, 8.5%)', $r['css']['--prismarr-surface-2']); + self::assertSame('hsl(0, 0%, 5%)', $r['css']['--prismarr-sidebar']); + self::assertArrayHasKey('--prismarr-surface', $r['css']); + self::assertArrayHasKey('--tblr-border-color', $r['css']); + self::assertSame('hsla(0, 0%, 6.5%, 0.95)', $r['css']['--prismarr-topbar-bg']); + } + + public function testLightPresetSetsLightFlag(): void + { + $r = $this->serviceFor('catppuccin_latte')->resolve(); + self::assertTrue($r['light']); + } + + public function testResolutionIsCachedUntilReset(): void + { + $svc = $this->serviceFor('midnight'); + $first = $svc->resolve(); + $svc->reset(); + $second = $svc->resolve(); + self::assertSame($first, $second); + } +} diff --git a/symfony/tests/Service/TorrentPagePollerTest.php b/symfony/tests/Service/TorrentPagePollerTest.php new file mode 100644 index 00000000..ff2b012c --- /dev/null +++ b/symfony/tests/Service/TorrentPagePollerTest.php @@ -0,0 +1,151 @@ + with a fresh closure while the + * outgoing page's closure — and its live interval — survive. Because all three + * pages render the SAME element IDs (qbt-list, qbt-stat-total, ...), an orphaned + * poller's updateList()/updateStats() guards still pass on its successor's DOM, + * so it keeps writing its own client's torrents over the page the user is + * actually looking at. Two pollers then fight at 3s each and it reads as data + * corruption ("Transmission is showing my qBittorrent torrents"). + * + * The handle therefore may NOT live in the page closure: it has to sit on a + * single shared global so the incoming page — and base.html.twig's + * turbo:before-render cleanup — can reach the outgoing page's timer and kill it. + * One name serves all three because only one torrent page is ever mounted. + */ +class TorrentPagePollerTest extends TestCase +{ + private const TIMER = '_prismarrTorrentPagePollTimer'; + + private const TEMPLATES = [ + 'qbittorrent' => __DIR__ . '/../../templates/qbittorrent/index.html.twig', + 'deluge' => __DIR__ . '/../../templates/deluge/index.html.twig', + 'transmission' => __DIR__ . '/../../templates/transmission/index.html.twig', + ]; + + private const BASE_TWIG = __DIR__ . '/../../templates/base.html.twig'; + + /** + * @return iterable + */ + public static function templateProvider(): iterable + { + foreach (array_keys(self::TEMPLATES) as $page) { + yield $page => [$page]; + } + } + + #[DataProvider('templateProvider')] + public function testPollTimerHandleIsNotHeldInThePageClosure(string $page): void + { + // A closure-scoped `refreshTimer` is exactly the bug: clearInterval on it + // can only ever reach THIS script instance's timer, never the orphan left + // behind by the page Turbo just replaced. + self::assertDoesNotMatchRegularExpression( + '/\brefreshTimer\b/', + $this->template($page), + "{$page}/index.html.twig still references a closure-scoped refreshTimer; " + . 'the handle must live on window.' . self::TIMER . '.', + ); + } + + #[DataProvider('templateProvider')] + public function testPollTimerIsStoredOnTheSharedGlobal(string $page): void + { + self::assertStringContainsString( + 'window.' . self::TIMER . ' = setInterval(refreshData, REFRESH_INTERVAL);', + $this->template($page), + "{$page}/index.html.twig must publish its poll interval on the shared global.", + ); + } + + #[DataProvider('templateProvider')] + public function testStartAndStopRefreshBothClearTheSharedGlobal(string $page): void + { + $tpl = $this->template($page); + + // Both entry points must go through the shared handle. startRefresh() + // clearing it is what makes a torrent -> torrent navigation self-healing + // even before the base.html.twig cleanup runs. + foreach (['startRefresh', 'stopRefresh'] as $fn) { + self::assertMatchesRegularExpression( + '/function ' . $fn . '\(\)\s*\{[^}]*clearPollTimer\(\);/', + $this->excerpt($tpl, 'function ' . $fn . '()'), + "{$page}/index.html.twig: {$fn}() must clear the shared poll timer.", + ); + } + + self::assertMatchesRegularExpression( + '/clearInterval\(window\.' . self::TIMER . '\);\s*window\.' . self::TIMER . ' = null;/', + $this->excerpt($tpl, 'function clearPollTimer()'), + "{$page}/index.html.twig: clearPollTimer() must clear AND null the shared global.", + ); + } + + public function testTurboBeforeRenderClearsTheTorrentPagePollTimer(): void + { + // Navigating a torrent page -> a NON-torrent page runs no torrent script, + // so nothing else would ever clear the interval. This cleanup is the only + // thing standing between that navigation and a permanently orphaned poller. + self::assertStringContainsString( + "'" . self::TIMER . "'", + $this->cleanupArray(), + 'base.html.twig turbo:before-render cleanup must list ' . self::TIMER . '.', + ); + } + + public function testCleanupArrayStillListsThePreviouslyRegisteredTimers(): void + { + // Guards the same way TransmissionRegistrationTest does: adding the new + // shared handle must not drop any handle already being cleaned up. + $array = $this->cleanupArray(); + + foreach ([ + '_prismarrQbtPollTimer', + '_prismarrQbtVpnTimer', + '_prismarrDelugePollTimer', + '_prismarrUnifiPollTimer', + '_prismarrTransmissionPollTimer', + ] as $timer) { + self::assertStringContainsString("'{$timer}'", $array, "cleanup array dropped {$timer}."); + } + } + + private function template(string $page): string + { + return file_get_contents(self::TEMPLATES[$page]); + } + + /** + * Asserting against a whole 168 KB template dumps the entire file into the + * failure message. Narrow to the region of interest so a regression reads. + */ + private function excerpt(string $haystack, string $needle, int $len = 400): string + { + $pos = strpos($haystack, $needle); + self::assertNotFalse($pos, "expected to find \"{$needle}\" in the template."); + + return substr($haystack, $pos, $len); + } + + private function cleanupArray(): string + { + $base = file_get_contents(self::BASE_TWIG); + // The turbo:before-render cleanup array — the single `[...]` literal that + // is forEach'd over and clearInterval'd, ~line 2357. + self::assertSame( + 1, + preg_match('/\[\s*\'_prismarrQbtPollTimer\'[^\]]*\]/', $base, $m), + 'could not locate the turbo:before-render cleanup array in base.html.twig.', + ); + + return $m[0]; + } +} diff --git a/symfony/tests/Service/TransmissionRegistrationTest.php b/symfony/tests/Service/TransmissionRegistrationTest.php new file mode 100644 index 00000000..d048a277 --- /dev/null +++ b/symfony/tests/Service/TransmissionRegistrationTest.php @@ -0,0 +1,87 @@ + every path asked for, in order */ + public array $paths = []; + /** @var list every body sent, index-aligned with $paths */ + public array $bodies = []; + + /** @param array $responses keyed by path substring */ + public function __construct( + private array $responses = [], + private bool $failTransport = false, + ) {} + + public function fetch(string $path, ?array $body = null): ?array + { + $this->paths[] = $path; + $this->bodies[] = $body; + if ($this->failTransport) return null; + foreach ($this->responses as $needle => $payload) { + if (str_contains($path, $needle)) return $payload; + } + return null; + } + + public function transportFailed(): bool + { + return $this->failTransport; + } +} diff --git a/symfony/tests/Service/Unifi/UnifiHistoryReaderTest.php b/symfony/tests/Service/Unifi/UnifiHistoryReaderTest.php new file mode 100644 index 00000000..130d2c1d --- /dev/null +++ b/symfony/tests/Service/Unifi/UnifiHistoryReaderTest.php @@ -0,0 +1,119 @@ + 1751742000000, 'wan-tx_bytes' => 1.0e8, 'wan-rx_bytes' => 2.0e9], + ['time' => 1751738400000, 'wan-tx_bytes' => 5.0e7, 'wan-rx_bytes' => 1.0e9], + ]; + private const SPEEDTEST = [ + ['time' => 1751742000000, 'xput_download' => 914.2, 'xput_upload' => 918.0, 'latency' => 3], + ['time' => 1751655600000, 'xput_download' => 812.5, 'xput_upload' => 900.1, 'latency' => 4], + ]; + + /** @return array{0: UnifiHistoryReader, 1: StubUnifiFetcher} */ + private function reader(array $responses, bool $fail = false): array + { + $stub = new StubUnifiFetcher($responses, $fail); + return [new UnifiHistoryReader($stub, new NullLogger()), $stub]; + } + + private function all(): array + { + return ['hourly.site' => self::REPORT, 'archive.speedtest' => self::SPEEDTEST]; + } + + public function testMapsBothSeriesSortedAscending(): void + { + [$reader] = $this->reader($this->all()); + $r = $reader->read(); + + // ms → s, rx = download, unsorted input sorted ascending. + $this->assertSame(1751738400, $r['usage7d'][0]['ts']); + $this->assertSame(1.0e9, $r['usage7d'][0]['downBytes']); + $this->assertSame(5.0e7, $r['usage7d'][0]['upBytes']); + + $this->assertSame(1751655600, $r['speedtests'][0]['ts']); + $this->assertSame(812.5, $r['speedtests'][0]['downMbps']); + $this->assertSame(900.1, $r['speedtests'][0]['upMbps']); + $this->assertSame(4, $r['speedtests'][0]['latencyMs']); + } + + public function testRequestWindowsAre7dAnd30dInMilliseconds(): void + { + [$reader, $stub] = $this->reader($this->all()); + $reader->nowOverride = 1751800000; + $reader->read(); + + $report = $speedtest = null; + foreach ($stub->paths as $i => $p) { + if (str_contains($p, 'hourly.site')) $report = $stub->bodies[$i]; + if (str_contains($p, 'archive.speedtest')) $speedtest = $stub->bodies[$i]; + } + $this->assertSame((1751800000 - 7 * 86400) * 1000, $report['start']); + $this->assertSame(1751800000 * 1000, $report['end']); + $this->assertSame((1751800000 - 30 * 86400) * 1000, $speedtest['start']); + } + + public function testOneEndpointMissingLeavesTheOther(): void + { + [$reader] = $this->reader(['hourly.site' => self::REPORT, 'archive.speedtest' => null]); + $r = $reader->read(); + + $this->assertNotNull($r['usage7d']); + $this->assertNull($r['speedtests']); + } + + public function testAllEndpointsFailingReturnsNullAndDoesNotCache(): void + { + [$reader, $stub] = $this->reader(['hourly.site' => null, 'archive.speedtest' => null]); + + $this->assertNull($reader->read()); + $reader->read(); + $this->assertCount(4, $stub->paths); // retried, not served from cache + } + + public function testTransportFailureShortCircuitsRemainingCalls(): void + { + [$reader, $stub] = $this->reader([], fail: true); + + $this->assertNull($reader->read()); + $this->assertCount(1, $stub->paths); // report only, speedtest skipped + } + + public function testTtlCachesWithinWindow(): void + { + [$reader, $stub] = $this->reader($this->all()); + + $this->assertSame($reader->read(), $reader->read()); + $this->assertCount(2, $stub->paths); // not 4 + } + + public function testGarbageRowsSkippedNotFatal(): void + { + [$reader] = $this->reader([ + 'hourly.site' => [ + 'not-an-array', + ['time' => 'nope', 'wan-rx_bytes' => 1.0], + ['time' => 1751738400000, 'wan-rx_bytes' => 'x', 'wan-tx_bytes' => null], + ['time' => 1751742000000, 'wan-rx_bytes' => 5.0e8, 'wan-tx_bytes' => 1.0e8], + ], + 'archive.speedtest' => [ + ['time' => 1751742000000, 'xput_download' => null, 'xput_upload' => 'x', 'latency' => null], + ], + ]); + $r = $reader->read(); + + $this->assertCount(2, $r['usage7d']); // only rows with a usable ts + $this->assertSame(0.0, $r['usage7d'][0]['downBytes']); // non-numeric byte count → 0.0 + $this->assertNull($r['speedtests'][0]['downMbps']); // but null throughput STAYS null + $this->assertNull($r['speedtests'][0]['upMbps']); + $this->assertNull($r['speedtests'][0]['latencyMs']); + } +} diff --git a/symfony/tests/Service/Unifi/UnifiInfraReaderTest.php b/symfony/tests/Service/Unifi/UnifiInfraReaderTest.php new file mode 100644 index 00000000..917eecc5 --- /dev/null +++ b/symfony/tests/Service/Unifi/UnifiInfraReaderTest.php @@ -0,0 +1,242 @@ + 'MAJ UCG Fiber', 'type' => 'ucg', 'model' => 'UCG-Fiber', 'state' => 1, + 'ip' => '192.168.1.1', 'uptime' => 27420, 'upgradable' => false, 'num_sta' => 47, + // Gateways alone carry this array; APs and switches have no temperature + // at all (has_temperature: false). Verified in Task 0. + 'temperatures' => [['name' => 'CPU', 'type' => 'cpu', 'value' => 52.0]], + 'system-stats' => ['cpu' => '10.3', 'mem' => '78.7']], + ['name' => 'Upstairs U7 Lite', 'type' => 'uap', 'model' => 'U7-Lite', 'state' => 1, + 'ip' => '192.168.1.20', 'uptime' => 349260, 'upgradable' => true, 'num_sta' => 14, + 'system-stats' => ['cpu' => '2.0', 'mem' => '77.2'], + 'radio_table_stats' => [ + // Width is `bw`, not `ht`. Radio-level satisfaction is -1 in the + // field; one radio here carries the real sentinel to lock that in. + ['radio' => 'ng', 'channel' => 6, 'bw' => 20, 'tx_power' => 23, + 'cu_total' => 46, 'tx_retries_pct' => 13.8, 'satisfaction' => 94, 'num_sta' => 13], + ['radio' => 'na', 'channel' => 60, 'bw' => 80, 'tx_power' => 24, + 'cu_total' => 3, 'tx_retries_pct' => 10.6, 'satisfaction' => -1, 'num_sta' => 1], + ]], + ['name' => 'Garage Switch', 'type' => 'usw', 'model' => 'USW-8', 'state' => 0], + ]; + private const ROGUE = [ + ['essid' => 'ATTXEDuStX', 'channel' => 40, 'signal' => -33, 'oui' => 'Nokia'], + ['essid' => '', 'channel' => 161, 'signal' => -38], + ]; + private const NETWORKS = [ + ['name' => 'Default', 'vlan' => 1, 'ip_subnet' => '192.168.1.1/24', 'purpose' => 'corporate'], + ['name' => 'IoT', 'vlan' => 20, 'ip_subnet' => '192.168.20.1/24', 'purpose' => 'corporate'], + ['name' => 'WAN', 'purpose' => 'wan'], // excluded — not a LAN + ]; + + /** @return array{0: UnifiInfraReader, 1: StubUnifiFetcher} */ + private function reader(array $responses, bool $fail = false): array + { + $stub = new StubUnifiFetcher($responses, $fail); + return [new UnifiInfraReader($stub, new NullLogger()), $stub]; + } + + private function all(): array + { + return ['stat/device' => self::DEVICES, 'stat/rogueap' => self::ROGUE, + 'rest/networkconf' => self::NETWORKS]; + } + + /** + * A gateway reports its WAN address in `ip`, which is the wrong answer in a + * LAN device inventory — the console showed a public address next to four + * 192.168.x ones. Prefer `lan_ip` when present; everything without it (every + * switch and AP) must keep using `ip`. + */ + public function testGatewayPrefersItsLanAddressOverTheWanAddress(): void + { + [$reader] = $this->reader(['stat/device' => [ + ['name' => 'GW', 'type' => 'ucg', 'state' => 1, + 'ip' => '203.0.113.9', 'lan_ip' => '192.0.2.1'], + ['name' => 'AP', 'type' => 'uap', 'state' => 1, 'ip' => '192.0.2.20'], + ]]); + $d = $reader->read()['devices']; + + $this->assertSame('192.0.2.1', $d[0]['ip']); // gateway → LAN, not WAN + $this->assertSame('192.0.2.20', $d[1]['ip']); // no lan_ip → unchanged + } + + public function testDevicesMappedAndSortedOfflineFirst(): void + { + [$reader] = $this->reader($this->all()); + $d = $reader->read()['devices']; + + $this->assertSame('Garage Switch', $d[0]['name']); // offline first — it's the news + $this->assertFalse($d[0]['online']); + $this->assertSame('switch', $d[0]['kind']); + $this->assertSame('gateway', $d[1]['kind']); + $this->assertSame('192.168.1.1', $d[1]['ip']); + $this->assertSame(27420, $d[1]['uptimeSeconds']); + $this->assertSame(52.0, $d[1]['tempC']); // temperatures[0].value, gateway only + $this->assertSame(10.3, $d[1]['cpuPercent']); + $this->assertSame(78.7, $d[1]['memPercent']); + $this->assertSame(47, $d[1]['clients']); + $this->assertFalse($d[1]['upgradable']); + $this->assertTrue($d[2]['upgradable']); + // The AP has no `temperatures` array at all — null, not 0.0. + $this->assertNull($d[2]['tempC']); + } + + public function testCountsDriveTheSectionHeader(): void + { + [$reader] = $this->reader($this->all()); + $c = $reader->read()['counts']; + + $this->assertSame(3, $c['devices']); + $this->assertSame(2, $c['online']); + $this->assertSame(1, $c['upgradable']); + $this->assertSame(2, $c['neighbors']); + } + + public function testRadiosFlattenedWithBandLabels(): void + { + [$reader] = $this->reader($this->all()); + $r = $reader->read()['radios']; + + $this->assertCount(2, $r); // only the AP has radios + $this->assertSame('Upstairs U7 Lite', $r[0]['device']); + $this->assertSame('2.4 GHz', $r[0]['band']); + $this->assertSame(6, $r[0]['channel']); + $this->assertSame(20, $r[0]['widthMhz']); + $this->assertSame(23, $r[0]['txPowerDbm']); + $this->assertSame(46.0, $r[0]['utilizationPercent']); + $this->assertSame(13.8, $r[0]['retryPercent']); + $this->assertSame(94, $r[0]['satisfaction']); + $this->assertSame(13, $r[0]['clients']); + $this->assertSame('5 GHz', $r[1]['band']); + $this->assertSame(80, $r[1]['widthMhz']); // from `bw`, not `ht` + } + + /** + * Every radio on the live console reports satisfaction: -1 (the AP-level + * figure is the valid one). Rendering "-1%" would be a visible bug, so the + * mapper turns any negative into null and the template shows a dash. + */ + public function testNegativeRadioSatisfactionBecomesNull(): void + { + [$reader] = $this->reader($this->all()); + $r = $reader->read()['radios']; + + $this->assertNull($r[1]['satisfaction']); + $this->assertSame(3.0, $r[1]['utilizationPercent']); // the rest still maps + } + + public function testUnknownRadioBandDegradesToDash(): void + { + [$reader] = $this->reader(['stat/device' => [ + ['name' => 'AP', 'type' => 'uap', 'state' => 1, + 'radio_table_stats' => [['radio' => 'wat', 'channel' => 1]]], + ]]); + $this->assertSame('—', $reader->read()['radios'][0]['band']); + } + + public function testNeighborsMappedHiddenSsidBecomesNullAndStrongestFirst(): void + { + [$reader, $stub] = $this->reader(['stat/rogueap' => [ + ['essid' => 'far', 'signal' => -80, 'channel' => 1], + ['essid' => 'near', 'signal' => -30, 'channel' => 6, 'oui' => 'Roku'], + ['essid' => '', 'signal' => -55, 'channel' => 11], + ]]); + $n = $reader->read()['neighbors']; + + $this->assertSame(['near', null, 'far'], array_column($n, 'ssid')); + $this->assertSame(-30, $n[0]['signalDbm']); + $this->assertSame('Roku', $n[0]['vendor']); + $this->assertNull($n[2]['vendor']); + + // The classic API 400s a bodyless GET on this endpoint, so it must go + // out as a POST carrying the time window. Locking that in here means a + // refactor back to a GET fails a test instead of silently emptying the + // panel in production. + $i = array_search('/stat/rogueap', $stub->paths, true); + $this->assertNotFalse($i); + $this->assertSame(['within' => 24], $stub->bodies[$i]); + } + + public function testNetworksExcludeNonLanPurposesAndSortByVlan(): void + { + [$reader] = $this->reader($this->all()); + $n = $reader->read()['networks']; + + $this->assertCount(2, $n); + $this->assertSame(['Default', 'IoT'], array_column($n, 'name')); + $this->assertSame(1, $n[0]['vlan']); + $this->assertSame('192.168.1.1/24', $n[0]['subnet']); + } + + public function testMissingEndpointDegradesOnePanelOnly(): void + { + [$reader] = $this->reader(['stat/device' => self::DEVICES, + 'stat/rogueap' => null, 'rest/networkconf' => null]); + $r = $reader->read(); + + $this->assertNotNull($r['devices']); + $this->assertNotNull($r['radios']); + $this->assertNull($r['neighbors']); + $this->assertNull($r['networks']); + $this->assertSame(0, $r['counts']['neighbors']); + } + + public function testDeviceWithoutRadiosYieldsNoRadioRows(): void + { + [$reader] = $this->reader(['stat/device' => [self::DEVICES[0]]]); + $this->assertNull($reader->read()['radios']); + } + + public function testEveryEndpointEmptyReturnsNullAndDoesNotCache(): void + { + [$reader, $stub] = $this->reader(['stat/device' => null, 'stat/rogueap' => null, + 'rest/networkconf' => null]); + + $this->assertNull($reader->read()); + $reader->read(); + $this->assertCount(6, $stub->paths); // retried + } + + public function testTransportFailureShortCircuits(): void + { + [$reader, $stub] = $this->reader([], fail: true); + + $this->assertNull($reader->read()); + $this->assertCount(1, $stub->paths); // device only + } + + public function testTtlCachesWithinWindow(): void + { + [$reader, $stub] = $this->reader($this->all()); + + $this->assertSame($reader->read(), $reader->read()); + $this->assertCount(3, $stub->paths); // not 6 + } + + public function testGarbageRowsSkippedNotFatal(): void + { + [$reader] = $this->reader(['stat/device' => [ + 'not-an-array', + ['type' => 'uap', 'state' => 'x', 'num_sta' => 'y', 'name' => '', + 'system-stats' => 'nope', 'radio_table_stats' => 'also-nope'], + ]]); + $r = $reader->read(); + + $this->assertCount(1, $r['devices']); + $this->assertNull($r['devices'][0]['name']); // empty string → null + $this->assertFalse($r['devices'][0]['online']); // non-numeric state → offline + $this->assertNull($r['devices'][0]['clients']); + $this->assertNull($r['devices'][0]['cpuPercent']); + $this->assertNull($r['radios']); // non-array radio table ignored + } +} diff --git a/symfony/tests/Service/Unifi/UnifiLiveReaderTest.php b/symfony/tests/Service/Unifi/UnifiLiveReaderTest.php new file mode 100644 index 00000000..dfbf3978 --- /dev/null +++ b/symfony/tests/Service/Unifi/UnifiLiveReaderTest.php @@ -0,0 +1,276 @@ + 'wan', 'status' => 'ok', 'wan_ip' => '203.0.113.7', + 'isp_name' => 'AT&T Internet', 'isp_organization' => 'AT&T Enterprises, LLC', + 'gw_system-stats' => ['cpu' => '11.0', 'mem' => '78.0', 'uptime' => '27420']], + ['subsystem' => 'www', 'status' => 'ok', 'tx_bytes-r' => '69125', 'rx_bytes-r' => '96875', + 'latency' => '3', 'uptime' => '27360', 'drops' => 4], + ['subsystem' => 'lan', 'status' => 'ok', 'num_user' => 17], + ['subsystem' => 'wlan', 'status' => 'ok', 'num_user' => 29, 'num_guest' => 2], + ]; + private const STA = [ + ['mac' => 'aa:bb:cc:00:00:01', 'name' => 'Amazon Echo', 'ip' => '192.168.1.50', + 'is_wired' => false, 'essid' => 'Hades', 'ap_mac' => 'ff:ff:00:00:00:01', 'radio' => 'ng', + 'signal' => -40, 'tx_bytes-r' => 1000, 'rx_bytes-r' => 5237, 'tx_bytes' => 1.0e9, 'rx_bytes' => 7.0e8], + ['mac' => 'aa:bb:cc:00:00:02', 'hostname' => 'kitchen-echo', 'ip' => '192.168.1.51', + 'is_wired' => false, 'essid' => 'Hades', 'ap_mac' => 'ff:ff:00:00:00:01', 'radio' => 'ng', + 'signal' => -42, 'tx_bytes-r' => 500, 'rx_bytes-r' => 1337, 'tx_bytes' => 5.0e8, 'rx_bytes' => 5.0e8], + ['mac' => 'aa:bb:cc:00:00:03', 'name' => 'HP Printer', 'ip' => '192.168.1.60', + 'is_wired' => true, 'tx_bytes-r' => 50, 'rx_bytes-r' => 6, 'tx_bytes' => 1.0e6, 'rx_bytes' => 2.0e6], + ]; + private const USERS = [ + // Matching reservation. + ['mac' => 'aa:bb:cc:00:00:01', 'name' => 'Amazon Echo', 'use_fixedip' => true, 'fixed_ip' => '192.168.1.50'], + // Mismatched: reserved .99 but live on .51. + ['mac' => 'aa:bb:cc:00:00:02', 'name' => 'Kitchen Echo', 'use_fixedip' => true, 'fixed_ip' => '192.168.1.99'], + // Reserved but not currently online. + ['mac' => 'aa:bb:cc:00:00:09', 'name' => 'Old Laptop', 'use_fixedip' => true, 'fixed_ip' => '192.168.1.77'], + // Not a reservation at all — must be excluded from the total. + ['mac' => 'aa:bb:cc:00:00:03', 'name' => 'HP Printer', 'use_fixedip' => false], + ]; + private const DEVICE_BASIC = [ + ['mac' => 'ff:ff:00:00:00:01', 'name' => 'Upstairs U7 Lite', 'type' => 'uap'], + ]; + + /** @return array{0: UnifiLiveReader, 1: StubUnifiFetcher} */ + private function reader(array $responses, bool $fail = false): array + { + $stub = new StubUnifiFetcher($responses, $fail); + return [new UnifiLiveReader($stub, new NullLogger()), $stub]; + } + + private function all(): array + { + return ['stat/health' => self::HEALTH, 'stat/sta' => self::STA, + 'rest/user' => self::USERS, 'stat/device-basic' => self::DEVICE_BASIC]; + } + + public function testWanAndClientsMatchTheWidgetShape(): void + { + [$reader] = $this->reader($this->all()); + $r = $reader->read(); + + $this->assertSame('ok', $r['wan']['status']); + $this->assertSame('203.0.113.7', $r['wan']['ip']); + $this->assertSame(27360, $r['wan']['uptimeSeconds']); + $this->assertSame(96875.0, $r['wan']['downBps']); // rx = download, BYTES/s + $this->assertSame(69125.0, $r['wan']['upBps']); + $this->assertSame(3, $r['wan']['latencyMs']); + $this->assertSame('AT&T Internet', $r['wan']['ispName']); // WAN tile subtitle + $this->assertSame(4, $r['wan']['drops']); + + $this->assertSame(17, $r['clients']['wired']); + $this->assertSame(31, $r['clients']['wireless']); // 29 + 2 guest + $this->assertSame(48, $r['clients']['total']); + $this->assertSame(2, $r['clients']['guest']); + } + + /** + * CPU and memory are the only gateway figures stat/health exposes. Asserting + * the absence of temp/load keys stops a future edit from re-adding a block + * that would render as a permanent dash (or worse, "0 °C"). + */ + public function testGatewayCarriesCpuAndMemoryOnly(): void + { + [$reader] = $this->reader($this->all()); + $g = $reader->read()['gateway']; + + $this->assertSame(11.0, $g['cpuPercent']); + $this->assertSame(78.0, $g['memPercent']); + $this->assertSame(['cpuPercent', 'memPercent'], array_keys($g)); + } + + public function testTalkersSortedByCurrentRateDescending(): void + { + [$reader] = $this->reader($this->all()); + $t = $reader->read()['talkers']; + + // tx+rx rate: Echo 6237, kitchen 1837, printer 56. + $this->assertSame(['Amazon Echo', 'kitchen-echo', 'HP Printer'], array_column($t, 'name')); + $this->assertSame(6237.0, $t[0]['bps']); + } + + public function testTopClientsSortedByTotalBytesDescending(): void + { + [$reader] = $this->reader($this->all()); + $t = $reader->read()['topClients']; + + // tx+rx total: Echo 1.7e9, kitchen 1.0e9, printer 3.0e6. + $this->assertSame(['Amazon Echo', 'kitchen-echo', 'HP Printer'], array_column($t, 'name')); + $this->assertSame(1.7e9, $t[0]['bytes']); + } + + public function testNamePrecedenceIsNameThenHostnameThenMac(): void + { + [$reader] = $this->reader(['stat/sta' => [ + ['mac' => 'aa:bb:cc:00:00:0a', 'tx_bytes-r' => 5, 'rx_bytes-r' => 0, 'tx_bytes' => 5, 'rx_bytes' => 0], + ]]); + $this->assertSame('aa:bb:cc:00:00:0a', $reader->read()['talkers'][0]['name']); + } + + public function testWirelessGroupsBySsidAndApWithAveragedSignal(): void + { + [$reader] = $this->reader($this->all()); + $w = $reader->read()['wireless']; + + $this->assertCount(1, $w); // both wireless clients share SSID + AP + $this->assertSame('Hades', $w[0]['ssid']); + $this->assertSame('Upstairs U7 Lite', $w[0]['ap']); + $this->assertSame('2.4 GHz', $w[0]['band']); + $this->assertSame(2, $w[0]['clients']); + $this->assertSame(-41, $w[0]['avgSignalDbm']); // mean of -40 and -42 + } + + public function testWirelessExcludesWiredClients(): void + { + [$reader] = $this->reader(['stat/sta' => [self::STA[2]]]); // the wired printer only + $this->assertNull($reader->read()['wireless']); + } + + public function testUnresolvableApNameLeavesApNull(): void + { + [$reader] = $this->reader(['stat/sta' => self::STA, 'stat/device-basic' => null]); + $this->assertNull($reader->read()['wireless'][0]['ap']); + } + + public function testReservationStatusesAndMismatchCount(): void + { + [$reader] = $this->reader($this->all()); + $res = $reader->read()['reservations']; + + $this->assertSame(3, $res['total']); // three reservations; the printer isn't one + $this->assertSame(1, $res['mismatched']); // only the kitchen echo + + $byName = []; + foreach ($res['rows'] as $row) { $byName[$row['name']] = $row; } + + $this->assertSame('ok', $byName['Amazon Echo']['status']); + $this->assertSame('192.168.1.50', $byName['Amazon Echo']['liveIp']); + + $this->assertSame('mismatch', $byName['Kitchen Echo']['status']); + $this->assertSame('192.168.1.99', $byName['Kitchen Echo']['reservedIp']); + $this->assertSame('192.168.1.51', $byName['Kitchen Echo']['liveIp']); + + $this->assertSame('offline', $byName['Old Laptop']['status']); + $this->assertNull($byName['Old Laptop']['liveIp']); + } + + public function testMismatchesSortFirstSoTheyAreVisibleWithoutScrolling(): void + { + [$reader] = $this->reader($this->all()); + $this->assertSame('mismatch', $reader->read()['reservations']['rows'][0]['status']); + } + + public function testReservationsNullWhenUserEndpointMissing(): void + { + [$reader] = $this->reader(['stat/health' => self::HEALTH, 'stat/sta' => self::STA]); + $r = $reader->read(); + + $this->assertNull($r['reservations']); + $this->assertNotNull($r['talkers']); // sibling panels unaffected + } + + public function testHealthMissingLeavesClientPanelsIntact(): void + { + [$reader] = $this->reader(['stat/sta' => self::STA, 'rest/user' => self::USERS]); + $r = $reader->read(); + + $this->assertNull($r['wan']); + $this->assertNull($r['gateway']); + $this->assertNull($r['clients']); + $this->assertNotNull($r['talkers']); + $this->assertNotNull($r['reservations']); + } + + public function testTransportFailureShortCircuits(): void + { + [$reader, $stub] = $this->reader([], fail: true); + + $this->assertNull($reader->read()); + $this->assertCount(1, $stub->paths); // health only + } + + public function testEverythingEmptyReturnsNullAndDoesNotCache(): void + { + [$reader, $stub] = $this->reader(['stat/health' => null, 'stat/sta' => null, + 'rest/user' => null, 'stat/device-basic' => null]); + $this->assertNull($reader->read()); + $reader->read(); + $this->assertGreaterThan(4, count($stub->paths)); // retried + } + + public function testLiveEndpointsRefreshButConfigEndpointsAreCachedLonger(): void + { + [$reader, $stub] = $this->reader($this->all()); + + $reader->read(); + $reader->nowOffset = 15.0; // past the 10s live TTL, inside the 300s config TTL + $reader->read(); + + $count = static fn(string $needle): int => count(array_filter( + $stub->paths, static fn(string $p): bool => str_contains($p, $needle))); + + $this->assertSame(2, $count('stat/health')); // refreshed + $this->assertSame(2, $count('stat/sta')); // refreshed + $this->assertSame(1, $count('rest/user')); // config — still cached + $this->assertSame(1, $count('stat/device-basic')); // config — still cached + } + + public function testTalkersAndTopClientsAreCapped(): void + { + $many = []; + for ($i = 0; $i < 40; $i++) { + $many[] = ['mac' => sprintf('aa:bb:cc:00:01:%02x', $i), 'name' => "c$i", + 'tx_bytes-r' => $i, 'rx_bytes-r' => 0, 'tx_bytes' => $i, 'rx_bytes' => 0]; + } + [$reader] = $this->reader(['stat/sta' => $many]); + $r = $reader->read(); + + $this->assertCount(8, $r['talkers']); + $this->assertCount(10, $r['topClients']); + $this->assertSame('c39', $r['talkers'][0]['name']); // busiest first + } + + public function testIdleClientsAreExcludedFromTalkers(): void + { + [$reader] = $this->reader(['stat/sta' => [ + ['mac' => 'aa:bb:cc:00:00:0b', 'name' => 'idle', 'tx_bytes-r' => 0, 'rx_bytes-r' => 0, + 'tx_bytes' => 1.0e6, 'rx_bytes' => 0], + ]]); + $r = $reader->read(); + + $this->assertNull($r['talkers']); // nothing is talking + $this->assertNotNull($r['topClients']); // but it has moved data historically + } + + public function testGarbageRowsSkippedNotFatal(): void + { + [$reader] = $this->reader(['stat/sta' => [ + 'not-an-array', + // Half-garbage rate ('x' is not numeric) and no mac: the row must + // still surface, named from `name`, with the unparseable half + // contributing 0 instead of blowing up. + ['name' => 'no-mac', 'tx_bytes-r' => 'x', 'rx_bytes-r' => 20], + ['mac' => 'aa:bb:cc:00:00:0c', 'name' => 'ok', 'is_wired' => 'maybe', + 'essid' => 'S', 'signal' => 'loud', 'tx_bytes-r' => 0, 'rx_bytes-r' => 0, + 'tx_bytes' => 10, 'rx_bytes' => 0], + ], 'rest/user' => ['not-an-array', ['use_fixedip' => true]]]); + $r = $reader->read(); + + $this->assertCount(1, $r['talkers']); // the no-mac row still names itself + $this->assertSame('no-mac', $r['talkers'][0]['name']); + $this->assertNull($r['wireless'][0]['avgSignalDbm']); // non-numeric signal → null, no crash + $this->assertNull($r['reservations']); // a reservation with no mac/ip is unusable + } +} diff --git a/symfony/tests/Service/UnifiHealthTest.php b/symfony/tests/Service/UnifiHealthTest.php new file mode 100644 index 00000000..232c2e4f --- /dev/null +++ b/symfony/tests/Service/UnifiHealthTest.php @@ -0,0 +1,85 @@ + $settings */ + private function makeService(array $settings, ?UnifiClient $unifi = null): HealthService + { + $config = $this->createMock(ConfigService::class); + $config->method('get')->willReturnCallback(fn(string $k) => $settings[$k] ?? null); + $config->method('has')->willReturnCallback( + fn(string $k) => ($settings[$k] ?? null) !== null && $settings[$k] !== '' + ); + + return new HealthService( + $this->createMock(RadarrClient::class), + $this->createMock(SonarrClient::class), + $this->createMock(ProwlarrClient::class), + $this->createMock(JellyseerrClient::class), + $this->createMock(QBittorrentClient::class), + $this->createMock(TmdbClient::class), + $config, + unifi: $unifi, + ); + } + + public function testUnifiIsToggleable(): void + { + $this->assertContains('unifi', HealthService::TOGGLEABLE_SERVICES); + } + + public function testConfiguredNeedsUrlAndKey(): void + { + $this->assertFalse($this->makeService([])->isConfigured('unifi')); + $this->assertFalse($this->makeService(['unifi_url' => 'https://192.168.1.1'])->isConfigured('unifi')); + $this->assertTrue($this->makeService([ + 'unifi_url' => 'https://192.168.1.1', 'unifi_api_key' => 'k', + ])->isConfigured('unifi')); + } + + public function testKillSwitchDisables(): void + { + $this->assertFalse($this->makeService([ + 'unifi_url' => 'https://192.168.1.1', 'unifi_api_key' => 'k', 'unifi_enabled' => '0', + ])->isConfigured('unifi')); + } + + public function testStatusForPingsTheUnifiClient(): void + { + $unifi = $this->createMock(UnifiClient::class); + $unifi->expects($this->once())->method('ping')->willReturn(true); + + $svc = $this->makeService( + ['unifi_url' => 'https://192.168.1.1', 'unifi_api_key' => 'k'], + $unifi, + ); + $this->assertTrue($svc->isHealthy('unifi')); + } + + public function testStatusForIsDownWhenPingFails(): void + { + $unifi = $this->createMock(UnifiClient::class); + $unifi->method('ping')->willReturn(false); + + $svc = $this->makeService( + ['unifi_url' => 'https://192.168.1.1', 'unifi_api_key' => 'k'], + $unifi, + ); + $this->assertFalse($svc->isHealthy('unifi')); + } +} diff --git a/symfony/tests/Service/UnraidHealthTest.php b/symfony/tests/Service/UnraidHealthTest.php new file mode 100644 index 00000000..39aa5379 --- /dev/null +++ b/symfony/tests/Service/UnraidHealthTest.php @@ -0,0 +1,85 @@ + $settings */ + private function makeService(array $settings, ?UnraidClient $unraid = null): HealthService + { + $config = $this->createMock(ConfigService::class); + $config->method('get')->willReturnCallback(fn(string $k) => $settings[$k] ?? null); + $config->method('has')->willReturnCallback( + fn(string $k) => ($settings[$k] ?? null) !== null && $settings[$k] !== '' + ); + + return new HealthService( + $this->createMock(RadarrClient::class), + $this->createMock(SonarrClient::class), + $this->createMock(ProwlarrClient::class), + $this->createMock(JellyseerrClient::class), + $this->createMock(QBittorrentClient::class), + $this->createMock(TmdbClient::class), + $config, + unraid: $unraid, + ); + } + + public function testUnraidIsToggleable(): void + { + $this->assertContains('unraid', HealthService::TOGGLEABLE_SERVICES); + } + + public function testConfiguredNeedsUrlAndKey(): void + { + $this->assertFalse($this->makeService([])->isConfigured('unraid')); + $this->assertFalse($this->makeService(['unraid_url' => 'https://tower.local'])->isConfigured('unraid')); + $this->assertTrue($this->makeService([ + 'unraid_url' => 'https://tower.local', 'unraid_api_key' => 'k', + ])->isConfigured('unraid')); + } + + public function testKillSwitchDisables(): void + { + $this->assertFalse($this->makeService([ + 'unraid_url' => 'https://tower.local', 'unraid_api_key' => 'k', 'unraid_enabled' => '0', + ])->isConfigured('unraid')); + } + + public function testStatusForPingsTheUnraidClient(): void + { + $unraid = $this->createMock(UnraidClient::class); + $unraid->expects($this->once())->method('ping')->willReturn(true); + + $svc = $this->makeService( + ['unraid_url' => 'https://tower.local', 'unraid_api_key' => 'k'], + $unraid, + ); + $this->assertTrue($svc->isHealthy('unraid')); + } + + public function testStatusForIsDownWhenPingFails(): void + { + $unraid = $this->createMock(UnraidClient::class); + $unraid->method('ping')->willReturn(false); + + $svc = $this->makeService( + ['unraid_url' => 'https://tower.local', 'unraid_api_key' => 'k'], + $unraid, + ); + $this->assertFalse($svc->isHealthy('unraid')); + } +} diff --git a/symfony/tests/Service/WorkerModeResetTest.php b/symfony/tests/Service/WorkerModeResetTest.php new file mode 100644 index 00000000..60b81915 --- /dev/null +++ b/symfony/tests/Service/WorkerModeResetTest.php @@ -0,0 +1,112 @@ + + */ + public static function resettableProvider(): array + { + return [ + [HealthService::class], + [DashboardController::class], + // Highest-severity path (audit): clears the per-request Radarr + // instance binding so a non-default instance can't leak to the + // next request. Already covered behaviourally by ClientResetTest; + // re-asserted here as part of the worker-mode contract set. + [RadarrClient::class], + ]; + } + + /** + * @param class-string $class + */ + #[DataProvider('resettableProvider')] + public function testServiceImplementsResetInterface(string $class): void + { + $this->assertTrue( + is_subclass_of($class, ResetInterface::class), + "$class must implement ResetInterface so Symfony auto-tags it kernel.reset and clears its per-request state between worker requests" + ); + } + + public function testHealthServiceResetClearsInProcessMemoAndGeneration(): void + { + // reset() only touches two private properties; skip the heavy + // constructor (13 collaborators) via newInstanceWithoutConstructor. + $ref = new \ReflectionClass(HealthService::class); + $service = $ref->newInstanceWithoutConstructor(); + + $statusCache = $ref->getProperty('statusCache'); + $statusCache->setAccessible(true); + $generation = $ref->getProperty('generation'); + $generation->setAccessible(true); + + // Simulate a request having populated the 10 s in-process memo and the + // pool generation token. + $statusCache->setValue($service, [ + 'radarr:radarr-4k' => ['result' => ['status' => 'up', 'latencyMs' => 12], 'at' => time()], + ]); + $generation->setValue($service, 'deadbeef'); + + $this->assertNotSame([], $statusCache->getValue($service)); + $this->assertNotNull($generation->getValue($service)); + + $service->reset(); + + $this->assertSame([], $statusCache->getValue($service), 'statusCache must be emptied on reset'); + $this->assertNull($generation->getValue($service), 'generation token must be dropped on reset'); + } + + public function testDashboardControllerResetNullsLibraryMemos(): void + { + $ref = new \ReflectionClass(DashboardController::class); + $controller = $ref->newInstanceWithoutConstructor(); + + $movies = $ref->getProperty('moviesCache'); + $movies->setAccessible(true); + $series = $ref->getProperty('seriesCache'); + $series->setAccessible(true); + + // Prime the per-request memo as the first dashboard paint would. + $movies->setValue($controller, [['id' => 1, 'title' => 'Dune']]); + $series->setValue($controller, [['id' => 2, 'title' => 'Severance']]); + + $this->assertNotNull($movies->getValue($controller)); + $this->assertNotNull($series->getValue($controller)); + + $controller->reset(); + + $this->assertNull($movies->getValue($controller), 'moviesCache must be nulled on reset'); + $this->assertNull($series->getValue($controller), 'seriesCache must be nulled on reset'); + } +} diff --git a/symfony/tests/Theme/ColorMathTest.php b/symfony/tests/Theme/ColorMathTest.php new file mode 100644 index 00000000..5e113b78 --- /dev/null +++ b/symfony/tests/Theme/ColorMathTest.php @@ -0,0 +1,32 @@ + $p) { + foreach (['label_key', 'light', 'bg', 'primary', 'positive', 'negative', 'contrast', 'textSaturation'] as $field) { + self::assertArrayHasKey($field, $p, "preset $key missing $field"); + } + self::assertIsBool($p['light']); + foreach (['bg', 'primary', 'positive', 'negative'] as $c) { + self::assertCount(3, $p[$c], "preset $key.$c must be [H,S,L]"); + } + } + } + + public function testOptionLabelsMatchKeys(): void + { + self::assertSame(array_keys(ThemePresets::PRESETS), ThemePresets::keys()); + self::assertSame( + array_keys(ThemePresets::PRESETS), + array_keys(ThemePresets::optionLabels()) + ); + } +} diff --git a/symfony/translations/messages+intl-icu.en.yaml b/symfony/translations/messages+intl-icu.en.yaml index 800d0839..468b2904 100644 --- a/symfony/translations/messages+intl-icu.en.yaml +++ b/symfony/translations/messages+intl-icu.en.yaml @@ -40,6 +40,7 @@ common: required: required coming_soon: Coming soon labels: + new_badge: New email: Email username: Username password: Password @@ -65,6 +66,8 @@ sidebar: nav: dashboard: Dashboard qbittorrent: qBittorrent + deluge: Deluge + transmission: Transmission discovery: Discover calendar: Calendar radarr: Radarr @@ -100,6 +103,7 @@ topbar: loading_online: Loading… in_library_badge: In library not_added_badge: Not added + add: Add group_local: In your library group_online: On TMDb / TheTVDB more_local: See more in your library @@ -289,6 +293,17 @@ setup: user: Username password: Password reverse_proxy_hint: 'Leave the username and password empty if qBittorrent sits behind a reverse proxy that injects authentication itself (qui, traefik forward auth, …).' + deluge: + section: Deluge + 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' @@ -354,6 +369,8 @@ home: dashboard: title: Dashboard + widget_error: + unreachable: This section failed to load greeting: morning: Good morning afternoon: Good afternoon @@ -413,6 +430,7 @@ dashboard: title: Current Plex activity settings_cta: 'Settings →' streams: Streams + viewers: Current viewers mbps: Mbps unknown_title: Unknown empty: No active Plex streams @@ -453,6 +471,57 @@ dashboard: cast: 'Cast:' studio: 'Studio:' tech: Stream + server: + title: Server + settings_cta: Settings + unreachable: Unraid is unreachable + array: Array + parity: Parity + disks: Disks + system: System + docker: Docker + ups: UPS + cpu: CPU + ram: RAM + uptime: Up since + running: running + battery: Battery + load: Load + parity_checking: Checking + elapsed: Elapsed + eta: ETA + errors: errors + last_check: Last check + network: + title: Network + settings_cta: Settings + unreachable: UniFi console unreachable — check the URL and API key in settings. + down: Download + up: Upload + clients: Clients + wired: wired + wireless: wireless + guest: guest + wan: WAN + uptime: Uptime + usage_24h: Usage — last 24 h + infrastructure: Infrastructure + online: online + offline: offline + houndarr: + title: Houndarr + settings_cta: Settings + tracked: Tracked + eligible: Eligible + gated: Cooldown + unreleased: Unreleased + searches_7d: Searches (7d) + updated: Updated + unreachable: Houndarr is unreachable + auth_error: Houndarr rejected the API key — check Settings + wanted: Wanted + cutoff_unmet: Cutoff unmet + arr_note: Wanted counts from Radarr/Sonarr type: film: Movie series: TV Show @@ -473,12 +542,33 @@ dashboard: discover: 'View in Discover' close: 'Close' runtime: '{min} min' - seasons: '{count} seasons' + seasons: '{count, plural, one {# season} other {# seasons}}' error: "Couldn't load the preview" + providers: 'Available on' + trailer: 'Trailer' + watchlist: 'Watchlist' + watchlist_added: 'On watchlist' + watchlist_add_toast: 'Added to your watchlist' + watchlist_remove_toast: 'Removed from your watchlist' status: downloaded: 'Downloaded' monitored: 'Monitored' missing: 'Missing' + date: + cinema: 'In theaters' + digital: 'Digital' + physical: 'Physical' + first_aired: 'First aired' + next_episode: 'Next episode' + ended: 'Ended' + airstatus: + continuing: 'Continuing' + ended: 'Ended' + layout: + edit: Edit layout + save: Save layout + cancel: Cancel + hide: Hide tautulli: title: Plex Activity @@ -537,6 +627,75 @@ tautulli: items: '{count, plural, one {# item} other {# items}}' episodes: '{count, plural, one {# episode} other {# episodes}}' +unifi: + title: Network + subtitle: 'Live network operations' + updated: 'Updated {seconds}s ago' + unreachable: 'UniFi console unreachable — check the URL and API key in settings.' + empty: 'No data for this section' + retry: Retry + tiles: + wan: Internet + cpu: Gateway CPU + memory: Gateway memory + clients: Clients + throughput: Throughput + latency: latency + drops: drops + wired: wired + wireless: wireless + guest: guest + history: + traffic: 'WAN traffic — last 7 days' + speedtests: 'Speedtest history — last 30 days' + speedtests_sparse: 'Only {count} runs in the window — enable scheduled speedtests on the console for a trend.' + # down/up/latency and peak arrive pre-formatted from SpeedtestChart — no units here. + latest: 'Latest: ↓{down} ↑{up} · {latency}' + latency_range: 'Latency {min} / {avg} / {max} ms' + peak: 'peak {peak}' + infra: + title: Infrastructure + device: Device + hidden_ssid: hidden + untagged: untagged + kind_gateway: gateway + kind_switch: switch + kind_ap: access point + kind_other: device + summary: '{devices} devices · {online} online · {upgradable} upgradable' + radios: 'Access points' + neighbors: 'Neighbor networks' + neighbors_unavailable: 'Neighbor scan not available on this console' + networks: Networks + network_name: Network + vlan: VLAN + subnet: Subnet + channel: Channel + power: TX power + utilization: Utilization + retry_pct: Retry + signal: Signal + vendor: Vendor + temperature: Temp + uptime: Uptime + upgradable: 'Update available' + clients: + wireless: 'Wireless clients' + device: Device + ssid: SSID + band: Band + access_point: 'Access point' + avg_signal: 'Avg signal' + talkers: 'Live talkers' + top: 'Top clients (total)' + reservations: 'DHCP reservations' + reservations_summary: '{total} reservations · {mismatched} mismatched' + reserved_ip: 'Reserved IP' + live_ip: 'Current IP' + status_ok: Match + status_mismatch: Mismatch + status_offline: Offline + profile: title: My profile identity: @@ -650,10 +809,15 @@ admin: prowlarr: Indexer aggregator jellyseerr: Request platform qbittorrent: Torrent client + deluge: Torrent client (Web UI) + transmission: Torrent client sabnzbd: Usenet downloader nzbget: Usenet downloader gluetun: 'VPN / port forwarding' tautulli: 'Plex activity (via Tautulli)' + unraid: 'Unraid server monitoring — array, disks, system, Docker, UPS (Unraid 7+, read-only API key)' + unifi: 'UniFi network monitoring — WAN throughput, clients, devices (Network 9.0+, read-only API key from Control Plane → Integrations)' + houndarr: 'Automated *arr backlog search (read-only widget)' sidebar_toggle: Menu sidebar_toggle_title: Show in sidebar status: @@ -740,6 +904,10 @@ admin: sidebar: title: Sidebar visibility description: Pick which services and features show up in the left sidebar. + dashboard_layout: + title: Dashboard layout + description: Drag to reorder the dashboard sections, and choose which ones to show. + reset_hint: 'Use "Reset display preferences" below to restore the default layout.' switch: on: Enabled off: Disabled @@ -772,6 +940,28 @@ admin: '12h': '12h (2:30 PM)' theme_color: label: Primary colour + help: Accent for buttons, links and highlights. “Auto” keeps the accent that comes with your theme. + theme: + label: Theme + help: Full colour scheme (background, surfaces, text) for the interface. Each theme ships its own accent — override it with Primary colour above. + preset: + midnight: Midnight + nord: Nord + catppuccin_latte: Catppuccin Latte + catppuccin_frappe: Catppuccin Frappé + catppuccin_macchiato: Catppuccin Macchiato + catppuccin_mocha: Catppuccin Mocha + dracula: Dracula + gruvbox_dark: Gruvbox Dark + kanagawa_dark: Kanagawa Dark + teal_city: Teal City + camouflage: Camouflage + tucan: Tucan + shades_of_purple: Shades of Purple + neon_pink: Neon Pink + solarized_light: Solarized Light + peachy: Peachy + zebra: Zebra qbit_refresh: label: qBittorrent refresh help: Auto-polling frequency on the Downloads page. @@ -781,6 +971,18 @@ admin: '5': '5 seconds' '10': '10 seconds (low load)' '0': Disabled + 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: How often the sidebar badge and completion toasts poll Transmission. 0 disables polling. + 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: @@ -822,6 +1024,11 @@ admin: clear: Clear tmdb: api_key: v3 API key + unraid: + skip_tls_verify: 'Skip TLS certificate verification (self-signed certificate)' + unifi: + site: Site name + skip_tls_verify: Skip TLS certificate verification (self-signed console certificate) internal: calendar: label: Calendar @@ -919,12 +1126,16 @@ admin: decouverte: title: Discover + countdown_prefix: 'D-' + badge_type_film: Movie + badge_type_series: TV search_placeholder: 'Search a movie or TV show on TMDb…' actions: filters: Filters watchlist: Watchlist explorer: Explorer filters: + drawer_title: Filters type: Type movies: Movies series: TV shows @@ -1179,7 +1390,7 @@ media: no_file: No file no_missing_episodes: No missing monitored episode. network_error: Network or upstream error. Check the service is reachable. - warning_format: '{source}: {message}' + warning_format: '{message}' label: season_prefix: 'Season {number}' @@ -1242,6 +1453,8 @@ media: filters: search_placeholder: "Search…" apply: Apply + open: Filters + drawer_title: Filters status: all: All monitored: Monitored @@ -1770,6 +1983,8 @@ media: filters: search_placeholder: "Search…" apply: Apply + open: Filters + drawer_title: Filters status: all: All monitored: Monitored @@ -3684,6 +3899,37 @@ qbittorrent: no_valid_file: No valid file torrent_not_found: Torrent not found +deluge: + title: Deluge + filters: + labels: Labels + table: + uploaded: Uploaded + completed: Completed + api: + empty_location: Destination path is required + +transmission: + page_title: Transmission + modal_detail: + trackers: + status_announcing: Announcing + status_queued: Queued + status_active: Active + status_idle: Idle + 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 61156722..e51e4bab 100644 --- a/symfony/translations/messages+intl-icu.fr.yaml +++ b/symfony/translations/messages+intl-icu.fr.yaml @@ -39,6 +39,7 @@ common: required: requis coming_soon: Bientôt labels: + new_badge: Nouveau email: Adresse email username: Nom d'utilisateur password: Mot de passe @@ -64,6 +65,8 @@ sidebar: nav: dashboard: Tableau de bord qbittorrent: qBittorrent + deluge: Deluge + transmission: Transmission discovery: Découverte calendar: Calendrier radarr: Radarr @@ -99,6 +102,7 @@ topbar: loading_online: Chargement… in_library_badge: En bibliothèque not_added_badge: Non ajouté + add: Ajouter group_local: Dans votre bibliothèque group_online: Sur TMDb / TheTVDB more_local: Voir plus dans votre bibliothèque @@ -288,6 +292,17 @@ setup: user: Utilisateur password: Mot de passe reverse_proxy_hint: 'Laissez l''utilisateur et le mot de passe vides si qBittorrent est derrière un reverse proxy qui injecte lui-même l''authentification (qui, traefik forward auth, …).' + deluge: + section: Deluge + 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' @@ -353,6 +368,8 @@ home: dashboard: title: Tableau de bord + widget_error: + unreachable: 'Cette section n''a pas pu être chargée' greeting: morning: Bonjour afternoon: Bon après-midi @@ -412,6 +429,7 @@ dashboard: title: Activité Plex en cours settings_cta: 'Réglages →' streams: Flux + viewers: Spectateurs actuels mbps: Mbps unknown_title: Inconnu empty: Aucun flux Plex en cours @@ -452,6 +470,57 @@ dashboard: cast: 'Distribution :' studio: 'Studio :' tech: Flux + server: + title: Serveur + settings_cta: Paramètres + unreachable: Unraid est injoignable + array: Baie + parity: Parité + disks: Disques + system: Système + docker: Docker + ups: Onduleur + cpu: CPU + ram: RAM + uptime: Démarré depuis + running: actifs + battery: Batterie + load: Charge + parity_checking: En cours + elapsed: Écoulé + eta: Fin estimée + errors: erreurs + last_check: Dernier contrôle + network: + title: Réseau + settings_cta: Paramètres + unreachable: Console UniFi injoignable — vérifiez l’URL et la clé API dans les paramètres. + down: Téléchargement + up: Envoi + clients: Clients + wired: filaire + wireless: sans fil + guest: invités + wan: WAN + uptime: Connexion + usage_24h: Utilisation — dernières 24 h + infrastructure: Infrastructure + online: en ligne + offline: hors ligne + houndarr: + title: Houndarr + settings_cta: Paramètres + tracked: Suivis + eligible: Éligibles + gated: 'En cooldown' + unreleased: 'À paraître' + searches_7d: 'Recherches (7 j)' + updated: 'Mis à jour' + unreachable: 'Houndarr est injoignable' + auth_error: 'Houndarr a rejeté la clé API — vérifiez les paramètres' + wanted: Manquants + cutoff_unmet: 'Sous le seuil' + arr_note: 'Comptes manquants issus de Radarr/Sonarr' type: film: Film series: Série @@ -472,12 +541,33 @@ dashboard: discover: 'Voir dans Découverte' close: 'Fermer' runtime: '{min} min' - seasons: '{count} saisons' + seasons: '{count, plural, one {# saison} other {# saisons}}' error: "Impossible de charger l'aperçu" + providers: 'Disponible sur' + trailer: 'Bande-annonce' + watchlist: 'À voir' + watchlist_added: 'Dans la liste' + watchlist_add_toast: 'Ajouté à votre liste à voir' + watchlist_remove_toast: 'Retiré de votre liste à voir' status: downloaded: 'Téléchargé' monitored: 'Suivi' missing: 'Manquant' + date: + cinema: 'Au cinéma' + digital: 'Numérique' + physical: 'Physique' + first_aired: 'Première diffusion' + next_episode: 'Prochain épisode' + ended: 'Terminée' + airstatus: + continuing: 'En cours' + ended: 'Terminée' + layout: + edit: Modifier la disposition + save: Enregistrer + cancel: Annuler + hide: Masquer tautulli: title: Activité Plex @@ -536,6 +626,74 @@ tautulli: items: '{count, plural, one {# élément} other {# éléments}}' episodes: '{count, plural, one {# épisode} other {# épisodes}}' +unifi: + title: Réseau + subtitle: "Supervision réseau en direct" + updated: 'Mis à jour il y a {seconds} s' + unreachable: "Console UniFi inaccessible — vérifiez l'URL et la clé API dans les paramètres." + empty: 'Aucune donnée pour cette section' + retry: Réessayer + tiles: + wan: Internet + cpu: 'Processeur passerelle' + memory: 'Mémoire passerelle' + clients: Clients + throughput: Débit + latency: latence + drops: pertes + wired: filaire + wireless: 'sans fil' + guest: invité + history: + traffic: 'Trafic WAN — 7 derniers jours' + speedtests: 'Historique des tests de débit — 30 derniers jours' + speedtests_sparse: "Seulement {count} tests sur la période — activez les tests planifiés sur la console pour obtenir une tendance." + latest: 'Dernier : ↓{down} ↑{up} · {latency}' + latency_range: 'Latence {min} / {avg} / {max} ms' + peak: 'pic {peak}' + infra: + title: Infrastructure + device: Équipement + hidden_ssid: masqué + untagged: 'non balisé' + kind_gateway: passerelle + kind_switch: commutateur + kind_ap: "point d'accès" + kind_other: équipement + summary: '{devices} équipements · {online} en ligne · {upgradable} à mettre à jour' + radios: "Points d'accès" + neighbors: 'Réseaux voisins' + neighbors_unavailable: "Analyse des réseaux voisins indisponible sur cette console" + networks: Réseaux + network_name: Réseau + vlan: VLAN + subnet: 'Sous-réseau' + channel: Canal + power: 'Puissance TX' + utilization: Utilisation + retry_pct: Réémissions + signal: Signal + vendor: Fabricant + temperature: Temp. + uptime: 'Temps de fonctionnement' + upgradable: 'Mise à jour disponible' + clients: + wireless: 'Clients sans fil' + device: Équipement + ssid: SSID + band: Bande + access_point: "Point d'accès" + avg_signal: 'Signal moyen' + talkers: 'Débits en direct' + top: 'Principaux clients (total)' + reservations: 'Réservations DHCP' + reservations_summary: '{total} réservations · {mismatched} incohérentes' + reserved_ip: 'IP réservée' + live_ip: 'IP actuelle' + status_ok: Conforme + status_mismatch: Incohérente + status_offline: 'Hors ligne' + profile: title: Mon profil identity: @@ -649,10 +807,15 @@ admin: prowlarr: Agrégateur d'indexeurs 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' tautulli: 'Activité Plex (via Tautulli)' + unraid: 'Supervision du serveur Unraid — baie, disques, système, Docker, onduleur (Unraid 7+, clé API en lecture seule)' + unifi: 'Supervision réseau UniFi — débit WAN, clients, équipements (Network 9.0+, clé API en lecture seule via Control Plane → Integrations)' + houndarr: 'Recherche automatique du backlog *arr (widget en lecture seule)' sidebar_toggle: Menu sidebar_toggle_title: Afficher dans le menu latéral status: @@ -739,6 +902,10 @@ admin: sidebar: title: Visibilité de la sidebar description: Choisis quels services et fonctionnalités apparaissent dans la barre latérale gauche. + dashboard_layout: + title: Disposition du tableau de bord + description: Glissez pour réordonner les sections, et choisissez celles à afficher. + reset_hint: 'Utilisez « Réinitialiser les préférences d''affichage » ci-dessous pour rétablir la disposition par défaut.' switch: on: Activées off: Désactivées @@ -771,6 +938,28 @@ admin: '12h': '12h (2:30 PM)' theme_color: label: Couleur principale + help: "Accent des boutons, liens et éléments mis en avant. « Auto » conserve l'accent fourni par votre thème." + theme: + label: Thème + help: "Palette complète (fond, surfaces, texte) de l'interface. Chaque thème a son propre accent — remplacez-le avec la Couleur principale ci-dessus." + preset: + midnight: Minuit + nord: Nord + catppuccin_latte: Catppuccin Latte + catppuccin_frappe: Catppuccin Frappé + catppuccin_macchiato: Catppuccin Macchiato + catppuccin_mocha: Catppuccin Mocha + dracula: Dracula + gruvbox_dark: Gruvbox (sombre) + kanagawa_dark: Kanagawa (sombre) + teal_city: Teal City + camouflage: Camouflage + tucan: Toucan + shades_of_purple: Shades of Purple + neon_pink: Rose néon + solarized_light: Solarized (clair) + peachy: Pêche + zebra: Zèbre qbit_refresh: label: Rafraîchissement qBittorrent help: Fréquence du polling automatique sur la page Téléchargements. @@ -780,6 +969,18 @@ admin: '5': '5 secondes' '10': '10 secondes (économe)' '0': Désactivé + 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 de mise à jour du badge et des notifications de fin. 0 désactive le polling. + 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: @@ -821,6 +1022,11 @@ admin: clear: Effacer tmdb: api_key: Clé API v3 + unraid: + skip_tls_verify: 'Ignorer la vérification du certificat TLS (certificat auto-signé)' + unifi: + site: Nom du site + skip_tls_verify: Ignorer la vérification du certificat TLS (certificat auto-signé de la console) internal: calendar: label: Calendrier @@ -918,12 +1124,16 @@ admin: decouverte: title: Découverte + countdown_prefix: 'J-' + badge_type_film: Film + badge_type_series: Série search_placeholder: 'Rechercher un film ou une série sur TMDb…' actions: filters: Filtres watchlist: Watchlist explorer: Explorer filters: + drawer_title: Filtres type: Type movies: Films series: Séries @@ -1178,7 +1388,7 @@ media: no_file: Aucun fichier no_missing_episodes: Aucun épisode manquant surveillé. network_error: Erreur réseau ou service injoignable. - warning_format: '{source} : {message}' + warning_format: '{message}' label: season_prefix: 'Saison {number}' @@ -1241,6 +1451,8 @@ media: filters: search_placeholder: "Rechercher…" apply: Appliquer + open: Filtres + drawer_title: Filtres status: all: Tous monitored: Suivis @@ -1769,6 +1981,8 @@ media: filters: search_placeholder: "Rechercher…" apply: Appliquer + open: Filtres + drawer_title: Filtres status: all: Toutes monitored: Suivies @@ -3683,6 +3897,37 @@ qbittorrent: no_valid_file: Aucun fichier valide torrent_not_found: Torrent introuvable +deluge: + title: Deluge + filters: + labels: Labels + table: + uploaded: Envoyé + completed: Terminé le + api: + empty_location: Le chemin de destination est requis + +transmission: + page_title: Transmission + modal_detail: + trackers: + status_announcing: Annonce + status_queued: En file + status_active: Actif + status_idle: Inactif + 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é.'