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;