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(