* feat(activity): refine play-method tags and add a Jellyfin-client pill
Two related tagging improvements to the admin activity views, squashed:
Split audio transcodes into their own tag. The Play Method summary and
Server Activity popover bucketed every session by its raw play_method,
lumping real video transcodes together with video-copy HLS repackages and
having no separate tag for audio-only transcodes. Classify each session by
the per-stream decisions the backend already reports:
- video re-encoded -> "transcode" (yellow)
- only audio re-encoded -> "audio" (red)
- streams only repackaged -> "remux" (blue, incl. video-copy HLS)
- nothing touched -> "direct" (green)
ordered direct -> remux -> transcode -> audio across the distribution bar,
legend, method filter/sort, the per-row badge, and the Server Activity
stream counts.
Add a Jellyfin-client "JF" pill. Sessions from a Jellyfin-ecosystem client
(Jellyfin Web, Findroid, Swiftfin, Infuse, etc.) get a purple "JF" pill
next to the play-method tag. Detection is UI-only: isJellyfinSession()
positively matches client_name (set from the Jellyfin MediaBrowser auth
header) and then the raw user agent against the known Jellyfin client
tokens, mirroring the server's client-labeling list. The pill is orthogonal
to the method classification — a session can be both "transcode" and JF.
Pure UI/presentation change; no backend behavior changes.
AI-use disclosure: implemented with AI assistance (Claude Code).
* fix(web): cache-control on SPA shell so deploys bust stale UI
The frontend handler served index.html with no cache directives, leaving
freshness to browser/CDN heuristics. A stale index.html at a CDN edge kept
serving old content-hashed bundles, so a client-side hard refresh couldn't
recover — one browser would show the new UI while another showed the old.
Apply the standard SPA cache policy:
- index.html (and SPA-route fallbacks): no-cache + a truncated-SHA-256
ETag, so the shell is cached but revalidated on every load and answers
an unchanged request with a cheap 304.
- /assets/* (Vite content-hashed bundles): public, max-age=31536000,
immutable — cached indefinitely; a new build changes the filename hash,
which busts them automatically.
- other stable-named bundled files (sw.js, icons, fonts): no-cache, so a
changed service worker or icon can't stay stuck in a cache.
Caching is preserved (no no-store anywhere); only the tiny HTML shell is
revalidated, which is what busts a stale UI on deploy.
* fix(activity): compute the method bucket server-side and unify every session surface
Review follow-ups for the play-method tags (PR #387):
- The server now emits effective_play_method (additive field) from the same
per-stream decisions that drive the badges, so all consumers — web, realtime
popover, and the Android/Apple admin views later — agree on the bucket
instead of each client re-reducing raw play_method. Rows with an unknown
play_method (stale rows from older nodes) stay unbucketed rather than being
misreported as audio transcodes off the bare transcode_audio flag; the web
fallback classifier mirrors that and reports "unknown".
- Jellyfin-ecosystem detection moved server-side as is_jellyfin_client, owned
next to the client-labeling rules so the two lists cannot drift; the web
token list is gone. Adds kodi/mpv/delfin/finamp, which reach Silo only
through the Jellyfin compat surface.
- The dashboard stream cards, stats session table, and household streams panel
now use the same classification as the activity page and popover — they
previously showed contradictory tags for the same live session.
- One shared method->label/color table in adminActivityPresentation.ts
replaces the four independent copies (METHOD_META + three switches); the
method column sort now uses the shared cost-order comparator instead of
alphabetical; dead "copy"/"hls" order entries removed and the reachable
"unknown" bucket is styled.
* fix(server): make SPA revalidation RFC-compliant and stop rebuilding the shell per request
Review follow-ups for the SPA cache policy (PR #387):
- Stable-URL bundled files (sw.js, icons, vendor bundles) now carry a content
ETag. The embedded FS has no modtimes, so http.FileServer emits no validator
of its own — no-cache alone forced a full re-download of multi-megabyte
vendor trees on every use because there was nothing to revalidate against.
- Shell and favicon conditional requests go through http.ServeContent, which
implements RFC 9110 If-None-Match semantics (weak comparison, ETag lists).
The previous exact string compare never matched once a fronting proxy
compressed the response and weakened the ETag to W/"...", silently killing
the 304 path in the most common deployment topology.
- The rendered shell (index read + branding render + SHA-256) is cached per
branding snapshot via the new Snapshot.RenderKey instead of being rebuilt on
every request — the 304 revalidation that no-cache makes the common case now
costs two header writes. The misnamed weakContentETag (it emits a strong
validator) is renamed contentETag.
* fix(activity): show the JF pill on every session surface, not just the mobile row
Review comments on PR #387: the JF pill only rendered inside Admin
Activity's sm:hidden mobile row, so the desktop table — and the other
session surfaces that now share the method classification — never
identified Jellyfin-compat sessions.
Extract the pill into a shared JellyfinSessionPill component (renders
nothing for native sessions) and drop it into the Admin Activity desktop
client line, the dashboard stream cards, the household streams panel,
and the stats active-session table.
* fix(playback): sync real encode decisions and client identity for compat transcodes
Review comments on PR #387:
- Jellyfin HLS sessions that copy video and re-encode only audio synced as
full video transcodes: ensureUpstreamPlayback resets transcodeAudio for the
transcode transport method, and the TargetCodecVideo "copy" decision lived
only in TranscodeOpts. A new SessionManager.SetTranscodeStreamDetails
mirrors the actual decisions onto the upstream session when the transcode
starts (local and remote-node paths, via an optional interface so test
fakes are unaffected), so these sessions now bucket as "audio"/"remux".
- Transcode recipe cards now record TranscodeAudio derived from the opts
(only an explicit "copy" leaves audio untouched — empty runs ffmpeg's aac
default), so a session rebuilt after a restart keeps the same bucket.
- Recipe cards carry client name/version/user-agent, and reconstruction
restores them, so the admin client label and the JF pill survive server
restarts; the compat fallback card populates them from the live
MediaBrowser request. Deliberately not projected into stream-token claims,
where a user agent would bloat every stream URL.
* feat(api): capability endpoint for the live-session activity fields
Review comment on PR #387: effective_play_method and is_jellyfin_client are
omitempty, so an independently deployed client cannot distinguish an older
server from a supported one reporting an unknown method or a non-Jellyfin
session. GET /admin/sessions/capabilities advertises both fields plus the
closed bucket vocabulary, following the additive capability-endpoint rule
(same pattern as /collections/capabilities).
* fix(playback): treat empty target audio codec as an AAC re-encode in live state
ffmpeg defaults an empty target audio codec to AAC (appendAudioArgs), and the
new recipe logic already records that as an audio transcode — but the live
native path computed transcodeAudio=false for an empty codec, so the running
stream reported remux until a restart flipped it to audio. Extract the
predicate into playback.TranscodesAudio, share it across the live path, the
recipe card, and the compat mirror, and make appendAudioArgs case-insensitive
so the ffmpeg switch agrees with the predicate for any spelling.
Part of #387 review follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(jellycompat): re-sync sessions after recording compat encode decisions
ensureUpstreamPlayback flushes the session (compat_start) before
ensureTranscodeSession / startRemoteTranscode record the actual codec
decisions, and that later mutation triggered no sync — so the admin view
showed a video-copy stream as a full video transcode until the periodic
reconciler ran. Trigger syncSessionsNow after the details are recorded
successfully; the helper is shared, so both the local and remote-node
paths are covered.
Part of #387 review follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
621 lines
21 KiB
Go
621 lines
21 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"`
|
|
}
|
|
|
|
// 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, '')
|
|
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,
|
|
); 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 = 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 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.
|
|
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:
|
|
return firstUserAgentProduct(userAgent)
|
|
}
|
|
}
|
|
|
|
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]
|
|
}
|
|
}
|