* 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>
686 lines
24 KiB
Go
686 lines
24 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
// playbackSessionRow represents a live playback row from playback_sessions_sync
|
|
// enriched with user and media information. play_method is the current transport
|
|
// method for the active stream; component-level behavior is exposed separately
|
|
// via video_decision and audio_decision.
|
|
type playbackSessionRow struct {
|
|
SessionID string `json:"session_id"`
|
|
UserID int `json:"user_id"`
|
|
Username string `json:"username"`
|
|
ProfileID string `json:"profile_id"`
|
|
ProfileName string `json:"profile_name,omitempty"`
|
|
MediaFileID int `json:"media_file_id"`
|
|
RequestedMediaFileID int `json:"requested_media_file_id"`
|
|
ContentID string `json:"content_id,omitempty"`
|
|
MediaTitle string `json:"media_title"`
|
|
MediaType string `json:"media_type"`
|
|
SeriesName string `json:"series_name,omitempty"`
|
|
EpisodeName string `json:"episode_name,omitempty"`
|
|
SeasonNumber *int `json:"season_number,omitempty"`
|
|
EpisodeNumber *int `json:"episode_number,omitempty"`
|
|
PosterURL string `json:"poster_url,omitempty"`
|
|
PlayMethod string `json:"play_method"`
|
|
ReportingNode string `json:"reporting_node"`
|
|
NodeDisplayName string `json:"node_display_name,omitempty"`
|
|
FileDuration *int `json:"file_duration"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
PositionSeconds float64 `json:"position_seconds"`
|
|
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"`
|
|
TranscodeNodeURL string `json:"-"`
|
|
TargetResolution string `json:"target_resolution,omitempty"`
|
|
TargetVideoCodec string `json:"target_video_codec,omitempty"`
|
|
TargetAudioCodec string `json:"target_audio_codec,omitempty"`
|
|
TargetBitrateKbps *int `json:"target_bitrate_kbps"`
|
|
TranscodeHWAccel string `json:"transcode_hw_accel,omitempty"`
|
|
SourceContainer string `json:"source_container,omitempty"`
|
|
SourceBitrateKbps *int `json:"source_bitrate_kbps"`
|
|
SourceVideoCodec string `json:"source_video_codec,omitempty"`
|
|
SourceVideoResolution string `json:"source_video_resolution,omitempty"`
|
|
SourceAudioCodec string `json:"source_audio_codec,omitempty"`
|
|
SourceAudioChannels *int `json:"source_audio_channels"`
|
|
SourceAudioLanguage string `json:"source_audio_language,omitempty"`
|
|
SourceAudioTitle string `json:"source_audio_title,omitempty"`
|
|
SourceAudioLayout string `json:"source_audio_layout,omitempty"`
|
|
RequestedVideoCodec string `json:"requested_video_codec,omitempty"`
|
|
RequestedVideoResolution string `json:"requested_video_resolution,omitempty"`
|
|
VideoDecision string `json:"video_decision,omitempty"`
|
|
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
|
|
// live admin session payload so independently deployed clients (Android,
|
|
// Apple) can feature-detect them. Both fields are omitempty on the wire, so
|
|
// absence on a row is otherwise indistinguishable from an older server.
|
|
type playbackSessionsCapabilitiesResponse struct {
|
|
// EffectivePlayMethod reports that rows carry effective_play_method.
|
|
EffectivePlayMethod bool `json:"effective_play_method"`
|
|
// EffectivePlayMethodValues is the closed bucket vocabulary a supported
|
|
// server emits (absent field = unknown).
|
|
EffectivePlayMethodValues []string `json:"effective_play_method_values"`
|
|
// IsJellyfinClient reports that rows carry is_jellyfin_client.
|
|
IsJellyfinClient bool `json:"is_jellyfin_client"`
|
|
}
|
|
|
|
// HandleGetSessionsCapabilities exposes additive feature support for the live
|
|
// admin session payload (GET /admin/sessions/capabilities).
|
|
func (h *AdminHandler) HandleGetSessionsCapabilities(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, playbackSessionsCapabilitiesResponse{
|
|
EffectivePlayMethod: true,
|
|
EffectivePlayMethodValues: []string{"direct", "remux", "transcode", "audio"},
|
|
IsJellyfinClient: true,
|
|
})
|
|
}
|
|
|
|
// PlaybackSessionsQuery scopes live session listing.
|
|
type PlaybackSessionsQuery struct {
|
|
// UserID, when positive, limits results to sessions owned by that account.
|
|
UserID int
|
|
}
|
|
|
|
type playbackSessionsReader interface {
|
|
Load(ctx context.Context, r *http.Request, query PlaybackSessionsQuery) ([]playbackSessionRow, error)
|
|
}
|
|
|
|
func resolvePlaybackSessionsLoader(
|
|
loader *PlaybackSessionsLoader,
|
|
pool *pgxpool.Pool,
|
|
storeProv userstore.UserStoreProvider,
|
|
detailSvc *catalog.DetailService,
|
|
) (*PlaybackSessionsLoader, error) {
|
|
if loader != nil {
|
|
return loader, nil
|
|
}
|
|
if pool == nil {
|
|
return nil, errors.New("database not configured")
|
|
}
|
|
return NewPlaybackSessionsLoader(pool, storeProv, detailSvc), nil
|
|
}
|
|
|
|
// PlaybackSessionsLoader reads enriched rows from playback_sessions_sync.
|
|
type PlaybackSessionsLoader struct {
|
|
pool *pgxpool.Pool
|
|
storeProv userstore.UserStoreProvider
|
|
DetailSvc *catalog.DetailService
|
|
}
|
|
|
|
func NewPlaybackSessionsLoader(
|
|
pool *pgxpool.Pool,
|
|
storeProv userstore.UserStoreProvider,
|
|
detailSvc *catalog.DetailService,
|
|
) *PlaybackSessionsLoader {
|
|
return &PlaybackSessionsLoader{
|
|
pool: pool,
|
|
storeProv: storeProv,
|
|
DetailSvc: detailSvc,
|
|
}
|
|
}
|
|
|
|
func (l *PlaybackSessionsLoader) Load(
|
|
ctx context.Context,
|
|
r *http.Request,
|
|
query PlaybackSessionsQuery,
|
|
) ([]playbackSessionRow, error) {
|
|
if l == nil || l.pool == nil {
|
|
return nil, errors.New("database not configured")
|
|
}
|
|
|
|
sql := `
|
|
SELECT
|
|
s.session_id,
|
|
s.user_id,
|
|
COALESCE(u.username, ''),
|
|
COALESCE(s.profile_id, ''),
|
|
s.media_file_id,
|
|
COALESCE(s.requested_media_file_id, s.media_file_id, 0),
|
|
COALESCE(mf.episode_id, mf.content_id, ''),
|
|
COALESCE(mi.title, ''),
|
|
COALESCE(mi.type, ''),
|
|
COALESCE(series_mi.title, ''),
|
|
COALESCE(e.title, ''),
|
|
e.season_number,
|
|
e.episode_number,
|
|
COALESCE(CASE WHEN e.series_id IS NOT NULL THEN series_mi.poster_path ELSE mi.poster_path END, ''),
|
|
s.play_method,
|
|
s.reporting_node,
|
|
COALESCE(remote_node.name, ''),
|
|
mf.duration,
|
|
s.started_at,
|
|
s.updated_at,
|
|
COALESCE(s.position_seconds, 0),
|
|
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,
|
|
COALESCE(s.transcode_node_url, ''),
|
|
COALESCE(s.target_resolution, ''),
|
|
COALESCE(s.target_video_codec, ''),
|
|
COALESCE(s.target_audio_codec, ''),
|
|
s.target_bitrate_kbps,
|
|
COALESCE(s.transcode_hw_accel, ''),
|
|
COALESCE(mf.container, ''),
|
|
mf.bitrate,
|
|
COALESCE(mf.codec_video, ''),
|
|
COALESCE(mf.resolution, ''),
|
|
COALESCE(mf.codec_audio, ''),
|
|
mf.audio_channels,
|
|
COALESCE(mf.audio_tracks::text, '[]'),
|
|
COALESCE(requested_mf.codec_video, ''),
|
|
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
|
|
LEFT JOIN media_files requested_mf ON requested_mf.id = COALESCE(s.requested_media_file_id, s.media_file_id)
|
|
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
|
LEFT JOIN episodes e ON e.content_id = mf.episode_id
|
|
LEFT JOIN media_items series_mi ON series_mi.content_id = e.series_id
|
|
LEFT JOIN stream_nodes remote_node ON remote_node.url = s.transcode_node_url`
|
|
|
|
var args []any
|
|
if query.UserID > 0 {
|
|
sql += " WHERE s.user_id = $1"
|
|
args = append(args, query.UserID)
|
|
}
|
|
sql += " ORDER BY s.started_at DESC LIMIT 200"
|
|
|
|
rows, err := l.pool.Query(ctx, sql, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying playback sessions: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
sessions := make([]playbackSessionRow, 0)
|
|
for rows.Next() {
|
|
var s playbackSessionRow
|
|
var posterPath string
|
|
var streamBitrateKbps *int
|
|
var targetBitrateKbps *int
|
|
var sourceBitrateKbps *int
|
|
var sourceAudioChannels *int
|
|
var audioTracksJSON []byte
|
|
if err := rows.Scan(
|
|
&s.SessionID, &s.UserID, &s.Username, &s.ProfileID, &s.MediaFileID, &s.RequestedMediaFileID, &s.ContentID,
|
|
&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.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,
|
|
&s.CompatOrigin,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scanning playback session: %w", err)
|
|
}
|
|
s.PosterURL = l.presignPosterURL(r, posterPath)
|
|
s.StreamBitrateKbps = streamBitrateKbps
|
|
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)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
l.populateProfileNames(ctx, sessions)
|
|
|
|
return sessions, nil
|
|
}
|
|
|
|
func (l *PlaybackSessionsLoader) presignPosterURL(r *http.Request, path string) string {
|
|
if l != nil && l.DetailSvc != nil {
|
|
return l.DetailSvc.PresignURL(r.Context(), cardThumbnailPath(path), "card")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func enrichPlaybackSessionRow(row *playbackSessionRow, audioTracksJSON []byte) {
|
|
if row == nil {
|
|
return
|
|
}
|
|
|
|
row.VideoDecision, row.AudioDecision = sessionComponentDecision(row.PlayMethod, row.TranscodeAudio, row.TargetVideoCodec)
|
|
row.EffectivePlayMethod = effectivePlayMethod(row.VideoDecision, row.AudioDecision)
|
|
row.IsJellyfinClient = row.CompatOrigin || isJellyfinEcosystemClient(row.ClientName, row.ClientUserAgent)
|
|
|
|
var audioTracks []models.AudioTrack
|
|
if len(audioTracksJSON) > 0 {
|
|
_ = json.Unmarshal(audioTracksJSON, &audioTracks)
|
|
}
|
|
if track := pickAdminAudioTrack(audioTracks, row.AudioTrackIndex); track != nil {
|
|
if codec := strings.TrimSpace(track.Codec); codec != "" {
|
|
row.SourceAudioCodec = codec
|
|
}
|
|
if track.Channels > 0 {
|
|
channels := track.Channels
|
|
row.SourceAudioChannels = &channels
|
|
}
|
|
row.SourceAudioLanguage = strings.TrimSpace(track.Language)
|
|
row.SourceAudioTitle = firstNonEmptyValue(track.Title, track.EmbeddedTitle)
|
|
row.SourceAudioLayout = strings.TrimSpace(track.Layout)
|
|
}
|
|
|
|
if row.StreamBitrateKbps == nil {
|
|
switch {
|
|
case row.TargetBitrateKbps != nil:
|
|
row.StreamBitrateKbps = row.TargetBitrateKbps
|
|
case row.SourceBitrateKbps != nil:
|
|
row.StreamBitrateKbps = row.SourceBitrateKbps
|
|
}
|
|
}
|
|
|
|
if row.AudioDecision == "transcode" && strings.TrimSpace(row.TargetAudioCodec) == "" {
|
|
row.TargetAudioCodec = "aac"
|
|
}
|
|
if row.VideoDecision == "transcode" && strings.TrimSpace(row.TargetVideoCodec) == "" {
|
|
row.TargetVideoCodec = "h264"
|
|
}
|
|
if row.VideoDecision == "transcode" && strings.TrimSpace(row.TargetResolution) == "" {
|
|
row.TargetResolution = row.SourceVideoResolution
|
|
}
|
|
if strings.TrimSpace(row.NodeDisplayName) == "" {
|
|
if strings.TrimSpace(row.TranscodeNodeURL) != "" {
|
|
row.NodeDisplayName = "Remote transcode"
|
|
} else {
|
|
row.NodeDisplayName = "Local server"
|
|
}
|
|
}
|
|
}
|
|
|
|
func pickAdminAudioTrack(tracks []models.AudioTrack, index int) *models.AudioTrack {
|
|
if len(tracks) == 0 {
|
|
return nil
|
|
}
|
|
if index >= 0 && index < len(tracks) {
|
|
track := tracks[index]
|
|
return &track
|
|
}
|
|
for _, track := range tracks {
|
|
if track.Default {
|
|
cp := track
|
|
return &cp
|
|
}
|
|
}
|
|
track := tracks[0]
|
|
return &track
|
|
}
|
|
|
|
func sessionComponentDecision(playMethod string, transcodeAudio bool, targetVideoCodec string) (string, string) {
|
|
switch strings.TrimSpace(playMethod) {
|
|
case "direct":
|
|
return "direct", "direct"
|
|
case "remux":
|
|
if transcodeAudio {
|
|
return "remux", "transcode"
|
|
}
|
|
return "remux", "remux"
|
|
case "transcode":
|
|
videoDec := "transcode"
|
|
if strings.EqualFold(strings.TrimSpace(targetVideoCodec), "copy") {
|
|
videoDec = "remux"
|
|
}
|
|
audioDec := "transcode"
|
|
if !transcodeAudio {
|
|
audioDec = "remux"
|
|
}
|
|
return videoDec, audioDec
|
|
default:
|
|
return "", ""
|
|
}
|
|
}
|
|
|
|
// effectivePlayMethod reduces the per-stream decisions to the single bucket
|
|
// the admin activity views aggregate on. Raw play_method is misleading there:
|
|
// an HLS repackage reports play_method "transcode" with a copied video stream,
|
|
// and an audio-only re-encode reports "remux" — the decisions carry what
|
|
// actually costs CPU.
|
|
// - video re-encoded -> "transcode"
|
|
// - only audio re-encoded -> "audio"
|
|
// - streams only repackaged -> "remux"
|
|
// - nothing touched -> "direct"
|
|
//
|
|
// Returns "" when the decisions are unknown (empty or unrecognized
|
|
// play_method, e.g. a stale row from an older node), so consumers can
|
|
// distinguish "unknown" from a definite bucket instead of inventing one.
|
|
func effectivePlayMethod(videoDecision, audioDecision string) string {
|
|
switch {
|
|
case videoDecision == "" && audioDecision == "":
|
|
return ""
|
|
case videoDecision == "transcode":
|
|
return "transcode"
|
|
case audioDecision == "transcode":
|
|
return "audio"
|
|
case videoDecision == "direct" && audioDecision == "direct":
|
|
return "direct"
|
|
default:
|
|
return "remux"
|
|
}
|
|
}
|
|
|
|
// jellyfinClientTokens identifies Jellyfin-ecosystem clients — sessions served
|
|
// through the Jellyfin compatibility surface — by client name (parsed from the
|
|
// MediaBrowser auth header) or user agent. This is the single source of truth
|
|
// behind is_jellyfin_client, which the admin UIs surface as the "JF" pill;
|
|
// keep it in step with the display-labeling rules in
|
|
// playbackClientDisplayName so a client that gets a label also gets the flag.
|
|
// Kodi and mpv reach Silo only through the Jellyfin compat surface today
|
|
// (jellyfin-kodi/JellyCon, jellyfin-mpv-shim). Generic browser tokens are
|
|
// deliberately excluded: the native web player shares those user agents.
|
|
var jellyfinClientTokens = []string{
|
|
"jellyfin",
|
|
"findroid",
|
|
"streamyfin",
|
|
"swiftfin",
|
|
"jellycon",
|
|
"wholphin",
|
|
"fladder",
|
|
"vidhub",
|
|
"senplayer",
|
|
"infuse",
|
|
"delfin",
|
|
"finamp",
|
|
"kodi",
|
|
"mpv",
|
|
}
|
|
|
|
// isJellyfinEcosystemClient reports whether the session's client metadata
|
|
// 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))
|
|
if value == "" {
|
|
continue
|
|
}
|
|
for _, token := range jellyfinClientTokens {
|
|
if strings.Contains(value, token) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func firstNonEmptyValue(values ...string) string {
|
|
for _, value := range values {
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
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:
|
|
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) {
|
|
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
|
|
}
|
|
|
|
profileNamesByUser := make(map[int]map[string]string)
|
|
for i := range sessions {
|
|
if strings.TrimSpace(sessions[i].ProfileID) == "" {
|
|
continue
|
|
}
|
|
if _, ok := profileNamesByUser[sessions[i].UserID]; ok {
|
|
continue
|
|
}
|
|
store, err := l.storeProv.ForUser(ctx, sessions[i].UserID)
|
|
if err != nil || store == nil {
|
|
continue
|
|
}
|
|
profiles, err := store.ListProfiles(ctx)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
names := make(map[string]string, len(profiles))
|
|
for _, profile := range profiles {
|
|
if trimmed := strings.TrimSpace(profile.Name); trimmed != "" {
|
|
names[profile.ID] = trimmed
|
|
}
|
|
}
|
|
profileNamesByUser[sessions[i].UserID] = names
|
|
}
|
|
|
|
for i := range sessions {
|
|
names := profileNamesByUser[sessions[i].UserID]
|
|
if names == nil {
|
|
continue
|
|
}
|
|
sessions[i].ProfileName = names[sessions[i].ProfileID]
|
|
}
|
|
}
|