diff --git a/.codex/audits/frontend-performance-2026-07-03/fixes/01-minify-frontend-bundles.md b/.codex/audits/frontend-performance-2026-07-03/fixes/01-minify-frontend-bundles.md deleted file mode 100644 index 7f3ed31a..00000000 --- a/.codex/audits/frontend-performance-2026-07-03/fixes/01-minify-frontend-bundles.md +++ /dev/null @@ -1,111 +0,0 @@ -# Fix 01: minify frontend bundles - -Priority: P0 -Risk: Low -Primary benefit: First-load transfer and JavaScript parse cost - -## Issue - -The production build currently invokes `bun build` without minification in [`scripts/build-ts.ts`](../../../../scripts/build-ts.ts). The audit measured: - -| Bundle | Current raw size | Current gzip estimate | Diagnostic minified raw | Diagnostic minified gzip | -|---|---:|---:|---:|---:| -| Global application bundle | 61KB | 13KB | 32KB | 9KB | -| Player bundle | 1.16MB | 241KB | 556KB | 175KB | - -The player bundle is isolated to the watch page, which is good, but its current raw size matters because the server does not yet compress responses. Even after HTTP compression is added, minification still reduces transferred bytes and parsing work. - -## Desired behavior - -- Production builds emit minified browser bundles. -- Development watch builds remain readable unless explicitly configured otherwise. -- A failed build still exits non-zero. -- Optional source maps can be generated without publishing source content unintentionally. -- Bundle-size changes are visible in CI or `just check` output. - -## Proposed implementation - -Add `--minify` to both production `bun build` invocations in `scripts/build-ts.ts`. - -Keep `--target browser`. The current code runs in browsers and must not be built for Bun or Node. - -Keep the player and general app as separate entry points. Do not merge them: loading HLS/player code on login, browse, and search would undo the main architectural benefit of the current split. - -Use one of these source-map policies: - -1. `none`: smallest and safest default if production stack traces are not collected. -2. `external`: emit map files without linking them from the JavaScript; upload them only to an error-reporting service. -3. `linked`: emit map files and add source-map references. Use only if serving maps publicly is acceptable. - -For this self-hosted application, `none` is the minimal default. Add external maps later only when there is a consumer for them. - -## Implementation steps - -1. Add `--minify` to the `player` and `app` argument lists. -2. Build into a clean `dist/` directory to avoid retaining obsolete chunks. -3. Print raw output sizes after the build. Treat this as visibility first, not a hard failure. -4. Add a documented bundle budget after a baseline has been stable for a few releases. -5. Run the existing TypeScript typecheck and browser-flow tests before comparing output. - -Suggested initial budgets: - -- `dist/static/app.js`: at most 45KB raw. -- `dist/static/player/main.js`: at most 650KB raw. -- Any future shared chunk: at most 250KB raw unless intentionally documented. - -## Code splitting - -The player build already passes `--splitting`, but a single static entry point will not necessarily create useful chunks. Do not split merely to create more files. A chunk is worthwhile when it avoids loading a feature in a common path. - -The strongest future candidate is a dynamic import of HLS.js only when the chosen source is HLS. Before doing that, measure how often the provider returns HLS versus direct media. If nearly all playback uses HLS, dynamic import adds another request without avoiding much code. - -## Affected files - -- `scripts/build-ts.ts` -- `justfile`, only if separate development and production build recipes become necessary -- Optional new bundle-size check under `scripts/` - -## Tests - -- Production build emits both expected entry points. -- Bundle smoke test imports/executes each generated module without a syntax error. -- Existing player browser-flow tests pass against the minified build. -- A bundle-size script fails clearly when an expected output is missing. -- If source maps are enabled, the selected map files are generated and excluded from ordinary static serving unless intentionally public. - -## Verification - -Run: - -```sh -just build-ts -just typecheck -bun test -ls -lh dist/static/app.js dist/static/player/main.js -``` - -Then verify in the browser: - -- Home, browse, search, and watch pages initialize normally. -- Player controls, subtitles, quality selection, episode navigation, and progress saving still work. -- There are no syntax errors in the browser console. - -## Observability - -Print raw and gzip-estimated bundle sizes in CI/release output. Track size changes over time; do not rely on a one-time before/after comparison. Runtime error reporting should include the deployed asset version so a minified stack trace can be matched to the correct build. - -## Rollout - -This can ship independently. Compare the generated asset sizes in the release artifact. If stack traces become too difficult to interpret, add external source maps rather than reverting minification. - -## Acceptance criteria - -- Both application bundles are minified in the production build. -- Player raw size is below 650KB with the current dependency set. -- Global app raw size is below 45KB. -- Existing frontend tests pass. -- No player feature regression is visible in the browser flow. - -## Reference - -- [Bun bundler options](https://github.com/oven-sh/bun/blob/main/docs/bundler/index.mdx) diff --git a/.codex/audits/frontend-performance-2026-07-03/fixes/02-static-compression-and-caching.md b/.codex/audits/frontend-performance-2026-07-03/fixes/02-static-compression-and-caching.md deleted file mode 100644 index 4d462a32..00000000 --- a/.codex/audits/frontend-performance-2026-07-03/fixes/02-static-compression-and-caching.md +++ /dev/null @@ -1,129 +0,0 @@ -# Fix 02: compress and cache static responses - -Priority: P0 -Risk: Medium -Primary benefit: Network transfer and repeated navigation cost - -## Issue - -The application uses Gin's static handlers for `/static` and `/dist` in `internal/server/server.go`. Asset URLs under `/dist` already include a version derived from modification time and size through `assetURL`, but responses have neither `Cache-Control` nor `Content-Encoding`. - -Observed consequences: - -- The 1.16MB player bundle was transferred uncompressed on first use. -- CSS and JavaScript were revalidated with `304 Not Modified` on full-page navigations. -- A cache-busting URL existed, but the response did not tell the browser it was safe to retain the asset immutably. - -## Desired behavior - -- Versioned `/dist` assets are cached for one year as immutable. -- Unversioned or user-specific responses are not given immutable caching. -- JavaScript, CSS, HTML, JSON, SVG, and text are compressed when beneficial. -- Video streams, HLS segments, range responses, and already compressed images are not buffered or recompressed. -- `Vary: Accept-Encoding` is set when content negotiation is used. - -## Proposed design - -Use two narrowly scoped middleware responsibilities: - -1. Static cache policy middleware for `/dist` and selected fingerprinted assets. -2. Compression middleware for ordinary textual responses, explicitly excluding playback proxy routes and unsuitable content types. - -The static cache middleware must run before the static handler writes headers. A simple policy is: - -- `/dist/...` with a non-empty `v` query: `public, max-age=31536000, immutable`. -- `/dist/...` without `v`: `public, max-age=0, must-revalidate` during transition. -- `/static/...` icons/images: use a shorter cache until their URLs are fingerprinted; then move them to immutable URLs. -- HTML and authenticated JSON: no shared immutable caching. - -Query-string versioning works in modern browsers, but content-hashed filenames are more robust with CDNs. Filename hashing can be a later improvement; it is not required to fix the present issue. - -## Compression options - -### Option A: middleware compression - -Use a maintained Gin/Go compression middleware or a small custom wrapper. It must: - -- Respect `Accept-Encoding`. -- Skip responses that already set `Content-Encoding`. -- Skip `HEAD`, `204`, `304`, and range/partial responses. -- Skip `/watch/proxy/stream` and `/watch/proxy/subtitle` unless subtitle text is handled separately and safely. -- Skip `video/*`, `audio/*`, PNG, JPEG, WebP, AVIF, ZIP, and other already compressed formats. -- Avoid compressing very small bodies where headers and CPU cost exceed savings. - -### Option B: precompressed build artifacts - -Generate `.gz` and `.br` variants of production JS/CSS and serve the correct file by `Accept-Encoding`. This minimizes runtime CPU and is attractive for a single-server deployment, but requires a custom static handler and correct `Vary`/content headers. - -Start with middleware compression unless profiling shows runtime CPU pressure. The application is modest and prioritizes operational simplicity. - -## Middleware ordering - -Recommended order: - -1. Request context/request ID. -2. Recovery and logging as currently required. -3. Compression decision. -4. Static cache policy. -5. Route/static handler. - -Confirm that the request logger records final status and bytes correctly when the writer is wrapped. - -## Security and correctness - -- Do not apply public caching to authenticated HTML, public watchlist JSON with user data, or session-dependent fragments. -- Never cache signed playback proxy responses by a public key unless the token and upstream expiry model explicitly support it. -- Preserve `Content-Range`, `Accept-Ranges`, and streaming behavior for media. -- Add `Vary: Accept-Encoding` to prevent an intermediary from serving gzip bytes to a client that did not request them. - -## Affected files - -- `internal/server/server.go` -- New middleware file under `internal/server/` -- `templates/funcs.go` if asset versioning changes -- Middleware and response-header tests under `internal/server/` - -## Tests - -Add table-driven tests covering: - -- Versioned JS receives immutable caching. -- Unversioned JS does not receive immutable caching. -- HTML is not publicly cached. -- Text response compresses when gzip is accepted. -- Response remains plain when compression is not accepted. -- `Vary: Accept-Encoding` is present. -- Playback proxy and range responses are not compressed or buffered. -- `304` and `HEAD` responses remain correct. - -## Verification - -Use header checks: - -```sh -curl -sSI 'http://localhost:3000/dist/static/app.js?v=test' -curl -sSI -H 'Accept-Encoding: gzip' 'http://localhost:3000/dist/static/player/main.js?v=test' -curl -sSI -H 'Accept-Encoding: gzip' 'http://localhost:3000/watch/proxy/stream?token=invalid' -``` - -Then use browser developer tools to confirm that a second full navigation uses memory/disk cache and does not revalidate versioned assets. - -## Observability - -Track uncompressed versus compressed response bytes, selected content encoding, compression CPU time if available, static `200` versus `304` counts, and playback proxy/range errors. A successful rollout should sharply reduce transferred JS/CSS bytes and repeated `304` requests without changing media error rates. - -## Rollout - -Ship cache headers and compression behind separate code paths so either can be reverted independently. Watch CPU, response bytes, error rates, and playback range-request behavior after release. - -## Acceptance criteria - -- Versioned `/dist` assets return one-year immutable caching. -- JavaScript and CSS are compressed for supporting clients. -- Repeated full-page navigation does not revalidate unchanged versioned assets. -- Playback streaming and range requests behave exactly as before. -- No authenticated response receives public immutable caching. - -## Reference - -- [Gin static-file and middleware documentation](https://github.com/gin-gonic/gin/blob/master/docs/doc.md) diff --git a/.codex/audits/frontend-performance-2026-07-03/fixes/03-image-asset-optimization.md b/.codex/audits/frontend-performance-2026-07-03/fixes/03-image-asset-optimization.md deleted file mode 100644 index d14b55df..00000000 --- a/.codex/audits/frontend-performance-2026-07-03/fixes/03-image-asset-optimization.md +++ /dev/null @@ -1,100 +0,0 @@ -# Fix 03: optimize logo and icon assets - -Priority: P1 -Risk: Low -Primary benefit: First-load transfer - -## Issue - -The navigation logo is a 1100x849 PNG weighing about 392KB, but it is displayed at roughly 40px high. The favicon is a 512x512 PNG weighing about 113KB. Their source dimensions and transfer sizes are much larger than their rendered use. - -These files are cached by the browser after first use, but the first authenticated page pays the cost. Without long-lived cache headers, they may also be revalidated more often than necessary. - -## Desired behavior - -- The navigation logo remains visually crisp on standard and high-density displays. -- Transparency is preserved. -- Favicons and Apple touch icons use purpose-sized files. -- The application manifest references valid dimensions. -- The total first-load logo/icon transfer is materially smaller. - -## Proposed asset set - -### Navigation logo - -Create a transparent asset around 128px high for a 40px CSS slot. This provides more than 3x density for high-DPI screens without shipping the 1100px source. - -Preferred formats: - -- WebP for the navigation image, with PNG fallback only if a target browser requires it. -- Optimized PNG if keeping one format is operationally simpler. - -Do not use AVIF for a tiny UI logo unless the encoder preserves edges and transparency without visible artifacts; byte savings may not justify compatibility and build complexity. - -### Favicons - -Use purpose-sized files: - -- 32x32 and optionally 16x16 favicon. -- 180x180 Apple touch icon. -- 192x192 and 512x512 manifest icons if the PWA manifest advertises those sizes. - -The large manifest icon should not be loaded as the ordinary browser favicon. - -## Implementation steps - -1. Keep the original logo source outside the runtime asset path or clearly mark it as source artwork. -2. Export the navigation logo at an appropriate high-density dimension. -3. Export favicon/touch/manifest variants at their declared sizes. -4. Update `templates/components/navigation.gohtml`, `templates/base.gohtml`, and `static/assets/manifest.json` to reference the correct variants. -5. Add explicit `width` and `height` attributes to the navigation `` to reserve layout space and avoid layout shift. -6. Combine with immutable caching after filenames or URLs are versioned. - -## Quality checks - -Inspect assets against both light and dark themes: - -- No white or dark halo around transparent edges. -- No visible ringing or color shift. -- Logo remains legible at its actual 40px display size. -- Touch icon background and mask behavior are intentional. -- Browser tabs show the correct favicon at small sizes. - -## Affected files - -- `static/assets/logo.png` or its replacement -- `static/assets/favicon.png` and icon variants -- `static/assets/manifest.json` -- `templates/base.gohtml` -- `templates/components/navigation.gohtml` - -## Tests - -- Manifest JSON remains valid and every referenced file exists. -- Declared icon dimensions match actual dimensions. -- Template render tests include the intended logo/favicon URLs and explicit navigation-image dimensions. -- Browser smoke test confirms logo and favicon requests return `200`. - -## Verification - -- Compare file sizes before and after. -- Open home, login, and watch pages at 1x and 2x device scale. -- Check the browser tab icon. -- Inspect the manifest in browser developer tools. -- Confirm image requests return the intended cache headers after Fix 02. - -## Observability - -Record asset byte sizes in the build/release output. Browser monitoring should watch image request failures and layout shift around navigation. No user-identifying telemetry is needed. - -## Rollout - -Ship new filenames or versioned URLs so old cached images cannot mask the result. Keep the previous source artwork outside the served runtime path for easy re-export, but do not retain oversized duplicates under URLs browsers may fetch. - -## Acceptance criteria - -- Navigation logo is below 50KB, preferably below 25KB. -- Ordinary favicon is below 20KB. -- No visible regression at the rendered size. -- Explicit image dimensions prevent navigation layout shift. -- Manifest icon declarations match actual pixel dimensions. diff --git a/.codex/audits/frontend-performance-2026-07-03/fixes/04-async-episode-navigation.md b/.codex/audits/frontend-performance-2026-07-03/fixes/04-async-episode-navigation.md deleted file mode 100644 index 7f1c2730..00000000 --- a/.codex/audits/frontend-performance-2026-07-03/fixes/04-async-episode-navigation.md +++ /dev/null @@ -1,144 +0,0 @@ -# Fix 04: make manual episode changes asynchronous - -Priority: P0 -Risk: Medium -Primary benefit: Episode-switch responsiveness and playback continuity - -## Issue - -Manual episode links in `templates/watch.gohtml` perform ordinary navigation to `/anime/:id/watch?ep=N`. In the audit, switching to episode 2 produced a new page shell after 0.91s and became playable after 2.59s. - -The autoplay path already has most of the desired implementation in `static/player/episodes/nav.ts`: it fetches the minimal episode payload, updates player state, loads the new source, updates the episode UI, and calls `history.pushState`. That behavior is currently tied to `goToNextEpisode` and is not reused by manual episode, Previous, or Next controls. - -## Goal - -Use the existing minimal episode API for every in-player episode transition while preserving normal links as a reliable fallback. - -```mermaid -flowchart LR - A["User selects episode"] --> B["Show player loading state"] - B --> C["Fetch minimal episode payload"] - C -->|"success"| D["Swap source and update player state"] - D --> E["Update URL with history.pushState"] - C -->|"failure"| F["Navigate to ordinary episode URL"] -``` - -## Proposed design - -Extract a reusable function such as `transitionToEpisode(episode, options)` from `goToNextEpisode`. - -The function should own: - -- Validating the requested episode. -- Saving/resetting transition progress. -- Cancelling any previous episode transition. -- Showing the loading overlay. -- Fetching `/api/watch/episode/:animeId/:episode?mode=...`. -- Selecting the returned/fallback mode. -- Resetting the media element and loading the source. -- Updating subtitles, quality options, skip segments, title, active episode, and URL. -- Restoring focus when appropriate. -- Falling back to the link's `href` on failure. - -`goToNextEpisode` then becomes a small policy wrapper that determines whether autoplay is enabled and calls `transitionToEpisode(nextEpisode)`. - -## Event handling - -Install one delegated click handler on the episode-list container rather than one listener per episode. Intercept only: - -- Primary-button clicks. -- No modifier keys. -- Same-origin episode links for the current anime. -- Episodes not already active. - -Do not intercept: - -- Command/Ctrl-click, Shift-click, or middle-click. -- Links targeting a new browsing context. -- Invalid/missing episode IDs. -- Navigation when the player is not initialized. - -Previous and Next controls should use the same transition function. Their real `href` values remain in the markup for no-JavaScript behavior and fallback. - -## Concurrency and cancellation - -Maintain one `AbortController` for episode payload fetches. When the user selects another episode before the previous one resolves: - -1. Abort the old request. -2. Do not show an error toast for the intentional abort. -3. Ignore stale responses using a monotonically increasing transition ID. -4. Leave the current video playing until the replacement source is ready, or pause immediately only if product behavior explicitly prefers that. - -The recommended behavior is to keep the current frame/video visible with a loading scrim, then swap once the new source is ready. - -## History behavior - -- Use `history.pushState` for user-initiated episode changes. -- Use `replaceState` only when normalizing the current URL. -- Add a `popstate` handler so Back/Forward transitions to the episode encoded in the URL without a full reload. -- If the `popstate` transition fails, reload the current URL to recover. - -## Progress correctness - -Before changing episodes: - -- Save the old episode's current progress using the existing beacon/keepalive path. -- Do not mark the new episode at time zero until the transition is committed. -- Ensure aborted transitions do not overwrite progress for the currently playing episode. -- Prevent `beforeunload` from double-saving the wrong episode during the fallback navigation. - -## Loading and error behavior - -- Set an explicit transition state so duplicate clicks are ignored or superseded. -- Show the player loader immediately. -- If payload fetch fails, use the original link URL. -- If payload succeeds but media metadata fails, retry source refresh once, then navigate normally. -- Preserve the current mode when available; display the existing mode-fallback toast when it is not. - -## Affected files - -- `static/player/episodes/nav.ts` -- `static/player/main.ts` -- `static/player/state.ts` -- `static/player/episodes/ui.ts` -- `templates/watch.gohtml` -- Browser-flow tests under `static/player/` - -## Tests - -Add tests for: - -- Clicking episode 2 fetches episode 2 and does not reload the document. -- Previous and Next use the same path. -- Modified clicks are not intercepted. -- A rapid episode 2 then episode 3 selection aborts episode 2 and commits episode 3 only. -- API failure navigates to the link URL. -- Mode fallback updates state and displays the notice. -- Back/Forward restores the correct episode. -- Progress belongs to the correct old/new episode. -- Active styling and episode-range switching update correctly. - -## Observability - -Measure: - -- `episode_transition_payload_ms` -- `episode_transition_media_ready_ms` -- `episode_transition_total_ms` -- Async success versus full-navigation fallback count -- Aborted/stale transition count - -Do not include signed stream tokens or upstream URLs in client logs. - -## Rollout - -Keep ordinary `href` navigation intact. The asynchronous path can be disabled quickly by removing the delegated interceptor. Roll out before adding prefetch so behavioral failures are easier to isolate. - -## Acceptance criteria - -- Manual episode, Previous, and Next actions do not reload the page on success. -- Warm episode transitions reach playable metadata in under 1.5 seconds at p75 under representative conditions. -- Back/Forward works. -- Modified clicks retain native browser behavior. -- Every failure can recover through ordinary navigation. -- Progress, mode, subtitles, and active episode remain correct. diff --git a/.codex/audits/frontend-performance-2026-07-03/fixes/05-playback-source-cache-and-prefetch.md b/.codex/audits/frontend-performance-2026-07-03/fixes/05-playback-source-cache-and-prefetch.md deleted file mode 100644 index 2672ae23..00000000 --- a/.codex/audits/frontend-performance-2026-07-03/fixes/05-playback-source-cache-and-prefetch.md +++ /dev/null @@ -1,181 +0,0 @@ -# Fix 05: cache and prefetch playback sources - -Priority: P0 -Risk: High -Primary benefit: Time from watch-page shell to playable media - -## Issue - -The first audited episode showed the player shell after 0.58s but did not expose playable metadata until 9.75s. A later episode was faster, which strongly suggests cold provider/CDN work is a major component. - -`BuildWatchData` resolves a provider source for every watch request. It then starts `warmStreamURL` in a goroutine, but that warm-up races the browser and closes the upstream response without creating a reusable application cache. Manual and autoplay episode transitions can therefore repeat source resolution and manifest work. - -## Goals - -- Avoid resolving the same `(anime, episode, mode)` source repeatedly. -- Collapse concurrent identical resolutions into one provider call. -- Prepare the likely next episode without blocking current playback. -- Cache only metadata/manifests at first, not large video bodies. -- Generate fresh proxy tokens when serving cached source metadata. -- Respect provider expiry and avoid serving stale signed URLs indefinitely. - -## Non-goals - -- Do not build a general video CDN. -- Do not cache complete episodes in memory or SQLite. -- Do not expose upstream URLs or referers to the browser. -- Do not make playback depend exclusively on prefetched data. - -## Cache the raw provider result, not proxy tokens - -The cache value should contain the provider result before `buildModeSource` signs it: - -- Upstream stream URL. -- Referer. -- Source type. -- Subtitle upstream URLs and labels. -- Resolution timestamp. -- Optional provider-declared expiry if one can be derived safely. - -On every response, create fresh proxy tokens from the cached raw value. Caching signed proxy tokens would couple cache lifetime to the in-process token store and make expiry harder to reason about. - -## Suggested cache shape - -Key: - -```text -anime_id | episode | normalized_mode -``` - -Value: - -```text -provider result | resolved_at | fresh_until | stale_until -``` - -Initial policy: - -- Fresh TTL: 5 minutes. -- Stale-on-provider-error window: an additional 10 minutes for completed anime. -- Maximum entries: 512. -- LRU or approximate oldest-entry eviction. -- No negative caching initially; later add a short 15-30 second negative TTL if repeated misses are observed. - -Provider URLs may expire sooner than expected. Make TTL configurable and log refresh failures. Never infer a multi-hour TTL unless the provider contract supports it. - -## Singleflight - -Wrap provider resolution with a request-collapsing mechanism keyed by the same cache key: - -1. Check fresh cache. -2. If missing/stale, join an existing in-flight resolution or become its owner. -3. Resolve through the provider once. -4. Store the raw provider result. -5. Return a cloned value to callers. - -Use a context policy deliberately. If the owning browser request is cancelled, all joined requests should not necessarily fail. A short service-owned timeout may be more appropriate, but it must still stop promptly during application shutdown. - -## Client-side episode payload prefetch - -After the current episode reaches playable metadata: - -1. Identify the next valid episode. -2. If `navigator.connection.saveData` is not enabled, fetch its minimal `/api/watch/episode/...` payload at low priority. -3. Store the parsed payload in a bounded in-memory map in the player. -4. When the user transitions, consume the prefetched payload if its mode and episode match. -5. Fall back to a normal fetch when absent or expired. - -Also prefetch on pointer hover/focus of a specific episode link after a short delay. Cancel hover prefetch when the target changes. - -Do not prefetch several episodes. One likely next episode plus one explicitly hovered episode is enough. - -## Manifest caching - -For HLS sources, cache the raw upstream playlist before rewriting it. Rewrite proxy URLs and issue tokens per response. - -Suggested initial policy: - -- Completed/VOD media playlist: 30-60 seconds. -- Airing/live playlist: either no cache or a very short 2-5 second TTL. -- Maximum body: retain the existing bounded playlist read limit. -- Key includes upstream URL and referer identity. -- Never cache media segment bodies in the first version. - -This cache belongs near `HandleProxyStream`, not inside the browser. It should preserve upstream status and relevant content headers while removing stale `Content-Length` after rewriting. - -## Connection warm-up - -Keep Go HTTP transports long-lived so DNS, TCP, and TLS connections are reused. If `warmStreamURL` remains, make it purposeful: - -- For a playlist, read the bounded manifest and populate the manifest cache. -- For direct media, avoid an unbounded GET. If a provider supports ranges, a small range probe may validate availability, but only after measuring that it improves time to metadata. - -Do not download bytes merely to create the appearance of optimization. - -## Failure behavior - -- Fresh cache hit: serve immediately. -- Stale cache plus successful refresh: replace and serve new value. -- Stale cache plus provider failure: optionally serve stale for completed media and record the event. -- No cache plus failure: return the existing error path. -- Media error after cached source: force one source refresh that bypasses cache, then fail normally. - -## Security - -- Never log raw upstream URLs, referers, subtitle URLs, or proxy tokens. -- Keep cached provider metadata process-local unless encryption and retention are designed explicitly. -- Preserve `proxytarget.Validate` before signing or proxying every cached URL. -- Bound memory and reject unexpectedly large manifests. - -## Affected files - -- `internal/playback/service.go` -- `internal/playback/watch_data.go` -- `internal/playback/handler/proxy_stream.go` -- `internal/playback/handler/hls_playlist.go` -- New bounded cache implementation under `internal/playback/` -- `static/player/episodes/nav.ts` -- `static/player/main.ts` -- Playback and proxy tests - -## Tests - -- Repeated source lookup hits the provider once within TTL. -- Concurrent identical lookups hit the provider once. -- Different episode/mode keys do not collide. -- Expired entries refresh. -- Forced refresh bypasses cache after media error. -- Fresh proxy tokens are generated from cached raw data. -- Raw HLS playlist is cached; rewritten tokenized output is not shared incorrectly. -- Cache enforces item and body-size limits. -- Prefetched episode payload is used once and cannot overwrite a newer transition. -- Save-Data disables automatic prefetch. - -## Observability - -Add counters/histograms for: - -- Source cache hit, miss, stale-hit, eviction. -- Singleflight owner versus joined requests. -- Provider source-resolution duration. -- Manifest cache hit/miss and upstream duration. -- Prefetch started, used, expired, cancelled, failed. -- Time from player initialization to metadata and first frame. - -## Rollout - -1. Add measurements without behavior changes. -2. Add source-result cache and singleflight. -3. Add next-episode payload prefetch. -4. Add manifest cache only after source-cache results are understood. -5. Tune TTLs from observed provider expiry and error data. - -## Acceptance criteria - -- Repeated identical source requests do not call the provider within the fresh TTL. -- Concurrent identical source requests collapse into one provider call. -- Warm episode transition reaches playable metadata under 1.5 seconds at p75. -- Cold start shows a meaningful loading state and trends below 4 seconds at p75. -- No upstream URL/token appears in logs or rendered HTML beyond existing opaque proxy tokens. -- Memory remains bounded under sustained browsing. -