Files
silo-server/web/src/pages/adminActivityPresentation.test.ts
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

297 lines
11 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { AdminSession } from "@/api/types";
import {
classifyActivityMethod,
compareActivityMethods,
isJellyfinSession,
formatContainerDetail,
formatDeliveredAudioSummary,
formatDeliveredContainerSummary,
formatDeliveredVideoSummary,
formatPlaybackDecisionSummary,
formatSourceContainerSummary,
formatTranscodeModeSummary,
formatVideoDetail,
formatVideoSummary,
normalizeContainerDecision,
normalizeStreamDecision,
} from "./adminActivityPresentation";
function makeSession(overrides: Partial<AdminSession> = {}): AdminSession {
return {
session_id: overrides.session_id ?? "session-1",
user_id: overrides.user_id ?? 1,
username: overrides.username ?? "admin",
profile_id: overrides.profile_id ?? "default",
media_file_id: overrides.media_file_id ?? 2,
requested_media_file_id: overrides.requested_media_file_id ?? 2,
media_title: overrides.media_title ?? "Example",
media_type: overrides.media_type ?? "movie",
play_method: overrides.play_method ?? "transcode",
reporting_node: overrides.reporting_node ?? "local",
file_duration: overrides.file_duration ?? 3600,
started_at: overrides.started_at ?? new Date().toISOString(),
updated_at: overrides.updated_at ?? new Date().toISOString(),
position_seconds: overrides.position_seconds ?? 300,
is_paused: overrides.is_paused ?? false,
client_name: overrides.client_name,
client_user_agent: overrides.client_user_agent,
effective_play_method: overrides.effective_play_method,
is_jellyfin_client: overrides.is_jellyfin_client,
audio_track_index: overrides.audio_track_index ?? 0,
transcode_audio: overrides.transcode_audio ?? true,
stream_bitrate_kbps: overrides.stream_bitrate_kbps ?? 8000,
target_bitrate_kbps: overrides.target_bitrate_kbps ?? 8000,
transcode_hw_accel: overrides.transcode_hw_accel,
source_container: overrides.source_container ?? "mkv",
source_bitrate_kbps: overrides.source_bitrate_kbps ?? 9000,
source_video_codec: overrides.source_video_codec ?? "h264",
source_video_resolution: overrides.source_video_resolution ?? "1080p",
video_decision: overrides.video_decision,
target_video_codec: overrides.target_video_codec ?? "h264",
target_resolution: overrides.target_resolution ?? "1080p",
source_audio_codec: overrides.source_audio_codec ?? "aac",
source_audio_channels: overrides.source_audio_channels ?? 2,
audio_decision: overrides.audio_decision,
target_audio_codec: overrides.target_audio_codec,
requested_video_codec: overrides.requested_video_codec ?? "hevc",
requested_video_resolution: overrides.requested_video_resolution ?? "2160p",
};
}
describe("adminActivityPresentation", () => {
it("uses the effective source as the primary video summary", () => {
expect(formatVideoSummary(makeSession())).toBe("H.264 · 1080p");
});
it("explains auto-switched requested sources in the detail line", () => {
expect(
formatVideoDetail(
makeSession({
media_file_id: 2,
requested_media_file_id: 1,
}),
),
).toBe("Auto-switched from HEVC · 2160p · Output → H.264 · 1080p");
});
it("summarizes the delivered transcode output for compact rows", () => {
const session = makeSession({
source_video_codec: "hevc",
source_video_resolution: "2160p",
target_video_codec: "h264",
target_resolution: "1080p",
source_audio_codec: "eac3",
source_audio_channels: 6,
target_audio_codec: "aac",
});
expect(formatPlaybackDecisionSummary(session)).toBe("transcode");
expect(normalizeContainerDecision(session.play_method)).toBe("hls");
expect(normalizeStreamDecision(session.video_decision || session.play_method)).toBe(
"transcode",
);
expect(
normalizeStreamDecision(
session.audio_decision || (session.transcode_audio ? "transcode" : session.play_method),
),
).toBe("transcode");
expect(formatSourceContainerSummary(session)).toBe("MKV");
expect(formatDeliveredContainerSummary(session)).toBe("HLS");
expect(formatContainerDetail(session)).toBe("MKV → HLS");
expect(formatDeliveredVideoSummary(session)).toBe("H.264 · 1080p");
expect(formatDeliveredAudioSummary(session)).toBe("AAC 5.1");
expect(formatTranscodeModeSummary(session)).toBe("HW/SW unknown");
});
it("keeps direct playback summaries on the effective source", () => {
const session = makeSession({
play_method: "direct",
video_decision: "direct",
audio_decision: "direct",
transcode_audio: false,
source_video_codec: "hevc",
source_video_resolution: "2160p",
target_video_codec: "h264",
target_resolution: "1080p",
source_audio_codec: "eac3",
source_audio_channels: 6,
target_audio_codec: "aac",
});
expect(formatPlaybackDecisionSummary(session)).toBe("direct");
expect(normalizeContainerDecision(session.play_method)).toBe("direct");
expect(normalizeStreamDecision(session.video_decision)).toBe("direct");
expect(normalizeStreamDecision(session.audio_decision)).toBe("direct");
expect(formatDeliveredContainerSummary(session)).toBe("MKV");
expect(formatContainerDetail(session)).toBe("Original container");
expect(formatDeliveredVideoSummary(session)).toBe("HEVC · 2160p");
expect(formatDeliveredAudioSummary(session)).toBe("EAC3 5.1");
expect(formatTranscodeModeSummary(session)).toBeNull();
});
it("labels hardware and software transcode modes", () => {
expect(formatTranscodeModeSummary(makeSession({ transcode_hw_accel: "qsv" }))).toBe("HW QSV");
expect(formatTranscodeModeSummary(makeSession({ transcode_hw_accel: "none" }))).toBe("SW");
expect(
formatTranscodeModeSummary(
makeSession({
play_method: "remux",
video_decision: "remux",
audio_decision: "transcode",
transcode_audio: true,
transcode_hw_accel: "qsv",
}),
),
).toBe("Audio SW");
});
it("buckets activity sessions by the backend's per-stream decisions", () => {
// Only the audio stream re-encoded (video copied) → "audio".
expect(
classifyActivityMethod(
makeSession({
play_method: "remux",
video_decision: "remux",
audio_decision: "transcode",
transcode_audio: true,
}),
),
).toBe("audio");
// Same audio-only shape reported via the HLS path (play_method "transcode",
// video copied) still counts as an audio transcode, not video.
expect(
classifyActivityMethod(
makeSession({
play_method: "transcode",
video_decision: "remux",
audio_decision: "transcode",
transcode_audio: true,
}),
),
).toBe("audio");
// Full video transcode (with or without audio) stays in the "transcode" bucket.
expect(
classifyActivityMethod(
makeSession({
play_method: "transcode",
video_decision: "transcode",
audio_decision: "transcode",
transcode_audio: true,
}),
),
).toBe("transcode");
// Video-copy HLS repackage (play_method "transcode" but nothing re-encoded)
// must count as remux, NOT a video transcode.
expect(
classifyActivityMethod(
makeSession({
play_method: "transcode",
video_decision: "remux",
audio_decision: "remux",
transcode_audio: false,
}),
),
).toBe("remux");
// Direct play and plain remux keep their expected buckets.
expect(
classifyActivityMethod(
makeSession({
play_method: "direct",
video_decision: "direct",
audio_decision: "direct",
transcode_audio: false,
}),
),
).toBe("direct");
expect(
classifyActivityMethod(
makeSession({
play_method: "remux",
video_decision: "remux",
audio_decision: "remux",
transcode_audio: false,
}),
),
).toBe("remux");
});
it("prefers the server-computed effective_play_method when present", () => {
// The server already reduced the decisions; the local fallback must not
// second-guess it even when the raw fields disagree.
expect(
classifyActivityMethod(
makeSession({
effective_play_method: "remux",
play_method: "transcode",
video_decision: "transcode",
audio_decision: "transcode",
}),
),
).toBe("remux");
});
it("buckets rows with unknown stream state as unknown, not audio", () => {
// A legacy/stale row (unrecognized play_method, no decisions) with the
// transcode_audio flag set is not a known audio transcode.
expect(
classifyActivityMethod(
makeSession({
play_method: "",
video_decision: undefined,
audio_decision: undefined,
transcode_audio: true,
}),
),
).toBe("unknown");
expect(
classifyActivityMethod(
makeSession({
play_method: "hls",
video_decision: undefined,
audio_decision: undefined,
transcode_audio: true,
}),
),
).toBe("unknown");
});
it("orders activity buckets with audio after video transcode", () => {
const sorted = ["audio", "unknown", "transcode", "direct", "remux"].sort(
compareActivityMethods,
);
expect(sorted).toEqual(["direct", "remux", "transcode", "audio", "unknown"]);
});
it("tags Jellyfin-ecosystem clients for the JF pill", () => {
// The server identifies Jellyfin-ecosystem clients (it owns the token
// list) and emits is_jellyfin_client; the UI trusts only that field.
expect(isJellyfinSession(makeSession({ is_jellyfin_client: true }))).toBe(true);
expect(isJellyfinSession(makeSession({ is_jellyfin_client: false }))).toBe(false);
// Servers without the field (or no client metadata at all) → no pill,
// even when the client name looks like a Jellyfin client.
expect(isJellyfinSession(makeSession({ client_name: "Jellyfin Web" }))).toBe(false);
expect(isJellyfinSession(makeSession())).toBe(false);
});
it("labels HLS copy-original sessions as container HLS with copied video", () => {
const session = makeSession({
play_method: "transcode",
video_decision: "remux",
audio_decision: "transcode",
target_video_codec: "copy",
target_audio_codec: "aac",
transcode_audio: true,
});
expect(normalizeContainerDecision(session.play_method)).toBe("hls");
expect(normalizeStreamDecision(session.video_decision)).toBe("copy");
expect(normalizeStreamDecision(session.audio_decision)).toBe("transcode");
expect(formatDeliveredContainerSummary(session)).toBe("HLS");
expect(formatVideoDetail(session)).toBe("Video stream copied");
});
});