From 227986094bf1b7df4eb538b0663a0d0a30e995aa Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:26:14 -0400 Subject: [PATCH] Propagate playback client metadata through session sync --- cmd/silo/session_sync.go | 3 + internal/api/handlers/playback.go | 30 +++- internal/api/handlers/playback_sessions.go | 146 +++++++++++++++++- internal/jellycompat/auth.go | 41 +++++ internal/playback/session.go | 55 +++++++ internal/worker/reconciler.go | 23 ++- ...add_playback_session_client_user_agent.sql | 15 ++ web/src/api/types.ts | 4 + .../profiles/HouseholdStreamsPanel.tsx | 8 +- web/src/pages/AdminActivity.tsx | 76 ++++++--- web/src/pages/AdminDashboard.tsx | 16 +- web/src/pages/AdminStats.tsx | 3 + web/src/pages/adminActivityPresentation.ts | 14 ++ 13 files changed, 394 insertions(+), 40 deletions(-) create mode 100644 migrations/sql/20260622195412_add_playback_session_client_user_agent.sql diff --git a/cmd/silo/session_sync.go b/cmd/silo/session_sync.go index 267ba0bb..44039c16 100644 --- a/cmd/silo/session_sync.go +++ b/cmd/silo/session_sync.go @@ -23,6 +23,9 @@ func buildLiveSessionSync(s *playback.Session, reportingNode string) worker.Sess PlayMethod: string(s.PlayMethod), ReportingNode: reportingNode, ClientIP: s.ClientIP, + ClientName: s.ClientName, + ClientVersion: s.ClientVersion, + ClientUserAgent: s.ClientUserAgent, AudioTrackIndex: s.AudioTrackIndex, TranscodeAudio: s.TranscodeAudio, StreamBitrateKbps: s.StreamBitrateKbps, diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index cecd5b92..3016d69a 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -134,12 +134,12 @@ type PlaybackHandler struct { // hwaccel, transcode dir). Wired to the live config in integrated mode // so admin changes apply to newly started transcodes. Read it through // playbackConfig(), which falls back to defaults when unset. - PlaybackConfig func() config.PlaybackConfig - FFmpegLogSink playback.FFmpegLogSink - transcodeMu sync.RWMutex - transcodes map[string]*playback.TranscodeSession - realtimeCommandMu sync.Mutex - realtimeCommands map[string]playbackCommandRecord + PlaybackConfig func() config.PlaybackConfig + FFmpegLogSink playback.FFmpegLogSink + transcodeMu sync.RWMutex + transcodes map[string]*playback.TranscodeSession + realtimeCommandMu sync.Mutex + realtimeCommands map[string]playbackCommandRecord } type PlaybackWatchScrobbler interface { @@ -1262,10 +1262,12 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req return } + clientInfo := playbackClientInfoFromRequest(r) + sessionCtx := playback.WithClientInfo(r.Context(), clientInfo) var session *playback.Session if starter, ok := h.sessionMgr.(sessionStarterWithFilesContext); ok { session, err = starter.StartSessionWithFilesContext( - r.Context(), + sessionCtx, userID, profileID, effectiveFile.ID, @@ -1321,6 +1323,9 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req AudioTrackIndex: audioTrackIndex, TranscodeAudio: session.TranscodeAudio, ClientIP: clientip.FromContext(r.Context()), + ClientName: clientInfo.Name, + ClientVersion: clientInfo.Version, + ClientUserAgent: clientInfo.UserAgent, StreamBitrateKbps: streamBitrateKbps, TargetAudioCodec: targetAudioCodec, }); err != nil { @@ -1442,6 +1447,17 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req writeJSON(w, http.StatusCreated, resp) } +func playbackClientInfoFromRequest(r *http.Request) playback.ClientInfo { + if r == nil { + return playback.ClientInfo{} + } + return playback.ClientInfo{ + Name: strings.TrimSpace(r.Header.Get("X-Silo-Client")), + Version: strings.TrimSpace(r.Header.Get("X-Silo-Client-Version")), + UserAgent: r.UserAgent(), + } +} + // subtitleURLExt returns the URL file extension for a subtitle codec. // ASS/SSA tracks get ".ass" so the frontend can request raw ASS data for // client-side rendering (JASSUB); PGS tracks get ".sup" for client-side diff --git a/internal/api/handlers/playback_sessions.go b/internal/api/handlers/playback_sessions.go index a101efc6..4e2ea7d1 100644 --- a/internal/api/handlers/playback_sessions.go +++ b/internal/api/handlers/playback_sessions.go @@ -46,6 +46,10 @@ type playbackSessionRow struct { IsPaused bool `json:"is_paused"` HasPlaybackControl bool `json:"has_playback_control"` ClientIP string `json:"client_ip,omitempty"` + ClientName string `json:"client_name,omitempty"` + ClientVersion string `json:"client_version,omitempty"` + ClientLabel string `json:"client_label,omitempty"` + ClientUserAgent string `json:"client_user_agent,omitempty"` AudioTrackIndex int `json:"audio_track_index"` TranscodeAudio bool `json:"transcode_audio"` StreamBitrateKbps *int `json:"stream_bitrate_kbps"` @@ -149,6 +153,9 @@ func (l *PlaybackSessionsLoader) Load( COALESCE(s.is_paused, FALSE), COALESCE(s.has_websocket, FALSE), COALESCE(HOST(s.client_ip), ''), + COALESCE(s.client_name, ''), + COALESCE(s.client_version, ''), + COALESCE(s.client_user_agent, ''), COALESCE(s.audio_track_index, 0), COALESCE(s.transcode_audio, FALSE), s.stream_bitrate_kbps, @@ -203,7 +210,8 @@ func (l *PlaybackSessionsLoader) Load( &s.MediaTitle, &s.MediaType, &s.SeriesName, &s.EpisodeName, &s.SeasonNumber, &s.EpisodeNumber, &posterPath, &s.PlayMethod, &s.ReportingNode, &s.NodeDisplayName, &s.FileDuration, &s.StartedAt, &s.UpdatedAt, - &s.PositionSeconds, &s.IsPaused, &s.HasPlaybackControl, &s.ClientIP, &s.AudioTrackIndex, &s.TranscodeAudio, &streamBitrateKbps, + &s.PositionSeconds, &s.IsPaused, &s.HasPlaybackControl, &s.ClientIP, &s.ClientName, &s.ClientVersion, + &s.ClientUserAgent, &s.AudioTrackIndex, &s.TranscodeAudio, &streamBitrateKbps, &s.TranscodeNodeURL, &s.TargetResolution, &s.TargetVideoCodec, &s.TargetAudioCodec, &targetBitrateKbps, &s.TranscodeHWAccel, &s.SourceContainer, &sourceBitrateKbps, &s.SourceVideoCodec, &s.SourceVideoResolution, &s.SourceAudioCodec, &sourceAudioChannels, &audioTracksJSON, &s.RequestedVideoCodec, &s.RequestedVideoResolution, @@ -215,6 +223,7 @@ func (l *PlaybackSessionsLoader) Load( s.TargetBitrateKbps = targetBitrateKbps s.SourceBitrateKbps = sourceBitrateKbps s.SourceAudioChannels = sourceAudioChannels + s.ClientLabel = playbackClientDisplayName(s.ClientName, s.ClientVersion, s.ClientUserAgent) enrichPlaybackSessionRow(&s, audioTracksJSON) sessions = append(sessions, s) } @@ -335,6 +344,141 @@ func firstNonEmptyValue(values ...string) string { return "" } +func playbackClientDisplayName(name, version, userAgent string) string { + name = strings.TrimSpace(name) + version = shortPlaybackClientVersion(version) + if name != "" { + if version != "" { + return name + " " + version + } + return name + } + + userAgent = strings.TrimSpace(userAgent) + if userAgent == "" { + return "" + } + + rules := []struct { + label string + tokens []string + versionTokens []string + }{ + {label: "Infuse", tokens: []string{"infuse"}, versionTokens: []string{"infuse-direct", "infuse"}}, + {label: "Findroid", tokens: []string{"findroid"}, versionTokens: []string{"findroid"}}, + {label: "Streamyfin", tokens: []string{"streamyfin"}, versionTokens: []string{"streamyfin"}}, + {label: "Swiftfin", tokens: []string{"swiftfin"}, versionTokens: []string{"swiftfin"}}, + {label: "Jellyfin", tokens: []string{"jellyfin"}, versionTokens: []string{"jellyfin"}}, + {label: "JellyCon", tokens: []string{"jellycon"}, versionTokens: []string{"jellycon"}}, + {label: "Wholphin", tokens: []string{"wholphin"}, versionTokens: []string{"wholphin"}}, + {label: "Fladder", tokens: []string{"fladder"}, versionTokens: []string{"fladder"}}, + {label: "VidHub", tokens: []string{"vidhub"}, versionTokens: []string{"vidhub"}}, + {label: "SenPlayer", tokens: []string{"senplayer"}, versionTokens: []string{"senplayer"}}, + {label: "Kodi", tokens: []string{"kodi"}, versionTokens: []string{"kodi"}}, + {label: "MPV", tokens: []string{"mpv"}, versionTokens: []string{"mpv"}}, + {label: "Edge", tokens: []string{"edg/"}, versionTokens: []string{"edg"}}, + {label: "Opera", tokens: []string{"opr/", "opera"}, versionTokens: []string{"opr", "opera"}}, + {label: "Firefox", tokens: []string{"firefox/", "fxios/"}, versionTokens: []string{"firefox", "fxios"}}, + {label: "Chrome", tokens: []string{"chrome/", "crios/"}, versionTokens: []string{"chrome", "crios"}}, + {label: "Safari", tokens: []string{"safari/"}, versionTokens: []string{"version"}}, + } + lower := strings.ToLower(userAgent) + for _, rule := range rules { + if !containsAny(lower, rule.tokens) { + continue + } + if version := firstProductVersion(userAgent, rule.versionTokens); version != "" { + return rule.label + " " + version + } + return rule.label + } + + switch { + case strings.Contains(lower, "applecoremedia"): + return "Apple player" + case strings.Contains(lower, "okhttp"): + return "Android client" + case strings.Contains(lower, "dart/"): + return "Flutter client" + case strings.Contains(lower, "go-http-client"): + return "Go client" + case strings.Contains(lower, "curl/"): + return "curl" + case strings.Contains(lower, "python-requests"): + return "Python requests" + default: + return firstUserAgentProduct(userAgent) + } +} + +func containsAny(value string, needles []string) bool { + for _, needle := range needles { + if strings.Contains(value, needle) { + return true + } + } + return false +} + +func firstProductVersion(userAgent string, tokens []string) string { + for _, field := range strings.Fields(userAgent) { + name, version, ok := strings.Cut(field, "/") + if !ok { + continue + } + name = strings.TrimSpace(strings.ToLower(name)) + for _, token := range tokens { + if name == strings.TrimSpace(strings.ToLower(token)) { + return shortPlaybackClientVersion(version) + } + } + } + return "" +} + +func shortPlaybackClientVersion(version string) string { + version = strings.Trim(strings.TrimSpace(version), `";),`) + if version == "" { + return "" + } + version = strings.Map(func(r rune) rune { + if (r >= '0' && r <= '9') || r == '.' { + return r + } + return -1 + }, version) + parts := strings.Split(version, ".") + filtered := parts[:0] + for _, part := range parts { + if part != "" { + filtered = append(filtered, part) + } + } + if len(filtered) == 0 { + return "" + } + if len(filtered) > 2 { + filtered = filtered[:2] + } + if len(filtered) == 2 && filtered[1] == "0" { + filtered = filtered[:1] + } + return strings.Join(filtered, ".") +} + +func firstUserAgentProduct(userAgent string) string { + for _, field := range strings.Fields(userAgent) { + name, _, ok := strings.Cut(field, "/") + if ok { + name = strings.Trim(name, `";(),`) + if name != "" && !strings.EqualFold(name, "Mozilla") { + return name + } + } + } + return "Unknown client" +} + func (l *PlaybackSessionsLoader) populateProfileNames(ctx context.Context, sessions []playbackSessionRow) { if l == nil || l.storeProv == nil || len(sessions) == 0 { return diff --git a/internal/jellycompat/auth.go b/internal/jellycompat/auth.go index ad226701..5dc34e28 100644 --- a/internal/jellycompat/auth.go +++ b/internal/jellycompat/auth.go @@ -9,6 +9,7 @@ import ( "time" "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/playback" ) type sessionContextKey string @@ -152,9 +153,49 @@ func safeTokenPrefix(token string) string { // and continues the handler chain. func serveWithSession(next http.Handler, w http.ResponseWriter, r *http.Request, session *Session) { ctx := context.WithValue(r.Context(), compatSessionKey, session) + ctx = playback.WithClientInfo(ctx, compatPlaybackClientInfo(r)) next.ServeHTTP(w, r.WithContext(ctx)) } +func compatPlaybackClientInfo(r *http.Request) playback.ClientInfo { + if r == nil { + return playback.ClientInfo{} + } + return playback.ClientInfo{ + Name: firstMediaBrowserAuthorizationValue(r, "Client"), + Version: firstMediaBrowserAuthorizationValue(r, "Version"), + UserAgent: r.UserAgent(), + } +} + +func firstMediaBrowserAuthorizationValue(r *http.Request, key string) string { + for _, headerName := range []string{"X-Emby-Authorization", "Authorization"} { + if value := mediaBrowserAuthorizationValue(r.Header.Get(headerName), key); value != "" { + return value + } + } + return "" +} + +func mediaBrowserAuthorizationValue(header, key string) string { + header = strings.TrimSpace(header) + if !strings.HasPrefix(strings.ToLower(header), "mediabrowser ") { + return "" + } + for _, part := range strings.Split(header, ",") { + part = strings.TrimSpace(part) + if strings.HasPrefix(strings.ToLower(part), "mediabrowser ") { + part = strings.TrimSpace(part[len("MediaBrowser "):]) + } + name, value, ok := strings.Cut(part, "=") + if !ok || !strings.EqualFold(strings.TrimSpace(name), key) { + continue + } + return strings.Trim(strings.TrimSpace(value), `"`) + } + return "" +} + // resolveCompatToken resolves a token to a compat session: a session-store token // (normal login) or, matching Jellyfin, an sa_ admin API key (synthesized // session bound to the key user's primary profile). Returns false when the token diff --git a/internal/playback/session.go b/internal/playback/session.go index 18463e29..9c7eb336 100644 --- a/internal/playback/session.go +++ b/internal/playback/session.go @@ -3,6 +3,7 @@ package playback import ( "context" "fmt" + "strings" "sync" "time" @@ -20,6 +21,9 @@ type Session struct { BasePlayMethod PlayMethod TranscodeAudio bool // when true, remux should transcode audio to AAC ClientIP string // resolved client IP for the playback session + ClientName string // reported playback client name, when available + ClientVersion string // reported playback client version, when available + ClientUserAgent string // trimmed request user agent for the playback session TranscodeNodeURL string // URL of assigned transcode node (empty = local/integrated) AudioTrackIndex int @@ -51,6 +55,9 @@ type SessionStreamState struct { AudioTrackIndex int TranscodeAudio bool ClientIP string + ClientName string + ClientVersion string + ClientUserAgent string StreamBitrateKbps int TargetResolution string TargetVideoCodec string @@ -59,6 +66,33 @@ type SessionStreamState struct { TranscodeHWAccel string } +type clientInfoContextKey struct{} + +// ClientInfo carries best-effort client metadata from request handling into +// the playback session manager. +type ClientInfo struct { + Name string + Version string + UserAgent string +} + +// WithClientInfo stores playback client metadata on a context. +func WithClientInfo(ctx context.Context, info ClientInfo) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, clientInfoContextKey{}, info) +} + +// ClientInfoFromContext returns playback client metadata stored on a context. +func ClientInfoFromContext(ctx context.Context) ClientInfo { + if ctx == nil { + return ClientInfo{} + } + info, _ := ctx.Value(clientInfoContextKey{}).(ClientInfo) + return info +} + // SessionManager tracks active playback sessions and enforces stream limits. type SessionManager struct { sessions map[string]*Session @@ -132,6 +166,14 @@ func (m *SessionManager) SetExpirationHook(fn func(*Session)) { m.expireHook = fn } +func normalizeClientMetadataValue(value string, maxLen int) string { + value = strings.TrimSpace(value) + if maxLen > 0 && len(value) > maxLen { + value = value[:maxLen] + } + return value +} + // StartSession creates a new playback session using the same file as both the // requested and effective source. func (m *SessionManager) StartSession(userID int, profileID string, fileID int, method PlayMethod, transcodeAudio bool) (*Session, error) { @@ -201,6 +243,7 @@ func (m *SessionManager) StartSessionWithFilesContext( } now := time.Now() + clientInfo := ClientInfoFromContext(ctx) s := &Session{ ID: uuid.New().String(), UserID: userID, @@ -212,6 +255,9 @@ func (m *SessionManager) StartSessionWithFilesContext( TranscodeAudio: transcodeAudio, Position: 0, IsPaused: false, + ClientName: normalizeClientMetadataValue(clientInfo.Name, 128), + ClientVersion: normalizeClientMetadataValue(clientInfo.Version, 64), + ClientUserAgent: normalizeClientMetadataValue(clientInfo.UserAgent, 512), StartedAt: now, UpdatedAt: now, LastActivityAt: now, @@ -297,6 +343,15 @@ func (m *SessionManager) UpdateStreamState(sessionID string, state SessionStream s.AudioTrackIndex = state.AudioTrackIndex s.TranscodeAudio = state.TranscodeAudio s.ClientIP = state.ClientIP + if value := normalizeClientMetadataValue(state.ClientName, 128); value != "" { + s.ClientName = value + } + if value := normalizeClientMetadataValue(state.ClientVersion, 64); value != "" { + s.ClientVersion = value + } + if value := normalizeClientMetadataValue(state.ClientUserAgent, 512); value != "" { + s.ClientUserAgent = value + } s.StreamBitrateKbps = state.StreamBitrateKbps s.TargetResolution = state.TargetResolution s.TargetVideoCodec = state.TargetVideoCodec diff --git a/internal/worker/reconciler.go b/internal/worker/reconciler.go index eda8cd5a..490dbb8c 100644 --- a/internal/worker/reconciler.go +++ b/internal/worker/reconciler.go @@ -26,6 +26,9 @@ type SessionSync struct { PlayMethod string // current live transport method for admin session views ReportingNode string ClientIP string + ClientName string + ClientVersion string + ClientUserAgent string AudioTrackIndex int TranscodeAudio bool StreamBitrateKbps int @@ -138,10 +141,11 @@ func (r *Reconciler) ReconcileNodeSessions(ctx context.Context, reportingNode st INSERT INTO playback_sessions_sync (session_id, user_id, profile_id, media_file_id, requested_media_file_id, play_method, reporting_node, started_at, updated_at, last_sync_at, client_ip, + client_name, client_version, client_user_agent, audio_track_index, transcode_audio, stream_bitrate_kbps, transcode_node_url, target_resolution, target_video_codec, target_audio_codec, target_bitrate_kbps, transcode_hw_accel, position_seconds, is_paused, has_websocket) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $10::inet, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $10::inet, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25) ON CONFLICT (session_id) DO UPDATE SET user_id = EXCLUDED.user_id, profile_id = EXCLUDED.profile_id, @@ -152,6 +156,9 @@ func (r *Reconciler) ReconcileNodeSessions(ctx context.Context, reportingNode st started_at = EXCLUDED.started_at, updated_at = EXCLUDED.updated_at, client_ip = EXCLUDED.client_ip, + client_name = EXCLUDED.client_name, + client_version = EXCLUDED.client_version, + client_user_agent = EXCLUDED.client_user_agent, audio_track_index = EXCLUDED.audio_track_index, transcode_audio = EXCLUDED.transcode_audio, stream_bitrate_kbps = EXCLUDED.stream_bitrate_kbps, @@ -166,8 +173,9 @@ func (r *Reconciler) ReconcileNodeSessions(ctx context.Context, reportingNode st has_websocket = EXCLUDED.has_websocket, last_sync_at = NOW() `, s.SessionID, s.UserID, s.ProfileID, s.MediaFileID, nullableInt(s.RequestedMediaFileID), s.PlayMethod, - sessionNode, s.StartedAt, s.UpdatedAt, nullableIP(s.ClientIP), s.AudioTrackIndex, - s.TranscodeAudio, nullableInt(s.StreamBitrateKbps), nullableString(s.TranscodeNodeURL), + sessionNode, s.StartedAt, s.UpdatedAt, nullableIP(s.ClientIP), + nullableString(s.ClientName), nullableString(s.ClientVersion), nullableString(s.ClientUserAgent), + s.AudioTrackIndex, s.TranscodeAudio, nullableInt(s.StreamBitrateKbps), nullableString(s.TranscodeNodeURL), nullableString(s.TargetResolution), nullableString(s.TargetVideoCodec), nullableString(s.TargetAudioCodec), nullableInt(s.TargetBitrateKbps), nullableString(s.TranscodeHWAccel), normalizePositionSeconds(s.PositionSeconds), @@ -230,6 +238,9 @@ func loadNodeSessionsSnapshot(ctx context.Context, tx pgx.Tx, reportingNode stri COALESCE(play_method, ''), COALESCE(reporting_node, ''), COALESCE(HOST(client_ip), ''), + COALESCE(client_name, ''), + COALESCE(client_version, ''), + COALESCE(client_user_agent, ''), COALESCE(audio_track_index, 0), COALESCE(transcode_audio, FALSE), COALESCE(stream_bitrate_kbps, 0), @@ -265,6 +276,9 @@ func loadNodeSessionsSnapshot(ctx context.Context, tx pgx.Tx, reportingNode stri &s.PlayMethod, &s.ReportingNode, &s.ClientIP, + &s.ClientName, + &s.ClientVersion, + &s.ClientUserAgent, &s.AudioTrackIndex, &s.TranscodeAudio, &s.StreamBitrateKbps, @@ -318,6 +332,9 @@ func sessionSnapshotsEqual(left, right []SessionSync) bool { left[i].PlayMethod != right[i].PlayMethod || left[i].ReportingNode != right[i].ReportingNode || left[i].ClientIP != right[i].ClientIP || + left[i].ClientName != right[i].ClientName || + left[i].ClientVersion != right[i].ClientVersion || + left[i].ClientUserAgent != right[i].ClientUserAgent || left[i].AudioTrackIndex != right[i].AudioTrackIndex || left[i].TranscodeAudio != right[i].TranscodeAudio || left[i].StreamBitrateKbps != right[i].StreamBitrateKbps || diff --git a/migrations/sql/20260622195412_add_playback_session_client_user_agent.sql b/migrations/sql/20260622195412_add_playback_session_client_user_agent.sql new file mode 100644 index 00000000..152ea963 --- /dev/null +++ b/migrations/sql/20260622195412_add_playback_session_client_user_agent.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE public.playback_sessions_sync + ADD COLUMN IF NOT EXISTS client_name text, + ADD COLUMN IF NOT EXISTS client_version text, + ADD COLUMN IF NOT EXISTS client_user_agent text; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE public.playback_sessions_sync + DROP COLUMN IF EXISTS client_user_agent, + DROP COLUMN IF EXISTS client_version, + DROP COLUMN IF EXISTS client_name; +-- +goose StatementEnd diff --git a/web/src/api/types.ts b/web/src/api/types.ts index acd62a19..0c57dea5 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -2276,6 +2276,10 @@ export interface AdminSession { is_paused: boolean; has_playback_control?: boolean; client_ip?: string; + client_name?: string; + client_version?: string; + client_label?: string; + client_user_agent?: string; audio_track_index: number; transcode_audio: boolean; stream_bitrate_kbps: number | null; diff --git a/web/src/components/profiles/HouseholdStreamsPanel.tsx b/web/src/components/profiles/HouseholdStreamsPanel.tsx index 4c66935b..c9a07543 100644 --- a/web/src/components/profiles/HouseholdStreamsPanel.tsx +++ b/web/src/components/profiles/HouseholdStreamsPanel.tsx @@ -8,6 +8,7 @@ import { useHouseholdSessions } from "@/hooks/queries/profiles"; import { formatDecisionLabel, formatSessionBitrate, + getSessionClientLabel, getPlaybackSessionSubtitle, getPlaybackSessionTitle, } from "@/pages/adminActivityPresentation"; @@ -33,7 +34,12 @@ function formatElapsed(startedAt: string): string { } function streamMeta(session: AdminSession): string { - return [formatElapsed(session.started_at), session.client_ip, session.node_display_name] + return [ + formatElapsed(session.started_at), + getSessionClientLabel(session), + session.client_ip, + session.node_display_name, + ] .filter(Boolean) .join(" · "); } diff --git a/web/src/pages/AdminActivity.tsx b/web/src/pages/AdminActivity.tsx index a22059e3..f0afcb70 100644 --- a/web/src/pages/AdminActivity.tsx +++ b/web/src/pages/AdminActivity.tsx @@ -22,6 +22,7 @@ import { formatSessionBitrate, formatSourceContainerSummary, formatTranscodeModeSummary, + getSessionClientLabel, formatVideoDetail, formatVideoSummary, normalizeContainerDecision, @@ -138,7 +139,10 @@ export default function AdminActivity() { s.username?.toLowerCase().includes(q) || s.media_title?.toLowerCase().includes(q) || s.series_name?.toLowerCase().includes(q) || - s.episode_name?.toLowerCase().includes(q), + s.episode_name?.toLowerCase().includes(q) || + getSessionClientLabel(s).toLowerCase().includes(q) || + s.client_user_agent?.toLowerCase().includes(q) || + s.client_ip?.toLowerCase().includes(q), ); } if (methodFilter) result = result.filter((s) => s.play_method === methodFilter); @@ -516,6 +520,7 @@ function StreamRow({ const streamBitrate = formatSessionBitrate(session.stream_bitrate_kbps); const streamMeta = [sourceContainer, streamBitrate].filter(Boolean).join(" · "); const clientIP = session.client_ip?.trim() || ""; + const clientLabel = getSessionClientLabel(session); const playbackPosition = formatPlaybackPosition(session); const transcodeMode = formatTranscodeModeSummary(session); const containerDecision = normalizeContainerDecision(session.play_method); @@ -566,36 +571,48 @@ function StreamRow({