* 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>
153 lines
4.9 KiB
Go
153 lines
4.9 KiB
Go
package branding
|
|
|
|
import (
|
|
"encoding/json"
|
|
"html"
|
|
"strings"
|
|
)
|
|
|
|
// Snapshot is an immutable view of the current branding configuration. It is
|
|
// the input to HTML templating, manifest generation, and the public API
|
|
// response.
|
|
type Snapshot struct {
|
|
ServerName string
|
|
LoginSubtitle string
|
|
AccentColor string // "" when unset
|
|
DefaultTheme string // "" when unset
|
|
|
|
assets map[AssetKind]string // kind -> content ref ("<hash><ext>")
|
|
}
|
|
|
|
// AssetRef returns the stored content ref for a kind, or "" when no custom
|
|
// asset is set.
|
|
func (s Snapshot) AssetRef(kind AssetKind) string { return s.assets[kind] }
|
|
|
|
// HasAsset reports whether a custom asset of the given kind is configured.
|
|
func (s Snapshot) HasAsset(kind AssetKind) bool { return s.assets[kind] != "" }
|
|
|
|
// AssetURL returns the stable public URL for a custom asset, or "" when unset.
|
|
// The content ref is carried as ?v= so the asset can be cached immutably.
|
|
func (s Snapshot) AssetURL(kind AssetKind) string {
|
|
return AssetURLFor(kind, s.assets[kind])
|
|
}
|
|
|
|
// AssetURLFor builds the stable public URL for a kind given its content ref.
|
|
// Returns "" when ref is empty.
|
|
func AssetURLFor(kind AssetKind, ref string) string {
|
|
if ref == "" {
|
|
return ""
|
|
}
|
|
return assetURLBase + string(kind) + "?v=" + ref
|
|
}
|
|
|
|
// ThemeColor returns the color used for the PWA manifest and the theme-color
|
|
// meta tag: the configured accent color, or the default when unset.
|
|
func (s Snapshot) ThemeColor() string {
|
|
if s.AccentColor != "" {
|
|
return s.AccentColor
|
|
}
|
|
return DefaultThemeColor
|
|
}
|
|
|
|
// RenderKey returns a stable identity covering every snapshot field the
|
|
// Render* functions read, so callers can cache rendered output and re-render
|
|
// only when the branding configuration actually changes. Keep in sync with
|
|
// RenderIndexHTML and RenderManifest.
|
|
func (s Snapshot) RenderKey() string {
|
|
return strings.Join([]string{
|
|
s.ServerName,
|
|
s.LoginSubtitle,
|
|
s.AccentColor,
|
|
s.DefaultTheme,
|
|
s.assets[KindWordmark],
|
|
s.assets[KindMark],
|
|
s.assets[KindFavicon],
|
|
s.assets[KindLoginBg],
|
|
}, "\x00")
|
|
}
|
|
|
|
// Favicon link literal as it appears in web/index.html. Kept in sync with that
|
|
// file; RenderIndexHTML rewrites it to a custom favicon when one is set.
|
|
const indexFaviconLink = `<link rel="icon" href="/favicon.ico" sizes="any" />`
|
|
|
|
// RenderIndexHTML injects branding into the SPA shell: the browser tab title
|
|
// and, when configured, the custom favicon link and a theme-color meta tag.
|
|
// Favicon/manifest paths themselves are served dynamically by the frontend
|
|
// handler, so only the title and the cache-bustable favicon href are rewritten
|
|
// here. Replacements that don't match are no-ops, so a build that changes the
|
|
// shell degrades gracefully to the bundled defaults.
|
|
func RenderIndexHTML(index []byte, snap Snapshot) []byte {
|
|
out := string(index)
|
|
|
|
out = strings.Replace(out,
|
|
"<title>Silo</title>",
|
|
"<title>"+html.EscapeString(snap.ServerName)+"</title>",
|
|
1,
|
|
)
|
|
|
|
if u := snap.AssetURL(KindFavicon); u != "" {
|
|
out = strings.Replace(out,
|
|
indexFaviconLink,
|
|
`<link rel="icon" href="`+html.EscapeString(u)+`" sizes="any" />`,
|
|
1,
|
|
)
|
|
}
|
|
|
|
if snap.AccentColor != "" {
|
|
meta := `<meta name="theme-color" content="` + html.EscapeString(snap.AccentColor) + `" />`
|
|
out = strings.Replace(out, "</head>", " "+meta+"\n </head>", 1)
|
|
}
|
|
|
|
return []byte(out)
|
|
}
|
|
|
|
type manifestIcon struct {
|
|
Src string `json:"src"`
|
|
Sizes string `json:"sizes"`
|
|
Type string `json:"type"`
|
|
Purpose string `json:"purpose,omitempty"`
|
|
}
|
|
|
|
type webManifest struct {
|
|
Name string `json:"name"`
|
|
ShortName string `json:"short_name"`
|
|
Icons []manifestIcon `json:"icons"`
|
|
ThemeColor string `json:"theme_color"`
|
|
BackgroundColor string `json:"background_color"`
|
|
Display string `json:"display"`
|
|
StartURL string `json:"start_url"`
|
|
}
|
|
|
|
// RenderManifest builds the dynamic web app manifest from the snapshot. When a
|
|
// custom mark is set its WebP is advertised at both common sizes; otherwise the
|
|
// bundled PNG icon set is used.
|
|
func RenderManifest(snap Snapshot) []byte {
|
|
var icons []manifestIcon
|
|
if u := snap.AssetURL(KindMark); u != "" {
|
|
icons = []manifestIcon{
|
|
{Src: u, Sizes: "192x192", Type: "image/webp"},
|
|
{Src: u, Sizes: "512x512", Type: "image/webp"},
|
|
{Src: u, Sizes: "512x512", Type: "image/webp", Purpose: "maskable"},
|
|
}
|
|
} else {
|
|
icons = []manifestIcon{
|
|
{Src: "/web-app-icon-192.png", Sizes: "192x192", Type: "image/png"},
|
|
{Src: "/web-app-icon-512.png", Sizes: "512x512", Type: "image/png"},
|
|
{Src: "/maskable-icon-512.png", Sizes: "512x512", Type: "image/png", Purpose: "maskable"},
|
|
}
|
|
}
|
|
|
|
m := webManifest{
|
|
Name: snap.ServerName,
|
|
ShortName: snap.ServerName,
|
|
Icons: icons,
|
|
ThemeColor: snap.ThemeColor(),
|
|
BackgroundColor: snap.ThemeColor(),
|
|
Display: "standalone",
|
|
StartURL: "/",
|
|
}
|
|
// Marshaling a fixed struct cannot fail.
|
|
b, _ := json.Marshal(m)
|
|
return b
|
|
}
|