Propagate playback client metadata through session sync

This commit is contained in:
Quick
2026-06-23 14:26:14 -04:00
parent 9819f0ff12
commit 227986094b
13 changed files with 394 additions and 40 deletions
+3
View File
@@ -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,
+23 -7
View File
@@ -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
+145 -1
View File
@@ -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
+41
View File
@@ -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
+55
View File
@@ -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
+20 -3
View File
@@ -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 ||
@@ -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
+4
View File
@@ -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;
@@ -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(" · ");
}
+51 -25
View File
@@ -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({
<div className="hidden grid-cols-[minmax(120px,1.1fr)_minmax(190px,1.7fr)_minmax(220px,1.9fr)_minmax(90px,0.8fr)_minmax(125px,0.9fr)_minmax(220px,1.4fr)] items-center gap-2 px-3 py-2.5 sm:grid">
{/* User */}
<div className="flex min-w-0 items-center gap-2">
<Link
to={userHref}
className="hover:text-primary flex min-w-0 items-center gap-2 transition-colors"
>
<Link to={userHref} className="hover:text-primary shrink-0 transition-colors">
<div
className="text-primary-foreground flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-[9px] font-bold"
style={{ background: "var(--primary)" }}
>
{username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<div className="block truncate text-[13px] font-medium">{username}</div>
{profileDisplay ? (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
<span className="border-primary/30 bg-primary/15 text-primary max-w-full truncate rounded border px-1.5 py-0.5 text-[10px] leading-none">
{profileDisplay}
</span>
</div>
) : null}
</div>
</Link>
{clientIP ? (
<button
type="button"
onClick={() => onIPLookup(clientIP)}
className="text-muted-foreground hover:text-primary min-w-0 cursor-pointer truncate text-[10px] transition-colors"
>
{clientIP}
</button>
) : null}
<div className="min-w-0">
<Link to={userHref} className="hover:text-primary block truncate transition-colors">
<span className="text-[13px] font-medium">{username}</span>
</Link>
{profileDisplay ? (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
<span className="border-primary/30 bg-primary/15 text-primary max-w-full truncate rounded border px-1.5 py-0.5 text-[10px] leading-none">
{profileDisplay}
</span>
</div>
) : null}
{(clientLabel || clientIP) && (
<div className="text-muted-foreground mt-1 flex min-w-0 items-center gap-1.5 text-[10px]">
{clientLabel ? (
<span
title={session.client_user_agent || clientLabel}
className="max-w-[8rem] min-w-0 truncate"
>
{clientLabel}
</span>
) : null}
{clientLabel && clientIP ? <span className="shrink-0">·</span> : null}
{clientIP ? (
<button
type="button"
onClick={() => onIPLookup(clientIP)}
className="hover:text-primary min-w-0 cursor-pointer truncate transition-colors"
>
{clientIP}
</button>
) : null}
</div>
)}
</div>
</div>
{/* Stream */}
@@ -771,8 +788,17 @@ function StreamRow({
<span className="flex-shrink-0">·</span>
<span className="flex-shrink-0 font-mono tabular-nums">{elapsed}</span>
</div>
{(clientIP || streamMeta) && (
{(clientLabel || clientIP || streamMeta) && (
<div className="text-muted-foreground mt-1 flex min-w-0 gap-1.5 text-[10px]">
{clientLabel ? (
<span
title={session.client_user_agent || clientLabel}
className="max-w-[9rem] shrink-0 truncate"
>
{clientLabel}
</span>
) : null}
{clientLabel && (clientIP || streamMeta) ? <span className="shrink-0">·</span> : null}
{clientIP ? (
<button
type="button"
+13 -3
View File
@@ -53,6 +53,7 @@ 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";
const REFRESH_SPINNER_MIN_VISIBLE_MS = 1_000;
const DASHBOARD_AUTO_REFRESH_MS = 60_000;
@@ -485,6 +486,7 @@ function StreamCard({ session }: { session: AdminSession }) {
: session.media_title || `File #${session.media_file_id}`;
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"
@@ -564,6 +566,14 @@ function StreamCard({ session }: { session: AdminSession }) {
>
{session.play_method || "unknown"}
</span>
{clientLabel ? (
<span
title={session.client_user_agent || clientLabel}
className="border-border/60 bg-muted/30 text-muted-foreground inline-flex max-w-[9rem] rounded border px-1.5 py-0.5 text-[9px] font-semibold"
>
<span className="truncate">{clientLabel}</span>
</span>
) : null}
{session.reporting_node && (
<span className="border-primary/10 bg-primary/5 text-primary inline-flex rounded border px-1.5 py-0.5 text-[9px] font-semibold">
{session.node_display_name || session.reporting_node}
@@ -913,6 +923,8 @@ function ActivityCard({
: s.media_title || `File #${s.media_file_id}`;
const username = s.username || `User #${s.user_id}`;
const profileDisplay = s.profile_name || s.profile_id || "";
const clientLabel = getSessionClientLabel(s);
const meta = [getTimeAgo(s.started_at), clientLabel].filter(Boolean).join(" · ");
return (
<div
key={s.session_id}
@@ -938,9 +950,7 @@ function ActivityCard({
{title}
</Link>
</div>
<div className="text-muted-foreground mt-0.5 text-[10px]">
{getTimeAgo(s.started_at)}
</div>
<div className="text-muted-foreground mt-0.5 text-[10px]">{meta}</div>
</div>
<div className="flex-shrink-0">
<AdminSessionActions session={s} compact />
+3
View File
@@ -12,6 +12,7 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { Film, FileVideo, Users, Play } from "lucide-react";
import type { AdminSession, AdminStats } from "@/api/types";
import { getSessionClientLabel } from "@/pages/adminActivityPresentation";
export default function AdminStats() {
const statsQuery = useAdminStats();
@@ -112,6 +113,7 @@ function SessionsSection({
<TableHead>User ID</TableHead>
<TableHead>File ID</TableHead>
<TableHead>Method</TableHead>
<TableHead>Client</TableHead>
<TableHead>Started</TableHead>
</TableRow>
</TableHeader>
@@ -122,6 +124,7 @@ function SessionsSection({
<TableCell>{s.user_id}</TableCell>
<TableCell>{s.media_file_id}</TableCell>
<TableCell>{s.play_method}</TableCell>
<TableCell>{getSessionClientLabel(s) || "—"}</TableCell>
<TableCell className="text-xs">
{new Date(s.started_at).toLocaleString()}
</TableCell>
@@ -104,6 +104,20 @@ export function formatSessionBitrate(kbps?: number | null): string | null {
return `${Math.round(kbps)} kbps`;
}
export function getSessionClientLabel(session: AdminSession): string {
const explicitLabel = session.client_label?.trim();
if (explicitLabel) {
return explicitLabel;
}
const clientName = session.client_name?.trim();
const clientVersion = session.client_version?.trim();
if (clientName && clientVersion) {
return `${clientName} ${clientVersion}`;
}
return clientName || "";
}
export function formatSourceContainerSummary(session: AdminSession): string {
return formatContainer(session.source_container) || "Unknown source";
}