@@ -952,6 +968,22 @@
{% endif %}
+ {% if service_visible_in_sidebar('tautulli') %}
+
+
+
+
+
+ Tautulli
+
+
+ {% endif %}
+
{# ── Sidebar footer: admin settings + logout (mobile included) ── #}
@@ -2442,6 +2474,42 @@
{% endif %}
+ {# ══════════════════════════════════════════════════════════════════════════
+ Live Plex streaming pill — hydrates off the existing now-playing endpoint.
+ Tautulli has no sidebar nav entry, so the pill is rendered unconditionally
+ and hidden by default; this poller reveals it when streams are active and
+ stops itself entirely when Tautulli isn't configured (so it doesn't poke a
+ disabled service every 30s).
+ ══════════════════════════════════════════════════════════════════════════ #}
+
+
{# ══════════════════════════════════════════════════════════════════════════
Usenet active-downloads badge — one poll per configured client, mirroring
the qBit/Seerr badge. Light JSON endpoint (active count only); each badge
diff --git a/symfony/templates/dashboard/_health.html.twig b/symfony/templates/dashboard/_health.html.twig
index 4560fe0..f348aa6 100644
--- a/symfony/templates/dashboard/_health.html.twig
+++ b/symfony/templates/dashboard/_health.html.twig
@@ -16,14 +16,22 @@
jellyseerr: '#a259ff',
qbittorrent: '#2f67ba',
tmdb: '#01B4E4',
+ tautulli: '#e5a00d',
+ } %}
+ {% set status_dot = {
+ up: 'is-up',
+ slow: 'is-slow',
+ very_slow: 'is-very-slow',
+ degraded: 'is-degraded',
+ down: 'is-down',
} %}
{% for chip in services_health %}
-
+
{{ chip.name }}
- {{ chip.state ? 'dashboard.health.status_ok'|trans : 'dashboard.health.status_ko'|trans }}
+ {{ ('dashboard.health.status_' ~ chip.status)|trans }}{% if chip.latencyMs is not null %} · {{ chip.latencyMs }} ms{% endif %}
diff --git a/symfony/templates/dashboard/_plex_activity.html.twig b/symfony/templates/dashboard/_plex_activity.html.twig
new file mode 100644
index 0000000..dfd0bec
--- /dev/null
+++ b/symfony/templates/dashboard/_plex_activity.html.twig
@@ -0,0 +1,43 @@
+{# Async-hydrated fragment — current Plex activity from Tautulli, rendered by
+ DashboardController::widgetPlex into the card's [data-dash-body]. Re-fetched
+ on a 10s interval (see index.html.twig). `plex` is the sanitized shape from
+ TautulliClient::getActivity() — no secrets, no raw payload. Fails open: the
+ error/empty states below keep the card intact when Tautulli is down. #}
+{% set active_tab = plex_tab|default('now') %}
+
+
+
+{% if plex.error == 'unconfigured' %}
+
{{ 'dashboard.plex.error.unconfigured'|trans }}
+{% elseif plex.error == 'auth' %}
+
{{ 'dashboard.plex.error.auth'|trans }}
+{% elseif plex.error == 'unreachable' or not plex.connected %}
+
{{ 'dashboard.plex.error.unreachable'|trans }}
+{% elseif plex.sessions is empty %}
+
{{ 'dashboard.plex.empty'|trans }}
+{% else %}
+ {% include 'dashboard/_plex_summary.html.twig' with {plex: plex} only %}
+
+ {# ── One card per active session ─────────────────────────────────── #}
+
+ {% for s in plex.sessions %}
+ {% include 'dashboard/_plex_session_card.html.twig' with {s: s} only %}
+ {% endfor %}
+
+{% endif %}
+
+
+
+ {% if plex_history is empty %}
+
{{ 'dashboard.plex.recent.empty'|trans }}
+ {% else %}
+
+ {% for h in plex_history %}
+ {% include 'dashboard/_plex_history_row.html.twig' with {h: h} only %}
+ {% endfor %}
+
+ {% endif %}
+
diff --git a/symfony/templates/dashboard/_plex_history_row.html.twig b/symfony/templates/dashboard/_plex_history_row.html.twig
new file mode 100644
index 0000000..59e263b
--- /dev/null
+++ b/symfony/templates/dashboard/_plex_history_row.html.twig
@@ -0,0 +1,22 @@
+{# One watch-history row. Expects `h` (a normalized history row) with an integer
+ `watchedAt` (Unix epoch seconds; 0/absent = unknown → time label omitted). #}
+{% import '_icons.html.twig' as ico %}
+
+
+ {{ ico.icon(h.mediaType == 'movie' ? 'movie' : 'device-tv', '', 22) }}
+ {% if h.posterPath %}
 }})
{% endif %}
+
+
+
+ {% if h.mediaType == 'episode' and h.grandparentTitle %}{{ h.grandparentTitle }}{% else %}{{ h.title }}{% endif %}
+ {% if h.year %}({{ h.year }}){% endif %}
+
+
+ {{ [h.userDisplayName, h.watchedAt|relative_date]|filter(v => v is not null)|join(' · ') }}
+
+ {% if h.percentComplete > 0 %}
+
+ {% endif %}
+
+
diff --git a/symfony/templates/dashboard/_plex_info_modal.html.twig b/symfony/templates/dashboard/_plex_info_modal.html.twig
new file mode 100644
index 0000000..2b814a2
--- /dev/null
+++ b/symfony/templates/dashboard/_plex_info_modal.html.twig
@@ -0,0 +1,47 @@
+{# Shared Plex info modal: shell + hidden data-API trigger + the delegated
+ open/keydown handler. Included by the dashboard and the Tautulli page.
+ Tabler doesn't expose window.bootstrap, so we click a hidden data-bs-toggle
+ trigger (same pattern as the usenet/qBit detail modals).
+ Include exactly ONCE per rendered document (the dashboard and the Tautulli
+ page are separate pages, so this holds); the window._plexModalDelegated guard
+ only de-dupes the JS listener, not the modal DOM itself. #}
+
+
+
diff --git a/symfony/templates/dashboard/_plex_metadata.html.twig b/symfony/templates/dashboard/_plex_metadata.html.twig
new file mode 100644
index 0000000..2bce7e2
--- /dev/null
+++ b/symfony/templates/dashboard/_plex_metadata.html.twig
@@ -0,0 +1,51 @@
+{# Modal body for the Plex info modal. `plex` is the getMetadata() envelope.
+ player/device are display-only live-session strings (empty when opened from
+ a recently-watched row). Server-rendered + sanitized — no secrets. #}
+{% set m = plex.metadata %}
+{% if plex.error or m is empty %}
+
{{ 'dashboard.plex.modal.error'|trans }}
+{% else %}
+
+
+ {% if m.mediaType == 'episode' and (m.season or m.episode) %}
+
{{ 'S%02dE%02d'|format(m.season ?? 0, m.episode ?? 0) }}{% if m.title %} · {{ m.title }}{% endif %}
+ {% endif %}
+
+
+ {% if m.contentRating %}{{ m.contentRating }}{% endif %}
+ {% if m.durationLabel %}{{ m.durationLabel }}{% endif %}
+ {% if m.ratings.audience %}★ {{ m.ratings.audience }}{% endif %}
+ {% if m.ratings.critic %}{{ 'dashboard.plex.modal.critic'|trans }} {{ m.ratings.critic }}{% endif %}
+ {% for g in m.genres %}{{ g }}{% endfor %}
+
+
+ {% if m.tagline %}
{{ m.tagline }}
{% endif %}
+ {% if m.summary %}
{{ m.summary }}
{% endif %}
+
+ {% if m.directors is not empty %}
{{ 'dashboard.plex.modal.directors'|trans }} {{ m.directors|join(', ') }}
{% endif %}
+ {% if m.writers is not empty %}
{{ 'dashboard.plex.modal.writers'|trans }} {{ m.writers|join(', ') }}
{% endif %}
+ {% if m.cast is not empty %}
{{ 'dashboard.plex.modal.cast'|trans }} {{ m.cast|join(', ') }}
{% endif %}
+ {% if m.studio %}
{{ 'dashboard.plex.modal.studio'|trans }} {{ m.studio }}
{% endif %}
+
+ {% set tech = [] %}
+ {% if m.media.resolution %}{% set tech = tech|merge([m.media.resolution]) %}{% endif %}
+ {% if m.media.videoCodec %}{% set tech = tech|merge([m.media.videoCodec|upper]) %}{% endif %}
+ {% if m.media.audioCodec %}{% set tech = tech|merge([m.media.audioCodec|upper]) %}{% endif %}
+ {% if m.media.container %}{% set tech = tech|merge([m.media.container|upper]) %}{% endif %}
+ {% if m.media.bitrateKbps > 0 %}{% set tech = tech|merge([(m.media.bitrateKbps / 1000)|round(1) ~ ' ' ~ ('dashboard.plex.mbps'|trans)]) %}{% endif %}
+ {% if tech is not empty or player or device %}
+
+
+
{{ 'dashboard.plex.modal.tech'|trans }}
+ {% if tech is not empty %}
{{ tech|join(' · ') }}
{% endif %}
+ {% if player or device %}
{{ [player, device]|filter(v => v)|join(' · ') }}
{% endif %}
+
+ {% endif %}
+
+{% endif %}
diff --git a/symfony/templates/dashboard/_plex_session_card.html.twig b/symfony/templates/dashboard/_plex_session_card.html.twig
new file mode 100644
index 0000000..9af9411
--- /dev/null
+++ b/symfony/templates/dashboard/_plex_session_card.html.twig
@@ -0,0 +1,51 @@
+{# One live Plex session card. Expects `s` (a normalized session). Carries the
+ info-modal triggers when a ratingKey is present. #}
+{% import '_icons.html.twig' as ico %}
+{% set state = (s.state|default(''))|lower %}
+{% set state_known = state in ['playing', 'paused', 'buffering'] %}
+{% set state_cls = {playing: 'bg-green-lt text-green', paused: 'bg-yellow-lt text-yellow', buffering: 'bg-azure-lt text-azure'} %}
+{% set dec = (s.transcodeDecision|default(''))|lower %}
+{% set dec_label = {'direct play': 'dashboard.plex.decision.direct_play', 'copy': 'dashboard.plex.decision.direct_stream', 'transcode': 'dashboard.plex.decision.transcode'} %}
+{% set dec_cls = {'direct play': 'bg-green-lt text-green', 'copy': 'bg-azure-lt text-azure', 'transcode': 'bg-orange-lt text-orange'} %}
+
+
+ {{ ico.icon(s.mediaType == 'movie' ? 'movie' : 'device-tv', '', 22) }}
+ {% if s.posterPath %}
+
 }})
+ {% endif %}
+
+
+
+ {{ s.title ?? 'dashboard.plex.unknown_title'|trans }}{% if s.year %} ({{ s.year }}){% endif %}
+
+
+ {%- set meta = [s.userDisplayName, s.product, s.player, s.device]|filter(v => v is not null) -%}
+ {{ meta|join(' · ') }}
+
+
+ {% if state %}{{ state_known ? ('dashboard.plex.state.' ~ state)|trans : state|capitalize }}{% endif %}
+ {% if dec %}{{ dec_label[dec] is defined ? dec_label[dec]|trans : dec|capitalize }}{% endif %}
+ {% if s.location %}{{ s.location }}{% endif %}
+ {% if s.quality %}{{ s.quality }}{% endif %}
+ {% if s.dynamicRange %}{{ s.dynamicRange }}{% endif %}
+ {% if s.bandwidthMbps > 0 %}{{ s.bandwidthMbps }} {{ 'dashboard.plex.mbps'|trans }}{% endif %}
+
+ {% if dec == 'transcode' %}
+ {% set v_trans = (s.videoDecision|default('')|lower == 'transcode') and s.videoCodec and s.streamVideoCodec and (s.videoCodec|lower != s.streamVideoCodec|lower) %}
+ {% set a_trans = (s.audioDecision|default('')|lower == 'transcode') and s.audioCodec and s.streamAudioCodec and (s.audioCodec|lower != s.streamAudioCodec|lower) %}
+
+ {{ 'dashboard.plex.track.video'|trans }}
+ {% if v_trans %}{{ s.videoCodec|upper }} → {{ s.streamVideoCodec|upper }}{% else %}{{ s.videoDecision ?? '—' }}{% endif %} ·
+ {{ 'dashboard.plex.track.audio'|trans }}
+ {% if a_trans %}{{ s.audioCodec|upper }} → {{ s.streamAudioCodec|upper }}{% else %}{{ s.audioDecision ?? '—' }}{% endif %} ·
+ {{ 'dashboard.plex.track.subtitle'|trans }} {{ s.subtitleDecision ?? '—' }}
+
+ {% endif %}
+
+
+
diff --git a/symfony/templates/dashboard/_plex_styles.html.twig b/symfony/templates/dashboard/_plex_styles.html.twig
new file mode 100644
index 0000000..990a0d3
--- /dev/null
+++ b/symfony/templates/dashboard/_plex_styles.html.twig
@@ -0,0 +1,24 @@
+
diff --git a/symfony/templates/dashboard/_plex_summary.html.twig b/symfony/templates/dashboard/_plex_summary.html.twig
new file mode 100644
index 0000000..fe33fcc
--- /dev/null
+++ b/symfony/templates/dashboard/_plex_summary.html.twig
@@ -0,0 +1,21 @@
+{# Stream-summary strip: stream count + Direct Play/Stream/Transcode badges +
+ total/LAN/WAN bandwidth. `plex` is the getActivity() envelope. Shared by the
+ 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 %}
+
+
+
{{ plex.streamCount }}
+
{{ 'dashboard.plex.streams'|trans }}
+
+
+ {% 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 %}
+ {% if plex.transcodeCount > 0 %}{{ 'dashboard.plex.summary.transcode'|trans({count: plex.transcodeCount}) }}{% endif %}
+
+
+ {{ ico.icon('activity', '', 14) }} {{ plex.bandwidth.totalMbps }} {{ 'dashboard.plex.mbps'|trans }}
+ {{ 'dashboard.plex.bandwidth.lan'|trans }} {{ plex.bandwidth.lanMbps }}
+ {{ 'dashboard.plex.bandwidth.wan'|trans }} {{ plex.bandwidth.wanMbps }}
+
+
diff --git a/symfony/templates/dashboard/index.html.twig b/symfony/templates/dashboard/index.html.twig
index 4a79673..ebb71ea 100644
--- a/symfony/templates/dashboard/index.html.twig
+++ b/symfony/templates/dashboard/index.html.twig
@@ -441,6 +441,11 @@
.service-chip-dot.is-ok { background: #22c55e; box-shadow: 0 0 8px rgba(34,197,94,.55); }
.service-chip-dot.is-ko { background: #ef4444; }
.service-chip-dot.is-unk { background: #6b7280; }
+ .service-chip-dot.is-up { background: #22c55e; box-shadow: 0 0 8px rgba(34,197,94,.55); }
+ .service-chip-dot.is-slow { background: #f59e0b; box-shadow: 0 0 8px rgba(245,158,11,.5); }
+ .service-chip-dot.is-very-slow { background: #f97316; box-shadow: 0 0 8px rgba(249,115,22,.5); }
+ .service-chip-dot.is-degraded { background: #94a3b8; }
+ .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; }
/* Async widget hydration skeletons (#27) */
@@ -456,6 +461,7 @@
.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; } }
+{% include 'dashboard/_plex_styles.html.twig' %}
{% endblock %}
{% block body %}
@@ -571,6 +577,34 @@