From c52ca7dd7a605b0d934cb5296ee4e817acf33522 Mon Sep 17 00:00:00 2001 From: CoffeeKnyte <67730400+CoffeeKnyte@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:27:58 +0800 Subject: [PATCH] feat(admin): identify compat sessions and Android devices in the live session view (#495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(admin): identify Android devices by model in live session view Android clients that send a bare default User-Agent (e.g. "Dalvik/2.1.0 (Linux; U; Android 11; AFTKRT Build/RS8180.3729N)") showed up as "Dalvik" in the admin live-session view, which tells an operator nothing about the device. Parse the model code out of the UA (the token between the last ';' and "Build/") and map the Amazon Fire TV family and NVIDIA Shield to product names. Unknown but parseable models fall back to "Android · " instead of "Dalvik"; multi-word models like "Pixel 7" are preserved whole. This is display-only: the session still stores the raw model code in its user agent, and no response field or contract changes. * feat(admin): mark Jellyfin-compat sessions with the JF pill by origin The admin "JF" pill was derived at read time by substring-matching a token list against the client name / user agent. A real Jellyfin client that authenticates through the compat surface but sends a bare User-Agent and no MediaBrowser client name (e.g. a Fire TV app) got no pill, even though it plainly came through the Jellyfin API. Stamp compat origin as immutable identity at session creation and carry it through to the admin view: - ClientInfo.IsCompat is set true in the jellycompat auth path; newSession copies it onto Session.IsJellyfinCompat. - The flag rides the durable RecipeCard (next to the client metadata that already exists so the pill survives reconstruction) and is restored in ReconstructSession, so a server restart keeps the pill. - buildLiveSessionSync -> worker.SessionSync -> a new compat_origin column on playback_sessions_sync (added migration); the reconciler upserts, reloads, and compares it so origin changes still publish and unchanged rows do not churn. - The handler ORs the stored origin with the existing name/UA heuristic, which stays as a fallback for rows written before this column existed. is_jellyfin_client keeps the same name and type on the wire; it is only sourced more accurately. * fix(admin): correct Android device labels --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> --- cmd/silo/session_sync.go | 1 + internal/api/handlers/playback_sessions.go | 75 ++++++++++- .../api/handlers/playback_sessions_test.go | 123 ++++++++++++++++++ internal/jellycompat/auth.go | 1 + internal/jellycompat/handlers_playback.go | 1 + internal/jellycompat/streams.go | 3 +- internal/playback/recipecard.go | 7 +- internal/playback/session.go | 3 + internal/playback/transcode_manager.go | 7 +- internal/worker/reconciler.go | 15 ++- ...824_add_playback_session_compat_origin.sql | 7 + 11 files changed, 226 insertions(+), 17 deletions(-) create mode 100644 migrations/sql/20260724101824_add_playback_session_compat_origin.sql diff --git a/cmd/silo/session_sync.go b/cmd/silo/session_sync.go index 44039c16..70bd1d57 100644 --- a/cmd/silo/session_sync.go +++ b/cmd/silo/session_sync.go @@ -40,5 +40,6 @@ func buildLiveSessionSync(s *playback.Session, reportingNode string) worker.Sess PositionSeconds: s.Position, IsPaused: s.IsPaused, HasWebSocket: s.HasWebSocket, + IsJellyfinCompat: s.IsJellyfinCompat, } } diff --git a/internal/api/handlers/playback_sessions.go b/internal/api/handlers/playback_sessions.go index e0d202ac..017a1f7c 100644 --- a/internal/api/handlers/playback_sessions.go +++ b/internal/api/handlers/playback_sessions.go @@ -74,6 +74,7 @@ type playbackSessionRow struct { AudioDecision string `json:"audio_decision,omitempty"` EffectivePlayMethod string `json:"effective_play_method,omitempty"` IsJellyfinClient bool `json:"is_jellyfin_client,omitempty"` + CompatOrigin bool `json:"-"` } // playbackSessionsCapabilitiesResponse advertises the additive fields of the @@ -199,7 +200,8 @@ func (l *PlaybackSessionsLoader) Load( mf.audio_channels, COALESCE(mf.audio_tracks::text, '[]'), COALESCE(requested_mf.codec_video, ''), - COALESCE(requested_mf.resolution, '') + COALESCE(requested_mf.resolution, ''), + COALESCE(s.compat_origin, FALSE) FROM playback_sessions_sync s LEFT JOIN users u ON u.id = s.user_id LEFT JOIN media_files mf ON mf.id = s.media_file_id @@ -241,6 +243,7 @@ func (l *PlaybackSessionsLoader) Load( &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, + &s.CompatOrigin, ); err != nil { return nil, fmt.Errorf("scanning playback session: %w", err) } @@ -275,7 +278,7 @@ 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) + row.IsJellyfinClient = row.CompatOrigin || isJellyfinEcosystemClient(row.ClientName, row.ClientUserAgent) var audioTracks []models.AudioTrack if len(audioTracksJSON) > 0 { @@ -418,9 +421,8 @@ var jellyfinClientTokens = []string{ } // 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. +// matches a known Jellyfin-ecosystem client. This heuristic remains a fallback +// for rows created before compat-origin identity was persisted. func isJellyfinEcosystemClient(clientName, userAgent string) bool { for _, value := range []string{clientName, userAgent} { value = strings.ToLower(strings.TrimSpace(value)) @@ -508,10 +510,73 @@ func playbackClientDisplayName(name, version, userAgent string) string { case strings.Contains(lower, "python-requests"): return "Python requests" default: + if label := androidDeviceLabel(userAgent); label != "" { + return label + } return firstUserAgentProduct(userAgent) } } +// knownAndroidDeviceLabels maps Android / Fire OS build model codes to friendly +// product names for the admin session view. Keys are uppercased so the lookup is +// case-insensitive. This only affects the displayed label — the session still +// stores the raw model code in its user agent. +var knownAndroidDeviceLabels = map[string]string{ + "AFTKRT": "Fire TV Stick 4K Max", + "AFTMM": "Fire TV Stick 4K", + "AFTKM": "Fire TV Stick 4K (2nd Gen)", + "AFTKA": "Fire TV Stick 4K Max (1st Gen)", + "AFTSSS": "Fire TV Stick (3rd Gen)", + "AFTSS": "Fire TV Stick Lite (1st Gen)", + "AFTT": "Fire TV Stick (2nd Gen)", + "AFTB": "Fire TV (1st Gen)", + "AFTS": "Fire TV (2nd Gen)", + "AFTN": "Fire TV (3rd Gen)", + "AFTR": "Fire TV Cube (2nd Gen)", + "AFTA": "Fire TV Cube (1st Gen)", + "SHIELD ANDROID TV": "NVIDIA Shield", +} + +// androidDeviceLabel derives a friendly device label from a bare Android / Fire OS +// user agent of the form "... (Linux; U; Android ; Build/)". +// The model is the whole segment between the last ';' and 'Build/', so multi-word +// models ("Pixel 7", "SHIELD Android TV") survive intact. Known model codes map to +// a product name; anything else falls back to "Android · " rather than the +// uninformative "Dalvik". Returns "" when no model can be parsed. +func androidDeviceLabel(userAgent string) string { + buildIndex := strings.Index(userAgent, "Build/") + if buildIndex < 0 { + return "" + } + + prefix := userAgent[:buildIndex] + separator := strings.LastIndex(prefix, ";") + if separator < 0 { + return "" + } + hasAndroidPlatform := false + for _, segment := range strings.Split(prefix[:separator], ";") { + segment = strings.TrimSpace(strings.Trim(segment, "()")) + if strings.HasPrefix(strings.ToLower(segment), "android ") { + hasAndroidPlatform = true + break + } + } + if !hasAndroidPlatform { + return "" + } + + model := prefix[separator+1:] + model = strings.TrimSpace(strings.Trim(strings.TrimSpace(model), "();")) + if model == "" { + return "" + } + if label, ok := knownAndroidDeviceLabels[strings.ToUpper(model)]; ok { + return label + } + return "Android · " + model +} + func containsAny(value string, needles []string) bool { for _, needle := range needles { if strings.Contains(value, needle) { diff --git a/internal/api/handlers/playback_sessions_test.go b/internal/api/handlers/playback_sessions_test.go index d41e89f0..06df4c82 100644 --- a/internal/api/handlers/playback_sessions_test.go +++ b/internal/api/handlers/playback_sessions_test.go @@ -103,3 +103,126 @@ func TestIsJellyfinEcosystemClient(t *testing.T) { }) } } + +func TestPlaybackClientDisplayNameAndroidDevices(t *testing.T) { + const curlClientLabel = "curl" + + cases := []struct { + name string + userAgent string + want string + }{ + { + name: "fire tv stick 4k max", + userAgent: "Dalvik/2.1.0 (Linux; U; Android 11; AFTKRT Build/RS8180.3729N)", + want: "Fire TV Stick 4K Max", + }, + { + name: "fire tv stick 4k", + userAgent: "Dalvik/2.1.0 (Linux; U; Android 9; AFTMM Build/PS7279)", + want: "Fire TV Stick 4K", + }, + { + name: "fire tv stick 4k second generation", + userAgent: "Dalvik/2.1.0 (Linux; U; Android 11; AFTKM Build/RS8139)", + want: "Fire TV Stick 4K (2nd Gen)", + }, + { + name: "fire tv stick 4k max first generation", + userAgent: "Dalvik/2.1.0 (Linux; U; Android 9; AFTKA Build/PS7646)", + want: "Fire TV Stick 4K Max (1st Gen)", + }, + { + name: "fire tv stick third generation", + userAgent: "Dalvik/2.1.0 (Linux; U; Android 9; AFTSSS Build/PS7279)", + want: "Fire TV Stick (3rd Gen)", + }, + { + name: "fire tv stick lite first generation", + userAgent: "Dalvik/2.1.0 (Linux; U; Android 9; AFTSS Build/PS7279)", + want: "Fire TV Stick Lite (1st Gen)", + }, + { + name: "fire tv stick second generation", + userAgent: "Dalvik/2.1.0 (Linux; U; Android 5.1; AFTT Build/LVY48F)", + want: "Fire TV Stick (2nd Gen)", + }, + { + name: "fire tv first generation", + userAgent: "Dalvik/2.1.0 (Linux; U; Android 4.2; AFTB Build/JDQ39)", + want: "Fire TV (1st Gen)", + }, + { + name: "fire tv second generation", + userAgent: "Dalvik/2.1.0 (Linux; U; Android 5.1; AFTS Build/LVY48F)", + want: "Fire TV (2nd Gen)", + }, + { + name: "fire tv third generation", + userAgent: "Dalvik/2.1.0 (Linux; U; Android 7.1; AFTN Build/NS6265)", + want: "Fire TV (3rd Gen)", + }, + { + name: "fire tv cube second generation", + userAgent: "Dalvik/2.1.0 (Linux; U; Android 9; AFTR Build/PS7646)", + want: "Fire TV Cube (2nd Gen)", + }, + { + name: "fire tv cube first generation", + userAgent: "Dalvik/2.1.0 (Linux; U; Android 7.1; AFTA Build/NS6265)", + want: "Fire TV Cube (1st Gen)", + }, + { + name: "unmapped multi-word android model preserved", + userAgent: "Dalvik/2.1.0 (Linux; U; Android 13; Pixel 7 Build/TQ3A)", + want: "Android · Pixel 7", + }, + { + name: "shield model", + userAgent: "Dalvik/2.1.0 (Linux; U; Android 11; SHIELD Android TV Build/RQ1A)", + want: "NVIDIA Shield", + }, + { + name: "chrome remains browser label", + userAgent: "Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0.0.0 Safari/537.36", + want: "Chrome 120", + }, + { + name: "non-android build user agent keeps existing fallback", + userAgent: "curl/8.0 (Linux; Device Build/42)", + want: curlClientLabel, + }, + { + name: "explicit client fallback wins over android device model", + userAgent: "curl/8.0 (Linux; U; Android 13; Pixel 7 Build/TQ3A)", + want: curlClientLabel, + }, + { + name: "android substring is not an android platform token", + userAgent: "Dalvik/2.1.0 (Linux; U; NotAndroid 13; Pixel 7 Build/TQ3A)", + want: "Dalvik", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := playbackClientDisplayName("", "", tc.userAgent) + if got != tc.want { + t.Fatalf("playbackClientDisplayName() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestEnrichPlaybackSessionRowUsesCompatOrigin(t *testing.T) { + row := playbackSessionRow{ + ClientName: "Unrecognized Client", + ClientUserAgent: "Dalvik/2.1.0", + CompatOrigin: true, + } + + enrichPlaybackSessionRow(&row, nil) + + if !row.IsJellyfinClient { + t.Fatal("compat-origin session must be marked as a Jellyfin client") + } +} diff --git a/internal/jellycompat/auth.go b/internal/jellycompat/auth.go index 334043c9..711c54da 100644 --- a/internal/jellycompat/auth.go +++ b/internal/jellycompat/auth.go @@ -166,6 +166,7 @@ func compatPlaybackClientInfo(r *http.Request) playback.ClientInfo { Name: firstMediaBrowserAuthorizationValue(r, "Client"), Version: firstMediaBrowserAuthorizationValue(r, "Version"), UserAgent: r.UserAgent(), + IsCompat: true, } } diff --git a/internal/jellycompat/handlers_playback.go b/internal/jellycompat/handlers_playback.go index c178cf2a..fa5f9dfa 100644 --- a/internal/jellycompat/handlers_playback.go +++ b/internal/jellycompat/handlers_playback.go @@ -547,6 +547,7 @@ func (h *PlaybackHandler) persistTranscodeRecipe( card.ClientName = upstream.ClientName card.ClientVersion = upstream.ClientVersion card.ClientUserAgent = upstream.ClientUserAgent + card.IsJellyfinCompat = upstream.IsJellyfinCompat recipe = &card } } diff --git a/internal/jellycompat/streams.go b/internal/jellycompat/streams.go index 1ac34fbe..d87c188d 100644 --- a/internal/jellycompat/streams.go +++ b/internal/jellycompat/streams.go @@ -1402,8 +1402,9 @@ func (h *PlaybackHandler) ensureUpstreamPlayback(ctx context.Context, compatSess // 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. + info := playback.ClientInfoFromContext(ctx) + card.IsJellyfinCompat = info.IsCompat 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 { diff --git a/internal/playback/recipecard.go b/internal/playback/recipecard.go index 1a830513..189f451d 100644 --- a/internal/playback/recipecard.go +++ b/internal/playback/recipecard.go @@ -38,9 +38,10 @@ type RecipeCard struct { // 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"` + ClientName string `json:"client_name,omitempty"` + ClientVersion string `json:"client_version,omitempty"` + ClientUserAgent string `json:"client_user_agent,omitempty"` + IsJellyfinCompat bool `json:"is_jellyfin_compat,omitempty"` // Encode parameters — mirror of the byte-affecting TranscodeOpts fields. // Unused (zero) for direct/remux cards, which carry no segment-based encode. diff --git a/internal/playback/session.go b/internal/playback/session.go index 57d824ba..9c66b5ed 100644 --- a/internal/playback/session.go +++ b/internal/playback/session.go @@ -27,6 +27,7 @@ type Session struct { 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 + IsJellyfinCompat bool // immutable origin identity for Jellyfin compatibility sessions TranscodeNodeURL string // URL of assigned transcode node (empty = local/integrated) TranscodeTransportID string // remote node process identity; empty means session ID @@ -140,6 +141,7 @@ type ClientInfo struct { Name string Version string UserAgent string + IsCompat bool } // WithClientInfo stores playback client metadata on a context. @@ -437,6 +439,7 @@ func newSession( ClientName: normalizeClientMetadataValue(clientInfo.Name, 128), ClientVersion: normalizeClientMetadataValue(clientInfo.Version, 64), ClientUserAgent: normalizeClientMetadataValue(clientInfo.UserAgent, 512), + IsJellyfinCompat: clientInfo.IsCompat, StartedAt: now, UpdatedAt: now, LastActivityAt: now, diff --git a/internal/playback/transcode_manager.go b/internal/playback/transcode_manager.go index 3264f064..308713a1 100644 --- a/internal/playback/transcode_manager.go +++ b/internal/playback/transcode_manager.go @@ -424,9 +424,10 @@ func (m *TranscodeManager) ReconstructSession(ctx context.Context, sessionID str 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), + ClientName: normalizeClientMetadataValue(card.ClientName, 128), + ClientVersion: normalizeClientMetadataValue(card.ClientVersion, 64), + ClientUserAgent: normalizeClientMetadataValue(card.ClientUserAgent, 512), + IsJellyfinCompat: card.IsJellyfinCompat, // 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/worker/reconciler.go b/internal/worker/reconciler.go index 07ae4978..54e08b8f 100644 --- a/internal/worker/reconciler.go +++ b/internal/worker/reconciler.go @@ -44,6 +44,7 @@ type SessionSync struct { PositionSeconds float64 IsPaused bool HasWebSocket bool + IsJellyfinCompat bool } // AggregateData represents the aggregate counts for a single user that are @@ -153,8 +154,8 @@ func (r *Reconciler) ReconcileNodeSessions(ctx context.Context, reportingNode st 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, $23, $24, $25) + transcode_hw_accel, position_seconds, is_paused, has_websocket, compat_origin) + 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, $26) ON CONFLICT (session_id) DO UPDATE SET user_id = EXCLUDED.user_id, profile_id = EXCLUDED.profile_id, @@ -180,6 +181,7 @@ func (r *Reconciler) ReconcileNodeSessions(ctx context.Context, reportingNode st position_seconds = EXCLUDED.position_seconds, is_paused = EXCLUDED.is_paused, has_websocket = EXCLUDED.has_websocket, + compat_origin = EXCLUDED.compat_origin, 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), @@ -188,7 +190,7 @@ func (r *Reconciler) ReconcileNodeSessions(ctx context.Context, reportingNode st nullableString(s.TargetResolution), nullableString(s.TargetVideoCodec), nullableString(s.TargetAudioCodec), nullableInt(s.TargetBitrateKbps), nullableString(s.TranscodeHWAccel), normalizePositionSeconds(s.PositionSeconds), - s.IsPaused, s.HasWebSocket) + s.IsPaused, s.HasWebSocket, s.IsJellyfinCompat) if err != nil { return fmt.Errorf("upserting session %s: %w", s.SessionID, err) } @@ -263,7 +265,8 @@ func loadNodeSessionsSnapshot(ctx context.Context, tx pgx.Tx, reportingNode stri updated_at, COALESCE(position_seconds, 0), COALESCE(is_paused, FALSE), - COALESCE(has_websocket, FALSE) + COALESCE(has_websocket, FALSE), + COALESCE(compat_origin, FALSE) FROM playback_sessions_sync WHERE COALESCE(reporting_node, '') = $1 ORDER BY session_id @@ -302,6 +305,7 @@ func loadNodeSessionsSnapshot(ctx context.Context, tx pgx.Tx, reportingNode stri &s.PositionSeconds, &s.IsPaused, &s.HasWebSocket, + &s.IsJellyfinCompat, ); err != nil { return nil, err } @@ -357,7 +361,8 @@ func sessionSnapshotsEqual(left, right []SessionSync) bool { !left[i].UpdatedAt.Equal(right[i].UpdatedAt) || normalizePositionSeconds(left[i].PositionSeconds) != normalizePositionSeconds(right[i].PositionSeconds) || left[i].IsPaused != right[i].IsPaused || - left[i].HasWebSocket != right[i].HasWebSocket { + left[i].HasWebSocket != right[i].HasWebSocket || + left[i].IsJellyfinCompat != right[i].IsJellyfinCompat { return false } } diff --git a/migrations/sql/20260724101824_add_playback_session_compat_origin.sql b/migrations/sql/20260724101824_add_playback_session_compat_origin.sql new file mode 100644 index 00000000..e5a91da2 --- /dev/null +++ b/migrations/sql/20260724101824_add_playback_session_compat_origin.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE playback_sessions_sync + ADD COLUMN compat_origin boolean NOT NULL DEFAULT false; + +-- +goose Down +ALTER TABLE playback_sessions_sync + DROP COLUMN compat_origin;