Files
silo-server/web/src/components/ServerActivity.tsx
T
8044eb84dd feat(activity): refine play-method tags and add a Jellyfin-client pill (#387)
* feat(activity): refine play-method tags and add a Jellyfin-client pill

Two related tagging improvements to the admin activity views, squashed:

Split audio transcodes into their own tag. The Play Method summary and
Server Activity popover bucketed every session by its raw play_method,
lumping real video transcodes together with video-copy HLS repackages and
having no separate tag for audio-only transcodes. Classify each session by
the per-stream decisions the backend already reports:
  - video re-encoded        -> "transcode" (yellow)
  - only audio re-encoded   -> "audio"     (red)
  - streams only repackaged -> "remux"     (blue, incl. video-copy HLS)
  - nothing touched         -> "direct"    (green)
ordered direct -> remux -> transcode -> audio across the distribution bar,
legend, method filter/sort, the per-row badge, and the Server Activity
stream counts.

Add a Jellyfin-client "JF" pill. Sessions from a Jellyfin-ecosystem client
(Jellyfin Web, Findroid, Swiftfin, Infuse, etc.) get a purple "JF" pill
next to the play-method tag. Detection is UI-only: isJellyfinSession()
positively matches client_name (set from the Jellyfin MediaBrowser auth
header) and then the raw user agent against the known Jellyfin client
tokens, mirroring the server's client-labeling list. The pill is orthogonal
to the method classification — a session can be both "transcode" and JF.

Pure UI/presentation change; no backend behavior changes.

AI-use disclosure: implemented with AI assistance (Claude Code).

* fix(web): cache-control on SPA shell so deploys bust stale UI

The frontend handler served index.html with no cache directives, leaving
freshness to browser/CDN heuristics. A stale index.html at a CDN edge kept
serving old content-hashed bundles, so a client-side hard refresh couldn't
recover — one browser would show the new UI while another showed the old.

Apply the standard SPA cache policy:
  - index.html (and SPA-route fallbacks): no-cache + a truncated-SHA-256
    ETag, so the shell is cached but revalidated on every load and answers
    an unchanged request with a cheap 304.
  - /assets/* (Vite content-hashed bundles): public, max-age=31536000,
    immutable — cached indefinitely; a new build changes the filename hash,
    which busts them automatically.
  - other stable-named bundled files (sw.js, icons, fonts): no-cache, so a
    changed service worker or icon can't stay stuck in a cache.

Caching is preserved (no no-store anywhere); only the tiny HTML shell is
revalidated, which is what busts a stale UI on deploy.

* fix(activity): compute the method bucket server-side and unify every session surface

Review follow-ups for the play-method tags (PR #387):

- The server now emits effective_play_method (additive field) from the same
  per-stream decisions that drive the badges, so all consumers — web, realtime
  popover, and the Android/Apple admin views later — agree on the bucket
  instead of each client re-reducing raw play_method. Rows with an unknown
  play_method (stale rows from older nodes) stay unbucketed rather than being
  misreported as audio transcodes off the bare transcode_audio flag; the web
  fallback classifier mirrors that and reports "unknown".
- Jellyfin-ecosystem detection moved server-side as is_jellyfin_client, owned
  next to the client-labeling rules so the two lists cannot drift; the web
  token list is gone. Adds kodi/mpv/delfin/finamp, which reach Silo only
  through the Jellyfin compat surface.
- The dashboard stream cards, stats session table, and household streams panel
  now use the same classification as the activity page and popover — they
  previously showed contradictory tags for the same live session.
- One shared method->label/color table in adminActivityPresentation.ts
  replaces the four independent copies (METHOD_META + three switches); the
  method column sort now uses the shared cost-order comparator instead of
  alphabetical; dead "copy"/"hls" order entries removed and the reachable
  "unknown" bucket is styled.

* fix(server): make SPA revalidation RFC-compliant and stop rebuilding the shell per request

Review follow-ups for the SPA cache policy (PR #387):

- Stable-URL bundled files (sw.js, icons, vendor bundles) now carry a content
  ETag. The embedded FS has no modtimes, so http.FileServer emits no validator
  of its own — no-cache alone forced a full re-download of multi-megabyte
  vendor trees on every use because there was nothing to revalidate against.
- Shell and favicon conditional requests go through http.ServeContent, which
  implements RFC 9110 If-None-Match semantics (weak comparison, ETag lists).
  The previous exact string compare never matched once a fronting proxy
  compressed the response and weakened the ETag to W/"...", silently killing
  the 304 path in the most common deployment topology.
- The rendered shell (index read + branding render + SHA-256) is cached per
  branding snapshot via the new Snapshot.RenderKey instead of being rebuilt on
  every request — the 304 revalidation that no-cache makes the common case now
  costs two header writes. The misnamed weakContentETag (it emits a strong
  validator) is renamed contentETag.

* fix(activity): show the JF pill on every session surface, not just the mobile row

Review comments on PR #387: the JF pill only rendered inside Admin
Activity's sm:hidden mobile row, so the desktop table — and the other
session surfaces that now share the method classification — never
identified Jellyfin-compat sessions.

Extract the pill into a shared JellyfinSessionPill component (renders
nothing for native sessions) and drop it into the Admin Activity desktop
client line, the dashboard stream cards, the household streams panel,
and the stats active-session table.

* fix(playback): sync real encode decisions and client identity for compat transcodes

Review comments on PR #387:

- Jellyfin HLS sessions that copy video and re-encode only audio synced as
  full video transcodes: ensureUpstreamPlayback resets transcodeAudio for the
  transcode transport method, and the TargetCodecVideo "copy" decision lived
  only in TranscodeOpts. A new SessionManager.SetTranscodeStreamDetails
  mirrors the actual decisions onto the upstream session when the transcode
  starts (local and remote-node paths, via an optional interface so test
  fakes are unaffected), so these sessions now bucket as "audio"/"remux".
- Transcode recipe cards now record TranscodeAudio derived from the opts
  (only an explicit "copy" leaves audio untouched — empty runs ffmpeg's aac
  default), so a session rebuilt after a restart keeps the same bucket.
- Recipe cards carry client name/version/user-agent, and reconstruction
  restores them, so the admin client label and the JF pill survive server
  restarts; the compat fallback card populates them from the live
  MediaBrowser request. Deliberately not projected into stream-token claims,
  where a user agent would bloat every stream URL.

* feat(api): capability endpoint for the live-session activity fields

Review comment on PR #387: effective_play_method and is_jellyfin_client are
omitempty, so an independently deployed client cannot distinguish an older
server from a supported one reporting an unknown method or a non-Jellyfin
session. GET /admin/sessions/capabilities advertises both fields plus the
closed bucket vocabulary, following the additive capability-endpoint rule
(same pattern as /collections/capabilities).

* fix(playback): treat empty target audio codec as an AAC re-encode in live state

ffmpeg defaults an empty target audio codec to AAC (appendAudioArgs), and the
new recipe logic already records that as an audio transcode — but the live
native path computed transcodeAudio=false for an empty codec, so the running
stream reported remux until a restart flipped it to audio. Extract the
predicate into playback.TranscodesAudio, share it across the live path, the
recipe card, and the compat mirror, and make appendAudioArgs case-insensitive
so the ffmpeg switch agrees with the predicate for any spelling.

Part of #387 review follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(jellycompat): re-sync sessions after recording compat encode decisions

ensureUpstreamPlayback flushes the session (compat_start) before
ensureTranscodeSession / startRemoteTranscode record the actual codec
decisions, and that later mutation triggered no sync — so the admin view
showed a video-copy stream as a full video transcode until the periodic
reconciler ran. Trigger syncSessionsNow after the details are recorded
successfully; the helper is shared, so both the local and remote-node
paths are covered.

Part of #387 review follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 17:12:36 -04:00

429 lines
15 KiB
TypeScript

import { useState, useMemo, useEffect, useRef } from "react";
import { Link, useLocation } from "react-router";
import { Popover as PopoverPrimitive } from "radix-ui";
import { Activity, ChevronRight, Loader, ScanLine } from "lucide-react";
import { useAdminSessions } from "@/hooks/queries/admin/stats";
import { useTasks } from "@/hooks/queries/admin/tasks";
import { useActiveScans } from "@/hooks/queries/admin/scans";
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
import {
useRealtimeEvents,
type RealtimeConnectionState,
} from "@/components/realtimeEventsContext";
import type { TaskInfo, ScanRun } from "@/api/types";
import {
activityMethodMeta,
classifyActivityMethod,
compareActivityMethods,
} from "@/pages/adminActivityPresentation";
const CONNECTION_PROBLEM_INDICATOR_DELAY_MS = 4_000;
const MAX_ACTIVITY_SCAN_ROWS = 25;
const MAX_BADGE_COUNT = 99;
const ACTIVE_SCAN_SNAPSHOT_LIMIT = 500;
interface ServerActivityProps {
/** Hide the trigger button entirely when there is no activity */
hideWhenEmpty?: boolean;
}
// ── Data hook ────────────────────────────────────────────────
// Channel subscriptions are owned by the parent layout
// (AdminLayoutEventChannels / AdminEventChannels), not here.
function useServerActivityData() {
const { data: sessions = [] } = useAdminSessions();
const { data: tasks = [] } = useTasks();
const { data: scans } = useActiveScans();
const { data: libraries = [] } = useAdminLibraries();
const { connectionState } = useRealtimeEvents();
const activeScans = useMemo(
() => (scans ?? []).filter((s) => s.status === "accepted" || s.status === "running"),
[scans],
);
const runningTasks = useMemo(() => tasks.filter((t) => t.state === "running"), [tasks]);
const streamCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const s of sessions) {
const method = classifyActivityMethod(s);
counts[method] = (counts[method] || 0) + 1;
}
return counts;
}, [sessions]);
const totalActive = sessions.length + runningTasks.length + activeScans.length;
const libraryName = (id: number) => libraries.find((l) => l.id === id)?.name ?? `Library #${id}`;
return {
sessions,
runningTasks,
activeScans,
streamCounts,
totalActive,
libraryName,
connectionState,
scansLoaded: scans !== undefined,
};
}
// ── Main component ───────────────────────────────────────────
function useDelayedConnectionProblem(connectionState: RealtimeConnectionState) {
const isNonLive = connectionState !== "live";
const previousIsNonLiveRef = useRef(false);
const [connectionProblemState, setConnectionProblemState] = useState(false);
useEffect(() => {
const wasNonLive = previousIsNonLiveRef.current;
previousIsNonLiveRef.current = isNonLive;
if (!isNonLive) {
setConnectionProblemState(false);
return;
}
if (wasNonLive) {
return;
}
const timeoutID = window.setTimeout(
() => setConnectionProblemState(true),
CONNECTION_PROBLEM_INDICATOR_DELAY_MS,
);
return () => window.clearTimeout(timeoutID);
}, [isNonLive]);
return isNonLive && connectionProblemState;
}
export default function ServerActivity({ hideWhenEmpty = false }: ServerActivityProps) {
const [open, setOpen] = useState(false);
const location = useLocation();
const {
sessions,
runningTasks,
activeScans,
streamCounts,
totalActive,
libraryName,
connectionState,
scansLoaded,
} = useServerActivityData();
const showConnectionProblem = useDelayedConnectionProblem(connectionState);
const visibleActiveScans = activeScans.slice(0, MAX_ACTIVITY_SCAN_ROWS);
const hiddenActiveScanCount = activeScans.length - visibleActiveScans.length;
const activeScansMayBeTruncated = activeScans.length >= ACTIVE_SCAN_SNAPSHOT_LIMIT;
const totalActiveLabel = formatBadgeCount(totalActive, activeScansMayBeTruncated);
// Keep mounted while popover is open so Radix can animate closed
if (hideWhenEmpty && totalActive === 0 && !open) return null;
return (
<PopoverPrimitive.Root key={location.pathname} open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild>
<button
type="button"
aria-label={
totalActive > 0
? `Server activity: ${activeScansMayBeTruncated ? "at least " : ""}${totalActive} active`
: "Server activity"
}
className="hover:bg-accent/60 relative flex h-9 w-9 items-center justify-center rounded-xl transition-colors"
>
<Activity
className={`h-[18px] w-[18px] ${
totalActive > 0 ? "text-primary" : "text-muted-foreground"
}`}
/>
{totalActive > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-[18px] min-w-[18px] animate-[pulse-opacity_2s_ease-in-out_infinite] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white shadow-sm">
{totalActiveLabel}
</span>
)}
{showConnectionProblem && (
<span
className="ring-background absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full bg-red-500 ring-2"
aria-hidden="true"
/>
)}
</button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
side="bottom"
align="end"
sideOffset={8}
className="data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 w-[320px] origin-[var(--radix-popover-content-transform-origin)] rounded-2xl border border-[color-mix(in_srgb,var(--border)_72%,transparent)] bg-[color-mix(in_srgb,var(--surface)_95%,transparent)] p-0 shadow-[0_24px_50px_-12px_rgb(0_0_0/0.5)] backdrop-blur-2xl"
>
{/* Header */}
<div className="flex items-center justify-between border-b border-[color-mix(in_srgb,var(--border)_50%,transparent)] px-4 py-3">
<span className="text-[13px] font-bold">Server Activity</span>
{connectionState !== "live" && (
<span className="text-warning text-[10px] font-medium">
{connectionState === "connecting" ? "Connecting…" : "Disconnected"}
</span>
)}
</div>
<div className="max-h-[400px] overflow-y-auto">
{totalActive === 0 && scansLoaded ? (
<div className="text-muted-foreground px-4 py-8 text-center text-sm">
No active server activity
</div>
) : (
<>
{/* Streams */}
<ActivitySection
title="Streams"
count={sessions.length}
href="/admin/activity"
onNavigate={() => setOpen(false)}
>
{sessions.length > 0 ? (
<div className="space-y-1.5">
{Object.entries(streamCounts)
.sort(([a], [b]) => compareActivityMethods(a, b))
.map(([method, count]) => (
<StreamCountRow key={method} method={method} count={count} />
))}
</div>
) : (
<EmptyRow>No active streams</EmptyRow>
)}
</ActivitySection>
{/* Tasks */}
<ActivitySection
title="Tasks"
count={runningTasks.length}
href="/admin/tasks"
onNavigate={() => setOpen(false)}
>
{runningTasks.length > 0 ? (
<div className="space-y-2">
{runningTasks.map((task) => (
<TaskRow key={task.key} task={task} />
))}
</div>
) : (
<EmptyRow>No running tasks</EmptyRow>
)}
</ActivitySection>
{/* Scans */}
<ActivitySection
title="Scans"
count={activeScans.length}
countIsLowerBound={activeScansMayBeTruncated}
href="/admin/libraries"
onNavigate={() => setOpen(false)}
last
>
{activeScans.length > 0 ? (
<div className="space-y-1.5">
{visibleActiveScans.map((scan) => (
<ScanRow
key={scan.id}
scan={scan}
libraryName={libraryName(scan.library_id)}
/>
))}
{hiddenActiveScanCount > 0 && (
<MoreRows
count={hiddenActiveScanCount}
label="more scans queued"
countIsLowerBound={activeScansMayBeTruncated}
/>
)}
</div>
) : (
<EmptyRow>No active scans</EmptyRow>
)}
</ActivitySection>
</>
)}
</div>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}
// ── Sub-components ───────────────────────────────────────────
function ActivitySection({
title,
count,
countIsLowerBound = false,
href,
onNavigate,
last = false,
children,
}: {
title: string;
count: number;
countIsLowerBound?: boolean;
href: string;
onNavigate: () => void;
last?: boolean;
children: React.ReactNode;
}) {
return (
<div
className={
last
? "px-4 py-3"
: "border-b border-[color-mix(in_srgb,var(--border)_40%,transparent)] px-4 py-3"
}
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-[10px] font-semibold tracking-[0.12em] uppercase">
{title}
</span>
{count > 0 && (
<span className="bg-primary/10 text-primary rounded-md px-1.5 py-0.5 text-[10px] leading-none font-bold">
{formatBadgeCount(count, countIsLowerBound)}
</span>
)}
</div>
<Link
to={href}
onClick={onNavigate}
className="text-muted-foreground hover:text-primary flex items-center gap-0.5 text-[10px] font-medium transition-colors"
>
View all
<ChevronRight className="h-3 w-3" />
</Link>
</div>
{children}
</div>
);
}
function formatBadgeCount(count: number, countIsLowerBound = false) {
if (countIsLowerBound) {
return count > MAX_BADGE_COUNT ? `${MAX_BADGE_COUNT}+` : `${count}+`;
}
return count > MAX_BADGE_COUNT ? `${MAX_BADGE_COUNT}+` : count.toString();
}
function StreamCountRow({ method, count }: { method: string; count: number }) {
const meta = activityMethodMeta(method);
return (
<div className="flex items-center gap-2.5">
<span className={`h-2 w-2 rounded-full ${meta.swatchClass}`} />
<span className="text-[12px] font-medium">
{count} {meta.label}
</span>
</div>
);
}
function TaskRow({ task }: { task: TaskInfo }) {
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="truncate text-[12px] font-medium">{task.name}</span>
<span className="text-muted-foreground ml-2 shrink-0 text-[10px] font-semibold tabular-nums">
{Math.round(task.progress)}%
</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full transition-[width] duration-300 ease-out"
style={{ width: `${Math.min(100, Math.max(0, task.progress))}%` }}
/>
</div>
{task.progress_message && (
<div className="text-muted-foreground truncate text-[10px]">{task.progress_message}</div>
)}
</div>
);
}
function ScanRow({ scan, libraryName }: { scan: ScanRun; libraryName: string }) {
const progressLabel = formatScanProgress(scan);
return (
<div className="flex items-start gap-2.5">
{scan.status === "running" ? (
<Loader className="text-primary mt-0.5 h-3.5 w-3.5 animate-spin" />
) : (
<ScanLine className="text-muted-foreground mt-0.5 h-3.5 w-3.5" />
)}
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex items-center gap-2">
<span className="truncate text-[12px] font-medium">{libraryName}</span>
<span className="text-muted-foreground ml-auto shrink-0 text-[10px]">
{scan.status === "running" ? "Scanning…" : "Queued"}
</span>
</div>
<div className="text-muted-foreground truncate text-[10px]">
{formatScanLabel(scan)}
{scan.path ? ` · ${scan.path}` : ""}
</div>
{progressLabel && (
<div className="text-muted-foreground/80 truncate text-[10px]">{progressLabel}</div>
)}
</div>
</div>
);
}
function formatScanLabel(scan: ScanRun) {
switch (scan.mode) {
case "library":
return "Full library";
case "subtree":
return "Subtree";
case "file":
return "Single file";
default:
return scan.mode;
}
}
function formatScanProgress(scan: ScanRun) {
const result = scan.result;
if (!result) {
return null;
}
if (result.total_files && result.files_processed) {
const percent = Math.max(
0,
Math.min(100, Math.round((result.files_processed / result.total_files) * 100)),
);
return `${result.message ?? "Processing files"} · ${result.files_processed.toLocaleString()} / ${result.total_files.toLocaleString()} (${percent}%)`;
}
if (result.message) {
return result.message;
}
return null;
}
function EmptyRow({ children }: { children: React.ReactNode }) {
return <div className="text-muted-foreground py-1 text-[11px]">{children}</div>;
}
function MoreRows({
count,
label,
countIsLowerBound = false,
}: {
count: number;
label: string;
countIsLowerBound?: boolean;
}) {
return (
<div className="text-muted-foreground border-t border-[color-mix(in_srgb,var(--border)_35%,transparent)] pt-1.5 text-[11px]">
{countIsLowerBound ? "At least " : ""}
{count.toLocaleString()} {label}
</div>
);
}