Files
silo-server/internal/server/frontend_test.go
T
8044eb84dd feat(activity): refine play-method tags and add a Jellyfin-client pill (#387)
* 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>
2026-07-18 17:12:36 -04:00

171 lines
5.8 KiB
Go

package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
)
func TestFrontendHandlerSetsSecurityHeadersOnSPAHTML(t *testing.T) {
prev := WebDistFS
WebDistFS = fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<!doctype html><div id=\"root\"></div>")},
"assets/app.js": &fstest.MapFile{
Data: []byte("console.log(1)"),
},
}
t.Cleanup(func() { WebDistFS = prev })
handler := FrontendHandler()
for _, path := range []string{"/", "/index.html", "/library/ebooks"} {
t.Run(path, func(t *testing.T) {
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
if got := rr.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
t.Fatalf("content-type = %q", got)
}
csp := rr.Header().Get("Content-Security-Policy")
if csp != frontendContentSecurityPolicy {
t.Fatalf("csp = %q", csp)
}
// The ebook-reader threat model depends on these directives; fail
// loudly if they are weakened.
for _, directive := range []string{
"script-src 'self' 'wasm-unsafe-eval'",
"object-src 'none'",
"base-uri 'self'",
} {
if !strings.Contains(csp, directive) {
t.Fatalf("csp missing %q: %q", directive, csp)
}
}
if strings.Contains(csp, "script-src 'self' 'wasm-unsafe-eval' ") {
t.Fatalf("script-src must not carry extra sources: %q", csp)
}
if got := rr.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("x-content-type-options = %q", got)
}
})
}
}
func TestFrontendHandlerServesStaticAssetsWithoutCSP(t *testing.T) {
prev := WebDistFS
WebDistFS = fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<!doctype html>")},
"assets/app.js": &fstest.MapFile{Data: []byte("console.log(1)")},
}
t.Cleanup(func() { WebDistFS = prev })
handler := FrontendHandler()
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
if got := rr.Header().Get("Content-Security-Policy"); got != "" {
t.Fatalf("static asset should not carry the SPA CSP, got %q", got)
}
if got := rr.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("x-content-type-options = %q", got)
}
}
func newFrontendTestHandler(t *testing.T) http.Handler {
t.Helper()
prev := WebDistFS
WebDistFS = fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<!doctype html><div id=\"root\"></div>")},
"assets/app.js": &fstest.MapFile{Data: []byte("console.log(1)")},
"sw.js": &fstest.MapFile{Data: []byte("self.addEventListener('fetch', () => {})")},
}
t.Cleanup(func() { WebDistFS = prev })
return FrontendHandler()
}
func TestFrontendShellCacheHeadersAndConditionalGet(t *testing.T) {
handler := newFrontendTestHandler(t)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
if got := rr.Header().Get("Cache-Control"); got != "no-cache" {
t.Fatalf("shell cache-control = %q, want no-cache", got)
}
etag := rr.Header().Get("ETag")
if etag == "" {
t.Fatal("shell response missing ETag")
}
// Revalidation with the exact ETag answers 304 with no body.
conditional := func(inm string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("If-None-Match", inm)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
if rec := conditional(etag); rec.Code != http.StatusNotModified {
t.Fatalf("If-None-Match exact: status = %d, want 304", rec.Code)
}
// A fronting proxy that compresses the shell weakens the ETag to W/"...";
// RFC 9110 weak comparison must still produce the 304 (a naive string
// compare here silently kills revalidation behind nginx gzip).
if rec := conditional("W/" + etag); rec.Code != http.StatusNotModified {
t.Fatalf("If-None-Match weakened: status = %d, want 304", rec.Code)
}
// ETag lists must match too.
if rec := conditional(`"stale-etag", ` + etag); rec.Code != http.StatusNotModified {
t.Fatalf("If-None-Match list: status = %d, want 304", rec.Code)
}
// A stale validator gets the full document.
if rec := conditional(`"stale-etag"`); rec.Code != http.StatusOK || rec.Body.Len() == 0 {
t.Fatalf("If-None-Match stale: status = %d body = %d bytes, want 200 with body", rec.Code, rec.Body.Len())
}
}
func TestFrontendStaticFilesCarryValidators(t *testing.T) {
handler := newFrontendTestHandler(t)
// Content-hashed bundles are immutable; the URL is the validator.
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil))
if got := rr.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
t.Fatalf("asset cache-control = %q", got)
}
// Stable-URL files must revalidate — and the embedded FS has no modtimes,
// so without an explicit ETag no-cache would force a full re-download on
// every use (there would be nothing to revalidate against).
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/sw.js", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
if got := rr.Header().Get("Cache-Control"); got != "no-cache" {
t.Fatalf("sw.js cache-control = %q, want no-cache", got)
}
etag := rr.Header().Get("ETag")
if etag == "" {
t.Fatal("stable-path static file missing ETag validator")
}
req := httptest.NewRequest(http.MethodGet, "/sw.js", nil)
req.Header.Set("If-None-Match", etag)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotModified {
t.Fatalf("sw.js If-None-Match: status = %d, want 304", rec.Code)
}
}