diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go
index 3e5eab8b..0143a30d 100644
--- a/internal/api/handlers/playback.go
+++ b/internal/api/handlers/playback.go
@@ -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",
diff --git a/internal/api/handlers/playback_sessions.go b/internal/api/handlers/playback_sessions.go
index 4e2ea7d1..e0d202ac 100644
--- a/internal/api/handlers/playback_sessions.go
+++ b/internal/api/handlers/playback_sessions.go
@@ -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 != "" {
diff --git a/internal/api/handlers/playback_sessions_test.go b/internal/api/handlers/playback_sessions_test.go
index e28e795c..d41e89f0 100644
--- a/internal/api/handlers/playback_sessions_test.go
+++ b/internal/api/handlers/playback_sessions_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/internal/api/router.go b/internal/api/router.go
index d7407ed9..33f38661 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -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)
diff --git a/internal/branding/render.go b/internal/branding/render.go
index e5a2d996..14792613 100644
--- a/internal/branding/render.go
+++ b/internal/branding/render.go
@@ -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 = ``
diff --git a/internal/jellycompat/handlers_playback.go b/internal/jellycompat/handlers_playback.go
index 85a017fe..1ed3503e 100644
--- a/internal/jellycompat/handlers_playback.go
+++ b/internal/jellycompat/handlers_playback.go
@@ -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
}
}
diff --git a/internal/jellycompat/streams.go b/internal/jellycompat/streams.go
index 78cbb544..1e01e743 100644
--- a/internal/jellycompat/streams.go
+++ b/internal/jellycompat/streams.go
@@ -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.
diff --git a/internal/playback/recipecard.go b/internal/playback/recipecard.go
index d4d00e0e..994b91c7 100644
--- a/internal/playback/recipecard.go
+++ b/internal/playback/recipecard.go
@@ -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,
diff --git a/internal/playback/recipecard_test.go b/internal/playback/recipecard_test.go
index 258abd73..a0cc5467 100644
--- a/internal/playback/recipecard_test.go
+++ b/internal/playback/recipecard_test.go
@@ -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) {
diff --git a/internal/playback/session.go b/internal/playback/session.go
index eeb6c4bb..976a0c1c 100644
--- a/internal/playback/session.go
+++ b/internal/playback/session.go
@@ -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()
diff --git a/internal/playback/transcode.go b/internal/playback/transcode.go
index c8f72948..51c9f58e 100644
--- a/internal/playback/transcode.go
+++ b/internal/playback/transcode.go
@@ -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"
}
diff --git a/internal/playback/transcode_args_test.go b/internal/playback/transcode_args_test.go
index b3e011b9..b7a65f6e 100644
--- a/internal/playback/transcode_args_test.go
+++ b/internal/playback/transcode_args_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/playback/transcode_manager.go b/internal/playback/transcode_manager.go
index c2dc974e..e5640ebe 100644
--- a/internal/playback/transcode_manager.go
+++ b/internal/playback/transcode_manager.go
@@ -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,
diff --git a/internal/server/branding_frontend_test.go b/internal/server/branding_frontend_test.go
index 7b74cedc..4e2d2b37 100644
--- a/internal/server/branding_frontend_test.go
+++ b/internal/server/branding_frontend_test.go
@@ -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(), "
Acme Media") {
+ 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(), "Renamed Media") {
+ 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()
diff --git a/internal/server/frontend.go b/internal/server/frontend.go
index c59f1b4f..cdb28eda 100644
--- a/internal/server/frontend.go
+++ b/internal/server/frontend.go
@@ -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
}
diff --git a/internal/server/frontend_test.go b/internal/server/frontend_test.go
index fbb3570f..05520ddc 100644
--- a/internal/server/frontend_test.go
+++ b/internal/server/frontend_test.go
@@ -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("")},
+ "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)
+ }
+}
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
index ba4b6198..88d1784e 100644
--- a/web/src/api/types.ts
+++ b/web/src/api/types.ts
@@ -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 {
diff --git a/web/src/components/JellyfinSessionPill.tsx b/web/src/components/JellyfinSessionPill.tsx
new file mode 100644
index 00000000..8eb6eb66
--- /dev/null
+++ b/web/src/components/JellyfinSessionPill.tsx
@@ -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 (
+
+ JF
+
+ );
+}
diff --git a/web/src/components/ServerActivity.tsx b/web/src/components/ServerActivity.tsx
index ebf8049d..00694881 100644
--- a/web/src/components/ServerActivity.tsx
+++ b/web/src/components/ServerActivity.tsx
@@ -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 = {};
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 ? (
- {Object.entries(streamCounts).map(([method, count]) => (
-
- ))}
+ {Object.entries(streamCounts)
+ .sort(([a], [b]) => compareActivityMethods(a, b))
+ .map(([method, count]) => (
+
+ ))}
) : (
No active streams
@@ -299,12 +306,6 @@ function ActivitySection({
);
}
-const METHOD_META: Record = {
- 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 (
-
+
- {count} {label}
+ {count} {meta.label}
);
diff --git a/web/src/components/profiles/HouseholdStreamsPanel.tsx b/web/src/components/profiles/HouseholdStreamsPanel.tsx
index c9a07543..a67c3394 100644
--- a/web/src/components/profiles/HouseholdStreamsPanel.tsx
+++ b/web/src/components/profiles/HouseholdStreamsPanel.tsx
@@ -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 = {
- 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 ? : }
{session.is_paused ? "Paused" : "Playing"}
-
+
- {formatDecisionLabel(session.play_method)}
+ {methodMeta.label}
{bitrate ? ` · ${bitrate}` : ""}
+
diff --git a/web/src/pages/AdminActivity.tsx b/web/src/pages/AdminActivity.tsx
index e42235da..8d2ccbfb 100644
--- a/web/src/pages/AdminActivity.tsx
+++ b/web/src/pages/AdminActivity.tsx
@@ -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 = {};
- 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() {
{Object.entries(methods)
- .sort(([a], [b]) => a.localeCompare(b))
+ .sort(([a], [b]) => compareActivityMethods(a, b))
.map(([method, count]) => (
))}
{Object.entries(methods)
- .sort(([a], [b]) => a.localeCompare(b))
+ .sort(([a], [b]) => compareActivityMethods(a, b))
.map(([method, count]) => (
) : null}
- {(clientLabel || clientIP) && (
+ {(clientLabel || clientIP || isJellyfinSession(session)) && (
+
{clientLabel ? (
- {session.play_method || "?"}
+ {activityMethod}
+
{itemHref ? (
@@ -943,7 +954,7 @@ function PlaybackSummaryLine({
{label}
{formatDecisionLabel(decision)}
@@ -1127,7 +1138,7 @@ function PlaybackDetailCard({
{label}
{formatDecisionLabel(decision)}
@@ -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";
- }
-}
diff --git a/web/src/pages/AdminDashboard.tsx b/web/src/pages/AdminDashboard.tsx
index 48c34432..1341ac0d 100644
--- a/web/src/pages/AdminDashboard.tsx
+++ b/web/src/pages/AdminDashboard.tsx
@@ -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 (
@@ -569,8 +570,9 @@ function StreamCard({ session }: { session: AdminSession }) {
- {session.play_method || "unknown"}
+ {method}
+
{clientLabel ? (
{s.session_id.slice(0, 8)}...
{s.user_id}
{s.media_file_id}
- {s.play_method}
- {getSessionClientLabel(s) || "—"}
+ {classifyActivityMethod(s)}
+
+
+ {getSessionClientLabel(s) || "—"}
+
+
+
{formatDateTime(s.started_at)}
))}
diff --git a/web/src/pages/adminActivityPresentation.test.ts b/web/src/pages/adminActivityPresentation.test.ts
index 725ea43b..6488b9ac 100644
--- a/web/src/pages/adminActivityPresentation.test.ts
+++ b/web/src/pages/adminActivityPresentation.test.ts
@@ -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 {
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",
diff --git a/web/src/pages/adminActivityPresentation.ts b/web/src/pages/adminActivityPresentation.ts
index a1e3646f..d9f8b8db 100644
--- a/web/src/pages/adminActivityPresentation.ts
+++ b/web/src/pages/adminActivityPresentation.ts
@@ -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 = {
+ 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(