chore: add codex audit reports
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,129 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,100 @@
|
||||
# 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 `<img>` 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.
|
||||
@@ -0,0 +1,144 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,181 @@
|
||||
# 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.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# Fix 06: improve player loading and recovery states
|
||||
|
||||
Priority: P1
|
||||
Risk: Low to medium
|
||||
Primary benefit: Perceived performance, accessibility, and error recovery
|
||||
|
||||
## Issue
|
||||
|
||||
The player displays an animated spinner over a black frame while loading. During the audited cold start, this state lasted almost ten seconds. The spinner does not explain whether the application is finding a source, loading metadata, buffering media, retrying, or stuck. It also lacks a useful live status for assistive technology.
|
||||
|
||||
Several player buttons also appeared without accessible names in the browser accessibility tree.
|
||||
|
||||
## Goal
|
||||
|
||||
Make waits understandable without adding noisy progress theater. The loading UI should expose a small state machine, announce meaningful changes, and always provide a recovery path.
|
||||
|
||||
## Proposed state model
|
||||
|
||||
```text
|
||||
idle
|
||||
resolving_source
|
||||
loading_media
|
||||
buffering
|
||||
retrying
|
||||
ready
|
||||
unavailable
|
||||
```
|
||||
|
||||
State meanings:
|
||||
|
||||
- `resolving_source`: waiting for the episode API/provider result.
|
||||
- `loading_media`: source is known; waiting for manifest/metadata.
|
||||
- `buffering`: metadata exists but playback has stalled.
|
||||
- `retrying`: refreshing the source after a media error.
|
||||
- `unavailable`: all bounded attempts failed.
|
||||
- `ready`: overlay hidden.
|
||||
|
||||
Do not attempt to show a fake percentage. The application does not know enough to estimate completion accurately.
|
||||
|
||||
## Visual behavior
|
||||
|
||||
- Show the spinner immediately to acknowledge the action.
|
||||
- Keep text visually quiet for short waits.
|
||||
- After roughly 3 seconds, show a short status such as “Loading video…” or “Finding a source…”.
|
||||
- After roughly 8-10 seconds, show “This is taking longer than usual” and a Retry action.
|
||||
- On terminal failure, show Retry and Back to details.
|
||||
- Keep episode title/context visible where possible.
|
||||
- Avoid replacing the whole page; recovery stays inside the player.
|
||||
|
||||
## Accessibility behavior
|
||||
|
||||
Use a stable status element inside the player:
|
||||
|
||||
```html
|
||||
<div role="status" aria-live="polite" aria-atomic="true">
|
||||
<span data-loading-message>Loading video…</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Do not announce every buffering event; debounce announcements to avoid screen-reader noise.
|
||||
- Use `aria-busy="true"` on the player container while a transition is active.
|
||||
- Move focus only for terminal errors, not normal loading.
|
||||
- Give play/pause, mute, fullscreen, seek, and navigation buttons stable accessible names.
|
||||
- Ensure disabled controls expose native `disabled` or an equivalent semantic state.
|
||||
|
||||
## State ownership
|
||||
|
||||
Centralize overlay changes in one module/function. Currently media event handlers manipulate `loading.style.display` directly in several places. Replace scattered writes with a function such as `setPlayerLoadState(state, options)`.
|
||||
|
||||
That function should:
|
||||
|
||||
- Update `data-load-state` on the player root.
|
||||
- Toggle visibility.
|
||||
- Set `aria-busy`.
|
||||
- Update status copy.
|
||||
- Start/cancel the long-wait timer.
|
||||
- Expose Retry only in retryable states.
|
||||
|
||||
## Retry policy
|
||||
|
||||
- Episode payload failure: one ordinary fallback navigation, unless already on that URL.
|
||||
- Media source error: refresh the current mode source once.
|
||||
- Repeated media failure: stop automatic retries and show Retry.
|
||||
- User Retry: force source refresh, reset media element, and reload.
|
||||
- Never create an infinite retry loop.
|
||||
|
||||
## Affected files
|
||||
|
||||
- `templates/components/video_player.gohtml`
|
||||
- `static/player/main.ts`
|
||||
- `static/player/source.ts`
|
||||
- `static/player/video.ts`
|
||||
- `static/player/state.ts`
|
||||
- `static/player/controls.ts`
|
||||
- Player browser-flow tests
|
||||
|
||||
## Tests
|
||||
|
||||
- Loader appears immediately on source change.
|
||||
- Status text appears only after the delay.
|
||||
- Ready metadata hides the loader and clears timers.
|
||||
- Waiting after ready enters buffered/loading state without repeated announcements.
|
||||
- One media error attempts one refresh.
|
||||
- Repeated failure exposes Retry and stops looping.
|
||||
- Retry performs a forced source refresh.
|
||||
- Every icon-only control has an accessible name.
|
||||
- `aria-busy` accurately follows transition state.
|
||||
|
||||
## Observability
|
||||
|
||||
Record client-side phase durations without sensitive URLs:
|
||||
|
||||
- Source resolution.
|
||||
- Media metadata wait.
|
||||
- Buffering count and duration.
|
||||
- Retry count.
|
||||
- Terminal unavailable state.
|
||||
|
||||
The existing HLS profiling hook can remain for HLS-specific detail; add source-independent timing for direct media too.
|
||||
|
||||
## Rollout
|
||||
|
||||
Ship the centralized state function and accessible labels first without changing retry counts. Then enable delayed copy and user Retry. This separates semantic/visual regressions from source-refresh behavior changes. Verify with direct media and HLS sources.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- No indefinite spinner exists without text or a recovery action.
|
||||
- A useful status appears after 3 seconds.
|
||||
- Retry appears after the long-wait threshold or a terminal failure.
|
||||
- Player root exposes `aria-busy` during loading.
|
||||
- All icon-only player controls have accessible names.
|
||||
- Retry loops are bounded.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Fix 07: represent recommendation refresh state
|
||||
|
||||
Priority: P1
|
||||
Risk: Medium
|
||||
Primary benefit: Honest background behavior and automatic completion
|
||||
|
||||
## Issue
|
||||
|
||||
Recommendation computation already runs in the background, which is the right architecture. The cache contract loses an important distinction, however:
|
||||
|
||||
- The user has no usable watchlist signal.
|
||||
- Recommendations are currently being computed.
|
||||
- Computation failed.
|
||||
- Stale recommendations exist while refresh runs.
|
||||
|
||||
On a cold cache, `getCachedTopPicksForYou` starts a goroutine and immediately returns an empty anime slice. The UI therefore renders “No top picks yet—add a few anime” even when the user just added an anime and a roughly ten-second computation is active. Results appear only after a manual reload.
|
||||
|
||||
## Goal
|
||||
|
||||
Preserve asynchronous computation while returning an explicit state that lets the UI show the truth and update automatically.
|
||||
|
||||
## Proposed domain model
|
||||
|
||||
Add a typed state to recommendation results:
|
||||
|
||||
```text
|
||||
empty no usable signals after a completed computation
|
||||
refreshing no result yet; computation in progress
|
||||
ready current result available
|
||||
stale previous result shown while refresh runs
|
||||
failed no result and the latest computation failed
|
||||
```
|
||||
|
||||
Include only the minimum supporting metadata needed by the UI, such as `retryAfter` or `updatedAt`. Do not expose internal provider errors.
|
||||
|
||||
## Cache behavior
|
||||
|
||||
### First request, no entry
|
||||
|
||||
1. Create an entry with `refreshing=true`.
|
||||
2. Start the background computation.
|
||||
3. Return `refreshing` with no items.
|
||||
|
||||
### Fresh data
|
||||
|
||||
Return `ready` immediately.
|
||||
|
||||
### Expired data
|
||||
|
||||
1. Preserve the old data.
|
||||
2. Mark refresh in progress.
|
||||
3. Return `stale` with old items.
|
||||
4. Refresh in the background.
|
||||
|
||||
### Watchlist mutation
|
||||
|
||||
Do not delete the cache entry. Mark it stale and start refresh immediately. This preserves existing recommendations while the new profile is computed.
|
||||
|
||||
### Completed empty result
|
||||
|
||||
Store a completed `empty` result so every request does not restart computation.
|
||||
|
||||
### Failure
|
||||
|
||||
- With stale data: keep serving stale and record refresh failure.
|
||||
- Without data: return `failed` with Retry.
|
||||
- Use bounded retry/backoff; do not restart on every poll.
|
||||
|
||||
## HTMX update flow
|
||||
|
||||
The simplest UI is an outer fragment that polls only while state is `refreshing`:
|
||||
|
||||
```html
|
||||
<section
|
||||
hx-get="/api/catalog/top-pick"
|
||||
hx-trigger="every 2s"
|
||||
hx-swap="outerHTML"
|
||||
aria-busy="true">
|
||||
<p role="status">Preparing recommendations…</p>
|
||||
</section>
|
||||
```
|
||||
|
||||
When the server returns `ready`, `stale`, `empty`, or `failed`, render markup without the polling trigger. Replacing the polling element naturally stops polling. HTTP status `286` can also stop polling, but removing the trigger from the final fragment is easier to inspect and does not depend on status-specific behavior.
|
||||
|
||||
Use a two-second interval rather than aggressive sub-second polling. The job takes seconds and does not need real-time granularity.
|
||||
|
||||
For the full Top Picks page, use the same state-aware fragment or a small page-specific status endpoint. Avoid duplicating state rules between home and `/top-picks`.
|
||||
|
||||
## Request synchronization
|
||||
|
||||
Ensure one client poll is active at a time. If a poll response can exceed the interval, use an HTMX synchronization policy or a longer interval. Server-side cache locking/singleflight remains authoritative; client synchronization is only a traffic guard.
|
||||
|
||||
## Copy
|
||||
|
||||
- Refreshing with no data: “Preparing recommendations…”
|
||||
- Stale: keep cards visible; optional subtle “Updating…” text.
|
||||
- Completed empty: current “Add a few anime…” guidance.
|
||||
- Failed with no data: “Recommendations could not be prepared.” plus Retry.
|
||||
|
||||
Do not show provider or scoring implementation details to the user.
|
||||
|
||||
## Affected files
|
||||
|
||||
- `internal/anime/recommendations.go`
|
||||
- Recommendation/cache domain types
|
||||
- Recommendation invalidator interface and watchlist integration
|
||||
- `internal/anime/catalog_handler.go`
|
||||
- `templates/index.gohtml`
|
||||
- `templates/top_picks.gohtml`
|
||||
- Recommendation and handler tests
|
||||
|
||||
## Tests
|
||||
|
||||
- Cold cache returns `refreshing`, not `empty`.
|
||||
- Completed empty computation returns `empty` and is cached.
|
||||
- Fresh data returns `ready`.
|
||||
- Expired data returns stale cards and starts exactly one refresh.
|
||||
- Watchlist mutation preserves stale cards and starts refresh immediately.
|
||||
- Failure preserves stale data when available.
|
||||
- Final fragments do not continue polling.
|
||||
- Home and Top Picks use the same state rules.
|
||||
|
||||
## Observability
|
||||
|
||||
Track:
|
||||
|
||||
- Refresh started/completed/failed.
|
||||
- Refresh duration.
|
||||
- Ready/stale/refreshing/empty responses.
|
||||
- Poll count per refresh.
|
||||
- Number of refreshes joined or suppressed.
|
||||
|
||||
## Rollout
|
||||
|
||||
Ship the state contract, server rendering, and templates together. During rollout, tolerate missing/zero state as `empty` only for compatibility with old tests; remove that fallback after all call sites are migrated.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A cold recommendation build never displays the completed-empty message.
|
||||
- Results appear without manual page reload.
|
||||
- Existing cards remain visible during refresh.
|
||||
- Exactly one computation runs per cache key.
|
||||
- Polling stops after a terminal state.
|
||||
- Failure has a visible bounded Retry path.
|
||||
|
||||
## Reference
|
||||
|
||||
- [HTMX polling and progress pattern](https://github.com/bigskysoftware/htmx/blob/v2.0.4/www/content/examples/progress-bar.md)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Fix 08: cache Simulcast discovery
|
||||
|
||||
Priority: P2
|
||||
Risk: Low to medium
|
||||
Primary benefit: Simulcast page latency and provider load
|
||||
|
||||
## Issue
|
||||
|
||||
`HandleSimulcast` first calls `LatestAvailableSeason`, which requests the next calendar season from AllAnime. It then calls `GetSimulcast`, which requests the selected season. These calls are serial.
|
||||
|
||||
The audit measured 1.38s before the page was ready. Seasonal data changes slowly relative to page views, so repeated synchronous provider requests are unnecessary.
|
||||
|
||||
## Goal
|
||||
|
||||
Cache seasonal show lists and latest-season discovery, reuse the next-season lookup when it is also the selected season, and serve stale data while refresh occurs.
|
||||
|
||||
## Proposed cache
|
||||
|
||||
Use one shared seasonal cache keyed by:
|
||||
|
||||
```text
|
||||
normalized season | year
|
||||
```
|
||||
|
||||
The latest-season check should call the same cached seasonal fetch function. If it fetches next season, `GetSimulcast` can reuse that entry rather than issuing another provider request.
|
||||
|
||||
Suggested policy:
|
||||
|
||||
- Fresh TTL: 30 minutes.
|
||||
- Stale window: 6 hours.
|
||||
- Maximum entries: 40 seasons, comfortably covering active navigation without unbounded growth.
|
||||
- Empty next-season result: cache for 10 minutes, because availability can appear around season boundaries.
|
||||
- Provider error: serve stale data when available.
|
||||
|
||||
## Stale-while-refresh behavior
|
||||
|
||||
1. Fresh entry: return immediately.
|
||||
2. Stale entry: return stale immediately and start one background refresh.
|
||||
3. Missing entry: fetch synchronously because there is no usable page data.
|
||||
4. Failed missing fetch: preserve current error behavior.
|
||||
|
||||
Use singleflight keyed by season/year to collapse concurrent misses.
|
||||
|
||||
## Latest-season caching
|
||||
|
||||
The latest season is a derived value. It can either:
|
||||
|
||||
- Be cached separately for 30-60 minutes, or
|
||||
- Be recomputed cheaply from the shared next-season cache.
|
||||
|
||||
Prefer the second option so there is one source of truth. Around a calendar-season rollover, use a shorter empty-result TTL to discover newly published shows promptly.
|
||||
|
||||
## Data ownership
|
||||
|
||||
Cache provider show values before converting them into template/domain anime values. Clone slices on read so handlers cannot mutate cached storage accidentally.
|
||||
|
||||
An in-memory cache is sufficient initially. Persisting seasonal data in SQLite adds migration and invalidation complexity without evidence that restart cold starts are a major problem.
|
||||
|
||||
## Affected files
|
||||
|
||||
- `internal/anime/discovery.go`
|
||||
- `internal/anime/simulcast.go`
|
||||
- `internal/anime/catalog_handler.go`
|
||||
- Anime module wiring if a cache dependency is separated
|
||||
- Simulcast/cache tests
|
||||
|
||||
## Tests
|
||||
|
||||
- Two requests for the same season call the provider once within TTL.
|
||||
- Latest-season lookup populates the shared seasonal cache.
|
||||
- Concurrent misses collapse into one provider call.
|
||||
- Stale data is served while one refresh runs.
|
||||
- Empty next season uses the shorter TTL.
|
||||
- Different season/year keys do not collide.
|
||||
- Cached slices are not mutable by callers.
|
||||
- Provider failure serves stale data when available.
|
||||
|
||||
## Observability
|
||||
|
||||
Track seasonal cache hit, miss, stale-hit, refresh duration, provider error, and empty-next-season result. Do not log full provider payloads.
|
||||
|
||||
## Rollout
|
||||
|
||||
Ship in-memory caching without changing routes or templates. A configuration flag is optional but probably unnecessary because the fallback is simply cache miss behavior.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Warm Simulcast requests do not call AllAnime.
|
||||
- Latest-season and selected-season requests reuse the same cached fetch path.
|
||||
- Warm page readiness is below 500ms locally and trends below 1 second at p75 in production.
|
||||
- Seasonal data refreshes within the chosen TTL.
|
||||
- Cache memory is bounded.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Fix 09: deduplicate canonical episode refreshes
|
||||
|
||||
Priority: P2
|
||||
Risk: Medium
|
||||
Primary benefit: Provider load and cold anime-detail completion
|
||||
|
||||
## Issue
|
||||
|
||||
The anime detail page loads release information and audio availability as independent HTMX fragments. Both can call `EpisodeService.GetCanonicalEpisodes` at nearly the same time.
|
||||
|
||||
During the cold audit, logs showed two episode refreshes for the same anime starting together. The Jikan layer collapsed part of its own refresh, but provider-ID resolution, availability checks, merging, and cache writes still ran twice.
|
||||
|
||||
## Goal
|
||||
|
||||
Allow independent fragments to remain asynchronous while ensuring only one canonical-episode refresh runs for a given anime and refresh mode.
|
||||
|
||||
## Where to deduplicate
|
||||
|
||||
Keep the existing fast cache checks outside the shared refresh. Only enter request collapsing after the service has decided that provider/Jikan refresh work is required.
|
||||
|
||||
Suggested key:
|
||||
|
||||
```text
|
||||
anime_id | refresh_policy
|
||||
```
|
||||
|
||||
At minimum distinguish ordinary refresh from an explicit force refresh. A force refresh must not silently join a request that is intentionally serving an existing fresh cache entry.
|
||||
|
||||
## Proposed flow
|
||||
|
||||
1. Validate anime input.
|
||||
2. Read the canonical episode cache.
|
||||
3. Return immediately when it is fresh and the caller did not force refresh.
|
||||
4. Enter a keyed singleflight refresh.
|
||||
5. Recheck the cache inside the shared function; another request may have completed between steps 2 and 4.
|
||||
6. Fetch Jikan/provider data once.
|
||||
7. Merge and write the canonical cache once.
|
||||
8. Return cloned result data to all waiting callers.
|
||||
|
||||
The second cache check is important. It prevents back-to-back refreshes caused by requests that both observed the same stale entry before either joined the shared call.
|
||||
|
||||
## Context and cancellation
|
||||
|
||||
Do not let one browser cancellation terminate useful work for all joined callers.
|
||||
|
||||
Recommended policy:
|
||||
|
||||
- Create a service-owned bounded refresh context using a strict timeout.
|
||||
- Preserve tracing/request values when safe, but detach individual request cancellation.
|
||||
- Let each waiting caller stop waiting when its own context is cancelled.
|
||||
- Allow the shared refresh to finish for the cache and remaining callers.
|
||||
- Stop all refreshes during application shutdown through a service lifecycle context if one is available.
|
||||
|
||||
Using the first caller's context directly is simpler but creates a fragile leader/follower relationship: closing one detail fragment can fail the other fragment's refresh.
|
||||
|
||||
## Result ownership
|
||||
|
||||
Canonical episode results contain slices. Return a clone to callers or treat the type as immutable by contract. Do not let one handler mutate data shared with another joined request or cache entry.
|
||||
|
||||
## Error behavior
|
||||
|
||||
- If refresh fails and stale canonical data is allowed by current policy, serve stale as today.
|
||||
- All joined callers should observe the same refresh error/result.
|
||||
- Do not negative-cache provider failures beyond the existing failure/backoff policy.
|
||||
- Preserve failure counters and next-refresh timestamps exactly once.
|
||||
|
||||
## Affected files
|
||||
|
||||
- `internal/episodes/service/service.go`
|
||||
- Optional helper under `internal/episodes/service/`
|
||||
- Episode service constructor/state
|
||||
- Episode service concurrency tests
|
||||
|
||||
## Tests
|
||||
|
||||
- Two concurrent ordinary refreshes call Jikan/provider once.
|
||||
- Cache is rechecked inside the shared refresh.
|
||||
- Force and ordinary refresh policies do not collide incorrectly.
|
||||
- One caller cancellation does not cancel the refresh for another caller.
|
||||
- All waiters receive equivalent cloned results.
|
||||
- Failed refresh records failure once.
|
||||
- Stale fallback behavior remains unchanged.
|
||||
- Race detector passes under concurrent refresh tests.
|
||||
|
||||
Run the focused tests with `-race` because this change introduces shared state.
|
||||
|
||||
## Observability
|
||||
|
||||
Track:
|
||||
|
||||
- Refresh owner count.
|
||||
- Joined caller count.
|
||||
- Cache hit after joining/recheck.
|
||||
- Shared refresh duration and result.
|
||||
- Caller cancellation while waiting.
|
||||
|
||||
## Rollout
|
||||
|
||||
No route or template changes are required. Ship behind the service boundary, then verify that duplicate `episodes_refresh_start` events for one anime disappear under concurrent fragment loads.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Concurrent release-info and audio-availability loads trigger one canonical refresh.
|
||||
- No additional provider request occurs after the inner cache recheck.
|
||||
- Caller cancellation does not poison unrelated waiters.
|
||||
- Existing stale/failure semantics remain intact.
|
||||
- `go test -race ./internal/episodes/...` passes.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Fix 10: parallelize watch-data assembly
|
||||
|
||||
Priority: P2
|
||||
Risk: Medium
|
||||
Primary benefit: Watch-page server response time
|
||||
|
||||
## Issue
|
||||
|
||||
After anime metadata and canonical episodes are available, `BuildWatchData` performs several independent operations serially:
|
||||
|
||||
- Stream/mode-source resolution.
|
||||
- Watch progress and watchlist status lookup.
|
||||
- Season/relation loading.
|
||||
- Skip-segment lookup.
|
||||
|
||||
The audited watch page spent roughly 0.46s in the server on the first tested request. Media startup was the larger problem, but shortening server work makes the shell and source token available sooner.
|
||||
|
||||
## Goal
|
||||
|
||||
Run independent work concurrently without hiding errors, breaking cancellation, or increasing provider load uncontrollably.
|
||||
|
||||
## Dependency boundary
|
||||
|
||||
Keep these steps serial:
|
||||
|
||||
1. Fetch anime metadata.
|
||||
2. Ensure the local anime row.
|
||||
3. Build title candidates.
|
||||
4. Load canonical episodes.
|
||||
5. Resolve requested mode/fallback policy from episode availability.
|
||||
|
||||
After step 5, these operations can run concurrently:
|
||||
|
||||
- Resolve stream source for the requested/fallback mode.
|
||||
- Load watch progress/status.
|
||||
- Load seasons/relations.
|
||||
- Load skip-segment overrides.
|
||||
|
||||
Build the final payload only after all required results have joined.
|
||||
|
||||
## Error classification
|
||||
|
||||
Not every operation has the same severity:
|
||||
|
||||
- Stream resolution: required for playable success; preserve current no-stream error behavior.
|
||||
- Progress lookup: user-state concern; decide whether failure should fail the page or render from zero. Do not silently change existing semantics.
|
||||
- Seasons/relations: optional enhancement; current code already logs and returns an empty list on failure.
|
||||
- Skip segments: optional enhancement; current code logs and continues.
|
||||
|
||||
Represent these policies explicitly instead of relying on whichever goroutine returns first.
|
||||
|
||||
## Concurrency mechanism
|
||||
|
||||
Use a small fixed group of goroutines tied to the request context. Four operations are enough; do not introduce a generic worker pool.
|
||||
|
||||
Each goroutine writes only to its own result variable. Join before reading any result. Run the race detector.
|
||||
|
||||
If using an error group, do not let an optional seasons error cancel required stream resolution. Either:
|
||||
|
||||
- Handle optional errors inside their goroutines and return nil, or
|
||||
- Use separate result wrappers and join all operations before applying severity rules.
|
||||
|
||||
## Provider pressure
|
||||
|
||||
Stream resolution and season/relation loading may both touch external systems when caches are cold. Concurrency can reduce latency but increase burst pressure. Mitigations:
|
||||
|
||||
- Land Fix 05 source caching and Fix 09 deduplication first where practical.
|
||||
- Rely on existing Jikan rate limiting/cache behavior.
|
||||
- Keep concurrency bounded to the fixed set above.
|
||||
- Measure upstream retry/rate-limit events before and after rollout.
|
||||
|
||||
## Cancellation
|
||||
|
||||
- Request cancellation should stop all request-owned operations.
|
||||
- Background warm-up should remain separately bounded and should not inherit an already cancelled context.
|
||||
- Do not detach operations that are useful only for the current response.
|
||||
|
||||
## Affected files
|
||||
|
||||
- `internal/playback/watch_data.go`
|
||||
- Playback service tests
|
||||
- Optional observability timing helpers
|
||||
|
||||
## Tests
|
||||
|
||||
- All independent functions start before the slowest one completes.
|
||||
- Payload matches the previous serial implementation.
|
||||
- Required stream failure produces the same page error.
|
||||
- Optional season/segment failures continue with empty data and log once.
|
||||
- Request cancellation stops outstanding request work.
|
||||
- No result variable race occurs.
|
||||
- Existing mode fallback remains correct.
|
||||
|
||||
Use deterministic fake functions/channels rather than timing-sensitive sleeps in concurrency tests.
|
||||
|
||||
## Observability
|
||||
|
||||
Add spans/timers for:
|
||||
|
||||
- Anime metadata.
|
||||
- Canonical episodes.
|
||||
- Stream resolution.
|
||||
- Progress lookup.
|
||||
- Seasons lookup.
|
||||
- Segment lookup.
|
||||
- Total `BuildWatchData`.
|
||||
|
||||
The goal is to confirm that total time approaches the slowest parallel branch rather than the sum of all branches.
|
||||
|
||||
## Rollout
|
||||
|
||||
Refactor with behavior-preserving tests first. Ship after source caching if possible. Compare total watch-data duration, upstream request count, and rate-limit errors.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Independent branches overlap in tests and tracing.
|
||||
- Watch-page data is behaviorally identical.
|
||||
- Optional failures retain current fallback behavior.
|
||||
- Server watch-page duration decreases without increased provider error rate.
|
||||
- `go test -race ./internal/playback/...` passes.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Fix 11: make watchlist updates local-first
|
||||
|
||||
Priority: P2
|
||||
Risk: Low to medium
|
||||
Primary benefit: Existing-anime watchlist mutation latency
|
||||
|
||||
## Issue
|
||||
|
||||
`watchlistService.UpdateEntry` calls `GetAnimeByID` before entering the transaction for every update. It later checks whether the anime row already exists.
|
||||
|
||||
This means changing an existing watchlist entry can wait on Jikan even though all required anime data is already stored locally. During the audit, adding an uncached anime took roughly 0.59s on the server. The frontend hides much of this with optimistic UI, but unnecessary network dependency still increases failure and rollback risk.
|
||||
|
||||
## Goal
|
||||
|
||||
- Existing anime rows: update watchlist state using SQLite only.
|
||||
- Missing anime rows: fetch metadata, insert/upsert anime, then create the watchlist entry.
|
||||
- Preserve atomic watchlist writes and optimistic client rollback.
|
||||
|
||||
## Proposed flow
|
||||
|
||||
```text
|
||||
validate request
|
||||
-> read anime row
|
||||
-> exists: skip Jikan
|
||||
-> missing: fetch Jikan metadata
|
||||
-> transaction
|
||||
-> ensure anime row when fetched
|
||||
-> read existing watchlist progress
|
||||
-> upsert watchlist entry
|
||||
-> invalidate/start recommendation refresh
|
||||
```
|
||||
|
||||
## Transaction strategy
|
||||
|
||||
Perform the initial existence check outside the write transaction so a slow network request never holds a SQLite transaction open.
|
||||
|
||||
Inside the transaction:
|
||||
|
||||
- Recheck or use `INSERT ... ON CONFLICT` when metadata was fetched; another request may have inserted the anime meanwhile.
|
||||
- Preserve existing progress fields exactly as the current implementation does.
|
||||
- Upsert the watchlist entry atomically.
|
||||
|
||||
The preflight read is an optimization hint, not an integrity guarantee. Database constraints and the transaction remain authoritative.
|
||||
|
||||
## Missing-anime failure
|
||||
|
||||
If the anime row is missing and Jikan fetch fails, return a clear service error before attempting a foreign-key-invalid watchlist insert. The client should roll back its optimistic state and show the existing failure toast.
|
||||
|
||||
Do not create placeholder anime rows unless a separate product decision defines acceptable placeholder title/image semantics. The current schema requires meaningful non-null anime data.
|
||||
|
||||
## Existing-anime refresh policy
|
||||
|
||||
Skipping Jikan means local metadata may age. That is a separate concern from changing watchlist status. If metadata refresh is needed:
|
||||
|
||||
- Queue/best-effort refresh in the background after the mutation, or
|
||||
- Let existing catalog/detail refresh paths maintain metadata.
|
||||
|
||||
Do not keep the user action blocked solely to refresh display metadata.
|
||||
|
||||
## Recommendation invalidation
|
||||
|
||||
Invalidate or start recommendation refresh only after the transaction commits. Integrate with Fix 07 so existing recommendations become stale and refresh begins immediately.
|
||||
|
||||
## Affected files
|
||||
|
||||
- `internal/watchlist/service.go`
|
||||
- Watchlist repository interface only if a more direct existence query is added
|
||||
- Watchlist service tests
|
||||
|
||||
## Tests
|
||||
|
||||
- Existing anime update does not call Jikan.
|
||||
- Missing anime fetches Jikan once and inserts anime before watchlist entry.
|
||||
- Concurrent missing-anime updates remain correct under conflict.
|
||||
- Missing anime plus provider failure returns a clear error and writes nothing.
|
||||
- Existing progress is preserved.
|
||||
- Recommendation invalidation occurs only after commit.
|
||||
- Transaction failure does not invalidate recommendations.
|
||||
|
||||
## Observability
|
||||
|
||||
Track local-hit versus metadata-fetch update paths and mutation duration. This should make it obvious whether most watchlist actions are DB-only.
|
||||
|
||||
## Rollout
|
||||
|
||||
This is an internal service refactor with no contract change. Preserve the existing API and optimistic frontend. Verify mutation latency and rollback rate after release.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Status changes for known anime issue no external request.
|
||||
- Missing anime is inserted safely before the watchlist entry.
|
||||
- Failed metadata fetch writes nothing.
|
||||
- Existing progress remains unchanged.
|
||||
- Recommendation invalidation happens after a successful commit only.
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Fix 12: reduce the Reviews payload
|
||||
|
||||
Priority: P2
|
||||
Risk: Medium
|
||||
Primary benefit: Initial HTML transfer, DOM size, and scanability
|
||||
|
||||
## Issue
|
||||
|
||||
The audited Reviews page took 1.20s and returned about 234KB of initial HTML. Every review body is rendered in full. One long review can dominate the viewport and response size before the user has chosen to read it.
|
||||
|
||||
HTTP compression from Fix 02 will produce a large immediate improvement because review text compresses well, but it will not reduce DOM size, layout work, or the visual wall of text.
|
||||
|
||||
## Goal
|
||||
|
||||
- Preserve access to full reviews.
|
||||
- Render a compact, useful initial list.
|
||||
- Fetch a full review body only when requested.
|
||||
- Keep pagination/infinite loading intact.
|
||||
- Preserve server-side HTML escaping and accessibility.
|
||||
|
||||
## Recommended design
|
||||
|
||||
Render every review card returned by the upstream page, but render only a bounded preview body initially.
|
||||
|
||||
Each card includes:
|
||||
|
||||
- Existing author/date/score/tags/reactions.
|
||||
- Review preview, for example 900-1200 Unicode characters.
|
||||
- “Read more” button only when truncated.
|
||||
- Stable review ID and source page in the expansion request.
|
||||
|
||||
When expanded, HTMX requests a server-rendered full-body fragment and swaps only that card's body. The full body can include a “Collapse” action that restores the preview locally or requests the preview fragment.
|
||||
|
||||
Do not use a CSS line clamp alone: it improves appearance but still transfers and lays out the full text.
|
||||
|
||||
## Preview generation
|
||||
|
||||
Generate previews on the server using Unicode runes, not byte slicing. Prefer these rules:
|
||||
|
||||
1. Normalize only presentation-safe whitespace; preserve paragraph intent.
|
||||
2. Stop near the configured limit at a word boundary.
|
||||
3. Add an ellipsis only when truncated.
|
||||
4. Keep the original full review unchanged in the domain/provider cache.
|
||||
5. Return `IsTruncated` explicitly to the template.
|
||||
|
||||
Avoid summarization or rewriting. The preview must be a faithful prefix of the user's review.
|
||||
|
||||
## Full-review endpoint
|
||||
|
||||
One possible route:
|
||||
|
||||
```text
|
||||
GET /anime/:animeID/reviews/:reviewID?source_page=N
|
||||
```
|
||||
|
||||
The handler:
|
||||
|
||||
1. Validates anime ID, review ID, and source page.
|
||||
2. Loads the relevant Jikan review page through the existing cache.
|
||||
3. Finds the exact review ID.
|
||||
4. Renders a full-review-body fragment.
|
||||
5. Returns `404` if the review is absent instead of guessing another page.
|
||||
|
||||
The source page parameter prevents scanning every upstream page. Do not trust it without validating that the returned review ID matches.
|
||||
|
||||
An even simpler alternative is linking to the existing external review URL. Use that only if leaving the application is acceptable product behavior.
|
||||
|
||||
## Pagination behavior
|
||||
|
||||
Continue rendering every review metadata card from each upstream page, so no reviews are skipped. The existing intersection sentinel can continue loading subsequent pages. Those appended cards should also use previews.
|
||||
|
||||
Prevent repeated sentinel requests with the current `once` behavior and preserve an explicit failure state if the next page cannot load.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Use a real button for Read more/Collapse.
|
||||
- Set `aria-expanded` and `aria-controls` against a stable review-body ID.
|
||||
- After expansion, keep focus on the same control or move it only if necessary.
|
||||
- Do not hide spoiler labels when truncating.
|
||||
- Expanded text remains ordinary readable HTML text, not a modal.
|
||||
|
||||
## Caching
|
||||
|
||||
- Initial and expansion handlers should use the existing Jikan cache.
|
||||
- Do not add shared caching of user-specific page chrome.
|
||||
- Compression applies to both full pages and fragments.
|
||||
- A small server-side preview computation needs no separate cache unless profiling proves otherwise.
|
||||
|
||||
## Affected files
|
||||
|
||||
- `internal/anime/reviews_handler.go`
|
||||
- `internal/anime/service.go` or a presentation mapper
|
||||
- Review display/domain types
|
||||
- `templates/reviews.gohtml`
|
||||
- Anime handler registration
|
||||
- Review handler/template tests
|
||||
|
||||
## Tests
|
||||
|
||||
- Short review renders fully without Read more.
|
||||
- Long review truncates at a valid Unicode/word boundary.
|
||||
- Preview is a faithful prefix.
|
||||
- Full endpoint returns the exact review by ID and page.
|
||||
- Wrong review/page combination returns `404`.
|
||||
- HTMX fragment contains full escaped text.
|
||||
- Appended pagination cards also use previews.
|
||||
- Spoiler/preliminary labels and reactions remain intact.
|
||||
- `aria-expanded`/`aria-controls` are correct.
|
||||
|
||||
## Observability
|
||||
|
||||
Track initial Reviews response bytes, server duration, expansion requests, and next-page failures. Do not log review text.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Ship HTTP compression first.
|
||||
2. Add preview fields and templates behind a conservative preview length.
|
||||
3. Add full expansion endpoint and browser verification.
|
||||
4. Adjust preview length from real usage, not solely byte targets.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Initial uncompressed Reviews HTML is below 100KB for the audited anime.
|
||||
- Full review text remains available on demand.
|
||||
- No review is skipped because of previewing.
|
||||
- Expansion requires no full page reload.
|
||||
- Unicode text, spoiler state, and keyboard behavior remain correct.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Frontend performance fix pack
|
||||
|
||||
This folder turns the findings in [`../audit.md`](../audit.md) into implementation-ready plans. Each plan explains the problem, the proposed design, affected code, edge cases, tests, rollout strategy, observability, and acceptance criteria.
|
||||
|
||||
These are planning documents only. No application source has been changed.
|
||||
|
||||
## Recommended order
|
||||
|
||||
| Order | Priority | Fix | Expected outcome | Depends on |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | P0 | [Minify frontend bundles](01-minify-frontend-bundles.md) | Smaller JavaScript, especially on the watch page | None |
|
||||
| 2 | P0 | [Compress and cache static responses](02-static-compression-and-caching.md) | Avoid transferring 1.16MB of raw player JavaScript and repeated revalidation | Fix 1 recommended |
|
||||
| 3 | P1 | [Optimize logo and icon assets](03-image-asset-optimization.md) | Remove roughly 500KB of oversized first-load images | None |
|
||||
| 4 | P0 | [Make manual episode changes asynchronous](04-async-episode-navigation.md) | Remove full-page reloads between episodes | None |
|
||||
| 5 | P0 | [Cache and prefetch playback sources](05-playback-source-cache-and-prefetch.md) | Reduce time from player shell to playable video | Fix 4 recommended |
|
||||
| 6 | P1 | [Improve player loading and recovery states](06-player-loading-and-recovery.md) | Make unavoidable waits understandable and accessible | Can ship independently |
|
||||
| 7 | P1 | [Represent recommendation refresh state](07-recommendation-refresh-state.md) | Stop showing a false empty state during background work | None |
|
||||
| 8 | P2 | [Cache Simulcast discovery](08-simulcast-cache.md) | Remove repeated serial provider requests | None |
|
||||
| 9 | P2 | [Deduplicate canonical episode refreshes](09-canonical-episode-singleflight.md) | Prevent duplicate provider work on anime details | None |
|
||||
| 10 | P2 | [Parallelize watch-data assembly](10-parallel-watch-data-assembly.md) | Reduce watch-page server time without changing behavior | Fix 9 recommended |
|
||||
| 11 | P2 | [Make watchlist updates local-first](11-watchlist-local-first-update.md) | Keep existing-anime status changes DB-only | None |
|
||||
| 12 | P2 | [Reduce Reviews payload](12-reviews-payload-reduction.md) | Lower initial HTML size and rendering cost | Fix 2 recommended |
|
||||
|
||||
## Delivery slices
|
||||
|
||||
### Slice A: low-risk transfer wins
|
||||
|
||||
Ship fixes 1-3 together. They do not change product behavior and should be the safest first release. Capture bundle sizes and response headers before and after deployment.
|
||||
|
||||
### Slice B: playback responsiveness
|
||||
|
||||
Ship fix 4 first, then fix 5. Fix 6 can ship with either. Keep ordinary page navigation as a fallback until asynchronous transitions have proven reliable.
|
||||
|
||||
### Slice C: honest background work
|
||||
|
||||
Ship fix 7. It changes recommendation-state semantics, the handler contract, and the rendered UI together.
|
||||
|
||||
### Slice D: provider and payload cleanup
|
||||
|
||||
Ship fixes 8-12 independently. They are smaller optimizations and should not block the P0 work.
|
||||
|
||||
## Shared performance gates
|
||||
|
||||
- Do not replace the current server-rendered architecture with a client-side router.
|
||||
- Keep every asynchronous action recoverable through an ordinary URL or retry action.
|
||||
- Bound all caches by item count and TTL.
|
||||
- Never cache user-specific HTML in shared static caches.
|
||||
- Preserve request cancellation through `context.Context` and `AbortController`.
|
||||
- Add measurements before optimizing so regressions are visible.
|
||||
- Validate on a throttled connection as well as localhost; localhost hides transfer and network latency.
|
||||
|
||||
## Suggested release targets
|
||||
|
||||
These targets are directional and should be confirmed under representative production conditions:
|
||||
|
||||
- Home catalog ready: under 1 second at p75.
|
||||
- Search results after debounce: under 500ms at p75.
|
||||
- Watch page shell: under 800ms at p75.
|
||||
- Warm episode change: under 1.5 seconds to playable at p75.
|
||||
- Cold episode start: under 4 seconds to playable at p75, with a useful status shown after 3 seconds.
|
||||
- Versioned static assets: no revalidation during their one-year cache lifetime.
|
||||
- Reviews initial HTML: under 100KB uncompressed.
|
||||
|
||||
Reference in New Issue
Block a user