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>
This commit is contained in:
CoffeeKnyte
2026-07-18 17:12:36 -04:00
committed by GitHub
co-authored by Quick104 Claude Fable 5
parent b96e359b4e
commit 8044eb84dd
25 changed files with 1069 additions and 141 deletions
+1 -1
View File
@@ -2524,7 +2524,7 @@ func (h *PlaybackHandler) finalizeTranscodeStart(r *http.Request, st transcodeSt
if streamBitrateKbps <= 0 {
streamBitrateKbps = st.file.Bitrate
}
transcodeAudio := st.req.TargetCodecAudio != "" && !strings.EqualFold(st.req.TargetCodecAudio, "copy")
transcodeAudio := playback.TranscodesAudio(st.req.TargetCodecAudio)
baseMethod := semanticPlayMethod(st.session)
slog.InfoContext(r.Context(), "transcode start preserved base playback state", "component", "api",
+101
View File
@@ -72,6 +72,32 @@ type playbackSessionRow struct {
RequestedVideoResolution string `json:"requested_video_resolution,omitempty"`
VideoDecision string `json:"video_decision,omitempty"`
AudioDecision string `json:"audio_decision,omitempty"`
EffectivePlayMethod string `json:"effective_play_method,omitempty"`
IsJellyfinClient bool `json:"is_jellyfin_client,omitempty"`
}
// playbackSessionsCapabilitiesResponse advertises the additive fields of the
// live admin session payload so independently deployed clients (Android,
// Apple) can feature-detect them. Both fields are omitempty on the wire, so
// absence on a row is otherwise indistinguishable from an older server.
type playbackSessionsCapabilitiesResponse struct {
// EffectivePlayMethod reports that rows carry effective_play_method.
EffectivePlayMethod bool `json:"effective_play_method"`
// EffectivePlayMethodValues is the closed bucket vocabulary a supported
// server emits (absent field = unknown).
EffectivePlayMethodValues []string `json:"effective_play_method_values"`
// IsJellyfinClient reports that rows carry is_jellyfin_client.
IsJellyfinClient bool `json:"is_jellyfin_client"`
}
// HandleGetSessionsCapabilities exposes additive feature support for the live
// admin session payload (GET /admin/sessions/capabilities).
func (h *AdminHandler) HandleGetSessionsCapabilities(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, playbackSessionsCapabilitiesResponse{
EffectivePlayMethod: true,
EffectivePlayMethodValues: []string{"direct", "remux", "transcode", "audio"},
IsJellyfinClient: true,
})
}
// PlaybackSessionsQuery scopes live session listing.
@@ -248,6 +274,8 @@ func enrichPlaybackSessionRow(row *playbackSessionRow, audioTracksJSON []byte) {
}
row.VideoDecision, row.AudioDecision = sessionComponentDecision(row.PlayMethod, row.TranscodeAudio, row.TargetVideoCodec)
row.EffectivePlayMethod = effectivePlayMethod(row.VideoDecision, row.AudioDecision)
row.IsJellyfinClient = isJellyfinEcosystemClient(row.ClientName, row.ClientUserAgent)
var audioTracks []models.AudioTrack
if len(audioTracksJSON) > 0 {
@@ -335,6 +363,79 @@ func sessionComponentDecision(playMethod string, transcodeAudio bool, targetVide
}
}
// effectivePlayMethod reduces the per-stream decisions to the single bucket
// the admin activity views aggregate on. Raw play_method is misleading there:
// an HLS repackage reports play_method "transcode" with a copied video stream,
// and an audio-only re-encode reports "remux" — the decisions carry what
// actually costs CPU.
// - video re-encoded -> "transcode"
// - only audio re-encoded -> "audio"
// - streams only repackaged -> "remux"
// - nothing touched -> "direct"
//
// Returns "" when the decisions are unknown (empty or unrecognized
// play_method, e.g. a stale row from an older node), so consumers can
// distinguish "unknown" from a definite bucket instead of inventing one.
func effectivePlayMethod(videoDecision, audioDecision string) string {
switch {
case videoDecision == "" && audioDecision == "":
return ""
case videoDecision == "transcode":
return "transcode"
case audioDecision == "transcode":
return "audio"
case videoDecision == "direct" && audioDecision == "direct":
return "direct"
default:
return "remux"
}
}
// jellyfinClientTokens identifies Jellyfin-ecosystem clients — sessions served
// through the Jellyfin compatibility surface — by client name (parsed from the
// MediaBrowser auth header) or user agent. This is the single source of truth
// behind is_jellyfin_client, which the admin UIs surface as the "JF" pill;
// keep it in step with the display-labeling rules in
// playbackClientDisplayName so a client that gets a label also gets the flag.
// Kodi and mpv reach Silo only through the Jellyfin compat surface today
// (jellyfin-kodi/JellyCon, jellyfin-mpv-shim). Generic browser tokens are
// deliberately excluded: the native web player shares those user agents.
var jellyfinClientTokens = []string{
"jellyfin",
"findroid",
"streamyfin",
"swiftfin",
"jellycon",
"wholphin",
"fladder",
"vidhub",
"senplayer",
"infuse",
"delfin",
"finamp",
"kodi",
"mpv",
}
// isJellyfinEcosystemClient reports whether the session's client metadata
// matches a known Jellyfin-ecosystem client. This is a heuristic: an
// unrecognized fork simply gets no JF pill (cosmetic). Stamping a compat-origin
// flag on the session at creation would be exact and is the eventual fix.
func isJellyfinEcosystemClient(clientName, userAgent string) bool {
for _, value := range []string{clientName, userAgent} {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
continue
}
for _, token := range jellyfinClientTokens {
if strings.Contains(value, token) {
return true
}
}
}
return false
}
func firstNonEmptyValue(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
@@ -1,6 +1,11 @@
package handlers
import "testing"
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestSessionComponentDecisionLabelsCopiedAudioDuringHLSAsRemux(t *testing.T) {
videoDecision, audioDecision := sessionComponentDecision("transcode", false, "copy")
@@ -12,3 +17,89 @@ func TestSessionComponentDecisionLabelsCopiedAudioDuringHLSAsRemux(t *testing.T)
t.Fatalf("audioDecision = %q, want remux", audioDecision)
}
}
// TestEffectivePlayMethodBuckets pins the bucket for every decision pair
// sessionComponentDecision can produce, plus the unknown case.
func TestEffectivePlayMethodBuckets(t *testing.T) {
cases := []struct {
name string
playMethod string
transcodeAudio bool
targetVideo string
want string
}{
{"direct play", "direct", false, "", "direct"},
{"plain remux", "remux", false, "", "remux"},
{"audio-only re-encode via remux", "remux", true, "", "audio"},
{"full video transcode", "transcode", true, "h264", "transcode"},
{"video transcode with copied audio", "transcode", false, "h264", "transcode"},
{"video-copy HLS repackage", "transcode", false, "copy", "remux"},
{"video-copy HLS with audio re-encode", "transcode", true, "copy", "audio"},
// Unknown play_method (stale row from an older node): the bucket must
// stay empty rather than inventing a method from transcode_audio.
{"unknown method with transcode_audio set", "hls", true, "", ""},
{"empty method", "", false, "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
video, audio := sessionComponentDecision(tc.playMethod, tc.transcodeAudio, tc.targetVideo)
if got := effectivePlayMethod(video, audio); got != tc.want {
t.Fatalf("effectivePlayMethod(%q, %q) = %q, want %q", video, audio, got, tc.want)
}
})
}
}
// TestSessionsCapabilitiesAdvertisesActivityFields pins the feature-detection
// contract: the additive session fields are omitempty on the wire, so this
// endpoint is how independently deployed clients distinguish an older server
// from a supported one reporting an unknown method / non-Jellyfin session.
func TestSessionsCapabilitiesAdvertisesActivityFields(t *testing.T) {
rr := httptest.NewRecorder()
(&AdminHandler{}).HandleGetSessionsCapabilities(rr, httptest.NewRequest(http.MethodGet, "/admin/sessions/capabilities", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
var resp playbackSessionsCapabilitiesResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode capabilities: %v", err)
}
if !resp.EffectivePlayMethod || !resp.IsJellyfinClient {
t.Fatalf("capabilities must advertise both fields: %+v", resp)
}
want := []string{"direct", "remux", "transcode", "audio"}
if len(resp.EffectivePlayMethodValues) != len(want) {
t.Fatalf("bucket vocabulary = %v, want %v", resp.EffectivePlayMethodValues, want)
}
for i, v := range want {
if resp.EffectivePlayMethodValues[i] != v {
t.Fatalf("bucket vocabulary = %v, want %v", resp.EffectivePlayMethodValues, want)
}
}
}
func TestIsJellyfinEcosystemClient(t *testing.T) {
cases := []struct {
name string
clientName string
userAgent string
want bool
}{
{"jellyfin web by name", "Jellyfin Web", "", true},
{"findroid by name", "Findroid", "Findroid/0.15", true},
{"infuse by user agent only", "", "Infuse-Direct/8.4.6", true},
{"kodi addon by name", "Kodi", "Kodi/21.0", true},
{"mpv shim by user agent", "", "mpv 0.38.0", true},
{"native android client", "Silo Android", "okhttp/4.12", false},
{"generic browser", "", "Mozilla/5.0 (X11) Chrome/120.0 Safari/537.36", false},
{"no metadata", "", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isJellyfinEcosystemClient(tc.clientName, tc.userAgent); got != tc.want {
t.Fatalf("isJellyfinEcosystemClient(%q, %q) = %v, want %v", tc.clientName, tc.userAgent, got, tc.want)
}
})
}
}
+1
View File
@@ -2492,6 +2492,7 @@ func NewRouter(deps Dependencies) chi.Router {
}
r.Get("/sessions", adminHandler.HandleListSessions)
r.Get("/sessions/capabilities", adminHandler.HandleGetSessionsCapabilities)
r.Get("/playback-history", adminHandler.HandleListPlaybackHistory)
r.Get("/unmatched", adminHandler.HandleListUnmatched)
r.Get("/stats", adminHandler.HandleGetStats)
+17
View File
@@ -49,6 +49,23 @@ func (s Snapshot) ThemeColor() string {
return DefaultThemeColor
}
// RenderKey returns a stable identity covering every snapshot field the
// Render* functions read, so callers can cache rendered output and re-render
// only when the branding configuration actually changes. Keep in sync with
// RenderIndexHTML and RenderManifest.
func (s Snapshot) RenderKey() string {
return strings.Join([]string{
s.ServerName,
s.LoginSubtitle,
s.AccentColor,
s.DefaultTheme,
s.assets[KindWordmark],
s.assets[KindMark],
s.assets[KindFavicon],
s.assets[KindLoginBg],
}, "\x00")
}
// Favicon link literal as it appears in web/index.html. Kept in sync with that
// file; RenderIndexHTML rewrites it to a custom favicon when one is set.
const indexFaviconLink = `<link rel="icon" href="/favicon.ico" sizes="any" />`
+42
View File
@@ -101,6 +101,38 @@ type sessionStarterContext interface {
StartSessionWithContext(ctx context.Context, userID int, profileID string, fileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
}
// transcodeStreamDetailsSetter is implemented by the native SessionManager.
// Optional (like sessionStarterContext) so lightweight test fakes don't have
// to; without it the session keeps transport-level defaults only.
type transcodeStreamDetailsSetter interface {
SetTranscodeStreamDetails(sessionID, targetVideoCodec, targetAudioCodec string, transcodeAudio bool) error
}
// recordTranscodeStreamDetails mirrors the encode decisions of a started
// transcode onto the upstream session. StartSession only records the transport
// method (play_method "transcode", transcodeAudio false), so without this an
// audio-only re-encode — video copied — syncs to session sync and the admin
// activity views as a full video transcode. Shared by the local
// (ensureTranscodeSession) and remote (startRemoteTranscode) paths.
func (h *PlaybackHandler) recordTranscodeStreamDetails(ctx context.Context, upstreamSessionID string, opts playback.TranscodeOpts) {
setter, ok := h.sessionMgr.(transcodeStreamDetailsSetter)
if !ok {
return
}
transcodeAudio := playback.TranscodesAudio(opts.TargetCodecAudio)
if err := setter.SetTranscodeStreamDetails(upstreamSessionID, opts.TargetCodecVideo, opts.TargetCodecAudio, transcodeAudio); err != nil {
slog.WarnContext(ctx, "record transcode stream details failed", "component", "jellycompat",
"error", err, "playback_session_id", upstreamSessionID)
return
}
// The "compat_start" sync inside ensureUpstreamPlayback already flushed
// this session with transport-level defaults, so flush again now that the
// real encode decisions are on it — otherwise the admin view shows a
// video-copy stream as a full video transcode until the next reconciler
// tick.
h.syncSessionsNow(ctx, "compat_transcode_details")
}
// FilePathResolver looks up media files by ID.
type FilePathResolver interface {
GetByID(ctx context.Context, id int) (*models.MediaFile, error)
@@ -443,6 +475,10 @@ func (h *PlaybackHandler) startRemoteTranscode(
opts.TargetCodecVideo = "copy"
}
// Same mirror as the local path: the node runs the encode, but the
// upstream session here is what session sync and admin views read.
h.recordTranscodeStreamDetails(ctx, upstreamSessionID, opts)
if err := h.persistTranscodeRecipe(ctx, playSessionID, upstreamSessionID, opts); err != nil {
// Roll back the already-started node ffmpeg so it isn't leaked.
h.tm.CloseTranscodeSession(upstreamSessionID, transcodeNodeURL)
@@ -479,6 +515,12 @@ func (h *PlaybackHandler) persistTranscodeRecipe(
if h.sessionMgr != nil {
if upstream, err := h.sessionMgr.GetSession(upstreamSessionID); err == nil && upstream != nil {
card := playback.NewRecipeCard(upstream.UserID, upstream.ProfileID, upstream.MediaFileID, upstream.TranscodeNodeURL, opts)
// Mirror the client metadata into the card so a session rebuilt
// after a restart keeps its client label and Jellyfin
// identification instead of syncing with empty client fields.
card.ClientName = upstream.ClientName
card.ClientVersion = upstream.ClientVersion
card.ClientUserAgent = upstream.ClientUserAgent
recipe = &card
}
}
+14 -1
View File
@@ -1041,7 +1041,16 @@ func (h *PlaybackHandler) ensureUpstreamPlayback(ctx context.Context, compatSess
return nil, err
}
if h.tm != nil {
if reconstructed := h.tm.ReconstructSession(ctx, playSession.UpstreamSessionID, compatSession.StreamAppUserID, h.upstreamRecipeCard(playSession, compatSession, source, method)); reconstructed != nil {
card := h.upstreamRecipeCard(playSession, compatSession, source, method)
// Cards minted before client metadata was recorded (and the
// direct/remux fallback cards built here from scratch) carry
// none; the current compat request identifies the client, so
// the reconstructed session keeps its label and JF pill.
if card.ClientName == "" && card.ClientUserAgent == "" {
info := playback.ClientInfoFromContext(ctx)
card.ClientName, card.ClientVersion, card.ClientUserAgent = info.Name, info.Version, info.UserAgent
}
if reconstructed := h.tm.ReconstructSession(ctx, playSession.UpstreamSessionID, compatSession.StreamAppUserID, card); reconstructed != nil {
_ = h.syncUpstreamAudioSelection(playSession, source)
return playSession, nil
}
@@ -1266,6 +1275,10 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio
h.tm.RegisterTranscodeSession(upstreamSessionID, transcodeSession)
unlock()
// Mirror the actual encode decisions onto the upstream session before the
// recipe is persisted — video-copy HLS must not sync as a video transcode.
h.recordTranscodeStreamDetails(ctx, upstreamSessionID, opts)
// Register the exit monitor and persist the reconstruction recipe (shared with
// the remote path). On a failed compat-store write roll back this abandoned
// transcode rather than leaking it.
+12 -1
View File
@@ -28,10 +28,20 @@ type RecipeCard struct {
// back-compat with cards written before direct/remux were reconstructable.
PlayMethod PlayMethod `json:"play_method,omitempty"`
// TranscodeAudio mirrors Session.TranscodeAudio; used by the remux path to
// re-spawn ffmpeg with the same audio handling on reconstruct.
// re-spawn ffmpeg with the same audio handling on reconstruct, and by the
// admin activity views to classify the reconstructed session (audio
// re-encode vs repackage).
TranscodeAudio bool `json:"transcode_audio,omitempty"`
RemuxDVMode RemuxDVMode `json:"remux_dv_mode,omitempty"`
// Client metadata mirrored from the session so admin views (client label,
// Jellyfin pill) survive reconstruction. Carried only by stored cards —
// deliberately NOT projected into stream-token claims, where a user agent
// would bloat every stream URL.
ClientName string `json:"client_name,omitempty"`
ClientVersion string `json:"client_version,omitempty"`
ClientUserAgent string `json:"client_user_agent,omitempty"`
// Encode parameters — mirror of the byte-affecting TranscodeOpts fields.
// Unused (zero) for direct/remux cards, which carry no segment-based encode.
InputPath string `json:"input_path"`
@@ -69,6 +79,7 @@ func NewRecipeCard(userID int, profileID string, mediaFileID int, transcodeNodeU
TranscodeNodeURL: transcodeNodeURL,
TranscodeTransportID: opts.TranscodeTransportID,
PlayMethod: PlayTranscode,
TranscodeAudio: TranscodesAudio(opts.TargetCodecAudio),
InputPath: opts.InputPath,
OutputSubdir: opts.OutputSubdir,
SourceVideoCodec: opts.SourceVideoCodec,
+95
View File
@@ -78,6 +78,101 @@ func TestRecipeCardPlayMethodConstructors(t *testing.T) {
}
}
// A transcode card must record whether audio is actually re-encoded so the
// reconstructed session classifies correctly in admin views: only an explicit
// "copy" leaves the audio untouched (an empty codec runs ffmpeg's aac default).
func TestRecipeCardDerivesTranscodeAudioFromOpts(t *testing.T) {
cases := []struct {
codec string
want bool
}{
{"aac", true},
{"eac3", true},
{"", true},
{"copy", false},
{"COPY", false},
}
for _, tc := range cases {
card := NewRecipeCard(1, "p", 2, "", TranscodeOpts{SessionID: "t", TargetCodecAudio: tc.codec})
if card.TranscodeAudio != tc.want {
t.Errorf("TargetCodecAudio %q: TranscodeAudio = %v, want %v", tc.codec, card.TranscodeAudio, tc.want)
}
}
}
// Client metadata rides in stored cards (label + JF pill survive restarts) but
// must NOT leak into stream-token claims, where a user agent would bloat every
// stream URL.
func TestRecipeCardClientMetadataStoredNotInClaims(t *testing.T) {
card := NewRecipeCard(1, "p", 2, "", TranscodeOpts{SessionID: "t"})
card.ClientName = "Findroid"
card.ClientVersion = "0.15"
card.ClientUserAgent = "Findroid/0.15"
encoded, err := json.Marshal(card)
if err != nil {
t.Fatalf("marshal card: %v", err)
}
var back RecipeCard
if err := json.Unmarshal(encoded, &back); err != nil {
t.Fatalf("unmarshal card: %v", err)
}
if back.ClientName != "Findroid" || back.ClientVersion != "0.15" || back.ClientUserAgent != "Findroid/0.15" {
t.Fatalf("client metadata lost in stored-card round trip: %+v", back)
}
claims := card.ToClaims()
fromClaims := RecipeCardFromClaims(&claims)
if fromClaims.ClientName != "" || fromClaims.ClientUserAgent != "" {
t.Fatalf("client metadata must not travel via token claims: %+v", fromClaims)
}
}
// A session rebuilt from a card keeps the client identity for its lifetime,
// so the admin client label and Jellyfin pill survive a server restart.
func TestReconstructSessionRestoresClientMetadata(t *testing.T) {
tm := NewTranscodeManager()
tm.Sessions = NewSessionManager(0, 0)
card := NewRecipeCard(42, "profile-1", 77, "", TranscodeOpts{SessionID: "sess-jf", InputPath: "/media/movie.mkv"})
card.ClientName = "Findroid"
card.ClientVersion = "0.15"
card.ClientUserAgent = "Findroid/0.15 (Android)"
session := tm.ReconstructSession(t.Context(), "sess-jf", 42, card)
if session == nil {
t.Fatal("reconstruct returned nil")
}
if session.ClientName != "Findroid" || session.ClientVersion != "0.15" || session.ClientUserAgent != "Findroid/0.15 (Android)" {
t.Fatalf("client metadata not restored: %+v", session)
}
if !session.TranscodeAudio {
t.Fatal("TranscodeAudio must be restored from the card (aac default re-encodes)")
}
}
// SetTranscodeStreamDetails records the running transcode's encode decisions
// on the live session so sync rows classify by actual work (video copy =
// repackage) rather than the transport method.
func TestSetTranscodeStreamDetails(t *testing.T) {
m := NewSessionManager(0, 0)
m.RegisterReconstructed(&Session{ID: "sess-1", UserID: 7, PlayMethod: PlayTranscode})
if err := m.SetTranscodeStreamDetails("sess-1", "copy", "aac", true); err != nil {
t.Fatalf("SetTranscodeStreamDetails: %v", err)
}
s, err := m.GetSession("sess-1")
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if s.TargetVideoCodec != "copy" || s.TargetAudioCodec != "aac" || !s.TranscodeAudio {
t.Fatalf("details not recorded: %+v", s)
}
if err := m.SetTranscodeStreamDetails("missing", "h264", "aac", true); err == nil {
t.Fatal("expected ErrSessionNotFound for unknown session")
}
}
// A card persisted before the play_method discriminator existed must decode with
// an empty PlayMethod so reconstruct can treat it as a transcode (back-compat).
func TestRecipeCardLegacyDecodeHasEmptyPlayMethod(t *testing.T) {
+21
View File
@@ -889,6 +889,27 @@ func (m *SessionManager) RollbackReplacement(sessionID string, rollback SessionR
return nil
}
// SetTranscodeStreamDetails records the actual encode decisions of a running
// transcode on the session — video copy vs re-encode, and whether audio is
// re-encoded — so session sync and the admin activity views classify the
// stream by what ffmpeg is doing rather than by the transport method alone
// (an HLS session with copied video is a repackage, not a video transcode).
func (m *SessionManager) SetTranscodeStreamDetails(sessionID, targetVideoCodec, targetAudioCodec string, transcodeAudio bool) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
s.TargetVideoCodec = targetVideoCodec
s.TargetAudioCodec = targetAudioCodec
s.TranscodeAudio = transcodeAudio
m.touchSessionLocked(s)
return nil
}
// SetTranscodeNodeURL assigns a transcode node URL to an existing session.
func (m *SessionManager) SetTranscodeNodeURL(sessionID, url string) error {
m.mu.Lock()
+13 -1
View File
@@ -652,13 +652,25 @@ func appendVideoFilterArgs(args []string, opts TranscodeOpts) []string {
return args
}
// TranscodesAudio reports whether a transcode with the given target audio
// codec re-encodes the audio stream. Only an explicit "copy" passes audio
// through; an empty codec runs ffmpeg's AAC default (see appendAudioArgs), so
// every consumer of the session's audio decision — live stream state, recipe
// cards, and the compat mirror — must share this predicate or the activity
// bucket flips between remux and audio across restarts.
func TranscodesAudio(targetCodecAudio string) bool {
return !strings.EqualFold(targetCodecAudio, "copy")
}
// appendAudioArgs adds audio codec arguments. Supports "copy" for passthrough,
// plus opus / aac / eac3 / ac3 as re-encode targets. EAC3 and AC3 are useful
// when we must transcode video but want to preserve surround channels for an
// HDMI receiver — both are legal in HLS fMP4 (not MPEG-TS; ensure the HLS
// packager is fMP4 when emitting these).
func appendAudioArgs(args []string, opts TranscodeOpts) []string {
codec := opts.TargetCodecAudio
// Case-insensitive so the switch agrees with TranscodesAudio for any
// client-supplied spelling.
codec := strings.ToLower(opts.TargetCodecAudio)
if codec == "" {
codec = "aac"
}
+25
View File
@@ -539,3 +539,28 @@ func TestBuildFFmpegArgs_EncodedTranscodePreservesExistingTimestampPolicy(t *tes
t.Fatalf("encoded args should keep avoid_negative_ts disabled: %s", joined)
}
}
// TranscodesAudio must agree with appendAudioArgs: only an explicit "copy"
// passes audio through; an empty codec runs ffmpeg's AAC default.
func TestTranscodesAudioMatchesFFmpegDefault(t *testing.T) {
cases := []struct {
codec string
want bool
}{
{"copy", false},
{"COPY", false},
{"", true},
{"aac", true},
{"opus", true},
}
for _, tc := range cases {
if got := TranscodesAudio(tc.codec); got != tc.want {
t.Errorf("TranscodesAudio(%q) = %v, want %v", tc.codec, got, tc.want)
}
args := appendAudioArgs(nil, TranscodeOpts{TargetCodecAudio: tc.codec})
copied := strings.Contains(strings.Join(args, " "), "-c:a copy")
if copied != !tc.want {
t.Errorf("appendAudioArgs(%q) copy=%v disagrees with TranscodesAudio=%v", tc.codec, copied, tc.want)
}
}
}
+5
View File
@@ -384,6 +384,11 @@ func (m *TranscodeManager) ReconstructSession(ctx context.Context, sessionID str
TargetAudioCodec: card.TargetCodecAudio,
TargetBitrateKbps: card.TargetBitrateKbps,
TranscodeHWAccel: card.HWAccel,
// Client metadata survives the restart so the admin views keep the
// client label and Jellyfin identification for the session's lifetime.
ClientName: normalizeClientMetadataValue(card.ClientName, 128),
ClientVersion: normalizeClientMetadataValue(card.ClientVersion, 64),
ClientUserAgent: normalizeClientMetadataValue(card.ClientUserAgent, 512),
// Preserve the byte-affecting recipe so an audio switch after a restart
// rebuilds the same stream (subtitles/cadence) instead of dropping them.
SubtitleTrackIndex: card.SubtitleTrackIndex,
+33
View File
@@ -61,6 +61,39 @@ func TestFrontendInjectsServerNameIntoTitle(t *testing.T) {
}
}
// TestFrontendShellCacheFollowsBrandingChanges guards the rendered-shell
// cache: one handler instance must re-render (and re-tag) the shell when the
// branding snapshot changes, not keep serving the first rendering forever.
func TestFrontendShellCacheFollowsBrandingChanges(t *testing.T) {
settings := fakeSettings{branding.KeyServerName: "Acme Media"}
withBranding(t, settings)
handler := FrontendHandler()
first := httptest.NewRecorder()
handler.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/", nil))
if !strings.Contains(first.Body.String(), "<title>Acme Media</title>") {
t.Fatalf("initial title not branded: %q", first.Body.String())
}
// Repeat request with unchanged branding: same ETag (served from cache).
repeat := httptest.NewRecorder()
handler.ServeHTTP(repeat, httptest.NewRequest(http.MethodGet, "/", nil))
if first.Header().Get("ETag") != repeat.Header().Get("ETag") {
t.Fatalf("etag changed without a branding change: %q vs %q",
first.Header().Get("ETag"), repeat.Header().Get("ETag"))
}
settings[branding.KeyServerName] = "Renamed Media"
renamed := httptest.NewRecorder()
handler.ServeHTTP(renamed, httptest.NewRequest(http.MethodGet, "/", nil))
if !strings.Contains(renamed.Body.String(), "<title>Renamed Media</title>") {
t.Fatalf("renamed title not rendered: %q", renamed.Body.String())
}
if renamed.Header().Get("ETag") == first.Header().Get("ETag") {
t.Fatal("etag must change when the rendered shell changes")
}
}
func TestFrontendServesDynamicManifest(t *testing.T) {
withBranding(t, fakeSettings{branding.KeyServerName: "Acme Media"})
rr := httptest.NewRecorder()
+152 -45
View File
@@ -1,9 +1,15 @@
package server
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"io/fs"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Silo-Server/silo-server/internal/branding"
)
@@ -68,51 +74,156 @@ func FrontendHandler() http.Handler {
})
}
fileServer := http.FileServer(http.FS(WebDistFS))
// The embedded build output is immutable for the process lifetime, so the
// unbranded shell can be read once here. A read failure falls back to a
// per-request error response.
rawIndex, _ := fs.ReadFile(WebDistFS, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
path := r.URL.Path
return &frontendHandler{
fileServer: http.FileServer(http.FS(WebDistFS)),
rawIndex: rawIndex,
}
}
// Dynamic branding endpoints must be handled before the static file
// server, which would otherwise serve the bundled defaults shadowing
// them. Both fall through to the static asset when no override applies.
if Branding != nil {
switch path {
case "/site.webmanifest":
serveDynamicManifest(w, r)
// frontendHandler serves the embedded SPA. It is a struct rather than a
// closure so its caches are scoped to one handler instance and reset when a
// new handler is constructed over a different WebDistFS (as tests do).
type frontendHandler struct {
fileServer http.Handler
rawIndex []byte // unbranded index.html, read once at construction
// staticETags caches content ETags for stable-URL bundled files (sw.js,
// icons, vendor bundles). The embedded FS never changes, so a path's ETag
// is computed at most once.
staticETags sync.Map // path string -> etag string
// shell caches the branded index.html and its ETag for the last-seen
// branding snapshot, so steady-state shell requests — especially the 304
// revalidations that no-cache makes the common case — skip re-reading,
// re-rendering, and re-hashing the document.
shell atomic.Pointer[renderedShell]
}
type renderedShell struct {
brandingKey string
body []byte
etag string
}
func (h *frontendHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
path := r.URL.Path
// Dynamic branding endpoints must be handled before the static file
// server, which would otherwise serve the bundled defaults shadowing
// them. Both fall through to the static asset when no override applies.
if Branding != nil {
switch path {
case "/site.webmanifest":
serveDynamicManifest(w, r)
return
case "/favicon.ico":
if serveCustomFavicon(w, r) {
return
case "/favicon.ico":
if serveCustomFavicon(w, r) {
return
}
}
}
// Try to serve the file directly. index.html is excluded so the SPA
// HTML always goes through the fallback below and carries the CSP.
if path != "/" && path != "/index.html" && !strings.HasSuffix(path, "/") {
if f, err := WebDistFS.Open(strings.TrimPrefix(path, "/")); err == nil {
_ = f.Close()
if strings.HasPrefix(path, "/assets/") {
// Vite content-hashes /assets/ filenames, so those URLs are
// immutable: a new build produces new URLs, which is what
// lets browsers cache them for a year yet pick up deploys.
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
// Every other bundled file (service worker, icons, vendor
// bundles) keeps its URL across builds, so it must be
// revalidated. The embedded FS carries no modtimes, meaning
// http.FileServer emits no validator of its own — without
// this ETag, no-cache would force a full re-download on
// every use instead of a 304.
w.Header().Set("Cache-Control", "no-cache")
if etag := h.staticETag(path); etag != "" {
w.Header().Set("ETag", etag)
}
}
}
// Try to serve the file directly. index.html is excluded so the SPA
// HTML always goes through the fallback below and carries the CSP.
if path != "/" && path != "/index.html" && !strings.HasSuffix(path, "/") {
if f, err := WebDistFS.Open(strings.TrimPrefix(path, "/")); err == nil {
f.Close()
fileServer.ServeHTTP(w, r)
return
}
}
// SPA fallback: serve index.html
indexBytes, err := fs.ReadFile(WebDistFS, "index.html")
if err != nil {
http.Error(w, "index.html not found", http.StatusInternalServerError)
h.fileServer.ServeHTTP(w, r)
return
}
if Branding != nil {
indexBytes = branding.RenderIndexHTML(indexBytes, Branding.Load(r.Context()))
}
// SPA fallback: serve the (branded) index.html shell.
shell, ok := h.brandedShell(r)
if !ok {
http.Error(w, "index.html not found", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", frontendContentSecurityPolicy)
// The HTML shell keeps a stable URL across builds, so it must never be
// served stale: every deploy changes which content-hashed /assets/*
// bundles it references. no-cache lets browsers and CDNs store it but
// forces revalidation on each load; the ETag turns an unchanged shell
// into a cheap 304. ServeContent implements RFC 9110 conditional
// semantics (weak comparison, ETag lists), so revalidation keeps working
// behind proxies that compress the body and weaken the ETag to W/"...".
w.Header().Set("ETag", shell.etag)
w.Header().Set("Cache-Control", "no-cache")
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(shell.body))
}
// brandedShell returns the branding-rendered index.html and its ETag, reusing
// the cached rendering while the branding snapshot is unchanged.
func (h *frontendHandler) brandedShell(r *http.Request) (*renderedShell, bool) {
if h.rawIndex == nil {
return nil, false
}
var brandingKey string
var snap branding.Snapshot
if Branding != nil {
snap = Branding.Load(r.Context())
brandingKey = snap.RenderKey()
}
if cached := h.shell.Load(); cached != nil && cached.brandingKey == brandingKey {
return cached, true
}
body := h.rawIndex
if Branding != nil {
body = branding.RenderIndexHTML(h.rawIndex, snap)
}
rendered := &renderedShell{brandingKey: brandingKey, body: body, etag: contentETag(body)}
h.shell.Store(rendered)
return rendered, true
}
// staticETag returns the content ETag for a bundled static file, computing and
// caching it on first use. Returns "" for paths that can't be read as files
// (directories), which are served without a validator.
func (h *frontendHandler) staticETag(path string) string {
if v, ok := h.staticETags.Load(path); ok {
if etag, ok := v.(string); ok {
return etag
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", frontendContentSecurityPolicy)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(indexBytes)
})
}
data, err := fs.ReadFile(WebDistFS, strings.TrimPrefix(path, "/"))
if err != nil {
return ""
}
etag := contentETag(data)
h.staticETags.Store(path, etag)
return etag
}
// contentETag derives a strong validator from response bytes so a no-cache
// resource can answer conditional requests with a 304 instead of re-sending the
// body. Truncated SHA-256 is ample for cache validation (not a security token).
func contentETag(b []byte) string {
sum := sha256.Sum256(b)
return `"` + hex.EncodeToString(sum[:16]) + `"`
}
// serveDynamicManifest writes the branding-aware web app manifest.
@@ -136,18 +247,14 @@ func serveCustomFavicon(w http.ResponseWriter, r *http.Request) bool {
// X-Content-Type-Options is already set on the response by the caller. The
// favicon may be an admin-uploaded SVG; harden it against script execution
// on direct navigation (stored-XSS defense), matching the API asset route.
etag := `"` + ref + `"`
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Security-Policy", branding.AssetContentSecurityPolicy)
w.Header().Set("ETag", etag)
w.Header().Set("ETag", `"`+ref+`"`)
// Stable path (no content hash in the URL), so revalidate rather than cache
// long-lived; the ETag lets browsers skip the body when unchanged.
// ServeContent handles If-None-Match with RFC 9110 semantics (weak
// comparison, ETag lists) rather than a naive string compare.
w.Header().Set("Cache-Control", "public, max-age=300")
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return true
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data))
return true
}
+90
View File
@@ -78,3 +78,93 @@ func TestFrontendHandlerServesStaticAssetsWithoutCSP(t *testing.T) {
t.Fatalf("x-content-type-options = %q", got)
}
}
func newFrontendTestHandler(t *testing.T) http.Handler {
t.Helper()
prev := WebDistFS
WebDistFS = fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<!doctype html><div id=\"root\"></div>")},
"assets/app.js": &fstest.MapFile{Data: []byte("console.log(1)")},
"sw.js": &fstest.MapFile{Data: []byte("self.addEventListener('fetch', () => {})")},
}
t.Cleanup(func() { WebDistFS = prev })
return FrontendHandler()
}
func TestFrontendShellCacheHeadersAndConditionalGet(t *testing.T) {
handler := newFrontendTestHandler(t)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
if got := rr.Header().Get("Cache-Control"); got != "no-cache" {
t.Fatalf("shell cache-control = %q, want no-cache", got)
}
etag := rr.Header().Get("ETag")
if etag == "" {
t.Fatal("shell response missing ETag")
}
// Revalidation with the exact ETag answers 304 with no body.
conditional := func(inm string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("If-None-Match", inm)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
if rec := conditional(etag); rec.Code != http.StatusNotModified {
t.Fatalf("If-None-Match exact: status = %d, want 304", rec.Code)
}
// A fronting proxy that compresses the shell weakens the ETag to W/"...";
// RFC 9110 weak comparison must still produce the 304 (a naive string
// compare here silently kills revalidation behind nginx gzip).
if rec := conditional("W/" + etag); rec.Code != http.StatusNotModified {
t.Fatalf("If-None-Match weakened: status = %d, want 304", rec.Code)
}
// ETag lists must match too.
if rec := conditional(`"stale-etag", ` + etag); rec.Code != http.StatusNotModified {
t.Fatalf("If-None-Match list: status = %d, want 304", rec.Code)
}
// A stale validator gets the full document.
if rec := conditional(`"stale-etag"`); rec.Code != http.StatusOK || rec.Body.Len() == 0 {
t.Fatalf("If-None-Match stale: status = %d body = %d bytes, want 200 with body", rec.Code, rec.Body.Len())
}
}
func TestFrontendStaticFilesCarryValidators(t *testing.T) {
handler := newFrontendTestHandler(t)
// Content-hashed bundles are immutable; the URL is the validator.
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil))
if got := rr.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
t.Fatalf("asset cache-control = %q", got)
}
// Stable-URL files must revalidate — and the embedded FS has no modtimes,
// so without an explicit ETag no-cache would force a full re-download on
// every use (there would be nothing to revalidate against).
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/sw.js", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
if got := rr.Header().Get("Cache-Control"); got != "no-cache" {
t.Fatalf("sw.js cache-control = %q, want no-cache", got)
}
etag := rr.Header().Get("ETag")
if etag == "" {
t.Fatal("stable-path static file missing ETag validator")
}
req := httptest.NewRequest(http.MethodGet, "/sw.js", nil)
req.Header.Set("If-None-Match", etag)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotModified {
t.Fatalf("sw.js If-None-Match: status = %d, want 304", rec.Code)
}
}
+5
View File
@@ -2425,6 +2425,11 @@ export interface AdminSession {
requested_video_resolution?: string;
video_decision?: string;
audio_decision?: string;
/** Server-computed activity bucket: direct | remux | transcode | audio.
* Absent when the per-stream decisions are unknown. */
effective_play_method?: string;
/** Server-side identification of Jellyfin-ecosystem clients (the JF pill). */
is_jellyfin_client?: boolean;
}
export interface OperationalLogEntry {
@@ -0,0 +1,21 @@
import type { AdminSession } from "@/api/types";
import { isJellyfinSession } from "@/pages/adminActivityPresentation";
/**
* Purple "JF" pill marking a session served through the Jellyfin compatibility
* surface (server-identified via is_jellyfin_client). Renders nothing for
* native sessions, so callers can drop it into any badge row unconditionally.
*/
export function JellyfinSessionPill({ session }: { session: AdminSession }) {
if (!isJellyfinSession(session)) {
return null;
}
return (
<span
className="inline-flex flex-shrink-0 rounded border border-[#AA5CC3]/30 bg-[#AA5CC3]/15 px-1.5 py-0.5 text-[9px] font-semibold text-[#AA5CC3]"
title="Jellyfin client"
>
JF
</span>
);
}
+14 -13
View File
@@ -11,6 +11,11 @@ import {
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;
@@ -42,7 +47,7 @@ function useServerActivityData() {
const streamCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const s of sessions) {
const method = s.play_method || "unknown";
const method = classifyActivityMethod(s);
counts[method] = (counts[method] || 0) + 1;
}
return counts;
@@ -181,9 +186,11 @@ export default function ServerActivity({ hideWhenEmpty = false }: ServerActivity
>
{sessions.length > 0 ? (
<div className="space-y-1.5">
{Object.entries(streamCounts).map(([method, count]) => (
<StreamCountRow key={method} method={method} count={count} />
))}
{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>
@@ -299,12 +306,6 @@ function ActivitySection({
);
}
const METHOD_META: Record<string, { label: string; color: string }> = {
direct: { label: "Direct Play", color: "bg-success" },
remux: { label: "Remux", color: "bg-info" },
transcode: { label: "Transcode", color: "bg-warning" },
};
function formatBadgeCount(count: number, countIsLowerBound = false) {
if (countIsLowerBound) {
return count > MAX_BADGE_COUNT ? `${MAX_BADGE_COUNT}+` : `${count}+`;
@@ -313,12 +314,12 @@ function formatBadgeCount(count: number, countIsLowerBound = false) {
}
function StreamCountRow({ method, count }: { method: string; count: number }) {
const { label = method, color = "bg-muted-foreground" } = METHOD_META[method] ?? {};
const meta = activityMethodMeta(method);
return (
<div className="flex items-center gap-2.5">
<span className={`h-2 w-2 rounded-full ${color}`} />
<span className={`h-2 w-2 rounded-full ${meta.swatchClass}`} />
<span className="text-[12px] font-medium">
{count} {label}
{count} {meta.label}
</span>
</div>
);
@@ -3,22 +3,18 @@ import { Loader, Pause, Play, Radio } from "lucide-react";
import type { AdminSession } from "@/api/types";
import { Badge } from "@/components/ui/badge";
import { JellyfinSessionPill } from "@/components/JellyfinSessionPill";
import { Skeleton } from "@/components/ui/skeleton";
import { useHouseholdSessions } from "@/hooks/queries/profiles";
import {
formatDecisionLabel,
activityMethodMeta,
classifyActivityMethod,
formatSessionBitrate,
getSessionClientLabel,
getPlaybackSessionSubtitle,
getPlaybackSessionTitle,
} from "@/pages/adminActivityPresentation";
const METHOD_COLORS: Record<string, string> = {
direct: "bg-success",
remux: "bg-info",
transcode: "bg-warning",
};
function formatElapsed(startedAt: string): string {
const started = new Date(startedAt).getTime();
if (Number.isNaN(started)) {
@@ -48,8 +44,7 @@ function StreamRow({ session }: { session: AdminSession }) {
const title = getPlaybackSessionTitle(session);
const subtitle = getPlaybackSessionSubtitle(session);
const profileLabel = session.profile_name?.trim() || "Profile";
const playMethod = session.play_method || "unknown";
const methodColor = METHOD_COLORS[playMethod] ?? "bg-muted-foreground";
const methodMeta = activityMethodMeta(classifyActivityMethod(session));
const bitrate = formatSessionBitrate(session.stream_bitrate_kbps);
const watchHref =
session.content_id && session.media_file_id
@@ -71,11 +66,12 @@ function StreamRow({ session }: { session: AdminSession }) {
{session.is_paused ? <Pause className="h-3 w-3" /> : <Play className="h-3 w-3" />}
{session.is_paused ? "Paused" : "Playing"}
</Badge>
<span className={`inline-flex h-2 w-2 rounded-full ${methodColor}`} />
<span className={`inline-flex h-2 w-2 rounded-full ${methodMeta.swatchClass}`} />
<span className="text-muted-foreground text-xs">
{formatDecisionLabel(session.play_method)}
{methodMeta.label}
{bitrate ? ` · ${bitrate}` : ""}
</span>
<JellyfinSessionPill session={session} />
</div>
<div className="min-w-0">
+24 -56
View File
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router";
import { AdminSessionActions } from "@/components/AdminSessionActions";
import { JellyfinSessionPill } from "@/components/JellyfinSessionPill";
import { useRealtimeEvents } from "@/components/realtimeEventsContext";
import { useOperationalLogs } from "@/hooks/queries/admin/logs";
import { usePageActivity } from "@/hooks/usePageActivity";
@@ -12,6 +13,11 @@ import type { AdminSession, OperationalLogEntry, IPUserEntry } from "@/api/types
import { useIPUsers } from "@/hooks/queries/admin/ips";
import { useAdminSessions } from "@/hooks/queries/admin/stats";
import {
activityMethodMeta,
classifyActivityMethod,
compareActivityMethods,
decisionBadgeClass,
isJellyfinSession,
formatAudioDetail,
formatContainerDetail,
formatDeliveredAudioSummary,
@@ -118,8 +124,10 @@ export default function AdminActivity() {
// Aggregate counts
const methods = useMemo(() => {
const counts: Record<string, number> = {};
for (const s of sessions)
counts[s.play_method || "unknown"] = (counts[s.play_method || "unknown"] || 0) + 1;
for (const s of sessions) {
const method = classifyActivityMethod(s);
counts[method] = (counts[method] || 0) + 1;
}
return counts;
}, [sessions]);
@@ -146,7 +154,7 @@ export default function AdminActivity() {
s.client_ip?.toLowerCase().includes(q),
);
}
if (methodFilter) result = result.filter((s) => s.play_method === methodFilter);
if (methodFilter) result = result.filter((s) => classifyActivityMethod(s) === methodFilter);
if (nodeFilter) result = result.filter((s) => s.reporting_node === nodeFilter);
if (typeFilter) result = result.filter((s) => s.media_type === typeFilter);
@@ -160,7 +168,7 @@ export default function AdminActivity() {
cmp = getDisplayTitle(a).localeCompare(getDisplayTitle(b));
break;
case "method":
cmp = (a.play_method || "").localeCompare(b.play_method || "");
cmp = compareActivityMethods(classifyActivityMethod(a), classifyActivityMethod(b));
break;
case "node":
cmp = (a.reporting_node || "").localeCompare(b.reporting_node || "");
@@ -328,18 +336,18 @@ export default function AdminActivity() {
</div>
<div className="flex h-1.5 overflow-hidden rounded-full">
{Object.entries(methods)
.sort(([a], [b]) => a.localeCompare(b))
.sort(([a], [b]) => compareActivityMethods(a, b))
.map(([method, count]) => (
<div
key={method}
className={`transition-all duration-500 ${methodBarColor(method)}`}
className={`transition-all duration-500 ${activityMethodMeta(method).swatchClass}`}
style={{ width: `${(count / sessions.length) * 100}%` }}
/>
))}
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1">
{Object.entries(methods)
.sort(([a], [b]) => a.localeCompare(b))
.sort(([a], [b]) => compareActivityMethods(a, b))
.map(([method, count]) => (
<button
key={method}
@@ -349,7 +357,7 @@ export default function AdminActivity() {
}`}
>
<span
className={`inline-block h-2 w-2 rounded-full ${methodDotColor(method)}`}
className={`inline-block h-2 w-2 rounded-full ${activityMethodMeta(method).swatchClass}`}
/>
<span className="font-medium capitalize">{method}</span>
<span className="text-muted-foreground tabular-nums">{count}</span>
@@ -522,6 +530,7 @@ function StreamRow({
const clientLabel = getSessionClientLabel(session);
const playbackPosition = formatPlaybackPosition(session);
const transcodeMode = formatTranscodeModeSummary(session);
const activityMethod = classifyActivityMethod(session);
const containerDecision = normalizeContainerDecision(session.play_method);
const videoDecision = normalizeStreamDecision(session.video_decision || session.play_method);
const audioDecision = normalizeStreamDecision(
@@ -589,8 +598,9 @@ function StreamRow({
</span>
</div>
) : null}
{(clientLabel || clientIP) && (
{(clientLabel || clientIP || isJellyfinSession(session)) && (
<div className="text-muted-foreground mt-1 flex min-w-0 items-center gap-1.5 text-[10px]">
<JellyfinSessionPill session={session} />
{clientLabel ? (
<span
title={session.client_user_agent || clientLabel}
@@ -771,10 +781,11 @@ function StreamRow({
) : null}
</Link>
<span
className={`inline-flex flex-shrink-0 rounded border px-1.5 py-0.5 text-[9px] font-semibold ${methodBadgeColor(session.play_method)}`}
className={`inline-flex flex-shrink-0 rounded border px-1.5 py-0.5 text-[9px] font-semibold capitalize ${activityMethodMeta(activityMethod).badgeClass}`}
>
{session.play_method || "?"}
{activityMethod}
</span>
<JellyfinSessionPill session={session} />
</div>
<div className="text-muted-foreground mt-0.5 flex items-center gap-1.5 text-[11px]">
{itemHref ? (
@@ -943,7 +954,7 @@ function PlaybackSummaryLine({
{label}
</span>
<span
className={`inline-flex shrink-0 rounded border px-1.5 py-0.5 text-[8px] leading-none font-semibold ${methodBadgeColor(decision)}`}
className={`inline-flex shrink-0 rounded border px-1.5 py-0.5 text-[8px] leading-none font-semibold ${decisionBadgeClass(decision)}`}
>
{formatDecisionLabel(decision)}
</span>
@@ -1127,7 +1138,7 @@ function PlaybackDetailCard({
{label}
</span>
<span
className={`inline-flex rounded border px-1.5 py-0.5 text-[9px] font-semibold ${methodBadgeColor(decision)}`}
className={`inline-flex rounded border px-1.5 py-0.5 text-[9px] font-semibold ${decisionBadgeClass(decision)}`}
>
{formatDecisionLabel(decision)}
</span>
@@ -1297,46 +1308,3 @@ function stringAttr(entry: OperationalLogEntry, key: string) {
if (typeof value === "number") return String(value);
return "-";
}
function methodBadgeColor(method: string): string {
switch (method) {
case "direct":
return "bg-success/10 text-success border-success/15";
case "copy":
case "remux":
case "hls":
return "bg-info/10 text-info border-info/15";
case "transcode":
return "bg-warning/10 text-warning border-warning/15";
default:
return "bg-surface text-muted-foreground border-border";
}
}
function methodBarColor(method: string): string {
switch (method) {
case "direct":
return "bg-success";
case "copy":
case "remux":
case "hls":
return "bg-info";
case "transcode":
return "bg-warning";
default:
return "bg-muted-foreground";
}
}
function methodDotColor(method: string): string {
switch (method) {
case "direct":
return "bg-success";
case "remux":
return "bg-info";
case "transcode":
return "bg-warning";
default:
return "bg-muted-foreground";
}
}
+10 -8
View File
@@ -54,7 +54,12 @@ import { usePageActivity } from "@/hooks/usePageActivity";
import { cn } from "@/lib/utils";
import { buildAdminCommandNavSections } from "@/lib/adminNavigation";
import { compareActiveScans, formatActiveScanMode, formatActiveScanProgress } from "@/lib/scanRuns";
import { getSessionClientLabel } from "@/pages/adminActivityPresentation";
import { JellyfinSessionPill } from "@/components/JellyfinSessionPill";
import {
activityMethodMeta,
classifyActivityMethod,
getSessionClientLabel,
} from "@/pages/adminActivityPresentation";
const REFRESH_SPINNER_MIN_VISIBLE_MS = 1_000;
const DASHBOARD_AUTO_REFRESH_MS = 60_000;
@@ -492,12 +497,8 @@ function StreamCard({ session }: { session: AdminSession }) {
const username = session.username || `User #${session.user_id}`;
const elapsed = getTimeAgo(session.started_at);
const clientLabel = getSessionClientLabel(session);
const methodColor =
session.play_method === "direct"
? "bg-success/10 text-success border-success/15"
: session.play_method === "remux"
? "bg-info/10 text-info border-info/15"
: "bg-warning/10 text-warning border-warning/15";
const method = classifyActivityMethod(session);
const methodColor = activityMethodMeta(method).badgeClass;
return (
<div className="surface-panel flex gap-3.5 rounded-2xl border-0 p-3.5 transition-colors duration-150">
@@ -569,8 +570,9 @@ function StreamCard({ session }: { session: AdminSession }) {
<span
className={`inline-flex rounded border px-1.5 py-0.5 text-[9px] font-semibold ${methodColor}`}
>
{session.play_method || "unknown"}
{method}
</span>
<JellyfinSessionPill session={session} />
{clientLabel ? (
<span
title={session.client_user_agent || clientLabel}
+9 -3
View File
@@ -12,7 +12,8 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { Film, FileVideo, Users, Play } from "lucide-react";
import type { AdminSession, AdminStats } from "@/api/types";
import { getSessionClientLabel } from "@/pages/adminActivityPresentation";
import { JellyfinSessionPill } from "@/components/JellyfinSessionPill";
import { classifyActivityMethod, getSessionClientLabel } from "@/pages/adminActivityPresentation";
import { formatDateTime } from "@/lib/datetime";
export default function AdminStats() {
@@ -124,8 +125,13 @@ function SessionsSection({
<TableCell className="font-mono text-xs">{s.session_id.slice(0, 8)}...</TableCell>
<TableCell>{s.user_id}</TableCell>
<TableCell>{s.media_file_id}</TableCell>
<TableCell>{s.play_method}</TableCell>
<TableCell>{getSessionClientLabel(s) || "—"}</TableCell>
<TableCell className="capitalize">{classifyActivityMethod(s)}</TableCell>
<TableCell>
<span className="inline-flex items-center gap-1.5">
{getSessionClientLabel(s) || "—"}
<JellyfinSessionPill session={s} />
</span>
</TableCell>
<TableCell className="text-xs">{formatDateTime(s.started_at)}</TableCell>
</TableRow>
))}
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import type { AdminSession } from "@/api/types";
import {
classifyActivityMethod,
compareActivityMethods,
isJellyfinSession,
formatContainerDetail,
formatDeliveredAudioSummary,
formatDeliveredContainerSummary,
@@ -31,6 +34,10 @@ function makeSession(overrides: Partial<AdminSession> = {}): AdminSession {
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,
@@ -139,6 +146,137 @@ describe("adminActivityPresentation", () => {
).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",
+127
View File
@@ -46,6 +46,133 @@ export function normalizeStreamDecision(decision?: string): string {
}
}
/**
* Classify a session into a single activity "method" bucket for aggregation and
* filtering:
* - video is re-encoded -> "transcode" (video transcode)
* - only audio is re-encoded -> "audio" (audio transcode)
* - streams only repackaged/copied -> "remux" (incl. video-copy HLS)
* - nothing touched -> "direct"
* - nothing known -> "unknown"
* The server computes this reduction as effective_play_method (the
* authoritative value, shared with the other admin clients); the local
* fallback below covers servers that don't emit the field yet.
*/
export function classifyActivityMethod(session: AdminSession): string {
if (session.effective_play_method) {
return session.effective_play_method;
}
const videoDecision = normalizeStreamDecision(session.video_decision || session.play_method);
const audioDecision = normalizeStreamDecision(
session.audio_decision || (session.transcode_audio ? "transcode" : session.play_method),
);
if (videoDecision === "transcode") {
return "transcode";
}
// An empty video decision means the stream state is unknown (empty or
// unrecognized play_method on a legacy row); don't invent an audio
// transcode from the bare transcode_audio flag.
if (audioDecision === "transcode" && videoDecision !== "") {
return "audio";
}
if (videoDecision === "direct" && audioDecision === "direct") {
return "direct";
}
if (videoDecision === "copy" || audioDecision === "copy") {
return "remux";
}
return "unknown";
}
// Display order for the activity method buckets. Escalates by cost and keeps the
// audio-transcode tag AFTER the video-transcode tag in the Play Method line and
// the Server Activity popover; unknown sorts last.
const ACTIVITY_METHOD_ORDER = ["direct", "remux", "transcode", "audio", "unknown"];
function activityMethodRank(method: string): number {
const index = ACTIVITY_METHOD_ORDER.indexOf(method);
return index === -1 ? ACTIVITY_METHOD_ORDER.length : index;
}
/** Sort comparator for activity method keys, audio last. Falls back to
* alphabetical for anything outside the known order. */
export function compareActivityMethods(a: string, b: string): number {
const diff = activityMethodRank(a) - activityMethodRank(b);
return diff !== 0 ? diff : a.localeCompare(b);
}
export interface ActivityMethodMeta {
/** Human label ("Direct Play"); the bucket key itself is the short tag. */
label: string;
/** Solid swatch class for distribution bars and legend dots. */
swatchClass: string;
/** Tinted badge classes for the per-row method tag. */
badgeClass: string;
}
// Single source for the bucket -> label/color mapping. Every surface that
// renders method tags, distribution bars, or legend dots derives from this so
// the Admin Activity page, the dashboard stream cards, and the Server Activity
// popover cannot drift apart.
const ACTIVITY_METHOD_META: Record<string, ActivityMethodMeta> = {
direct: {
label: "Direct Play",
swatchClass: "bg-success",
badgeClass: "bg-success/10 text-success border-success/15",
},
remux: {
label: "Remux",
swatchClass: "bg-info",
badgeClass: "bg-info/10 text-info border-info/15",
},
transcode: {
label: "Transcode",
swatchClass: "bg-warning",
badgeClass: "bg-warning/10 text-warning border-warning/15",
},
audio: {
label: "Audio Transcode",
swatchClass: "bg-destructive",
badgeClass: "bg-destructive/10 text-destructive border-destructive/15",
},
};
const UNKNOWN_ACTIVITY_METHOD_META: ActivityMethodMeta = {
label: "Unknown",
swatchClass: "bg-muted-foreground",
badgeClass: "bg-surface text-muted-foreground border-border",
};
/** Presentation (label + colors) for an activity method bucket. */
export function activityMethodMeta(method: string): ActivityMethodMeta {
return ACTIVITY_METHOD_META[method] ?? UNKNOWN_ACTIVITY_METHOD_META;
}
/**
* Badge classes for a per-stream decision value (direct/copy/remux/hls/
* transcode). Copy and HLS are repackaging, so they share the remux tint.
*/
export function decisionBadgeClass(decision: string): string {
switch (decision) {
case "copy":
case "hls":
return activityMethodMeta("remux").badgeClass;
default:
return activityMethodMeta(decision).badgeClass;
}
}
/**
* Report whether a session comes from a Jellyfin-ecosystem client, surfaced as
* the orthogonal "JF" pill next to the method tag. The server owns the client
* identification (its token list lives beside its client-labeling rules) and
* emits is_jellyfin_client; sessions from servers without the field simply get
* no pill.
*/
export function isJellyfinSession(session: AdminSession): boolean {
return session.is_jellyfin_client === true;
}
export function formatPlaybackDecisionSummary(session: AdminSession): string {
const videoDecision = normalizeStreamDecision(session.video_decision || session.play_method);
const audioDecision = normalizeStreamDecision(