* feat(activity): report exact client app version, build, and channel
The admin Activity page could not name the build a session was streaming
from. Android already sent X-Silo-Client-Version and the server already
stored it intact, but playbackClientDisplayName routed it through
shortPlaybackClientVersion, which strips non-numeric runes, truncates to
two components, and drops a trailing ".0" — so a client reporting "1.0.0"
rendered as "Silo Android TV 1". The Apple clients sent no client name or
version at all and fell back to user-agent sniffing.
Adds two additive, opaque wire fields alongside the existing client
headers — X-Silo-Client-Build (<=64) and X-Silo-Client-Channel (<=32) —
with client_playback_context.app_build/app_channel as the v3 fallback,
which is also where the previously discarded app_version now gets used.
The server never parses, compares, or enum-validates either value: Apple
uses a per-platform TestFlight sequence and Android a per-marketing-
version counter, and keeping them opaque lets both coexist without a
shared scheme. Any future minimum-version gating belongs on
client_version, which is semver.
Only the named-client branch of playbackClientDisplayName stops
truncating; the user-agent branch keeps shortPlaybackClientVersion, so
browser labels stay "Chrome 120" rather than a full UA version string.
The compact session row is unchanged in width — it is shared with
AdminDashboard, AdminStats, and HouseholdStreamsPanel — and the exact
string lands in the row tooltip and a new Client card in the expanded
panel.
Diagnostic logs carry client_name/version/build/channel on both
"playback plan decided" lines and on session expiry. opslog stores an
open attrs JSONB, so this needs no migration. activity_log is
deliberately untouched: it is the highest-volume table and the value is
constant per device.
Jellyfin compat sessions keep an empty build — the MediaBrowser auth
header vocabulary has no build concept, and synthesizing one from a user
agent would be a guess.
Part of the client-version-visibility work spanning silo-android and
silo-apple.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(activity): resolve client identity without polluting client_version
Review follow-up on the client build/channel work. Fourteen findings; the
substantive ones:
The v3 body fallback took client_playback_context.app_version whenever the
header was absent. The web player sends the literal "web" there and sends no
X-Silo-Client, so every browser session would have stamped client_version="web"
— the one field the contract promises is semver and the field a future
minimum-version gate has to key on. client_playback_context carries no app name,
so the body can never identify a nameless client anyway; the fallback now
applies only to a client that sent X-Silo-Client, and a test pins the "web"
case.
An over-long app_build or app_channel in the start body failed the whole request
with 400 while the same value in a header was silently clamped — an opaque
diagnostic label could refuse playback. validateCapabilitiesV3 now clamps both
with the same helper the header path uses, which is what the docs already
claimed.
Route events posted out of band resolved identity from headers only, so a client
reporting its build in the start body attributed plan_selected to a build and
every later event of the same attempt to none. They now fill empty fields from
the session, as the replan path already did.
playbackClientFullDisplayName discarded build and channel whenever the client
reported no name, so the new Client card could never show a build for a
user-agent-labelled session. It now qualifies whatever label the compact
formatter resolved, which also drops its duplicated name+version assembly.
normalizeClientMetadataValue truncated by bytes; a multi-byte header value cut
mid-rune yields invalid UTF-8, which Postgres rejects — and the per-node session
upserts share one transaction, so one malformed client string would fail that
whole node's sync. It now clamps on a rune boundary.
replan-request.schema.json never got app_build/app_channel even though
ReplanRequestV3 reuses ClientPlaybackContextV3 and validates the same bounds. A
new contract test asserts every $def the two request schemas share is identical,
so the copies cannot drift again.
Also: the four client log attrs move to ClientInfo.LogAttrs(), which is now
their single definition and omits fields the client did not report rather than
persisting empty keys into opslog; startPlannedPlaybackV3 takes the resolved
identity instead of re-parsing the headers; client_label_full is omitted when it
would repeat client_label; getSessionClientLabelFull delegates to
getSessionClientLabel instead of re-implementing it; the Activity search matches
the exact label so a build number is findable; and the web ClientPlaybackContextV3
type mirrors the two new optional fields.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(activity): clamp client identity at the request boundary, by runes
Follow-up to bot review on the previous commit.
The 64/32 clamp for X-Silo-Client-Build / -Channel only ran where newSession
stamped its fields, but the resolved ClientInfo is written straight to the
plan-decision log and to playback_route_events. A client sending a header-sized
build reached both despite the published bound. ClientInfo.Normalized() is now
the single definition of those limits and runs at the request boundary —
playbackClientInfoFromRequest and playbackClientInfoForStartV3 — with newSession
still normalizing because identities also arrive from the Jellyfin and
Audiobookshelf compat surfaces.
normalizeClientMetadataValue now clamps by runes rather than bytes. The bounds
are published to clients as JSON Schema maxLength, which counts characters, so a
byte clamp cut values the contract calls valid — a 32-character emoji channel
was 128 bytes. It also scrubs invalid UTF-8 outright rather than only after a
mid-rune cut, since a header may carry bytes that were never valid UTF-8 and a
text column refuses them.
Two tests cover it: oversized headers clamp at the boundary, and a 40-rune
multi-byte channel lands on the 32-character bound as valid UTF-8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(activity): strip control characters from client identity
A JSON NUL escape in a v3 start body's app_version, app_build or app_channel
decodes to a real NUL. That is valid UTF-8, so the UTF-8 repair leaves it and
TrimSpace does not treat it as whitespace — but Postgres refuses NUL in a text
column. The per-node session upserts share one transaction, so a single such
start would stop every live session on that node from reconciling until the
offending session went away. Headers cannot carry it (net/http rejects bytes
below 0x20), which is why only the body path this PR added is exposed.
normalizeClientMetadataValue now strips control characters outright rather than
NUL alone: none of them belong in an identity label rendered in the admin UI and
written to structured logs.
Reported by Codex review on b43b7ef06.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(activity): satisfy goconst and misspell on the changed log lines
CI's `golangci-lint --new-from-merge-base` failed on the previous commits: the
two decision-log calls were reformatted into slice literals, which brought their
"component" key inside the changed-lines window where goconst flags it against
the existing logComponentKey constant, and a doc comment used the British
"labelled". Both lines now use the constant, and the spelling is corrected here
and in docs/settings-api.md.
The file's other 16 "component" literals are left alone: CI only requires the
lines a branch touches to be clean, and rewriting them would bury this change in
unrelated churn.
Verified with the same command and version CI runs (golangci-lint v2.12.2,
--new-from-merge-base=origin/main): 0 issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(playback): require context-aware session starts
startPlannedPlaybackV3 probed for StartSessionWithFilesContext with a type
assertion and fell back to the context-free StartSessionWithFiles. The context
is how the reporting client's identity reaches the new session, so any
implementation missing the method would start sessions carrying no client name,
version, build or channel — silently, and now that build and channel ride the
same path, silently losing more.
SessionManagerInterface requires the method instead, so a non-conforming
implementation fails to compile rather than dropping the identity at run time.
The one test double gains a three-line method; production already implemented it.
Raised as a nitpick by CodeRabbit review; pre-existing, but it is this PR's data
that the fallback drops.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
742 lines
26 KiB
Go
742 lines
26 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"`
|
|
ClientBuild string `json:"client_build,omitempty"`
|
|
ClientChannel string `json:"client_channel,omitempty"`
|
|
ClientLabel string `json:"client_label,omitempty"`
|
|
ClientLabelFull string `json:"client_label_full,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"`
|
|
// ClientBuild reports that rows carry client_build (and the exact-version
|
|
// client_label_full derived from it).
|
|
ClientBuild bool `json:"client_build"`
|
|
// ClientChannel reports that rows carry client_channel.
|
|
ClientChannel bool `json:"client_channel"`
|
|
}
|
|
|
|
// 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,
|
|
ClientBuild: true,
|
|
ClientChannel: 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_build, ''),
|
|
COALESCE(s.client_channel, ''),
|
|
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.ClientBuild, &s.ClientChannel,
|
|
&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)
|
|
// Only worth the bytes when it says more than the compact label — which
|
|
// it does not for any client without a build or a non-release channel,
|
|
// i.e. most of a 200-row page. Clients read client_label when
|
|
// client_label_full is absent, so omitting the duplicate costs nothing.
|
|
if full := playbackClientFullDisplayName(s.ClientName, s.ClientVersion, s.ClientBuild, s.ClientChannel, s.ClientUserAgent); full != s.ClientLabel {
|
|
s.ClientLabelFull = full
|
|
}
|
|
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 ""
|
|
}
|
|
|
|
// playbackClientFullDisplayName renders the client's exact identity — version,
|
|
// opaque build, and non-default channel — for surfaces that can afford the
|
|
// width (expanded session details, tooltips). The name-and-version half is
|
|
// whatever the compact label resolved, so a client identified only by its user
|
|
// agent still gets its build named: a client that bothered to report a build
|
|
// has earned having it displayed, whether or not it also named itself.
|
|
func playbackClientFullDisplayName(name, version, build, channel, userAgent string) string {
|
|
label := playbackClientDisplayName(name, version, userAgent)
|
|
if label == "" {
|
|
return ""
|
|
}
|
|
|
|
build = strings.TrimSpace(build)
|
|
channel = strings.TrimSpace(channel)
|
|
// "release" is the assumed channel, so naming it adds width without adding
|
|
// information; every other channel is worth calling out.
|
|
if strings.EqualFold(channel, "release") {
|
|
channel = ""
|
|
}
|
|
var qualifiers []string
|
|
if build != "" {
|
|
qualifiers = append(qualifiers, "build "+build)
|
|
}
|
|
if channel != "" {
|
|
qualifiers = append(qualifiers, channel)
|
|
}
|
|
if len(qualifiers) > 0 {
|
|
label += " (" + strings.Join(qualifiers, ", ") + ")"
|
|
}
|
|
return label
|
|
}
|
|
|
|
// playbackClientDisplayName renders the compact label the session lists show.
|
|
// A client that reports its own name keeps its version verbatim — truncating
|
|
// "1.0.0" to "1" hid the only field that identifies the running build. Labels
|
|
// derived from a user agent stay shortened, where a full browser version
|
|
// ("120.0.6099.109") is noise.
|
|
func playbackClientDisplayName(name, version, userAgent string) string {
|
|
name = strings.TrimSpace(name)
|
|
if name != "" {
|
|
if version = strings.TrimSpace(version); 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]
|
|
}
|
|
}
|