Propagate playback client metadata through session sync
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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(" · ");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user