feat(admin): identify compat sessions and Android devices in the live session view (#495)

* 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 · <MODEL>"
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>
This commit is contained in:
CoffeeKnyte
2026-07-28 21:27:58 -04:00
committed by GitHub
co-authored by Quick
parent 54cfc48ce3
commit c52ca7dd7a
11 changed files with 226 additions and 17 deletions
+1
View File
@@ -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,
}
}
+70 -5
View File
@@ -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 <ver>; <MODEL> Build/<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 · <MODEL>" 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) {
@@ -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")
}
}
+1
View File
@@ -166,6 +166,7 @@ func compatPlaybackClientInfo(r *http.Request) playback.ClientInfo {
Name: firstMediaBrowserAuthorizationValue(r, "Client"),
Version: firstMediaBrowserAuthorizationValue(r, "Version"),
UserAgent: r.UserAgent(),
IsCompat: true,
}
}
@@ -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
}
}
+2 -1
View File
@@ -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 {
+4 -3
View File
@@ -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.
+3
View File
@@ -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,
+4 -3
View File
@@ -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,
+10 -5
View File
@@ -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
}
}
@@ -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;