Merge upstream/main

This commit is contained in:
2026-07-07 22:29:06 +02:00
157 changed files with 11390 additions and 1128 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -1,5 +1,4 @@
[tools] [tools]
go = "1.25.7"
bun = "1.3.14" bun = "1.3.14"
just = "1.53.0" just = "1.53.0"
golangci-lint = "2.12.2" golangci-lint = "2.12.2"

View File

@@ -1,7 +1,7 @@
# MyAnimeList # MyAnimeList
<p align="center"> <p align="center">
<img src="/static/assets/logo.png" alt="MyAnimeList logo" width="120" /> <img src="/static/assets/logo-128.png" alt="MyAnimeList logo" width="120" />
</p> </p>
<p align="center"> <p align="center">
@@ -168,6 +168,17 @@ Configuration is loaded from environment variables, and a local `.env` file is r
</details> </details>
### Public Watchlist JSON
`GET /api/public/users/{user_id}/watchlist` returns a versioned, agent-friendly JSON view of a
user's complete watchlist. It includes status summaries, episode progress,
added/completed/last-watched dates, and localized titles.
`GET /watchlist/export` downloads the current user's watchlist as `watchlist.json`.
This endpoint is intentionally unauthenticated. A deployment that should keep watchlist and
playback data private must restrict access to `/api/public/` at the network or reverse-proxy layer.
### Repository Map ### Repository Map
| Path | Responsibility | | Path | Responsibility |

View File

@@ -55,6 +55,10 @@ dependency monitoring.
Use a strong `PLAYBACK_PROXY_SECRET` if playback proxy token signing is enabled. Do not commit real Use a strong `PLAYBACK_PROXY_SECRET` if playback proxy token signing is enabled. Do not commit real
secrets, provider tokens, session data, or production databases to the repository. secrets, provider tokens, session data, or production databases to the repository.
The read-only `/api/public/users/{user_id}/watchlist` endpoint intentionally exposes watchlist
statuses, episode progress, and watchlist dates without authentication. Restrict `/api/public/` at
the network or reverse-proxy layer when that data should not be public.
## Dependency Security ## Dependency Security
Dependencies are managed through Go modules and Bun. When updating dependencies, run the normal Dependencies are managed through Go modules and Bun. When updating dependencies, run the normal

View File

@@ -111,6 +111,33 @@ func TestLoadCachedRandomPoolIgnoresExpiredAnimeCache(t *testing.T) {
} }
} }
func TestGetJikanCacheStatsCountsRowsAndExpiry(t *testing.T) {
sqlDB := newTestCacheDB(t)
defer func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
}()
oldest := time.Date(2026, 6, 20, 12, 0, 0, 0, time.UTC)
insertCachedResponse(t, sqlDB, "expired", TopAnimeResponse{Data: []Anime{{MalID: 1}}}, oldest)
insertCachedResponse(t, sqlDB, "fresh", TopAnimeResponse{Data: []Anime{{MalID: 2}}}, time.Now().Add(time.Hour))
stats, err := db.New(sqlDB).GetJikanCacheStats(context.Background())
if err != nil {
t.Fatalf("GetJikanCacheStats: %v", err)
}
if stats.TotalRows != 2 {
t.Fatalf("TotalRows = %d, want 2", stats.TotalRows)
}
if stats.ExpiredRows != 1 {
t.Fatalf("ExpiredRows = %d, want 1", stats.ExpiredRows)
}
if stats.OldestExpiresAtSeconds != oldest.Unix() {
t.Fatalf("OldestExpiresAtSeconds = %d, want %d", stats.OldestExpiresAtSeconds, oldest.Unix())
}
}
func newTestCacheDB(t *testing.T) *sql.DB { func newTestCacheDB(t *testing.T) *sql.DB {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()

View File

@@ -321,6 +321,7 @@ type Episode struct {
MalID int `json:"mal_id"` MalID int `json:"mal_id"`
Title string `json:"title"` Title string `json:"title"`
Episode string `json:"episode"` Episode string `json:"episode"`
Aired string `json:"aired"`
Filler bool `json:"filler"` Filler bool `json:"filler"`
Recap bool `json:"recap"` Recap bool `json:"recap"`
Images *EpisodeImages `json:"images,omitempty"` Images *EpisodeImages `json:"images,omitempty"`

View File

@@ -9,9 +9,10 @@ import (
) )
type AvailableEpisodes struct { type AvailableEpisodes struct {
Sub []string Sub []string
Dub []string Dub []string
Raw []string Raw []string
Titles map[int]string
} }
func (c *AllAnimeProvider) GetEpisodeAvailability(ctx context.Context, animeID int, titleCandidates []string) (domain.EpisodeAvailability, error) { func (c *AllAnimeProvider) GetEpisodeAvailability(ctx context.Context, animeID int, titleCandidates []string) (domain.EpisodeAvailability, error) {
@@ -30,18 +31,25 @@ func (c *AllAnimeProvider) GetEpisodeAvailabilityByProviderID(ctx context.Contex
sub := episodeNums(append(available.Sub, available.Raw...)) sub := episodeNums(append(available.Sub, available.Raw...))
dub := episodeNums(available.Dub) dub := episodeNums(available.Dub)
return domain.EpisodeAvailability{Sub: sub, Dub: dub}, nil return domain.EpisodeAvailability{Sub: sub, Dub: dub, Titles: available.Titles}, nil
} }
func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) { func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) {
graphqlQuery := `query($showId: String!) { graphqlQuery := `query($showId: String!, $start: Float!, $end: Float!) {
show(_id: $showId) { show(_id: $showId) {
availableEpisodesDetail availableEpisodesDetail
lastEpisodeInfo }
episodeInfos(showId: $showId, episodeNumStart: $start, episodeNumEnd: $end) {
episodeIdNum
notes
} }
}` }`
result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{"showId": showID}) result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{
"showId": showID,
"start": 1,
"end": 100000,
})
if err != nil { if err != nil {
return AvailableEpisodes{}, err return AvailableEpisodes{}, err
} }
@@ -61,10 +69,25 @@ func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID stri
return AvailableEpisodes{}, errors.New("invalid detail") return AvailableEpisodes{}, errors.New("invalid detail")
} }
titles := map[int]string{}
infos, _ := data["episodeInfos"].([]any)
for _, value := range infos {
info, ok := value.(map[string]any)
if !ok {
continue
}
number := numericInt(info["episodeIdNum"])
title := plainText(stringValue(info["notes"]))
if number > 0 && title != "" {
titles[number] = title
}
}
return AvailableEpisodes{ return AvailableEpisodes{
Sub: stringsFrom(detail["sub"]), Sub: stringsFrom(detail["sub"]),
Dub: stringsFrom(detail["dub"]), Dub: stringsFrom(detail["dub"]),
Raw: stringsFrom(detail["raw"]), Raw: stringsFrom(detail["raw"]),
Titles: titles,
}, nil }, nil
} }

View File

@@ -26,6 +26,7 @@ type AllAnimeProvider struct {
httpClient *http.Client httpClient *http.Client
utlsClient *http.Client utlsClient *http.Client
extractor *providerExtractor extractor *providerExtractor
baseURL string
} }
func NewAllAnimeProvider() *AllAnimeProvider { func NewAllAnimeProvider() *AllAnimeProvider {
@@ -38,6 +39,7 @@ func NewAllAnimeProvider() *AllAnimeProvider {
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
}, },
extractor: newProviderExtractor(), extractor: newProviderExtractor(),
baseURL: allAnimeBaseURL,
} }
} }
@@ -45,9 +47,16 @@ func (c *AllAnimeProvider) Name() string {
return "AllAnime" return "AllAnime"
} }
func (c *AllAnimeProvider) apiBaseURL() string {
if c.baseURL != "" {
return c.baseURL
}
return allAnimeBaseURL
}
func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string) (*domain.StreamResult, error) { func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string) (*domain.StreamResult, error) {
showID := c.showID(ctx, animeID, titleCandidates, mode) showID, err := c.strictShowID(ctx, animeID, titleCandidates, mode)
if showID == "" { if err != nil {
return nil, fmt.Errorf("allanime: show not found for malID %d", animeID) return nil, fmt.Errorf("allanime: show not found for malID %d", animeID)
} }
@@ -89,7 +98,7 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
return nil, fmt.Errorf("marshal graphql payload: %w", err) return nil, fmt.Errorf("marshal graphql payload: %w", err)
} }
req, err := http.NewRequestWithContext(ctx, http.MethodPost, allAnimeBaseURL+"/api", bytes.NewReader(body)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiBaseURL()+"/api", bytes.NewReader(body))
if err != nil { if err != nil {
return nil, fmt.Errorf("create graphql request: %w", err) return nil, fmt.Errorf("create graphql request: %w", err)
} }

View File

@@ -8,6 +8,8 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"mal/internal/domain" "mal/internal/domain"
"net/http"
"strings"
"testing" "testing"
) )
@@ -248,6 +250,29 @@ func TestBuildStreamSource(t *testing.T) {
}) })
} }
func TestGetStreamsRequiresExactMalIDMatch(t *testing.T) {
t.Parallel()
provider := &AllAnimeProvider{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"wrong-show","malId":"1","name":"Wrong Anime"}]}}}`), nil
}),
},
utlsClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatal("GetStreams should not fetch episode sources without an exact MAL ID match")
return nil, nil
}),
},
}
_, err := provider.GetStreams(context.Background(), 62076, []string{"Super no Ura de Yani Suu Futari"}, "1", "sub")
if err == nil || !strings.Contains(err.Error(), "show not found") {
t.Fatalf("GetStreams() error = %v, want show not found", err)
}
}
func TestResolveDirectSourceSkipsEmbeds(t *testing.T) { func TestResolveDirectSourceSkipsEmbeds(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -5,16 +5,80 @@ import (
"crypto/cipher" "crypto/cipher"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
)
const (
aaEpoch = "4128"
aaBuildID = "9"
aaKeyAHex = "b1a9a4d051988f1b1b12dbb747439d9bd64b09ea17835600a7eaa4de87c1ad87"
aaKeyB64 = "k7DLdv5SGiuEyGUtcncl5wQOR7r4aenLfDV3AOBKlAU="
) )
var ( var (
aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"} aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"}
aaCryptoVersion byte = 1
) )
func xorKey() ([]byte, error) {
keyA, err := hex.DecodeString(aaKeyAHex)
if err != nil {
return nil, fmt.Errorf("decode key_a: %w", err)
}
keyB, err := base64.StdEncoding.DecodeString(aaKeyB64)
if err != nil {
return nil, fmt.Errorf("decode key_b: %w", err)
}
key := make([]byte, 32)
for i := range key {
key[i] = keyA[i] ^ keyB[i]
}
return key, nil
}
func makeAALease(queryHash string) (string, error) {
aesKey, err := xorKey()
if err != nil {
return "", err
}
ts := (time.Now().UnixMilli() / 300_000) * 300_000
ivSeed := fmt.Sprintf("%s:%s:%s:%d", aaEpoch, aaBuildID, queryHash, ts)
ivHash := sha256.Sum256([]byte(ivSeed))
iv := ivHash[:12]
payload := fmt.Sprintf(`{"v":%d,"ts":%d,"epoch":%s,"buildId":"%s","qh":"%s"}`, aaCryptoVersion, ts, aaEpoch, aaBuildID, queryHash)
block, err := aes.NewCipher(aesKey)
if err != nil {
return "", fmt.Errorf("create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("create gcm: %w", err)
}
ciphertext := gcm.Seal(nil, iv, []byte(payload), nil)
envelope := append([]byte{aaCryptoVersion}, iv...)
envelope = append(envelope, ciphertext...)
return base64.StdEncoding.EncodeToString(envelope), nil
}
type aesCandidate struct {
key []byte
}
func decryptTobeparsed(encoded string) ([]byte, error) { func decryptTobeparsed(encoded string) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(encoded) raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil { if err != nil {
@@ -29,30 +93,39 @@ func decryptTobeparsed(encoded string) ([]byte, error) {
iv := raw[1:13] iv := raw[1:13]
cipherText := raw[13 : len(raw)-16] cipherText := raw[13 : len(raw)-16]
for _, keyStr := range aesKeys { candidates := make([]aesCandidate, 0, len(aesKeys)+1)
key := sha256.Sum256([]byte(keyStr))
block, err := aes.NewCipher(key[:]) xorK, err := xorKey()
if err == nil {
candidates = append(candidates, aesCandidate{key: xorK})
}
for _, keyStr := range aesKeys {
k := sha256.Sum256([]byte(keyStr))
candidates = append(candidates, aesCandidate{key: k[:]})
}
for _, candidate := range candidates {
block, err := aes.NewCipher(candidate.key)
if err != nil { if err != nil {
continue continue
} }
if version == 1 { if version == 1 {
gcm, err := cipher.NewGCM(block)
if err == nil {
combined := append(append([]byte{}, cipherText...), raw[len(raw)-16:]...)
plainText, openErr := gcm.Open(nil, iv, combined, nil)
if openErr == nil && json.Valid(plainText) {
return plainText, nil
}
}
plainText := tryDecryptCTR(block, iv, cipherText) plainText := tryDecryptCTR(block, iv, cipherText)
if json.Valid(plainText) { if json.Valid(plainText) {
return plainText, nil return plainText, nil
} }
} }
gcm, err := cipher.NewGCM(block)
if err == nil {
tag := raw[len(raw)-16:]
combined := append(append([]byte{}, cipherText...), tag...)
plainText, openErr := gcm.Open(nil, iv, combined, nil)
if openErr == nil && json.Valid(plainText) {
return plainText, nil
}
}
} }
return nil, errors.New("decryption failed") return nil, errors.New("decryption failed")
@@ -159,6 +232,10 @@ func parseGraphQLResponse(respBody []byte, decodeErrPrefix string) (map[string]a
return nil, fmt.Errorf("%s: %w", decodeErrPrefix, err) return nil, fmt.Errorf("%s: %w", decodeErrPrefix, err)
} }
if _, ok := parsed["data"]; ok {
return parsed, nil
}
if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 { if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 {
return nil, fmt.Errorf("graphql error: %v", errs[0]) return nil, fmt.Errorf("graphql error: %v", errs[0])
} }

View File

@@ -150,17 +150,17 @@ func TestGraphqlRequestWithHash_Plain(t *testing.T) {
t.Parallel() t.Parallel()
provider := &AllAnimeProvider{ provider := &AllAnimeProvider{
utlsClient: &http.Client{ httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodGet { if req.Method != http.MethodPost {
t.Errorf("method = %q, want GET", req.Method) t.Errorf("method = %q, want POST", req.Method)
}
if !strings.Contains(req.URL.String(), episodeQueryHash) {
t.Errorf("url should contain hash, got %q", req.URL.String())
} }
if req.Header.Get("Referer") != allAnimeReferer { if req.Header.Get("Referer") != allAnimeReferer {
t.Errorf("Referer = %q", req.Header.Get("Referer")) t.Errorf("Referer = %q", req.Header.Get("Referer"))
} }
if req.Header.Get("x-build-id") != "9" {
t.Errorf("x-build-id = %q", req.Header.Get("x-build-id"))
}
return mockStringResponse(http.StatusOK, `{"data":{"episode":{"sourceUrls":[{"sourceUrl":"https://example.test/v.mp4","sourceName":"default"}]}}}`), nil return mockStringResponse(http.StatusOK, `{"data":{"episode":{"sourceUrls":[{"sourceUrl":"https://example.test/v.mp4","sourceName":"default"}]}}}`), nil
}), }),
}, },
@@ -190,7 +190,7 @@ func TestGraphqlRequestWithHash_Encrypted(t *testing.T) {
encryptedPayload := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://e.test/v.mp4","sourceName":"default"}]}`)) encryptedPayload := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://e.test/v.mp4","sourceName":"default"}]}`))
provider := &AllAnimeProvider{ provider := &AllAnimeProvider{
utlsClient: &http.Client{ httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encryptedPayload+`"}}`), nil return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encryptedPayload+`"}}`), nil
}), }),
@@ -205,7 +205,11 @@ func TestGraphqlRequestWithHash_Encrypted(t *testing.T) {
t.Fatalf("graphqlRequestWithHash: %v", err) t.Fatalf("graphqlRequestWithHash: %v", err)
} }
sources := nestedSlice(result, "episode", "sourceUrls") ep, ok := result["episode"].(map[string]any)
if !ok {
t.Fatal("result missing episode key")
}
sources := nestedSlice(ep, "sourceUrls")
if len(sources) != 1 { if len(sources) != 1 {
t.Fatalf("got %d sources, want 1", len(sources)) t.Fatalf("got %d sources, want 1", len(sources))
} }
@@ -215,7 +219,7 @@ func TestGraphqlRequestWithHash_Non200(t *testing.T) {
t.Parallel() t.Parallel()
provider := &AllAnimeProvider{ provider := &AllAnimeProvider{
utlsClient: &http.Client{ httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusNotFound, `not found`), nil return mockStringResponse(http.StatusNotFound, `not found`), nil
}), }),
@@ -235,7 +239,7 @@ func TestGraphqlRequestWithHash_EmptyData(t *testing.T) {
t.Parallel() t.Parallel()
provider := &AllAnimeProvider{ provider := &AllAnimeProvider{
utlsClient: &http.Client{ httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusOK, `{"data":{}}`), nil return mockStringResponse(http.StatusOK, `{"data":{}}`), nil
}), }),
@@ -258,12 +262,6 @@ func TestGetEpisodeSources_EncryptedHash(t *testing.T) {
provider := &AllAnimeProvider{ provider := &AllAnimeProvider{
httpClient: &http.Client{ httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Error("fallback POST should not be called")
return nil, nil
}),
},
utlsClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil
}), }),
@@ -292,15 +290,15 @@ func TestGetEpisodeSources_FallbackPost(t *testing.T) {
provider := &AllAnimeProvider{ provider := &AllAnimeProvider{
httpClient: &http.Client{ httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
req.Body.Close()
if strings.Contains(string(body), `"extensions":`) {
return mockStringResponse(http.StatusOK, `{"data":{}}`), nil
}
fallbackCalled = true fallbackCalled = true
return mockStringResponse(http.StatusOK, sourceResponse), nil return mockStringResponse(http.StatusOK, sourceResponse), nil
}), }),
}, },
utlsClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusNotFound, `not found`), nil
}),
},
extractor: newProviderExtractor(), extractor: newProviderExtractor(),
} }
@@ -325,11 +323,6 @@ func TestGetEpisodeSources_BothFail(t *testing.T) {
return mockStringResponse(http.StatusNotFound, `not found`), nil return mockStringResponse(http.StatusNotFound, `not found`), nil
}), }),
}, },
utlsClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusNotFound, `not found`), nil
}),
},
extractor: newProviderExtractor(), extractor: newProviderExtractor(),
} }
@@ -343,17 +336,19 @@ func TestGetAvailableEpisodes(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
name string name string
body string body string
wantSub int wantSub int
wantDub int wantDub int
wantErr bool wantTitle string
wantErr bool
}{ }{
{ {
name: "sub and dub available", name: "sub and dub available",
body: `{"data":{"show":{"availableEpisodesDetail":{"sub":["1","2","3"],"dub":["1"]},"lastEpisodeInfo":{}}}}`, body: `{"data":{"show":{"availableEpisodesDetail":{"sub":["1","2","3"],"dub":["1"]}},"episodeInfos":[{"episodeIdNum":1,"notes":"The Beginning"}]}}`,
wantSub: 3, wantSub: 3,
wantDub: 1, wantDub: 1,
wantTitle: "The Beginning",
}, },
{ {
name: "sub only", name: "sub only",
@@ -394,10 +389,18 @@ func TestGetAvailableEpisodes(t *testing.T) {
if len(available.Dub) != tt.wantDub { if len(available.Dub) != tt.wantDub {
t.Errorf("Dub count = %d, want %d", len(available.Dub), tt.wantDub) t.Errorf("Dub count = %d, want %d", len(available.Dub), tt.wantDub)
} }
assertEpisodeTitle(t, available, tt.wantTitle)
}) })
} }
} }
func assertEpisodeTitle(t *testing.T, available AvailableEpisodes, want string) {
t.Helper()
if want != "" && available.Titles[1] != want {
t.Errorf("episode 1 title = %q, want %q", available.Titles[1], want)
}
}
func TestSearch(t *testing.T) { func TestSearch(t *testing.T) {
t.Parallel() t.Parallel()
@@ -454,11 +457,11 @@ func TestGetStreams_FullSuccess(t *testing.T) {
provider := &AllAnimeProvider{ provider := &AllAnimeProvider{
httpClient: &http.Client{ httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusOK, searchBody), nil body, _ := io.ReadAll(req.Body)
}), req.Body.Close()
}, if strings.Contains(string(body), `"search":`) {
utlsClient: &http.Client{ return mockStringResponse(http.StatusOK, searchBody), nil
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { }
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil
}), }),
}, },
@@ -489,12 +492,6 @@ func TestGetStreams_ShowNotFound(t *testing.T) {
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[]}}}`), nil return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[]}}}`), nil
}), }),
}, },
utlsClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Error("should not call episode sources when show not found")
return nil, nil
}),
},
extractor: newProviderExtractor(), extractor: newProviderExtractor(),
} }
@@ -510,11 +507,11 @@ func TestGetStreams_NoSources(t *testing.T) {
provider := &AllAnimeProvider{ provider := &AllAnimeProvider{
httpClient: &http.Client{ httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"showX","malId":"1","name":"Anime"}]}}}`), nil body, _ := io.ReadAll(req.Body)
}), req.Body.Close()
}, if strings.Contains(string(body), `"search":`) {
utlsClient: &http.Client{ return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"showX","malId":"1","name":"Anime"}]}}}`), nil
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { }
return mockStringResponse(http.StatusNotFound, `not found`), nil return mockStringResponse(http.StatusNotFound, `not found`), nil
}), }),
}, },

View File

@@ -68,7 +68,7 @@ func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string
TranslationType: mode, TranslationType: mode,
} }
data, err := graphql.Post[searchData](ctx, c.httpClient, allAnimeBaseURL+"/api", searchQuery, vars, graphql.PostOptions{ data, err := graphql.Post[searchData](ctx, c.httpClient, c.apiBaseURL()+"/api", searchQuery, vars, graphql.PostOptions{
Headers: map[string]string{ Headers: map[string]string{
"Referer": allAnimeReferer, "Referer": allAnimeReferer,
"User-Agent": defaultUserAgent, "User-Agent": defaultUserAgent,
@@ -99,36 +99,6 @@ func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string
return out, nil return out, nil
} }
func (c *AllAnimeProvider) showID(ctx context.Context, animeID int, titleCandidates []string, mode string) string {
targetMalIDStr := strconv.Itoa(animeID)
fallbackID := ""
for _, title := range titleCandidates {
searchResults, err := c.Search(ctx, title, mode)
if err != nil || len(searchResults) == 0 {
continue
}
if showID := exactMatchShowID(searchResults, targetMalIDStr); showID != "" {
return showID
}
if fallbackID == "" {
fallbackID = searchResults[0].ID
}
}
return fallbackID
}
func exactMatchShowID(searchResults []searchResult, targetMalID string) string {
for _, res := range searchResults {
if res.MalID == targetMalID {
return res.ID
}
}
return ""
}
func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error) { func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error) {
for _, mode := range []string{"sub", "dub"} { for _, mode := range []string{"sub", "dub"} {
showID, err := c.strictShowID(ctx, animeID, titleCandidates, mode) showID, err := c.strictShowID(ctx, animeID, titleCandidates, mode)

View File

@@ -0,0 +1,164 @@
package allanime
import (
"bytes"
"context"
"errors"
"fmt"
stdhtml "html"
"strconv"
"strings"
"golang.org/x/net/html"
)
type ProviderShow struct {
ID string
Name string
EnglishName string
Description string
MalID int
Status string
Thumbnail string
Type string
Year int
EpisodeCount int
SubEpisodes []int
DubEpisodes []int
}
func (c *AllAnimeProvider) DirectSequels(ctx context.Context, show ProviderShow) ([]string, error) {
query := `query($showId: String!) { show(_id: $showId) { relatedShows } }`
result, err := c.graphqlRequest(ctx, query, map[string]any{"showId": show.ID})
if err != nil {
return nil, err
}
data, _ := result["data"].(map[string]any)
rawShow, _ := data["show"].(map[string]any)
relations, _ := rawShow["relatedShows"].([]any)
ids := make([]string, 0, len(relations))
for _, raw := range relations {
relation, _ := raw.(map[string]any)
id, _ := relation["showId"].(string)
if relation["relation"] == "sequel" && id != "" {
ids = append(ids, id)
}
}
return ids, nil
}
func (c *AllAnimeProvider) GetProviderShow(ctx context.Context, showID string) (ProviderShow, error) {
query := `query($showId: String!) { show(_id: $showId) { _id name englishName description malId status thumbnail type season episodeCount availableEpisodesDetail } }`
result, err := c.graphqlRequest(ctx, query, map[string]any{"showId": showID})
if err != nil {
return ProviderShow{}, err
}
data, _ := result["data"].(map[string]any)
raw, _ := data["show"].(map[string]any)
if raw == nil {
return ProviderShow{}, fmt.Errorf("allanime: show %s not found", showID)
}
return providerShowFrom(raw), nil
}
func (c *AllAnimeProvider) SeasonalShows(ctx context.Context, season string, year int) ([]ProviderShow, error) {
const pageSize = 40
query := `query($search: SearchInput, $page: Int) { shows(search: $search, limit: 40, page: $page, countryOrigin: ALL) { edges { _id name englishName description malId status thumbnail type season episodeCount availableEpisodesDetail } } }`
if season == "" {
return nil, errors.New("allanime: season is required")
}
search := map[string]any{
"allowAdult": false,
"allowUnknown": false,
"season": strings.ToUpper(season[:1]) + strings.ToLower(season[1:]),
"types": []string{"TV"},
"includeTypes": true,
}
out := make([]ProviderShow, 0)
seen := make(map[int]bool)
for page := 1; page <= 20; page++ {
result, err := c.graphqlRequest(ctx, query, map[string]any{"search": search, "page": page})
if err != nil {
return nil, err
}
data, _ := result["data"].(map[string]any)
shows, _ := data["shows"].(map[string]any)
edges, _ := shows["edges"].([]any)
newestYear := 0
for _, edge := range edges {
raw, _ := edge.(map[string]any)
show := providerShowFrom(raw)
newestYear = max(newestYear, show.Year)
if seen[show.MalID] || !isPlayableSeasonShow(show, year) {
continue
}
seen[show.MalID] = true
out = append(out, show)
}
if len(edges) < pageSize || (newestYear > 0 && newestYear < year) {
break
}
}
return out, nil
}
func isPlayableSeasonShow(show ProviderShow, year int) bool {
return show.Year == year && show.MalID > 0 && show.Type == "TV" && max(len(show.SubEpisodes), len(show.DubEpisodes)) > 0
}
func providerShowFrom(raw map[string]any) ProviderShow {
detail, _ := raw["availableEpisodesDetail"].(map[string]any)
season, _ := raw["season"].(map[string]any)
return ProviderShow{
ID: stringValue(raw["_id"]),
Name: stringValue(raw["name"]),
EnglishName: stringValue(raw["englishName"]),
Description: plainText(stringValue(raw["description"])),
MalID: intValue(raw["malId"]),
Status: stringValue(raw["status"]),
Thumbnail: stringValue(raw["thumbnail"]),
Type: stringValue(raw["type"]),
Year: numericInt(season["year"]),
EpisodeCount: intValue(raw["episodeCount"]),
SubEpisodes: episodeNums(stringsFrom(detail["sub"])),
DubEpisodes: episodeNums(stringsFrom(detail["dub"])),
}
}
func plainText(value string) string {
tokenizer := html.NewTokenizer(bytes.NewBufferString(value))
var out strings.Builder
for {
switch tokenizer.Next() {
case html.ErrorToken:
return out.String()
case html.TextToken:
text := strings.Join(strings.Fields(stdhtml.UnescapeString(string(tokenizer.Text()))), " ")
if text == "" {
continue
}
if out.Len() > 0 {
out.WriteByte(' ')
}
out.WriteString(text)
}
}
}
func stringValue(value any) string {
valueString, _ := value.(string)
return valueString
}
func intValue(value any) int {
n, _ := strconv.Atoi(stringValue(value))
return n
}
func numericInt(value any) int {
if n := intValue(value); n > 0 {
return n
}
n, _ := value.(float64)
return int(n)
}

View File

@@ -0,0 +1,100 @@
package allanime
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestDirectSequelsReturnsOnlyPlayableSequels(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"show":{"relatedShows":[
{"relation":"sequel","showId":"season-2"},
{"relation":"side story","showId":"ova"}
]}}}`))
}))
defer server.Close()
provider := NewAllAnimeProvider()
provider.httpClient = server.Client()
provider.baseURL = server.URL
providerShow := ProviderShow{ID: "season-1"}
got, err := provider.DirectSequels(context.Background(), providerShow)
if err != nil {
t.Fatalf("DirectSequels: %v", err)
}
if len(got) != 1 || got[0] != "season-2" {
t.Fatalf("DirectSequels = %v, want [season-2]", got)
}
}
func TestGetProviderShowKeepsOnlyPositiveIntegerEpisodes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"show":{"_id":"season-2","name":"Example Season 2","englishName":"Example Season 2","description":"A useful<br><i>synopsis</i> &amp; summary.","malId":"42","status":"Finished","episodeCount":"2","availableEpisodesDetail":{"sub":["2","1","0","1.5"],"dub":["1"],"raw":[]}}}}`))
}))
defer server.Close()
provider := NewAllAnimeProvider()
provider.httpClient = server.Client()
provider.baseURL = server.URL
got, err := provider.GetProviderShow(context.Background(), "season-2")
if err != nil {
t.Fatalf("GetProviderShow: %v", err)
}
if got.MalID != 42 || len(got.SubEpisodes) != 2 || len(got.DubEpisodes) != 1 {
t.Fatalf("GetProviderShow = %+v", got)
}
if got.Description != "A useful synopsis & summary." {
t.Fatalf("Description = %q", got.Description)
}
}
func TestSeasonalShowsReturnsPlayableTVAnime(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var request struct {
Variables struct {
Page int `json:"page"`
Search map[string]any `json:"search"`
} `json:"variables"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode request: %v", err)
}
if _, ok := request.Variables.Search["year"]; ok {
t.Fatal("seasonal request must filter normalized years locally")
}
edges := make([]map[string]any, 0)
switch request.Variables.Page {
case 1:
for range 40 {
edges = append(edges, map[string]any{"_id": "empty", "malId": "12", "type": "TV", "availableEpisodesDetail": map[string]any{"sub": []string{}, "dub": []string{}}})
}
case 2:
edges = append(edges,
map[string]any{"_id": "tv", "name": "Summer Show", "malId": "10", "type": "TV", "season": map[string]any{"quarter": "Summer", "year": "2026"}, "availableEpisodesDetail": map[string]any{"sub": []string{"1"}, "dub": []string{}}},
map[string]any{"_id": "old", "name": "Old Summer Show", "malId": "11", "type": "TV", "season": map[string]any{"quarter": "Summer", "year": 2025}, "availableEpisodesDetail": map[string]any{"sub": []string{"1"}, "dub": []string{}}},
)
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"shows": map[string]any{"edges": edges}}})
}))
defer server.Close()
provider := NewAllAnimeProvider()
provider.httpClient = server.Client()
provider.baseURL = server.URL
got, err := provider.SeasonalShows(context.Background(), "summer", 2026)
if err != nil {
t.Fatalf("SeasonalShows: %v", err)
}
if len(got) != 1 || got[0].MalID != 10 || got[0].Year != 2026 {
t.Fatalf("SeasonalShows = %+v", got)
}
}

View File

@@ -1,11 +1,12 @@
package allanime package allanime
import ( import (
"bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strings" "strings"
) )
@@ -172,11 +173,15 @@ func isHTTPURL(value string) bool {
} }
func buildStreamSource(url, sourceType, provider string) StreamSource { func buildStreamSource(url, sourceType, provider string) StreamSource {
referer := allAnimeReferer
if strings.Contains(strings.ToLower(provider), "yt-mp4") {
referer = allAnimeSiteURL
}
return StreamSource{ return StreamSource{
URL: url, URL: url,
Provider: provider, Provider: provider,
Type: sourceType, Type: sourceType,
Referer: allAnimeReferer, Referer: referer,
} }
} }
@@ -240,28 +245,24 @@ func stringMapValue(item map[string]any, key string) (string, bool) {
} }
func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) { func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) {
req, err := newHashRequest(ctx, showID, episode, mode) req, err := c.newHashRequest(ctx, showID, episode, mode)
if err != nil { if err != nil {
return nil, fmt.Errorf("create GET request: %w", err) return nil, fmt.Errorf("create POST request: %w", err)
} }
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", defaultUserAgent) req.Header.Set("User-Agent", defaultUserAgent)
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
req.Header.Set("Accept-Encoding", "identity")
req.Header.Set("Referer", allAnimeReferer) req.Header.Set("Referer", allAnimeReferer)
req.Header.Set("Origin", allAnimeOrigin) req.Header.Set("Origin", allAnimeOrigin)
req.Header.Set("Sec-Fetch-Dest", "empty") req.Header.Set("x-build-id", "9")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Site", "cross-site")
statusCode, respBody, err := executeAndReadResponse(c.utlsClient, req, "execute GET request", "read response") statusCode, respBody, err := executeAndReadResponse(c.httpClient, req, "execute POST request (aaReq)", "read response")
if err != nil { if err != nil {
return nil, err return nil, err
} }
if statusCode != http.StatusOK { if statusCode != http.StatusOK {
return nil, fmt.Errorf("GET status %d: %s", statusCode, string(respBody)) return nil, fmt.Errorf("POST status %d: %s", statusCode, string(respBody))
} }
parsed, err := parseGraphQLResponse(respBody, "decode response") parsed, err := parseGraphQLResponse(respBody, "decode response")
@@ -289,15 +290,33 @@ func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, e
return nil, errors.New("no usable data in response") return nil, errors.New("no usable data in response")
} }
func newHashRequest(ctx context.Context, showID, episode, mode string) (*http.Request, error) { func (c *AllAnimeProvider) newHashRequest(ctx context.Context, showID, episode, mode string) (*http.Request, error) {
varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, strings.ToLower(mode), episode) aaReq, err := makeAALease(episodeQueryHash)
extJSON := fmt.Sprintf(`{"persistedQuery":{"version":1,"sha256Hash":"%s"}}`, episodeQueryHash) if err != nil {
return nil, fmt.Errorf("create aa lease: %w", err)
}
params := url.Values{} payload := map[string]any{
params.Set("variables", varsJSON) "variables": map[string]any{
params.Set("extensions", extJSON) "showId": showID,
"translationType": strings.ToLower(mode),
"episodeString": episode,
},
"extensions": map[string]any{
"persistedQuery": map[string]any{
"version": 1,
"sha256Hash": episodeQueryHash,
},
"aaReq": aaReq,
},
}
return http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api?%s", allAnimeBaseURL, params.Encode()), nil) body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal payload: %w", err)
}
return http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/api", allAnimeBaseURL), bytes.NewReader(body))
} }
func detectStreamType(sourceURL string) string { func detectStreamType(sourceURL string) string {

View File

@@ -0,0 +1,272 @@
package tvmaze
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"mal/internal/domain"
)
var (
seasonNumberPattern = regexp.MustCompile(`(?i)(?:\bseason\s+(\d+)|\b(\d+)(?:st|nd|rd|th)\s+season)\b`)
seasonSuffixPattern = regexp.MustCompile(`(?i)\s+(?:(?:season\s+\d+)|(?:\d+(?:st|nd|rd|th)\s+season))(?:\s+part\s+\d+)?\s*$`)
partNumberPattern = regexp.MustCompile(`(?i)\bpart\s+(\d+)\b`)
)
const baseURL = "https://api.tvmaze.com"
type Client struct {
httpClient *http.Client
baseURL string
}
type searchResult struct {
Show show `json:"show"`
}
type show struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
}
type episode struct {
Name string `json:"name"`
Season int `json:"season"`
Airdate string `json:"airdate"`
}
func NewClient() *Client {
return &Client{
httpClient: &http.Client{Timeout: 5 * time.Second},
baseURL: baseURL,
}
}
func (c *Client) Name() string {
return "TVmaze"
}
func (c *Client) ResolveEpisodeProviderID(ctx context.Context, _ int, titleCandidates []string) (string, error) {
matches := map[int]struct{}{}
for _, candidate := range titleSearchCandidates(titleCandidates, 6) {
results, err := c.search(ctx, candidate)
if err != nil {
return "", err
}
addExactMatches(matches, results, normalizeTitle(candidate))
if len(matches) == 1 {
for id := range matches {
return strconv.Itoa(id), nil
}
}
}
if len(matches) > 1 {
return "", errors.New("tvmaze: multiple exact show matches")
}
return "", errors.New("tvmaze: no exact show match")
}
func uniqueTitleCandidates(candidates []string, limit int) []string {
seen := map[string]struct{}{}
unique := make([]string, 0, min(len(candidates), limit))
for _, candidate := range candidates {
normalized := normalizeTitle(candidate)
if normalized == "" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
unique = append(unique, candidate)
if len(unique) == limit {
break
}
}
return unique
}
func addExactMatches(matches map[int]struct{}, results []searchResult, normalizedTitle string) {
for _, result := range results {
if result.Show.ID <= 0 || normalizeTitle(result.Show.Name) != normalizedTitle {
continue
}
if result.Show.Type != "" && !strings.EqualFold(result.Show.Type, "Animation") {
continue
}
matches[result.Show.ID] = struct{}{}
}
}
func (c *Client) GetEpisodeTitlesByProviderID(ctx context.Context, providerID string, anime domain.Anime, episodeCount int) (map[int]string, error) {
var episodes []episode
if err := c.getJSON(ctx, "/shows/"+url.PathEscape(providerID)+"/episodes", &episodes); err != nil {
return nil, err
}
episodes, err := episodesForAnimeSeason(episodes, anime, episodeCount)
if err != nil {
return nil, err
}
titles := make(map[int]string, len(episodes))
for i, item := range episodes {
title := strings.TrimSpace(item.Name)
if title != "" {
titles[i+1] = title
}
}
if len(titles) == 0 {
return nil, errors.New("tvmaze: show has no episode titles")
}
return titles, nil
}
func titleSearchCandidates(candidates []string, limit int) []string {
expanded := make([]string, 0, len(candidates)*2)
for _, candidate := range candidates {
parent := strings.TrimSpace(seasonSuffixPattern.ReplaceAllString(candidate, ""))
if parent != "" && parent != strings.TrimSpace(candidate) {
expanded = append(expanded, parent)
}
expanded = append(expanded, candidate)
}
return uniqueTitleCandidates(expanded, limit)
}
func episodesForAnimeSeason(episodes []episode, anime domain.Anime, episodeCount int) ([]episode, error) {
seasons := groupEpisodesBySeason(episodes)
season := animeSeasonNumber(anime)
if season == 0 {
season = seasonForYear(seasons, anime.Year)
}
if season == 0 {
season = 1
}
selected := seasons[season]
if len(selected) == 0 {
return nil, fmt.Errorf("tvmaze: season %d has no episodes", season)
}
if episodeCount <= 0 || episodeCount >= len(selected) {
return selected, nil
}
if animePartNumber(anime) > 1 {
return selected[len(selected)-episodeCount:], nil
}
return selected[:episodeCount], nil
}
func groupEpisodesBySeason(episodes []episode) map[int][]episode {
seasons := map[int][]episode{}
for _, item := range episodes {
if item.Season > 0 {
seasons[item.Season] = append(seasons[item.Season], item)
}
}
return seasons
}
func animeSeasonNumber(anime domain.Anime) int {
return titleNumber(animeTitles(anime), seasonNumberPattern)
}
func animePartNumber(anime domain.Anime) int {
return titleNumber(animeTitles(anime), partNumberPattern)
}
func titleNumber(titles []string, pattern *regexp.Regexp) int {
for _, title := range titles {
match := pattern.FindStringSubmatch(title)
if len(match) <= 1 {
continue
}
for _, value := range match[1:] {
number, err := strconv.Atoi(value)
if err == nil && number > 0 {
return number
}
}
}
return 0
}
func animeTitles(anime domain.Anime) []string {
titles := make([]string, 0, 3+len(anime.TitleSynonyms))
titles = append(titles, anime.Title, anime.TitleEnglish, anime.TitleJapanese)
titles = append(titles, anime.TitleSynonyms...)
return titles
}
func seasonForYear(seasons map[int][]episode, year int) int {
if year <= 0 {
return 0
}
matchedSeason := 0
for season, episodes := range seasons {
if len(episodes) == 0 || !strings.HasPrefix(episodes[0].Airdate, strconv.Itoa(year)+"-") {
continue
}
if matchedSeason != 0 {
return 0
}
matchedSeason = season
}
return matchedSeason
}
func (c *Client) search(ctx context.Context, title string) ([]searchResult, error) {
var results []searchResult
err := c.getJSON(ctx, "/search/shows?q="+url.QueryEscape(title), &results)
return results, err
}
func (c *Client) getJSON(ctx context.Context, path string, target any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.apiURL()+path, nil)
if err != nil {
return fmt.Errorf("tvmaze: create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "mal/1.0")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("tvmaze: request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("tvmaze: status %d", resp.StatusCode)
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(target); err != nil {
return fmt.Errorf("tvmaze: decode response: %w", err)
}
return nil
}
func (c *Client) apiURL() string {
if c.baseURL != "" {
return c.baseURL
}
return baseURL
}
func normalizeTitle(value string) string {
return strings.Map(func(r rune) rune {
if unicode.IsLetter(r) || unicode.IsNumber(r) {
return unicode.ToLower(r)
}
return -1
}, strings.TrimSpace(value))
}

View File

@@ -0,0 +1,107 @@
package tvmaze
import (
"context"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"mal/integrations/jikan"
"mal/internal/domain"
)
func TestResolveEpisodeProviderIDRequiresExactAnimatedTitle(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search/shows" {
t.Fatalf("path = %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"show":{"id":1,"name":"May I Ask for One Final Thing Extra","type":"Animation"}},
{"show":{"id":80712,"name":"May I Ask for One Final Thing?","type":"Animation"}},
{"show":{"id":99,"name":"May I Ask for One Final Thing?","type":"Scripted"}}
]`))
}))
defer server.Close()
client := newTestClient(server)
id, err := client.ResolveEpisodeProviderID(context.Background(), 59846, []string{"May I Ask for One Final Thing?"})
if err != nil {
t.Fatal(err)
}
if id != "80712" {
t.Fatalf("id = %q, want 80712", id)
}
}
func TestGetEpisodeTitlesUsesAiringOrder(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/shows/80712/episodes" {
t.Fatalf("path = %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"season":1,"number":1,"name":"Wrong season"},
{"season":2,"number":1,"name":" First title "},
{"season":2,"number":2,"name":"Second title"},
{"season":2,"number":3,"name":"Future title"},
{"season":3,"number":1,"name":"Another wrong season"}
]`))
}))
defer server.Close()
client := newTestClient(server)
titles, err := client.GetEpisodeTitlesByProviderID(context.Background(), "80712", domain.Anime{Anime: jikan.Anime{
Title: "Example 2nd Season",
}}, 2)
if err != nil {
t.Fatal(err)
}
want := map[int]string{1: "First title", 2: "Second title"}
if !reflect.DeepEqual(titles, want) {
t.Fatalf("titles = %#v, want %#v", titles, want)
}
}
func TestGetEpisodeTitlesSelectsSplitCourWithinSeason(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"season":2,"number":1,"name":"Cour one A"},
{"season":2,"number":2,"name":"Cour one B"},
{"season":2,"number":3,"name":"Cour two A"},
{"season":2,"number":4,"name":"Cour two B"}
]`))
}))
defer server.Close()
client := newTestClient(server)
titles, err := client.GetEpisodeTitlesByProviderID(context.Background(), "14459", domain.Anime{Anime: jikan.Anime{
Title: "Re:Zero kara Hajimeru Isekai Seikatsu 2nd Season Part 2",
}}, 2)
if err != nil {
t.Fatal(err)
}
want := map[int]string{1: "Cour two A", 2: "Cour two B"}
if !reflect.DeepEqual(titles, want) {
t.Fatalf("titles = %#v, want %#v", titles, want)
}
}
func TestTitleSearchCandidatesUseParentShowName(t *testing.T) {
got := titleSearchCandidates([]string{
"Re:Zero kara Hajimeru Isekai Seikatsu 2nd Season Part 2",
}, 6)
want := []string{
"Re:Zero kara Hajimeru Isekai Seikatsu",
"Re:Zero kara Hajimeru Isekai Seikatsu 2nd Season Part 2",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("candidates = %#v, want %#v", got, want)
}
}
func newTestClient(server *httptest.Server) *Client {
return &Client{httpClient: server.Client(), baseURL: server.URL}
}

View File

@@ -4,10 +4,64 @@ import (
"mal/internal/observability" "mal/internal/observability"
"mal/internal/server" "mal/internal/server"
"net/http" "net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func (h *AnimeHandler) HandleSimulcast(c *gin.Context) {
c.HTML(http.StatusOK, "simulcast.gohtml", gin.H{
"CurrentPath": "/simulcast",
"User": server.CurrentUser(c),
"SimulcastURL": simulcastURL(c),
})
}
func (h *AnimeHandler) HandleSimulcastContent(c *gin.Context) {
now := time.Now()
current := calendarSeason(now.Year(), int(now.Month()))
latest := h.discoverySvc.LatestAvailableSeason(c.Request.Context(), current)
selected := seasonSelection(c.Query("season"), c.Query("year"), current, latest)
data, err := h.discoverySvc.GetSimulcast(c.Request.Context(), selected)
if err != nil {
observability.WarnContext(c.Request.Context(), "simulcast_fetch_failed", "anime", "", nil, err)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), server.CurrentUserID(c), data.Animes)
previous, next := seasonNavigation(selected, 2018, latest)
c.HTML(http.StatusOK, "simulcast.gohtml", gin.H{
"_fragment": "simulcast_content",
"CurrentPath": "/simulcast",
"User": server.CurrentUser(c),
"Animes": data.Animes,
"Season": data.Season,
"SeasonLabel": strings.ToUpper(data.Season[:1]) + data.Season[1:],
"Year": data.Year,
"SeasonOptions": seasonOptions(2018, latest),
"Previous": previous,
"Next": next,
"WatchlistMap": watchlistMap,
})
}
func simulcastURL(c *gin.Context) string {
query := url.Values{}
if season := c.Query("season"); season != "" {
query.Set("season", season)
}
if year := c.Query("year"); year != "" {
query.Set("year", year)
}
if encoded := query.Encode(); encoded != "" {
return "/api/simulcast?" + encoded
}
return "/api/simulcast"
}
func (h *AnimeHandler) HandleSearch(c *gin.Context) { func (h *AnimeHandler) HandleSearch(c *gin.Context) {
c.HTML(http.StatusOK, "search.gohtml", gin.H{ c.HTML(http.StatusOK, "search.gohtml", gin.H{
"User": server.CurrentUser(c), "User": server.CurrentUser(c),

View File

@@ -0,0 +1,64 @@
package anime
import (
"context"
"mal/integrations/playback/allanime"
"mal/templates"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
type countingSeasonalProvider struct {
calls int
}
func (p *countingSeasonalProvider) SeasonalShows(context.Context, string, int) ([]allanime.ProviderShow, error) {
p.calls++
return nil, nil
}
func TestSimulcastPageDefersProviderFetch(t *testing.T) {
gin.SetMode(gin.TestMode)
provider := &countingSeasonalProvider{}
handler := NewAnimeHandler(nil, nil, nil, newSeasonDiscoveryService(provider))
renderer, err := templates.ProvideRenderer()
if err != nil {
t.Fatalf("ProvideRenderer: %v", err)
}
router := gin.New()
router.HTMLRender = renderer
router.GET("/simulcast", handler.HandleSimulcast)
router.GET("/api/simulcast", handler.HandleSimulcastContent)
page := httptest.NewRecorder()
router.ServeHTTP(page, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/simulcast?season=winter&year=2024", nil))
if page.Code != http.StatusOK {
t.Fatalf("page status = %d, want %d", page.Code, http.StatusOK)
}
if provider.calls != 0 {
t.Fatalf("provider calls during page render = %d, want 0", provider.calls)
}
if !strings.Contains(page.Body.String(), `hx-get="/api/simulcast?season=winter&amp;year=2024"`) {
t.Fatalf("page did not preserve the selected season in the deferred request:\n%s", page.Body.String())
}
fragment := httptest.NewRecorder()
router.ServeHTTP(fragment, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/simulcast?season=winter&year=2024", nil))
if fragment.Code != http.StatusOK {
t.Fatalf("fragment status = %d, want %d", fragment.Code, http.StatusOK)
}
if provider.calls != 2 {
t.Fatalf("provider calls after fragment render = %d, want 2", provider.calls)
}
if strings.Contains(fragment.Body.String(), "<!DOCTYPE html>") {
t.Fatal("fragment response unexpectedly rendered the full page")
}
if !strings.Contains(fragment.Body.String(), `id="simulcast-content"`) {
t.Fatalf("fragment response is missing its replacement root:\n%s", fragment.Body.String())
}
}

View File

@@ -10,6 +10,7 @@ import (
"mal/internal/server" "mal/internal/server"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -27,15 +28,10 @@ type animeEpisodeCountDisplay struct {
Label string Label string
} }
func listedEpisodeCount(episodes []domain.EpisodeData) int { type animeReleaseInfoDisplay struct {
count := 0 Count int
for _, episode := range episodes { Label string
if episode.MalID <= 0 || episode.IsRecap { Status string
continue
}
count++
}
return count
} }
func releasedEpisodeCount(anime domain.Anime, now time.Time) int { func releasedEpisodeCount(anime domain.Anime, now time.Time) int {
@@ -56,15 +52,18 @@ func releasedEpisodeCount(anime domain.Anime, now time.Time) int {
} }
func (h *AnimeHandler) animeEpisodeCount(ctx context.Context, anime domain.Anime, now time.Time) animeEpisodeCountDisplay { func (h *AnimeHandler) animeEpisodeCount(ctx context.Context, anime domain.Anime, now time.Time) animeEpisodeCountDisplay {
info := h.animeReleaseInfo(ctx, anime, now)
return animeEpisodeCountDisplay{Count: info.Count, Label: info.Label}
}
func (h *AnimeHandler) animeReleaseInfo(ctx context.Context, anime domain.Anime, now time.Time) animeReleaseInfoDisplay {
if h.episodeSvc != nil { if h.episodeSvc != nil {
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout) episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout)
defer cancel() defer cancel()
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(episodeCtx, anime, false) episodeList, err := h.episodeSvc.GetCanonicalEpisodes(episodeCtx, anime, false)
if err == nil { if err == nil {
if count := len(episodeList.Episodes); count > 0 { return releaseInfoFromCanonical(anime, episodeList)
return animeEpisodeCountDisplay{Count: count, Label: "Available episodes"}
}
} else { } else {
observability.Warn( observability.Warn(
"anime_episode_availability_count_fetch_failed", "anime_episode_availability_count_fetch_failed",
@@ -78,45 +77,63 @@ func (h *AnimeHandler) animeEpisodeCount(ctx context.Context, anime domain.Anime
} }
} }
if h.svc != nil && anime.Airing { return animeInitialReleaseInfo(anime, now)
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout) }
defer cancel()
episodes, err := h.svc.GetAllEpisodes(episodeCtx, anime.MalID) func releaseInfoFromCanonical(anime domain.Anime, episodeList domain.CanonicalEpisodeList) animeReleaseInfoDisplay {
if err == nil { info := animeReleaseInfoDisplay{Status: trustedAnimeStatus(anime, len(episodeList.Episodes))}
if count := listedEpisodeCount(episodes); count > 0 { if count := len(episodeList.Episodes); count > 0 {
return animeEpisodeCountDisplay{Count: count, Label: "Listed episodes"} info.Count = count
} info.Label = canonicalEpisodeCountLabel(episodeList.Source)
} else { }
observability.Warn( return info
"anime_episode_count_fetch_failed", }
"anime",
"", func canonicalEpisodeCountLabel(source string) string {
map[string]any{ if source == "jikan_fallback" || source == "legacy_disabled" {
"anime_id": anime.MalID, return "Estimated aired episodes"
}, }
err, return "Available episodes"
) }
}
func animeInitialReleaseInfo(anime domain.Anime, now time.Time) animeReleaseInfoDisplay {
if isCurrentlyAiring(anime) {
return animeReleaseInfoDisplay{}
} }
info := animeReleaseInfoDisplay{Status: strings.TrimSpace(anime.Status)}
if anime.Episodes > 0 { if anime.Episodes > 0 {
return animeEpisodeCountDisplay{Count: anime.Episodes, Label: "Total episodes"} info.Count = anime.Episodes
info.Label = "Total episodes"
return info
} }
if count := releasedEpisodeCount(anime, now); count > 0 { if count := releasedEpisodeCount(anime, now); count > 0 {
return animeEpisodeCountDisplay{Count: count, Label: "Estimated aired episodes"} info.Count = count
info.Label = "Estimated aired episodes"
} }
return animeEpisodeCountDisplay{} return info
} }
func animeInitialEpisodeCount(anime domain.Anime, now time.Time) animeEpisodeCountDisplay { func animeInitialEpisodeCount(anime domain.Anime, now time.Time) animeEpisodeCountDisplay {
if anime.Episodes > 0 { info := animeInitialReleaseInfo(anime, now)
return animeEpisodeCountDisplay{Count: anime.Episodes, Label: "Total episodes"} return animeEpisodeCountDisplay{Count: info.Count, Label: info.Label}
}
func trustedAnimeStatus(anime domain.Anime, canonicalEpisodes int) string {
if canonicalEpisodes == 0 && isCurrentlyAiring(anime) {
return "Not yet aired"
} }
if count := releasedEpisodeCount(anime, now); count > 0 { if status := strings.TrimSpace(anime.Status); status != "" {
return animeEpisodeCountDisplay{Count: count, Label: "Estimated aired episodes"} return status
} }
return animeEpisodeCountDisplay{} if anime.Airing {
return "Currently Airing"
}
return ""
}
func isCurrentlyAiring(anime domain.Anime) bool {
return anime.Airing || strings.EqualFold(strings.TrimSpace(anime.Status), "Currently Airing")
} }
func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string { func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string {
@@ -203,7 +220,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
} }
} }
episodesCount := animeInitialEpisodeCount(anime, time.Now()) releaseInfo := animeInitialReleaseInfo(anime, time.Now())
c.HTML(http.StatusOK, "anime.gohtml", gin.H{ c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"Anime": anime, "Anime": anime,
@@ -213,8 +230,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
"WatchlistIDs": watchlistIDs, "WatchlistIDs": watchlistIDs,
"ContinueWatchingEp": ep, "ContinueWatchingEp": ep,
"ContinueWatchingTime": cwSeconds, "ContinueWatchingTime": cwSeconds,
"EpisodesCount": episodesCount.Count, "ReleaseInfo": releaseInfo,
"EpisodesCountLabel": episodesCount.Label,
}) })
} }
@@ -265,12 +281,12 @@ func (h *AnimeHandler) loadAnimeDetailsSection(ctx context.Context, id int, sect
case "statistics": case "statistics":
data, err := h.svc.GetStatistics(ctx, id) data, err := h.svc.GetStatistics(ctx, id)
return data, "anime_statistics", err return data, "anime_statistics", err
case "episode-count": case "episode-count", "release-info":
anime, err := h.svc.GetAnimeByID(ctx, id) anime, err := h.svc.GetAnimeByID(ctx, id)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
return h.animeEpisodeCount(ctx, anime, time.Now()), "anime_episode_count", nil return h.animeReleaseInfo(ctx, anime, time.Now()), "anime_release_info", nil
case "audio-availability": case "audio-availability":
anime, err := h.svc.GetAnimeByID(ctx, id) anime, err := h.svc.GetAnimeByID(ctx, id)
if err != nil { if err != nil {

View File

@@ -0,0 +1,22 @@
package anime
import (
"context"
"mal/integrations/playback/allanime"
)
type seasonProvider interface {
SeasonalShows(context.Context, string, int) ([]allanime.ProviderShow, error)
}
type SeasonDiscoveryService struct {
provider seasonProvider
}
func NewSeasonDiscoveryService(provider *allanime.AllAnimeProvider) *SeasonDiscoveryService {
return newSeasonDiscoveryService(provider)
}
func newSeasonDiscoveryService(provider seasonProvider) *SeasonDiscoveryService {
return &SeasonDiscoveryService{provider: provider}
}

View File

@@ -3,17 +3,15 @@ package anime
import ( import (
"context" "context"
"mal/internal/domain" "mal/internal/domain"
"sync"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type AnimeHandler struct { type AnimeHandler struct {
svc Service svc Service
watchlistSvc domain.WatchlistService watchlistSvc domain.WatchlistService
episodeSvc domain.EpisodeService episodeSvc domain.EpisodeService
scheduleCache map[string]cachedWeekSchedule discoverySvc *SeasonDiscoveryService
sync.Mutex
} }
type Service interface { type Service interface {
@@ -23,12 +21,12 @@ type Service interface {
WarmDetailSections(id int) WarmDetailSections(id int)
} }
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService) *AnimeHandler { func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService, discoverySvc *SeasonDiscoveryService) *AnimeHandler {
return &AnimeHandler{ return &AnimeHandler{
svc: svc, svc: svc,
watchlistSvc: watchlistSvc, watchlistSvc: watchlistSvc,
episodeSvc: episodeSvc, episodeSvc: episodeSvc,
scheduleCache: make(map[string]cachedWeekSchedule), discoverySvc: discoverySvc,
} }
} }
@@ -63,6 +61,8 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
r.GET("/search", h.HandleSearch) r.GET("/search", h.HandleSearch)
r.GET("/top-picks", h.HandleTopPicks) r.GET("/top-picks", h.HandleTopPicks)
r.GET("/browse", h.HandleBrowse) r.GET("/browse", h.HandleBrowse)
r.GET("/simulcast", h.HandleSimulcast)
r.GET("/api/simulcast", h.HandleSimulcastContent)
r.GET("/anime/:id", h.HandleAnimeDetails) r.GET("/anime/:id", h.HandleAnimeDetails)
r.GET("/anime/:id/reviews", h.HandleAnimeReviews) r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
r.GET("/api/watch-order", h.HandleHTMLWatchOrder) r.GET("/api/watch-order", h.HandleHTMLWatchOrder)

View File

@@ -115,20 +115,6 @@ func TestReleasedEpisodeCount(t *testing.T) {
} }
} }
func TestListedEpisodeCount(t *testing.T) {
episodes := []domain.EpisodeData{
{MalID: 1, Title: "Episode 1"},
{MalID: 2, Title: "Episode 2"},
{MalID: 3, Title: "Recap", IsRecap: true},
{Title: "missing id"},
}
got := listedEpisodeCount(episodes)
if got != 2 {
t.Fatalf("listedEpisodeCount() = %d, want 2", got)
}
}
func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) { func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) {
episodeSvc := &stubEpisodeService{ episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{ episodes: domain.CanonicalEpisodeList{
@@ -140,7 +126,7 @@ func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) {
}, },
}, },
} }
handler := NewAnimeHandler(nil, nil, episodeSvc) handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{ got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
MalID: 59970, MalID: 59970,
@@ -160,9 +146,120 @@ func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) {
} }
} }
func TestAnimeReleaseInfoUsesCanonicalEpisodes(t *testing.T) {
episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{
Source: "AllAnime",
Episodes: []domain.CanonicalEpisode{
{Number: 1},
{Number: 2},
{Number: 3},
},
},
}
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeReleaseInfo(context.Background(), domain.Anime{Anime: jikan.Anime{
MalID: 59970,
Status: "Currently Airing",
Airing: true,
Episodes: 12,
}}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC))
if got.Count != 3 || got.Label != "Available episodes" || got.Status != "Currently Airing" {
t.Fatalf("animeReleaseInfo() = %+v, want 3 available episodes and current status", got)
}
if episodeSvc.called != 1 {
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 1", episodeSvc.called)
}
}
func TestAnimeReleaseInfoDoesNotCallJikanFallbackAvailable(t *testing.T) {
episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{
Source: "jikan_fallback",
Episodes: []domain.CanonicalEpisode{
{Number: 1},
},
},
}
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeReleaseInfo(context.Background(), domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Status: "Currently Airing",
Airing: true,
}}, time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC))
if got.Count != 1 || got.Label != "Estimated aired episodes" {
t.Fatalf("animeReleaseInfo() = %+v, want estimated aired episode count", got)
}
}
func TestAnimeReleaseInfoMarksAiringAnimeWithoutCanonicalEpisodesAsNotYetAired(t *testing.T) {
episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{
Source: "jikan_fallback",
ReleaseChecked: true,
Episodes: []domain.CanonicalEpisode{},
},
}
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeReleaseInfo(context.Background(), domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Status: "Currently Airing",
Airing: true,
Episodes: 6,
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
}}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC))
if got.Count != 0 || got.Label != "" || got.Status != "Not yet aired" {
t.Fatalf("animeReleaseInfo() = %+v, want not-yet-aired status without count", got)
}
}
func TestAnimeEpisodeCountStopsWhenCanonicalEpisodesAreEmpty(t *testing.T) {
episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{
Source: "AllAnime",
Episodes: []domain.CanonicalEpisode{},
},
}
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
Episodes: 12,
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
}}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC))
if got.Count != 0 || got.Label != "" {
t.Fatalf("animeEpisodeCount() = %+v, want empty display", got)
}
if episodeSvc.called != 1 {
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 1", episodeSvc.called)
}
}
func TestAnimeInitialReleaseInfoDoesNotTrustCurrentlyAiringMetadata(t *testing.T) {
got := animeInitialReleaseInfo(domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Status: "Currently Airing",
Airing: true,
Episodes: 6,
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
}}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC))
if got.Count != 0 || got.Label != "" || got.Status != "" {
t.Fatalf("animeInitialReleaseInfo() = %+v, want empty unverified airing display", got)
}
}
func TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) { func TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) {
episodeSvc := &stubEpisodeService{err: errors.New("provider unavailable")} episodeSvc := &stubEpisodeService{err: errors.New("provider unavailable")}
handler := NewAnimeHandler(nil, nil, episodeSvc) handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{ got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
MalID: 59970, MalID: 59970,
@@ -175,7 +272,7 @@ func TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) {
} }
} }
func TestAnimeInitialEpisodeCountDoesNotCallEpisodeService(t *testing.T) { func TestAnimeInitialEpisodeCountDoesNotTrustCurrentlyAiringMetadata(t *testing.T) {
episodeSvc := &stubEpisodeService{ episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{ episodes: domain.CanonicalEpisodeList{
Episodes: []domain.CanonicalEpisode{{Number: 1}, {Number: 2}, {Number: 3}}, Episodes: []domain.CanonicalEpisode{{Number: 1}, {Number: 2}, {Number: 3}},
@@ -185,12 +282,13 @@ func TestAnimeInitialEpisodeCountDoesNotCallEpisodeService(t *testing.T) {
got := animeInitialEpisodeCount(domain.Anime{Anime: jikan.Anime{ got := animeInitialEpisodeCount(domain.Anime{Anime: jikan.Anime{
MalID: 59970, MalID: 59970,
Airing: true, Airing: true,
Status: "Currently Airing",
Episodes: 12, Episodes: 12,
Aired: jikan.Aired{From: "2026-04-03T00:00:00+00:00"}, Aired: jikan.Aired{From: "2026-04-03T00:00:00+00:00"},
}}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC)) }}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC))
if got.Count != 12 || got.Label != "Total episodes" { if got.Count != 0 || got.Label != "" {
t.Fatalf("animeInitialEpisodeCount() = %+v, want count=12 label=%q", got, "Total episodes") t.Fatalf("animeInitialEpisodeCount() = %+v, want empty display", got)
} }
if episodeSvc.called != 0 { if episodeSvc.called != 0 {
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 0", episodeSvc.called) t.Fatalf("GetCanonicalEpisodes() calls = %d, want 0", episodeSvc.called)
@@ -279,7 +377,7 @@ func TestAnimeAudioAvailabilityRequiresAllAnimeSource(t *testing.T) {
}, },
err: tt.err, err: tt.err,
} }
handler := NewAnimeHandler(nil, nil, episodeSvc) handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeAudioAvailability(context.Background(), domain.Anime{ got := handler.animeAudioAvailability(context.Background(), domain.Anime{
Anime: jikan.Anime{MalID: 52991}, Anime: jikan.Anime{MalID: 52991},

View File

@@ -10,6 +10,7 @@ import (
var Module = fx.Options( var Module = fx.Options(
fx.Provide( fx.Provide(
NewAnimeRepository, NewAnimeRepository,
NewSeasonDiscoveryService,
fx.Annotate( fx.Annotate(
NewAnimeService, NewAnimeService,
fx.As(new(Service)), fx.As(new(Service)),
@@ -17,6 +18,7 @@ var Module = fx.Options(
fx.As(new(domain.AnimeSearchService)), fx.As(new(domain.AnimeSearchService)),
fx.As(new(domain.AnimeDetailsService)), fx.As(new(domain.AnimeDetailsService)),
fx.As(new(domain.AnimePlaybackService)), fx.As(new(domain.AnimePlaybackService)),
fx.As(new(domain.RecommendationInvalidator)),
), ),
NewAnimeHandler, NewAnimeHandler,
), ),

View File

@@ -4,12 +4,136 @@ import (
"context" "context"
"mal/internal/anime/recommendations" "mal/internal/anime/recommendations"
"mal/internal/domain" "mal/internal/domain"
"mal/internal/observability"
"maps"
"strings"
"sync"
"time"
) )
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) { type recommendationComputeFunc func(context.Context, string, int) (domain.CatalogSectionData, error)
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, recommendations.TopPickLimit)
type topPicksCacheKey struct {
userID string
limit int
} }
func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) { type topPicksCacheEntry struct {
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, recommendations.TopPicksLimit) data domain.CatalogSectionData
updatedAt time.Time
hasData bool
refreshing bool
}
type topPicksCache struct {
mu sync.Mutex
entries map[topPicksCacheKey]*topPicksCacheEntry
}
const topPicksRefreshTimeout = 30 * time.Second
func (s *animeService) GetTopPickForYou(_ context.Context, userID string) (domain.CatalogSectionData, error) {
data := s.getCachedTopPicksForYou(userID, recommendations.TopPicksLimit)
if len(data.Animes) > recommendations.TopPickLimit {
data.Animes = data.Animes[:recommendations.TopPickLimit]
}
return data, nil
}
func (s *animeService) GetTopPicksForYou(_ context.Context, userID string) (domain.CatalogSectionData, error) {
return s.getCachedTopPicksForYou(userID, recommendations.TopPicksLimit), nil
}
func (s *animeService) fetchTopPicksForYou(ctx context.Context, userID string, limit int) (domain.CatalogSectionData, error) {
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, limit)
}
func (s *animeService) getCachedTopPicksForYou(userID string, limit int) domain.CatalogSectionData {
userID = strings.TrimSpace(userID)
if userID == "" {
return domain.CatalogSectionData{Animes: []domain.Anime{}}
}
key := topPicksCacheKey{userID: userID, limit: limit}
now := time.Now()
s.topPicksCache.mu.Lock()
entry := s.topPicksCache.entries[key]
if entry != nil && entry.hasData {
data := cloneCatalogSectionData(entry.data)
if now.Sub(entry.updatedAt) >= s.topPicksCacheTTL && !entry.refreshing {
entry.refreshing = true
go s.refreshTopPicksForYou(key)
}
s.topPicksCache.mu.Unlock()
return data
}
if entry == nil {
entry = &topPicksCacheEntry{}
s.topPicksCache.entries[key] = entry
}
if !entry.refreshing {
entry.refreshing = true
go s.refreshTopPicksForYou(key)
}
s.topPicksCache.mu.Unlock()
return domain.CatalogSectionData{Animes: []domain.Anime{}}
}
func (s *animeService) refreshTopPicksForYou(key topPicksCacheKey) {
ctx, cancel := context.WithTimeout(context.Background(), topPicksRefreshTimeout)
defer cancel()
data, err := s.computeTopPicks(ctx, key.userID, key.limit)
if err != nil {
observability.WarnContext(ctx,
"top_picks_refresh_failed",
"anime",
"",
map[string]any{"user_id": key.userID, "limit": key.limit},
err,
)
s.topPicksCache.mu.Lock()
if entry := s.topPicksCache.entries[key]; entry != nil {
entry.refreshing = false
}
s.topPicksCache.mu.Unlock()
return
}
s.topPicksCache.mu.Lock()
s.topPicksCache.entries[key] = &topPicksCacheEntry{
data: cloneCatalogSectionData(data),
updatedAt: time.Now(),
hasData: true,
}
s.topPicksCache.mu.Unlock()
}
func (s *animeService) InvalidateTopPicksForUser(userID string) {
userID = strings.TrimSpace(userID)
if userID == "" {
return
}
s.topPicksCache.mu.Lock()
defer s.topPicksCache.mu.Unlock()
for key := range s.topPicksCache.entries {
if key.userID == userID {
delete(s.topPicksCache.entries, key)
}
}
}
func cloneCatalogSectionData(data domain.CatalogSectionData) domain.CatalogSectionData {
data.Animes = append([]domain.Anime(nil), data.Animes...)
data.ContinueWatching = append(data.ContinueWatching[:0:0], data.ContinueWatching...)
if data.WatchlistMap != nil {
watchlistMap := make(map[int64]bool, len(data.WatchlistMap))
maps.Copy(watchlistMap, data.WatchlistMap)
data.WatchlistMap = watchlistMap
}
return data
} }

View File

@@ -74,6 +74,47 @@ func TestScoreRecommendationCandidateRewardsProfileOverlap(t *testing.T) {
} }
} }
func TestScoreRecommendationCandidateBuildsRationaleFromProfileMatches(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
profile := userTasteProfile{
genres: map[int]float64{1: 2.0},
themes: map[int]float64{10: 1.5},
studios: map[int]float64{20: 1.0},
demographics: map[int]float64{30: 1.0},
}
candidate := scoreRecommendationCandidate(now, profile, jikan.Anime{
MalID: 10,
Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}},
Themes: []jikan.NamedEntity{{MalID: 10, Name: "School"}},
Studios: []jikan.NamedEntity{{MalID: 20, Name: "Production I.G"}},
Demographics: []jikan.NamedEntity{{MalID: 30, Name: "Shounen"}},
}, 5.0, 0)
want := []string{"Action", "School", "Production I.G", "Shounen"}
if !slices.Equal(candidate.rationale, want) {
t.Fatalf("expected profile match rationale %v, got %v", want, candidate.rationale)
}
}
func TestScoreRecommendationCandidateOmitsRationaleWhenSignalsAreWeak(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
profile := userTasteProfile{
genres: map[int]float64{1: 2.0},
themes: map[int]float64{},
studios: map[int]float64{},
demographics: map[int]float64{},
}
candidate := scoreRecommendationCandidate(now, profile, jikan.Anime{
MalID: 10,
}, 5.0, 0)
if len(candidate.rationale) != 0 {
t.Fatalf("expected no rationale for weak signals, got %v", candidate.rationale)
}
}
func TestBuildTasteProfileUsesSeedWeights(t *testing.T) { func TestBuildTasteProfileUsesSeedWeights(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC) now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)

View File

@@ -24,7 +24,7 @@ func rerankRecommendationCandidates(candidates []recommendationCandidate, limit
continue continue
} }
selected = append(selected, domain.Anime{Anime: candidate.anime}) selected = append(selected, domain.Anime{Anime: candidate.anime, RecommendationRationale: candidate.rationale})
features := diversityFeatures(candidate.anime) features := diversityFeatures(candidate.anime)
seen.add(features) seen.add(features)
recent = append(recent, features) recent = append(recent, features)

View File

@@ -48,9 +48,23 @@ func scoreRecommendationCandidate(
themeMatches: themes, themeMatches: themes,
studioMatches: studios, studioMatches: studios,
demographicMatches: demos, demographicMatches: demos,
rationale: buildRecommendationRationale(profile, candidate),
} }
} }
func buildRecommendationRationale(profile userTasteProfile, candidate jikan.Anime) []string {
rationale := make([]string, 0, 4)
rationale = append(rationale, matchedEntityNames(profile.genres, candidate.Genres)...)
rationale = append(rationale, matchedEntityNames(profile.themes, candidate.Themes)...)
rationale = append(rationale, matchedEntityNames(profile.studios, candidate.Studios)...)
rationale = append(rationale, matchedEntityNames(profile.demographics, candidate.Demographics)...)
if len(rationale) > 4 {
return rationale[:4]
}
return rationale
}
func recommendationCandidateScoreAdjustments(now time.Time, profile userTasteProfile, candidate jikan.Anime) float64 { func recommendationCandidateScoreAdjustments(now time.Time, profile userTasteProfile, candidate jikan.Anime) float64 {
var score float64 var score float64
@@ -115,3 +129,22 @@ func weightedEntityMatch(weights map[int]float64, entities []jikan.NamedEntity)
return matches, score return matches, score
} }
func matchedEntityNames(weights map[int]float64, entities []jikan.NamedEntity) []string {
if len(weights) == 0 {
return []string{}
}
names := make([]string, 0, 1)
for _, entity := range entities {
if entity.Name == "" || weights[entity.MalID] <= 0 {
continue
}
names = append(names, entity.Name)
if len(names) >= 1 {
break
}
}
return names
}

View File

@@ -25,6 +25,7 @@ type recommendationCandidate struct {
themeMatches int themeMatches int
studioMatches int studioMatches int
demographicMatches int demographicMatches int
rationale []string
} }
type userTasteProfile struct { type userTasteProfile struct {

View File

@@ -0,0 +1,164 @@
package anime
import (
"context"
"errors"
"testing"
"time"
"mal/integrations/jikan"
"mal/internal/anime/recommendations"
"mal/internal/domain"
)
func TestGetTopPicksForYouReturnsEmptyOnCacheMissAndRefreshesInBackground(t *testing.T) {
refreshed := make(chan struct{}, 1)
svc := NewAnimeService(nil, nil)
svc.computeTopPicks = func(context.Context, string, int) (domain.CatalogSectionData, error) {
refreshed <- struct{}{}
return domain.CatalogSectionData{Animes: []domain.Anime{{Anime: jikan.Anime{MalID: 7}}}}, nil
}
got, err := svc.GetTopPicksForYou(context.Background(), "user-1")
if err != nil {
t.Fatalf("GetTopPicksForYou cache miss: %v", err)
}
if len(got.Animes) != 0 {
t.Fatalf("cache miss animes = %+v, want empty while refresh runs", got.Animes)
}
waitForRefresh(t, refreshed)
got, err = svc.GetTopPicksForYou(context.Background(), "user-1")
if err != nil {
t.Fatalf("GetTopPicksForYou cache hit: %v", err)
}
if len(got.Animes) != 1 || got.Animes[0].MalID != 7 {
t.Fatalf("cache hit animes = %+v, want anime 7", got.Animes)
}
}
func TestTopPickAndTopPicksShareCache(t *testing.T) {
refreshed := make(chan struct{}, 1)
limits := make(chan int, 1)
svc := NewAnimeService(nil, nil)
svc.computeTopPicks = func(_ context.Context, _ string, limit int) (domain.CatalogSectionData, error) {
limits <- limit
animes := make([]domain.Anime, recommendations.TopPickLimit+1)
for i := range animes {
animes[i].MalID = i + 1
}
refreshed <- struct{}{}
return domain.CatalogSectionData{Animes: animes}, nil
}
if _, err := svc.GetTopPickForYou(context.Background(), "user-1"); err != nil {
t.Fatalf("GetTopPickForYou cache miss: %v", err)
}
waitForRefresh(t, refreshed)
carousel, err := svc.GetTopPickForYou(context.Background(), "user-1")
if err != nil {
t.Fatalf("GetTopPickForYou cache hit: %v", err)
}
if len(carousel.Animes) != recommendations.TopPickLimit {
t.Fatalf("carousel animes = %d, want %d", len(carousel.Animes), recommendations.TopPickLimit)
}
all, err := svc.GetTopPicksForYou(context.Background(), "user-1")
if err != nil {
t.Fatalf("GetTopPicksForYou shared cache: %v", err)
}
if len(all.Animes) != recommendations.TopPickLimit+1 {
t.Fatalf("all animes = %d, want %d", len(all.Animes), recommendations.TopPickLimit+1)
}
if limit := <-limits; limit != recommendations.TopPicksLimit {
t.Fatalf("computed limit = %d, want %d", limit, recommendations.TopPicksLimit)
}
}
func TestGetTopPicksForYouReturnsStaleDataWhenRefreshFails(t *testing.T) {
svc := NewAnimeService(nil, nil)
svc.topPicksCacheTTL = time.Nanosecond
refreshed := make(chan struct{}, 2)
svc.computeTopPicks = func(context.Context, string, int) (domain.CatalogSectionData, error) {
refreshed <- struct{}{}
return domain.CatalogSectionData{Animes: []domain.Anime{{Anime: jikan.Anime{MalID: 11}}}}, nil
}
if _, err := svc.GetTopPicksForYou(context.Background(), "user-1"); err != nil {
t.Fatalf("prime cache: %v", err)
}
waitForRefresh(t, refreshed)
svc.computeTopPicks = func(context.Context, string, int) (domain.CatalogSectionData, error) {
refreshed <- struct{}{}
return domain.CatalogSectionData{}, errors.New("provider unavailable")
}
time.Sleep(time.Nanosecond)
got, err := svc.GetTopPicksForYou(context.Background(), "user-1")
if err != nil {
t.Fatalf("stale GetTopPicksForYou: %v", err)
}
if len(got.Animes) != 1 || got.Animes[0].MalID != 11 {
t.Fatalf("stale animes = %+v, want anime 11", got.Animes)
}
}
func TestInvalidateTopPicksForUserRefreshesNextRequest(t *testing.T) {
refreshed := make(chan struct{}, 2)
results := []int{3, 4}
svc := newPickSvc(refreshed, &results)
primePickCache(t, svc, refreshed)
svc.InvalidateTopPicksForUser("user-1")
got, err := svc.GetTopPicksForYou(context.Background(), "user-1")
if err != nil {
t.Fatalf("GetTopPicksForYou after invalidation: %v", err)
}
if len(got.Animes) != 0 {
t.Fatalf("invalidated cache animes = %+v, want empty while refresh runs", got.Animes)
}
waitForRefresh(t, refreshed)
got, err = svc.GetTopPicksForYou(context.Background(), "user-1")
if err != nil {
t.Fatalf("GetTopPicksForYou refreshed cache: %v", err)
}
if len(got.Animes) != 1 || got.Animes[0].MalID != 4 {
t.Fatalf("refreshed animes = %+v, want anime 4", got.Animes)
}
}
func newPickSvc(refreshed chan struct{}, results *[]int) *animeService {
svc := NewAnimeService(nil, nil)
svc.computeTopPicks = func(context.Context, string, int) (domain.CatalogSectionData, error) {
id := (*results)[0]
*results = (*results)[1:]
refreshed <- struct{}{}
return domain.CatalogSectionData{Animes: []domain.Anime{{Anime: jikan.Anime{MalID: id}}}}, nil
}
return svc
}
func primePickCache(t *testing.T, svc *animeService, refreshed chan struct{}) {
t.Helper()
if _, err := svc.GetTopPicksForYou(context.Background(), "user-1"); err != nil {
t.Fatalf("prime cache: %v", err)
}
waitForRefresh(t, refreshed)
}
func waitForRefresh(t *testing.T, refreshed chan struct{}) {
t.Helper()
select {
case <-refreshed:
case <-time.After(time.Second):
t.Fatal("background refresh did not run")
}
}

View File

@@ -25,3 +25,10 @@ func (r *animeRepository) GetWatchListEntry(ctx context.Context, params db.GetWa
func (r *animeRepository) GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) { func (r *animeRepository) GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) {
return r.queries.GetContinueWatchingEntries(ctx, userID) return r.queries.GetContinueWatchingEntries(ctx, userID)
} }
func (r *animeRepository) GetContinueWatchingCarouselEntries(ctx context.Context, userID string, limit int64) ([]db.GetContinueWatchingEntriesRow, error) {
return r.queries.GetContinueWatchingCarouselEntries(ctx, db.GetContinueWatchingCarouselEntriesParams{
UserID: userID,
Limit: limit,
})
}

View File

@@ -1,121 +0,0 @@
package anime
import (
"context"
"fmt"
"mal/integrations/animeschedule"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type cachedWeekSchedule struct {
fetchedAt time.Time
value animeschedule.WeekSchedule
}
func parseYearWeek(c *gin.Context) (int, int) {
year, _ := strconv.Atoi(c.Query("year"))
week, _ := strconv.Atoi(c.Query("week"))
if year <= 0 || week <= 0 {
now := time.Now()
y, w := now.ISOWeek()
if year <= 0 {
year = y
}
if week <= 0 {
week = w
}
}
return year, week
}
func scheduleTimezone(c *gin.Context) string {
timezone := strings.TrimSpace(c.Query("timezone"))
if timezone == "" {
return "UTC"
}
return timezone
}
func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int, week int, timezone string) (animeschedule.WeekSchedule, error) {
cacheKey := fmt.Sprintf("%d-%02d-%s", year, week, timezone)
const ttl = 10 * time.Minute
h.Lock()
cached, ok := h.scheduleCache[cacheKey]
h.Unlock()
if ok && time.Since(cached.fetchedAt) < ttl {
return cached.value, nil
}
value, err := animeschedule.FetchWeek(ctx, nil, year, week, timezone)
if err != nil {
return animeschedule.WeekSchedule{}, err
}
h.Lock()
h.scheduleCache[cacheKey] = cachedWeekSchedule{fetchedAt: time.Now(), value: value}
h.Unlock()
return value, nil
}
type scheduleDayView struct {
DateLabel string
WeekdayLabel string
Entries []animeschedule.Entry
}
func buildScheduleDays(schedule animeschedule.WeekSchedule, year int, week int) []scheduleDayView {
start := isoWeekStartMonday(year, week)
order := []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday, time.Saturday, time.Sunday}
out := make([]scheduleDayView, 0, 7)
for i, wd := range order {
date := start.AddDate(0, 0, i)
entries := schedule.Days[wd]
sort.SliceStable(entries, func(i, j int) bool {
if !entries[i].AirsAt.IsZero() && !entries[j].AirsAt.IsZero() {
return entries[i].AirsAt.Before(entries[j].AirsAt)
}
return localTimeMinutes(entries[i].LocalTime) < localTimeMinutes(entries[j].LocalTime)
})
out = append(out, scheduleDayView{
DateLabel: strings.ToUpper(date.Format("02 Jan")),
WeekdayLabel: wd.String(),
Entries: entries,
})
}
return out
}
func localTimeMinutes(localTime string) int {
for _, layout := range []string{"15:04", "03:04 PM"} {
t, err := time.Parse(layout, localTime)
if err == nil {
return t.Hour()*60 + t.Minute()
}
}
return 0
}
func isoWeekStartMonday(year int, week int) time.Time {
// ISO week 1 is the week with the year's first Thursday in it.
jan4 := time.Date(year, 1, 4, 12, 0, 0, 0, time.Local)
// Move back to Monday
offset := int(time.Monday - jan4.Weekday())
if offset > 0 {
offset -= 7
}
week1Monday := jan4.AddDate(0, 0, offset)
return week1Monday.AddDate(0, 0, (week-1)*7)
}
func adjacentISOWeek(year int, week int, deltaWeeks int) (int, int) {
target := isoWeekStartMonday(year, week).AddDate(0, 0, deltaWeeks*7)
return target.ISOWeek()
}

View File

@@ -15,10 +15,15 @@ import (
) )
type animeService struct { type animeService struct {
jikan *jikan.Client jikan *jikan.Client
repo domain.AnimeRepository repo domain.AnimeRepository
topPicksCache *topPicksCache
topPicksCacheTTL time.Duration
computeTopPicks recommendationComputeFunc
} }
const continueWatchingCarouselLimit int64 = 24
func wrapAnimes(in []jikan.Anime) []domain.Anime { func wrapAnimes(in []jikan.Anime) []domain.Anime {
out := make([]domain.Anime, 0, len(in)) out := make([]domain.Anime, 0, len(in))
for _, a := range in { for _, a := range in {
@@ -28,7 +33,14 @@ func wrapAnimes(in []jikan.Anime) []domain.Anime {
} }
func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) *animeService { func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) *animeService {
return &animeService{jikan: jikan, repo: repo} svc := &animeService{
jikan: jikan,
repo: repo,
topPicksCache: &topPicksCache{entries: map[topPicksCacheKey]*topPicksCacheEntry{}},
topPicksCacheTTL: 15 * time.Minute,
}
svc.computeTopPicks = svc.fetchTopPicksForYou
return svc
} }
func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (domain.CatalogSectionData, error) { func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (domain.CatalogSectionData, error) {
@@ -56,7 +68,7 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
if userID != "" && section == "Continue" { if userID != "" && section == "Continue" {
g.Go(func() error { g.Go(func() error {
var err error var err error
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID) cw, err = s.repo.GetContinueWatchingCarouselEntries(gCtx, userID, continueWatchingCarouselLimit)
if err != nil { if err != nil {
return fmt.Errorf("get continue watching entries for %q: %w", userID, err) return fmt.Errorf("get continue watching entries for %q: %w", userID, err)
} }

View File

@@ -0,0 +1,56 @@
package anime
import (
"context"
"mal/internal/db"
"testing"
)
type catalogRepoStub struct {
watchlist []db.GetUserWatchListRow
allContinueRows []db.GetContinueWatchingEntriesRow
carouselContinueRows []db.GetContinueWatchingEntriesRow
carouselLimit int64
}
func (r *catalogRepoStub) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) {
return r.watchlist, nil
}
func (r *catalogRepoStub) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) {
return db.WatchListEntry{}, nil
}
func (r *catalogRepoStub) GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) {
return r.allContinueRows, nil
}
func (r *catalogRepoStub) GetContinueWatchingCarouselEntries(ctx context.Context, userID string, limit int64) ([]db.GetContinueWatchingEntriesRow, error) {
r.carouselLimit = limit
return r.carouselContinueRows, nil
}
func TestGetCatalogSectionLimitsContinueWatchingCarousel(t *testing.T) {
repo := &catalogRepoStub{
allContinueRows: make([]db.GetContinueWatchingEntriesRow, 30),
carouselContinueRows: []db.GetContinueWatchingEntriesRow{
{AnimeID: 30},
{AnimeID: 29},
},
}
svc := NewAnimeService(nil, repo)
got, err := svc.GetCatalogSection(context.Background(), "user-1", "Continue")
if err != nil {
t.Fatalf("GetCatalogSection: %v", err)
}
if repo.carouselLimit != continueWatchingCarouselLimit {
t.Fatalf("carousel limit = %d, want %d", repo.carouselLimit, continueWatchingCarouselLimit)
}
if len(got.ContinueWatching) != len(repo.carouselContinueRows) {
t.Fatalf("len(ContinueWatching) = %d, want %d", len(got.ContinueWatching), len(repo.carouselContinueRows))
}
if got.ContinueWatching[0].AnimeID != 30 || got.ContinueWatching[1].AnimeID != 29 {
t.Fatalf("ContinueWatching = %+v, want anime IDs [30 29]", got.ContinueWatching)
}
}

144
internal/anime/simulcast.go Normal file
View File

@@ -0,0 +1,144 @@
package anime
import (
"context"
"mal/integrations/jikan"
"mal/internal/domain"
"slices"
"strconv"
"strings"
)
type animeSeason struct {
Season string
Year int
}
type seasonOption struct {
Season string
Year int
Label string
}
var seasons = []string{"winter", "spring", "summer", "fall"}
func seasonOptions(firstYear int, latest animeSeason) []seasonOption {
options := make([]seasonOption, 0, (latest.Year-firstYear+1)*len(seasons))
for year := latest.Year; year >= firstYear; year-- {
start := len(seasons) - 1
if year == latest.Year {
start = seasonIndex(latest.Season)
}
for i := start; i >= 0; i-- {
season := seasons[i]
options = append(options, seasonOption{
Season: season,
Year: year,
Label: strings.ToUpper(season[:1]) + season[1:] + " " + strconv.Itoa(year),
})
}
}
return options
}
func seasonIndex(season string) int {
for i, candidate := range seasons {
if strings.EqualFold(candidate, season) {
return i
}
}
return 0
}
func calendarSeason(year, month int) animeSeason {
index := (month - 1) / 3
if index < 0 || index >= len(seasons) {
index = 0
}
return animeSeason{Season: seasons[index], Year: year}
}
func adjacentSeason(season string, year, direction int) animeSeason {
index := 0
for i, candidate := range seasons {
if strings.EqualFold(candidate, season) {
index = i
break
}
}
index += direction
if index < 0 {
index = len(seasons) - 1
year--
} else if index >= len(seasons) {
index = 0
year++
}
return animeSeason{Season: seasons[index], Year: year}
}
func seasonNavigation(selected animeSeason, firstYear int, latest animeSeason) (*animeSeason, *animeSeason) {
var previous *animeSeason
var next *animeSeason
if selected.Year > firstYear || selected.Season != "winter" {
value := adjacentSeason(selected.Season, selected.Year, -1)
previous = &value
}
value := adjacentSeason(selected.Season, selected.Year, 1)
if value.Year < latest.Year || value.Year == latest.Year && seasonIndex(value.Season) <= seasonIndex(latest.Season) {
next = &value
}
return previous, next
}
func seasonSelection(rawSeason, rawYear string, current, latest animeSeason) animeSeason {
season := strings.ToLower(strings.TrimSpace(rawSeason))
validSeason := slices.Contains(seasons, season)
year, err := strconv.Atoi(rawYear)
if !validSeason || err != nil || year < 2000 || year > latest.Year || year == latest.Year && seasonIndex(season) > seasonIndex(latest.Season) {
return current
}
return animeSeason{Season: season, Year: year}
}
func (s *SeasonDiscoveryService) LatestAvailableSeason(ctx context.Context, current animeSeason) animeSeason {
next := adjacentSeason(current.Season, current.Year, 1)
shows, err := s.provider.SeasonalShows(ctx, next.Season, next.Year)
if err == nil && len(shows) > 0 {
return next
}
return current
}
type SimulcastData struct {
Animes []domain.Anime
Season string
Year int
}
func (s *SeasonDiscoveryService) GetSimulcast(ctx context.Context, selected animeSeason) (SimulcastData, error) {
shows, err := s.provider.SeasonalShows(ctx, selected.Season, selected.Year)
if err != nil {
return SimulcastData{}, err
}
animes := make([]domain.Anime, 0, len(shows))
for _, show := range shows {
anime := jikan.Anime{
MalID: show.MalID,
Title: show.Name,
TitleEnglish: show.EnglishName,
Synopsis: show.Description,
Status: show.Status,
Type: show.Type,
Year: show.Year,
Episodes: show.EpisodeCount,
}
anime.Images.Webp.LargeImageURL = show.Thumbnail
animes = append(animes, domain.Anime{Anime: anime})
}
return SimulcastData{
Animes: animes,
Season: selected.Season,
Year: selected.Year,
}, nil
}

View File

@@ -0,0 +1,88 @@
package anime
import (
"context"
"fmt"
"mal/integrations/playback/allanime"
"testing"
"time"
)
type seasonalProviderStub struct {
shows map[string][]allanime.ProviderShow
}
func (p seasonalProviderStub) SeasonalShows(_ context.Context, season string, year int) ([]allanime.ProviderShow, error) {
return p.shows[fmt.Sprintf("%s-%d", season, year)], nil
}
func TestAdjacentSeasonCrossesYearBoundary(t *testing.T) {
previous := adjacentSeason("winter", 2026, -1)
if previous.Season != "fall" || previous.Year != 2025 {
t.Fatalf("previous = %+v", previous)
}
next := adjacentSeason("fall", 2026, 1)
if next.Season != "winter" || next.Year != 2027 {
t.Fatalf("next = %+v", next)
}
}
func TestSeasonSelectionRejectsInvalidValues(t *testing.T) {
now := time.Date(2026, time.August, 1, 0, 0, 0, 0, time.UTC)
current := calendarSeason(now.Year(), int(now.Month()))
got := seasonSelection("monsoon", "nope", current, current)
if got.Season != "summer" || got.Year != 2026 {
t.Fatalf("seasonSelection = %+v", got)
}
}
func TestSeasonSelectionDefaultsToCalendarSeasonWhenNewerSeasonIsAvailable(t *testing.T) {
current := animeSeason{Season: "spring", Year: 2026}
latest := animeSeason{Season: "summer", Year: 2026}
got := seasonSelection("", "", current, latest)
if got != current {
t.Fatalf("seasonSelection = %+v, want %+v", got, current)
}
}
func TestLatestAvailableSeasonIncludesPlayableNextSeason(t *testing.T) {
provider := seasonalProviderStub{shows: map[string][]allanime.ProviderShow{
"summer-2026": {{MalID: 1}},
}}
svc := newSeasonDiscoveryService(provider)
got := svc.LatestAvailableSeason(context.Background(), animeSeason{Season: "spring", Year: 2026})
if got.Season != "summer" || got.Year != 2026 {
t.Fatalf("LatestAvailableSeason = %+v", got)
}
}
func TestSeasonOptionsRunNewestFirstWithoutFutureSeasons(t *testing.T) {
got := seasonOptions(2025, animeSeason{Season: "summer", Year: 2026})
want := []string{"Summer 2026", "Spring 2026", "Winter 2026", "Fall 2025", "Summer 2025", "Spring 2025", "Winter 2025"}
if len(got) != len(want) {
t.Fatalf("len(seasonOptions) = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i].Label != want[i] {
t.Fatalf("seasonOptions[%d] = %q, want %q", i, got[i].Label, want[i])
}
}
}
func TestSeasonNavigationStopsAtSupportedRange(t *testing.T) {
current := animeSeason{Season: "summer", Year: 2026}
if previous, _ := seasonNavigation(animeSeason{Season: "winter", Year: 2018}, 2018, current); previous != nil {
t.Fatalf("previous = %+v, want nil", previous)
}
if _, next := seasonNavigation(current, 2018, current); next != nil {
t.Fatalf("next = %+v, want nil", next)
}
}
func TestParseSeasonDefaultsToCalendarSeason(t *testing.T) {
got := calendarSeason(2026, 7)
if got.Season != "summer" || got.Year != 2026 {
t.Fatalf("calendarSeason = %+v", got)
}
}

58
internal/auth/cookie.go Normal file
View File

@@ -0,0 +1,58 @@
package auth
import (
"net/http"
"strings"
"mal/internal/domain"
"github.com/gin-gonic/gin"
)
const sessionCookieName = "session_id"
func setSessionCookie(c *gin.Context, value string, maxAge int) {
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookieName,
Value: value,
Path: "/",
MaxAge: maxAge,
Secure: isHTTPSRequest(c.Request),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func setPersistentSessionCookie(c *gin.Context, value string) {
setSessionCookie(c, value, int(domain.SessionLifetime.Seconds()))
}
func clearSessionCookie(c *gin.Context) {
setSessionCookie(c, "", -1)
}
func isHTTPSRequest(r *http.Request) bool {
if r.TLS != nil {
return true
}
proto := r.Header.Get("X-Forwarded-Proto")
if i := strings.IndexByte(proto, ','); i >= 0 {
proto = proto[:i]
}
if strings.EqualFold(strings.TrimSpace(proto), "https") {
return true
}
forwarded := r.Header.Values("Forwarded")
for _, value := range forwarded {
for part := range strings.SplitSeq(value, ";") {
key, val, ok := strings.Cut(strings.TrimSpace(part), "=")
if ok && strings.EqualFold(key, "proto") && strings.EqualFold(strings.Trim(val, `"`), "https") {
return true
}
}
}
return false
}

View File

@@ -20,7 +20,7 @@ func NewAuthHandler(svc domain.AuthService) *AuthHandler {
func (h *AuthHandler) Register(r *gin.Engine) { func (h *AuthHandler) Register(r *gin.Engine) {
r.GET("/login", h.HandleLoginPage) r.GET("/login", h.HandleLoginPage)
r.POST("/login", h.HandleLogin) r.POST("/login", h.HandleLogin)
r.GET("/logout", h.HandleLogout) r.POST("/logout", h.HandleLogout)
r.POST("/api/auth/login", h.HandleAPILogin) r.POST("/api/auth/login", h.HandleAPILogin)
} }
@@ -43,7 +43,7 @@ func (h *AuthHandler) HandleLogin(c *gin.Context) {
return return
} }
c.SetCookie("session_id", session.ID, int(domain.SessionLifetime.Seconds()), "/", "", false, true) setPersistentSessionCookie(c, session.ID)
if c.GetHeader("HX-Request") == "true" { if c.GetHeader("HX-Request") == "true" {
c.Header("HX-Redirect", "/") c.Header("HX-Redirect", "/")
c.Status(http.StatusOK) c.Status(http.StatusOK)
@@ -60,7 +60,7 @@ func (h *AuthHandler) HandleLogout(c *gin.Context) {
} }
} }
c.SetCookie("session_id", "", -1, "/", "", false, true) clearSessionCookie(c)
c.Redirect(http.StatusSeeOther, "/login") c.Redirect(http.StatusSeeOther, "/login")
} }

View File

@@ -2,6 +2,7 @@ package auth
import ( import (
"context" "context"
"crypto/tls"
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -72,6 +73,85 @@ func TestHandleAPILoginRejectsInvalidRequests(t *testing.T) {
} }
} }
func TestHandleLoginSetsHardenedSessionCookie(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
configure func(*http.Request)
wantSecure bool
}{
{name: "local http", wantSecure: false},
{name: "direct https", configure: func(req *http.Request) { req.TLS = &tls.ConnectionState{} }, wantSecure: true},
{name: "forwarded https", configure: func(req *http.Request) { req.Header.Set("X-Forwarded-Proto", "https") }, wantSecure: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := &fakeAuthService{}
router := gin.New()
NewAuthHandler(svc).Register(router)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", strings.NewReader("username=alice&password=correct"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if tt.configure != nil {
tt.configure(req)
}
router.ServeHTTP(rec, req)
cookie := findSetCookie(t, rec, "session_id")
if cookie.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want %v", cookie.SameSite, http.SameSiteLaxMode)
}
if cookie.Secure != tt.wantSecure {
t.Fatalf("Secure = %v, want %v", cookie.Secure, tt.wantSecure)
}
if !cookie.HttpOnly {
t.Fatalf("expected HttpOnly cookie")
}
})
}
}
func TestHandleLogoutRequiresPostAndClearsHardenedSessionCookie(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &fakeAuthService{}
router := gin.New()
NewAuthHandler(svc).Register(router)
getRec := httptest.NewRecorder()
getReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/logout", nil)
router.ServeHTTP(getRec, getReq)
if getRec.Code == http.StatusSeeOther {
t.Fatalf("GET /logout must not perform logout redirect")
}
postRec := httptest.NewRecorder()
postReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/logout", nil)
postReq.Header.Set("X-Forwarded-Proto", "https")
postReq.AddCookie(&http.Cookie{Name: "session_id", Value: "session-1"})
router.ServeHTTP(postRec, postReq)
if postRec.Code != http.StatusSeeOther {
t.Fatalf("POST /logout status = %d, want %d", postRec.Code, http.StatusSeeOther)
}
if svc.loggedOutSessionID != "session-1" {
t.Fatalf("logged out session = %q, want session-1", svc.loggedOutSessionID)
}
cookie := findSetCookie(t, postRec, "session_id")
if cookie.MaxAge >= 0 {
t.Fatalf("MaxAge = %d, want deletion cookie", cookie.MaxAge)
}
if cookie.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want %v", cookie.SameSite, http.SameSiteLaxMode)
}
if !cookie.Secure {
t.Fatalf("expected Secure cookie for forwarded https")
}
}
func TestAuthMiddlewareAllowsPublicRoutes(t *testing.T) { func TestAuthMiddlewareAllowsPublicRoutes(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
@@ -133,6 +213,7 @@ func TestAuthMiddlewareAuthenticatesCookieSessionAndRefreshes(t *testing.T) {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil) req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.AddCookie(&http.Cookie{Name: "session_id", Value: "session-1"}) req.AddCookie(&http.Cookie{Name: "session_id", Value: "session-1"})
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
@@ -148,6 +229,13 @@ func TestAuthMiddlewareAuthenticatesCookieSessionAndRefreshes(t *testing.T) {
if got := rec.Header().Values("Set-Cookie"); len(got) == 0 || !strings.Contains(got[0], "session_id=session-1") { if got := rec.Header().Values("Set-Cookie"); len(got) == 0 || !strings.Contains(got[0], "session_id=session-1") {
t.Fatalf("Set-Cookie = %v, want refreshed session cookie", got) t.Fatalf("Set-Cookie = %v, want refreshed session cookie", got)
} }
cookie := findSetCookie(t, rec, "session_id")
if cookie.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want %v", cookie.SameSite, http.SameSiteLaxMode)
}
if !cookie.Secure {
t.Fatalf("expected Secure cookie for forwarded https")
}
} }
func TestAuthMiddlewareRejectsUnauthenticatedRequests(t *testing.T) { func TestAuthMiddlewareRejectsUnauthenticatedRequests(t *testing.T) {
@@ -184,6 +272,26 @@ func TestAuthMiddlewareRejectsUnauthenticatedRequests(t *testing.T) {
} }
} }
func TestAuthMiddlewareAllowsPublicWatchlistAPI(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &fakeAuthService{validateErr: errors.New("no auth")}
router := gin.New()
router.Use(AuthMiddleware(svc))
router.GET("/api/public/users/:userID/watchlist", func(c *gin.Context) { c.Status(http.StatusOK) })
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/public/users/user-42/watchlist", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if svc.validateSessionCalled || svc.validateAPITokenCalled {
t.Fatalf("public watchlist endpoint should not authenticate")
}
}
type fakeAuthService struct { type fakeAuthService struct {
user *domain.User user *domain.User
@@ -253,3 +361,14 @@ func (s *fakeAuthService) RevokeAllAPITokensForUser(_ context.Context, userID st
s.revokedAPITokensForUser = userID s.revokedAPITokensForUser = userID
return nil return nil
} }
func findSetCookie(t *testing.T, rec *httptest.ResponseRecorder, name string) *http.Cookie {
t.Helper()
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == name {
return cookie
}
}
t.Fatalf("missing Set-Cookie %q in %v", name, rec.Header().Values("Set-Cookie"))
return nil
}

View File

@@ -18,7 +18,11 @@ var publicRoutes = []publicRoute{
// Pages. // Pages.
{method: http.MethodGet, path: "/login"}, {method: http.MethodGet, path: "/login"},
{method: http.MethodPost, path: "/login"}, {method: http.MethodPost, path: "/login"},
{method: http.MethodGet, path: "/logout"}, {method: http.MethodPost, path: "/logout"},
// Crawler noise.
{method: http.MethodGet, path: "/robots.txt"},
{method: http.MethodGet, path: "/sitemap.xml"},
// Static assets. // Static assets.
{path: "/static", prefix: true}, {path: "/static", prefix: true},
@@ -26,6 +30,9 @@ var publicRoutes = []publicRoute{
// Auth API. // Auth API.
{method: http.MethodPost, path: "/api/auth/login"}, {method: http.MethodPost, path: "/api/auth/login"},
// Explicitly public, read-only API resources.
{method: http.MethodGet, path: "/api/public/", prefix: true},
} }
func isPublicRequest(method string, path string) bool { func isPublicRequest(method string, path string) bool {
@@ -108,7 +115,7 @@ func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
if usesCookieSession { if usesCookieSession {
if refreshErr := svc.RefreshSession(c.Request.Context(), sessionID); refreshErr == nil { if refreshErr := svc.RefreshSession(c.Request.Context(), sessionID); refreshErr == nil {
c.SetCookie("session_id", sessionID, int(domain.SessionLifetime.Seconds()), "/", "", false, true) setPersistentSessionCookie(c, sessionID)
} }
} }

View File

@@ -0,0 +1,110 @@
package config
import (
"os"
"testing"
)
func TestFirstNonEmptyReturnsFirstNonBlank(t *testing.T) {
if got := firstNonEmpty("", "b", "c"); got != "b" {
t.Fatalf("got %q, want %q", got, "b")
}
}
func TestFirstNonEmptyReturnsEmptyWhenAllBlank(t *testing.T) {
if got := firstNonEmpty("", "", ""); got != "" {
t.Fatalf("got %q, want empty", got)
}
}
func TestFirstNonEmptySkipsWhitespaceOnly(t *testing.T) {
if got := firstNonEmpty(" ", "x"); got != "x" {
t.Fatalf("got %q, want %q", got, "x")
}
}
func TestTruthyRecognises1TrueYesYOn(t *testing.T) {
for _, v := range []string{"1", "true", "True", "TRUE", "yes", "Yes", "y", "Y", "on", "ON"} {
if !truthy(v) {
t.Fatalf("truthy(%q) = false, want true", v)
}
}
}
func TestTruthyReturnsFalseForEmptyAndGarbage(t *testing.T) {
for _, v := range []string{"", "0", "false", "no", "off", "maybe"} {
if truthy(v) {
t.Fatalf("truthy(%q) = true, want false", v)
}
}
}
func TestLoadDefaults(t *testing.T) {
cfg, err := Load()
if err != nil {
t.Fatalf("Load(): %v", err)
}
if cfg.Port != "3000" {
t.Fatalf("Port = %q, want %q", cfg.Port, "3000")
}
if cfg.DatabaseFile != "mal.db" {
t.Fatalf("DatabaseFile = %q, want %q", cfg.DatabaseFile, "mal.db")
}
if cfg.EpisodeAvailabilityMode != EpisodeAvailabilityModeAuto {
t.Fatalf("EpisodeAvailabilityMode = %q, want %q", cfg.EpisodeAvailabilityMode, EpisodeAvailabilityModeAuto)
}
}
func TestLoadReadsEnv(t *testing.T) {
os.Setenv("PORT", "8080")
os.Setenv("DATABASE_FILE", "/tmp/test.db")
os.Setenv("MAL_CORS_ALLOW_ALL", "1")
os.Setenv("MAL_JIKAN_TRACE", "true")
defer func() {
os.Unsetenv("PORT")
os.Unsetenv("DATABASE_FILE")
os.Unsetenv("MAL_CORS_ALLOW_ALL")
os.Unsetenv("MAL_JIKAN_TRACE")
}()
cfg, err := Load()
if err != nil {
t.Fatalf("Load(): %v", err)
}
if cfg.Port != "8080" {
t.Fatalf("Port = %q, want %q", cfg.Port, "8080")
}
if cfg.DatabaseFile != "/tmp/test.db" {
t.Fatalf("DatabaseFile = %q, want %q", cfg.DatabaseFile, "/tmp/test.db")
}
if !cfg.CORSAllowAll {
t.Fatal("CORSAllowAll = false, want true")
}
if !cfg.JikanTrace {
t.Fatal("JikanTrace = false, want true")
}
}
func TestLoadInvalidEpisodeAvailabilityMode(t *testing.T) {
os.Setenv("EPISODE_AVAILABILITY_MODE", "invalid")
defer os.Unsetenv("EPISODE_AVAILABILITY_MODE")
_, err := Load()
if err == nil {
t.Fatal("expected error for invalid EPISODE_AVAILABILITY_MODE")
}
}
func TestLoadEmptyPortDefaultsTo3000(t *testing.T) {
os.Setenv("PORT", "")
os.Setenv("DATABASE_FILE", "mal.db")
defer os.Unsetenv("PORT")
cfg, err := Load()
if err != nil {
t.Fatalf("Load(): %v", err)
}
if cfg.Port != "3000" {
t.Fatalf("Port = %q, want %q", cfg.Port, "3000")
}
}

View File

@@ -0,0 +1,142 @@
package database
import (
"context"
"database/sql"
"errors"
"reflect"
"testing"
dbfixes "mal/internal/database/fixes"
_ "github.com/mattn/go-sqlite3"
)
func TestRunDataFixListRunsUnappliedFixesAndRecordsSuccesses(t *testing.T) {
sqlDB := newEmptyTestDB(t)
defer closeTestDB(t, sqlDB)
var applied []string
fixes := []dbfixes.Fix{
{
ID: "first",
Apply: func(ctx context.Context, sqlDB *sql.DB, deps dbfixes.Dependencies) error {
applied = append(applied, "first")
return nil
},
},
{
ID: "second",
Apply: func(ctx context.Context, sqlDB *sql.DB, deps dbfixes.Dependencies) error {
applied = append(applied, "second")
return nil
},
},
}
if err := runDataFixList(sqlDB, dbfixes.Dependencies{}, fixes); err != nil {
t.Fatalf("runDataFixList: %v", err)
}
if !reflect.DeepEqual(applied, []string{"first", "second"}) {
t.Fatalf("applied fixes = %v, want [first second]", applied)
}
assertAppliedDataFixes(context.Background(), t, sqlDB, []string{"first", "second"})
}
func TestRunDataFixListSkipsAlreadyAppliedFixes(t *testing.T) {
sqlDB := newEmptyTestDB(t)
defer closeTestDB(t, sqlDB)
ctx := context.Background()
if _, err := sqlDB.ExecContext(ctx, `CREATE TABLE data_fixes (id TEXT PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`); err != nil {
t.Fatalf("create data_fixes: %v", err)
}
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO data_fixes (id) VALUES ('already-applied')`); err != nil {
t.Fatalf("insert applied fix: %v", err)
}
var ran bool
fixes := []dbfixes.Fix{
{
ID: "already-applied",
Apply: func(ctx context.Context, sqlDB *sql.DB, deps dbfixes.Dependencies) error {
ran = true
return nil
},
},
}
if err := runDataFixList(sqlDB, dbfixes.Dependencies{}, fixes); err != nil {
t.Fatalf("runDataFixList: %v", err)
}
if ran {
t.Fatal("already applied fix ran")
}
assertAppliedDataFixes(context.Background(), t, sqlDB, []string{"already-applied"})
}
func TestRunDataFixListDoesNotRecordFailedFix(t *testing.T) {
sqlDB := newEmptyTestDB(t)
defer closeTestDB(t, sqlDB)
fixErr := errors.New("boom")
fixes := []dbfixes.Fix{
{
ID: "fails",
Apply: func(ctx context.Context, sqlDB *sql.DB, deps dbfixes.Dependencies) error {
return fixErr
},
},
}
err := runDataFixList(sqlDB, dbfixes.Dependencies{}, fixes)
if !errors.Is(err, fixErr) {
t.Fatalf("runDataFixList error = %v, want wrapped %v", err, fixErr)
}
assertAppliedDataFixes(context.Background(), t, sqlDB, nil)
}
func newEmptyTestDB(t *testing.T) *sql.DB {
t.Helper()
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlDB.SetMaxOpenConns(1)
return sqlDB
}
func assertAppliedDataFixes(ctx context.Context, t *testing.T, sqlDB *sql.DB, want []string) {
t.Helper()
rows, err := sqlDB.QueryContext(ctx, `SELECT id FROM data_fixes ORDER BY id`)
if err != nil {
t.Fatalf("query data_fixes: %v", err)
}
defer func() {
if err := rows.Close(); err != nil {
t.Errorf("close rows: %v", err)
}
}()
var got []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
t.Fatalf("scan data_fix id: %v", err)
}
got = append(got, id)
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate data_fixes: %v", err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("applied data fixes = %v, want %v", got, want)
}
}

View File

@@ -7,6 +7,7 @@ import (
"testing" "testing"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose/v3"
) )
func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) { func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
@@ -45,6 +46,116 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
} }
} }
func TestWatchlistCompletionDateFollowsCompletedStatus(t *testing.T) {
sqlDB := newMigratedTestDB(t)
defer closeTestDB(t, sqlDB)
ctx := context.Background()
mustExecTestSQL(t, sqlDB, `INSERT INTO user (id, username, password_hash) VALUES ('user-1', 'alice', 'hash')`)
mustExecTestSQL(t, sqlDB, `INSERT INTO anime (id, title_original, image_url) VALUES (1, 'Anime', 'image.jpg')`)
queries := db.New(sqlDB)
upsertTestWatchlistStatus(ctx, t, queries, "completed")
completedAt, estimated := testWatchlistCompletion(ctx, t, sqlDB)
if !completedAt.Valid {
t.Fatalf("completed status should record completed_at")
}
if estimated {
t.Fatalf("new completion date should be exact")
}
upsertTestWatchlistStatus(ctx, t, queries, "watching")
completedAt, estimated = testWatchlistCompletion(ctx, t, sqlDB)
if completedAt.Valid || estimated {
t.Fatalf("watching status completion = %v estimated=%v, want empty", completedAt, estimated)
}
upsertTestWatchlistStatus(ctx, t, queries, "completed")
completedAt, estimated = testWatchlistCompletion(ctx, t, sqlDB)
if !completedAt.Valid || estimated {
t.Fatalf("re-completed status completion = %v estimated=%v, want exact date", completedAt, estimated)
}
}
func TestCompletionDateMigrationMarksHistoricalEstimates(t *testing.T) {
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer closeTestDB(t, sqlDB)
sqlDB.SetMaxOpenConns(1)
goose.SetBaseFS(migrationsFS)
goose.SetLogger(goose.NopLogger())
if err := goose.SetDialect("sqlite3"); err != nil {
t.Fatalf("set goose dialect: %v", err)
}
if err := goose.UpTo(sqlDB, "migrations", 25); err != nil {
t.Fatalf("migrate to version 25: %v", err)
}
ctx := context.Background()
mustExecTestSQL(t, sqlDB, `INSERT INTO user (id, username, password_hash) VALUES ('user-1', 'alice', 'hash')`)
mustExecTestSQL(t, sqlDB, `INSERT INTO anime (id, title_original, image_url) VALUES (1, 'Audited', '1.jpg'), (2, 'Estimated', '2.jpg')`)
mustExecTestSQL(t, sqlDB, `
INSERT INTO watch_list_entry (id, user_id, anime_id, status, updated_at)
VALUES
('entry-1', 'user-1', 1, 'completed', '2026-06-20 20:00:00'),
('entry-2', 'user-1', 2, 'completed', '2026-06-21 21:00:00')`)
mustExecTestSQL(t, sqlDB, `
INSERT INTO audit_log (id, occurred_at, user_id, action, resource_type, resource_id)
VALUES ('audit-1', '2026-06-19 19:00:00', 'user-1', 'watch_completed', 'anime', '1')`)
if err := goose.UpTo(sqlDB, "migrations", 26); err != nil {
t.Fatalf("migrate to version 26: %v", err)
}
assertHistoricalCompletion(ctx, t, sqlDB, 1, "2026-06-19T19:00:00Z", false)
assertHistoricalCompletion(ctx, t, sqlDB, 2, "2026-06-21T21:00:00Z", true)
}
func mustExecTestSQL(t *testing.T, sqlDB *sql.DB, query string) {
t.Helper()
if _, err := sqlDB.ExecContext(context.Background(), query); err != nil {
t.Fatalf("execute test SQL: %v", err)
}
}
func upsertTestWatchlistStatus(ctx context.Context, t *testing.T, queries *db.Queries, status string) {
t.Helper()
_, err := queries.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{
ID: "entry-1",
UserID: "user-1",
AnimeID: 1,
Status: status,
})
if err != nil {
t.Fatalf("upsert %s watchlist entry: %v", status, err)
}
}
func testWatchlistCompletion(ctx context.Context, t *testing.T, sqlDB *sql.DB) (sql.NullTime, bool) {
t.Helper()
var completedAt sql.NullTime
var estimated bool
if err := sqlDB.QueryRowContext(ctx, `SELECT completed_at, completed_at_estimated FROM watch_list_entry WHERE user_id = 'user-1' AND anime_id = 1`).Scan(&completedAt, &estimated); err != nil {
t.Fatalf("query completion date: %v", err)
}
return completedAt, estimated
}
func assertHistoricalCompletion(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64, wantTime string, wantEstimated bool) {
t.Helper()
var completedAt string
var estimated bool
if err := sqlDB.QueryRowContext(ctx, `SELECT completed_at, completed_at_estimated FROM watch_list_entry WHERE anime_id = ?`, animeID).Scan(&completedAt, &estimated); err != nil {
t.Fatalf("query completion for anime %d: %v", animeID, err)
}
if completedAt != wantTime || estimated != wantEstimated {
t.Fatalf("anime %d completion = %q estimated=%v, want %q estimated=%v", animeID, completedAt, estimated, wantTime, wantEstimated)
}
}
func TestCleanupExpiredJikanCache(t *testing.T) { func TestCleanupExpiredJikanCache(t *testing.T) {
sqlDB := newMigratedTestDB(t) sqlDB := newMigratedTestDB(t)
defer closeTestDB(t, sqlDB) defer closeTestDB(t, sqlDB)

View File

@@ -12,11 +12,13 @@ import (
) )
func RunDataFixes(sqlDB *sql.DB, deps dbfixes.Dependencies) error { func RunDataFixes(sqlDB *sql.DB, deps dbfixes.Dependencies) error {
return runDataFixList(sqlDB, deps, dbfixes.All())
}
func runDataFixList(sqlDB *sql.DB, deps dbfixes.Dependencies, fixes []dbfixes.Fix) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
fixes := dbfixes.All()
if len(fixes) == 0 { if len(fixes) == 0 {
return nil return nil
} }

View File

@@ -0,0 +1,183 @@
package fixes
import (
"context"
"database/sql"
"testing"
_ "github.com/mattn/go-sqlite3"
)
func TestEpisodeAvailabilityBackfillSetsMissingNextRefreshAt(t *testing.T) {
sqlDB := newFixTestDB(t)
defer closeTestDB(t, sqlDB)
ctx := context.Background()
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO episode_availability_cache (anime_id, data, next_refresh_at, updated_at) VALUES (1, '{}', NULL, '2000-01-01T00:00:00Z')`); err != nil {
t.Fatalf("insert missing next_refresh row: %v", err)
}
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO episode_availability_cache (anime_id, data, next_refresh_at, updated_at) VALUES (2, '{}', '2999-01-01T00:00:00Z', '2000-01-01T00:00:00Z')`); err != nil {
t.Fatalf("insert populated next_refresh row: %v", err)
}
runFix(ctx, t, sqlDB, "20260526_episode_availability_backfill_next_refresh_at", Dependencies{})
assertNextRefreshAtBackfilled(ctx, t, sqlDB, 1)
assertUpdatedAtChanged(ctx, t, sqlDB, 1, "2000-01-01T00:00:00Z")
assertNextRefreshAtEquals(ctx, t, sqlDB, 2, "2999-01-01T00:00:00Z")
backfilledNextRefreshAt := queryNextRefreshAt(ctx, t, sqlDB, 1)
runFix(ctx, t, sqlDB, "20260526_episode_availability_backfill_next_refresh_at", Dependencies{})
assertNextRefreshAtEquals(ctx, t, sqlDB, 1, backfilledNextRefreshAt)
}
func TestAvatarURLBackfillUsesDefaultAvatarForBlankURLs(t *testing.T) {
sqlDB := newFixTestDB(t)
defer closeTestDB(t, sqlDB)
ctx := context.Background()
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO user (id, username, password_hash, avatar_url) VALUES ('blank', 'alice', 'hash', '')`); err != nil {
t.Fatalf("insert blank avatar user: %v", err)
}
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO user (id, username, password_hash, avatar_url) VALUES ('existing', 'bob', 'hash', 'custom.png')`); err != nil {
t.Fatalf("insert existing avatar user: %v", err)
}
deps := Dependencies{DefaultAvatarURL: func(username string) string { return "avatar/" + username + ".png" }}
runFix(ctx, t, sqlDB, "20260528_backfill_avatar_url", deps)
assertUserAvatarURL(ctx, t, sqlDB, "blank", "avatar/alice.png")
assertUserAvatarURL(ctx, t, sqlDB, "existing", "custom.png")
runFix(ctx, t, sqlDB, "20260528_backfill_avatar_url", deps)
assertUserAvatarURL(ctx, t, sqlDB, "blank", "avatar/alice.png")
}
func TestListAnimeMissingDurationSecondsOnlyReturnsRowsWithNullDuration(t *testing.T) {
sqlDB := newFixTestDB(t)
defer closeTestDB(t, sqlDB)
ctx := context.Background()
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO anime (id, title_original, image_url, duration_seconds) VALUES (1, 'Missing', 'missing.png', NULL)`); err != nil {
t.Fatalf("insert missing duration anime: %v", err)
}
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO anime (id, title_original, image_url, duration_seconds) VALUES (2, 'Present', 'present.png', 1440)`); err != nil {
t.Fatalf("insert present duration anime: %v", err)
}
rows, err := listAnimeMissingDurationSeconds(ctx, sqlDB)
if err != nil {
t.Fatalf("listAnimeMissingDurationSeconds: %v", err)
}
if len(rows) != 1 {
t.Fatalf("missing duration rows = %v, want 1 row", rows)
}
if rows[0].id != 1 || rows[0].titleOriginal != "Missing" {
t.Fatalf("missing duration row = %+v, want anime 1", rows[0])
}
}
func runFix(ctx context.Context, t *testing.T, sqlDB *sql.DB, id string, deps Dependencies) {
t.Helper()
for _, fix := range All() {
if fix.ID != id {
continue
}
if err := fix.Apply(ctx, sqlDB, deps); err != nil {
t.Fatalf("apply fix %s: %v", id, err)
}
return
}
t.Fatalf("fix %s not registered", id)
}
func queryNextRefreshAt(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64) string {
t.Helper()
var v sql.NullString
if err := sqlDB.QueryRowContext(ctx, `SELECT next_refresh_at FROM episode_availability_cache WHERE anime_id = ?`, animeID).Scan(&v); err != nil {
t.Fatalf("query next_refresh_at for %d: %v", animeID, err)
}
if !v.Valid || v.String == "" {
t.Fatalf("next_refresh_at for %d = %q, valid=%v; want populated", animeID, v.String, v.Valid)
}
return v.String
}
func assertNextRefreshAtBackfilled(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64) {
t.Helper()
queryNextRefreshAt(ctx, t, sqlDB, animeID)
}
func assertUpdatedAtChanged(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64, original string) {
t.Helper()
var updatedAt string
if err := sqlDB.QueryRowContext(ctx, `SELECT updated_at FROM episode_availability_cache WHERE anime_id = ?`, animeID).Scan(&updatedAt); err != nil {
t.Fatalf("query updated_at for %d: %v", animeID, err)
}
if updatedAt == original {
t.Fatalf("updated_at for %d = %q, expected change", animeID, updatedAt)
}
}
func assertNextRefreshAtEquals(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64, want string) {
t.Helper()
var v sql.NullString
if err := sqlDB.QueryRowContext(ctx, `SELECT next_refresh_at FROM episode_availability_cache WHERE anime_id = ?`, animeID).Scan(&v); err != nil {
t.Fatalf("query next_refresh_at for %d: %v", animeID, err)
}
if !v.Valid {
t.Fatalf("next_refresh_at for %d is NULL", animeID)
}
if v.String != want {
t.Fatalf("next_refresh_at for %d = %q, want %q", animeID, v.String, want)
}
}
func assertUserAvatarURL(ctx context.Context, t *testing.T, sqlDB *sql.DB, id string, want string) {
t.Helper()
var got string
if err := sqlDB.QueryRowContext(ctx, `SELECT avatar_url FROM user WHERE id = ?`, id).Scan(&got); err != nil {
t.Fatalf("query avatar_url for %s: %v", id, err)
}
if got != want {
t.Fatalf("avatar_url for %s = %q, want %q", id, got, want)
}
}
func newFixTestDB(t *testing.T) *sql.DB {
t.Helper()
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlDB.SetMaxOpenConns(1)
ctx := context.Background()
for _, statement := range []string{
`CREATE TABLE user (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, avatar_url TEXT NOT NULL DEFAULT '')`,
`CREATE TABLE anime (id INTEGER PRIMARY KEY, title_original TEXT NOT NULL, title_english TEXT, title_japanese TEXT, image_url TEXT NOT NULL, airing BOOLEAN, duration_seconds REAL)`,
`CREATE TABLE episode_availability_cache (anime_id INTEGER PRIMARY KEY, data TEXT NOT NULL, next_refresh_at DATETIME, retry_until_at DATETIME, last_attempt_at DATETIME, last_success_at DATETIME, failure_count INTEGER NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
} {
if _, err := sqlDB.ExecContext(ctx, statement); err != nil {
closeTestDB(t, sqlDB)
t.Fatalf("create test schema: %v", err)
}
}
return sqlDB
}
func closeTestDB(t *testing.T, sqlDB *sql.DB) {
t.Helper()
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
}

View File

@@ -0,0 +1,32 @@
-- +goose Up
ALTER TABLE watch_list_entry ADD COLUMN completed_at DATETIME;
ALTER TABLE watch_list_entry ADD COLUMN completed_at_estimated BOOLEAN NOT NULL DEFAULT 0;
UPDATE watch_list_entry
SET completed_at = COALESCE(
(
SELECT MAX(a.occurred_at)
FROM audit_log a
WHERE a.user_id = watch_list_entry.user_id
AND a.action = 'watch_completed'
AND a.resource_type = 'anime'
AND a.resource_id = CAST(watch_list_entry.anime_id AS TEXT)
),
watch_list_entry.updated_at
),
completed_at_estimated = CASE
WHEN EXISTS (
SELECT 1
FROM audit_log a
WHERE a.user_id = watch_list_entry.user_id
AND a.action = 'watch_completed'
AND a.resource_type = 'anime'
AND a.resource_id = CAST(watch_list_entry.anime_id AS TEXT)
) THEN 0
ELSE 1
END
WHERE status = 'completed';
-- +goose Down
ALTER TABLE watch_list_entry DROP COLUMN completed_at_estimated;
ALTER TABLE watch_list_entry DROP COLUMN completed_at;

View File

@@ -0,0 +1,93 @@
package db
import (
"context"
"database/sql"
"fmt"
"testing"
_ "github.com/mattn/go-sqlite3"
)
func TestGetContinueWatchingCarouselEntriesLimitsNewestFirst(t *testing.T) {
ctx := context.Background()
sqlDB := newContinueWatchingTestDB(ctx, t)
defer closeContinueWatchingTestDB(t, sqlDB)
seedContinueWatchingRows(ctx, t, sqlDB, 30)
rows, err := New(sqlDB).GetContinueWatchingCarouselEntries(ctx, GetContinueWatchingCarouselEntriesParams{
UserID: "user-1",
Limit: 24,
})
if err != nil {
t.Fatalf("GetContinueWatchingCarouselEntries: %v", err)
}
if len(rows) != 24 {
t.Fatalf("len(rows) = %d, want 24", len(rows))
}
if rows[0].AnimeID != 30 {
t.Fatalf("first AnimeID = %d, want 30", rows[0].AnimeID)
}
if rows[len(rows)-1].AnimeID != 7 {
t.Fatalf("last AnimeID = %d, want 7", rows[len(rows)-1].AnimeID)
}
}
func newContinueWatchingTestDB(ctx context.Context, t *testing.T) *sql.DB {
t.Helper()
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
_, err = sqlDB.ExecContext(ctx, `
CREATE TABLE anime (
id INTEGER PRIMARY KEY,
title_original TEXT NOT NULL,
title_english TEXT,
title_japanese TEXT,
image_url TEXT NOT NULL,
duration_seconds REAL
);
CREATE TABLE continue_watching_entry (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
anime_id INTEGER NOT NULL REFERENCES anime(id),
current_episode INTEGER,
current_time_seconds REAL NOT NULL DEFAULT 0,
duration_seconds REAL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, anime_id)
);`)
if err != nil {
closeContinueWatchingTestDB(t, sqlDB)
t.Fatalf("create schema: %v", err)
}
return sqlDB
}
func closeContinueWatchingTestDB(t *testing.T, sqlDB *sql.DB) {
t.Helper()
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
}
func seedContinueWatchingRows(ctx context.Context, t *testing.T, sqlDB *sql.DB, totalRows int) {
t.Helper()
for i := 1; i <= totalRows; i++ {
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO anime (id, title_original, image_url) VALUES (?, ?, ?)`, i, fmt.Sprintf("Anime %02d", i), "image.jpg"); err != nil {
t.Fatalf("insert anime %d: %v", i, err)
}
if _, err := sqlDB.ExecContext(ctx, `
INSERT INTO continue_watching_entry (id, user_id, anime_id, current_episode, current_time_seconds, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('2026-01-01', printf('+%d minutes', ?)))`, fmt.Sprintf("cw-%02d", i), "user-1", i, i, 0, i); err != nil {
t.Fatalf("insert continue watching %d: %v", i, err)
}
}
}

View File

@@ -103,6 +103,38 @@ type JikanCache struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type RecommendationEvent struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID sql.NullInt64 `json:"anime_id"`
EventType string `json:"event_type"`
Source sql.NullString `json:"source"`
MetadataJson sql.NullString `json:"metadata_json"`
OccurredAt time.Time `json:"occurred_at"`
CreatedAt time.Time `json:"created_at"`
}
type RecommendationImpression struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Rail string `json:"rail"`
Position int64 `json:"position"`
RequestID sql.NullString `json:"request_id"`
MetadataJson sql.NullString `json:"metadata_json"`
OccurredAt time.Time `json:"occurred_at"`
CreatedAt time.Time `json:"created_at"`
}
type RecommendationProfileSnapshot struct {
UserID string `json:"user_id"`
ProfileJson string `json:"profile_json"`
SourceWindowStart sql.NullTime `json:"source_window_start"`
SourceWindowEnd sql.NullTime `json:"source_window_end"`
ComputedAt time.Time `json:"computed_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Session struct { type Session struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
@@ -131,13 +163,15 @@ type User struct {
} }
type WatchListEntry struct { type WatchListEntry struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"` AnimeID int64 `json:"anime_id"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
CurrentEpisode sql.NullInt64 `json:"current_episode"` CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"` LastEpisodeAt sql.NullTime `json:"last_episode_at"`
CurrentTimeSeconds float64 `json:"current_time_seconds"` CurrentTimeSeconds float64 `json:"current_time_seconds"`
CompletedAt sql.NullTime `json:"completed_at"`
CompletedAtEstimated bool `json:"completed_at_estimated"`
} }

View File

@@ -25,6 +25,7 @@ type Querier interface {
GetAnime(ctx context.Context, id int64) (Anime, error) GetAnime(ctx context.Context, id int64) (Anime, error)
GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error)
GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error) GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error)
GetContinueWatchingCarouselEntries(ctx context.Context, arg GetContinueWatchingCarouselEntriesParams) ([]GetContinueWatchingEntriesRow, error)
GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error) GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error)
GetContinueWatchingEntry(ctx context.Context, arg GetContinueWatchingEntryParams) (ContinueWatchingEntry, error) GetContinueWatchingEntry(ctx context.Context, arg GetContinueWatchingEntryParams) (ContinueWatchingEntry, error)
GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]AnimeFetchRetry, error) GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]AnimeFetchRetry, error)

View File

@@ -68,12 +68,32 @@ RETURNING *;
SELECT * FROM anime WHERE id = ? LIMIT 1; SELECT * FROM anime WHERE id = ? LIMIT 1;
-- name: UpsertWatchListEntry :one -- name: UpsertWatchListEntry :one
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at) INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, completed_at, completed_at_estimated, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (
sqlc.arg(id),
sqlc.arg(user_id),
sqlc.arg(anime_id),
sqlc.arg(status),
sqlc.arg(current_episode),
sqlc.arg(current_time_seconds),
CASE WHEN sqlc.arg(status) = 'completed' THEN CURRENT_TIMESTAMP END,
0,
CURRENT_TIMESTAMP
)
ON CONFLICT (user_id, anime_id) DO UPDATE SET ON CONFLICT (user_id, anime_id) DO UPDATE SET
status = excluded.status, status = excluded.status,
current_episode = excluded.current_episode, current_episode = excluded.current_episode,
current_time_seconds = excluded.current_time_seconds, current_time_seconds = excluded.current_time_seconds,
completed_at = CASE
WHEN excluded.status != 'completed' THEN NULL
WHEN watch_list_entry.status = 'completed' THEN COALESCE(watch_list_entry.completed_at, CURRENT_TIMESTAMP)
ELSE CURRENT_TIMESTAMP
END,
completed_at_estimated = CASE
WHEN excluded.status = 'completed' AND watch_list_entry.status = 'completed'
THEN watch_list_entry.completed_at_estimated
ELSE 0
END,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
RETURNING *; RETURNING *;
@@ -117,6 +137,27 @@ JOIN anime a ON c.anime_id = a.id
WHERE c.user_id = ? WHERE c.user_id = ?
ORDER BY c.updated_at DESC; ORDER BY c.updated_at DESC;
-- name: GetContinueWatchingCarouselEntries :many
SELECT
c.id,
c.user_id,
c.anime_id,
c.current_episode,
c.current_time_seconds,
c.duration_seconds,
c.created_at,
c.updated_at,
a.title_original,
a.title_english,
a.title_japanese,
a.image_url,
a.duration_seconds as anime_duration_seconds
FROM continue_watching_entry c
JOIN anime a ON c.anime_id = a.id
WHERE c.user_id = ?
ORDER BY c.updated_at DESC
LIMIT ?;
-- name: DeleteContinueWatchingEntry :exec -- name: DeleteContinueWatchingEntry :exec
DELETE FROM continue_watching_entry DELETE FROM continue_watching_entry
WHERE user_id = ? AND anime_id = ?; WHERE user_id = ? AND anime_id = ?;
@@ -127,7 +168,20 @@ WHERE user_id = ? AND anime_id = ? LIMIT 1;
-- name: GetUserWatchList :many -- name: GetUserWatchList :many
SELECT SELECT
e.*, e.id,
e.user_id,
e.anime_id,
e.status,
e.created_at,
e.updated_at,
e.current_episode,
e.last_episode_at,
e.current_time_seconds,
e.completed_at,
e.completed_at_estimated,
c.current_episode AS playback_current_episode,
c.current_time_seconds AS playback_current_time_seconds,
c.updated_at AS playback_updated_at,
a.title_original, a.title_original,
a.title_english, a.title_english,
a.title_japanese, a.title_japanese,
@@ -135,6 +189,7 @@ SELECT
a.airing a.airing
FROM watch_list_entry e FROM watch_list_entry e
JOIN anime a ON e.anime_id = a.id JOIN anime a ON e.anime_id = a.id
LEFT JOIN continue_watching_entry c ON c.user_id = e.user_id AND c.anime_id = e.anime_id
WHERE e.user_id = ? WHERE e.user_id = ?
ORDER BY e.updated_at DESC; ORDER BY e.updated_at DESC;

View File

@@ -380,6 +380,70 @@ func (q *Queries) GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUs
return items, nil return items, nil
} }
const getContinueWatchingCarouselEntries = `-- name: GetContinueWatchingCarouselEntries :many
SELECT
c.id,
c.user_id,
c.anime_id,
c.current_episode,
c.current_time_seconds,
c.duration_seconds,
c.created_at,
c.updated_at,
a.title_original,
a.title_english,
a.title_japanese,
a.image_url,
a.duration_seconds as anime_duration_seconds
FROM continue_watching_entry c
JOIN anime a ON c.anime_id = a.id
WHERE c.user_id = ?
ORDER BY c.updated_at DESC
LIMIT ?
`
type GetContinueWatchingCarouselEntriesParams struct {
UserID string `json:"user_id"`
Limit int64 `json:"limit"`
}
func (q *Queries) GetContinueWatchingCarouselEntries(ctx context.Context, arg GetContinueWatchingCarouselEntriesParams) ([]GetContinueWatchingEntriesRow, error) {
rows, err := q.db.QueryContext(ctx, getContinueWatchingCarouselEntries, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetContinueWatchingEntriesRow
for rows.Next() {
var i GetContinueWatchingEntriesRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.AnimeID,
&i.CurrentEpisode,
&i.CurrentTimeSeconds,
&i.DurationSeconds,
&i.CreatedAt,
&i.UpdatedAt,
&i.TitleOriginal,
&i.TitleEnglish,
&i.TitleJapanese,
&i.ImageUrl,
&i.AnimeDurationSeconds,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getContinueWatchingEntries = `-- name: GetContinueWatchingEntries :many const getContinueWatchingEntries = `-- name: GetContinueWatchingEntries :many
SELECT SELECT
c.id, c.id,
@@ -609,6 +673,7 @@ func (q *Queries) GetJikanCacheStale(ctx context.Context, key string) (string, e
return data, err return data, err
} }
const getSession = `-- name: GetSession :one const getSession = `-- name: GetSession :one
SELECT id, user_id, expires_at, created_at FROM session WHERE id = ? LIMIT 1 SELECT id, user_id, expires_at, created_at FROM session WHERE id = ? LIMIT 1
` `
@@ -794,7 +859,20 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
const getUserWatchList = `-- name: GetUserWatchList :many const getUserWatchList = `-- name: GetUserWatchList :many
SELECT SELECT
e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, e.current_time_seconds, e.id,
e.user_id,
e.anime_id,
e.status,
e.created_at,
e.updated_at,
e.current_episode,
e.last_episode_at,
e.current_time_seconds,
e.completed_at,
e.completed_at_estimated,
c.current_episode AS playback_current_episode,
c.current_time_seconds AS playback_current_time_seconds,
c.updated_at AS playback_updated_at,
a.title_original, a.title_original,
a.title_english, a.title_english,
a.title_japanese, a.title_japanese,
@@ -802,25 +880,31 @@ SELECT
a.airing a.airing
FROM watch_list_entry e FROM watch_list_entry e
JOIN anime a ON e.anime_id = a.id JOIN anime a ON e.anime_id = a.id
LEFT JOIN continue_watching_entry c ON c.user_id = e.user_id AND c.anime_id = e.anime_id
WHERE e.user_id = ? WHERE e.user_id = ?
ORDER BY e.updated_at DESC ORDER BY e.updated_at DESC
` `
type GetUserWatchListRow struct { type GetUserWatchListRow struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"` AnimeID int64 `json:"anime_id"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
CurrentEpisode sql.NullInt64 `json:"current_episode"` CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"` LastEpisodeAt sql.NullTime `json:"last_episode_at"`
CurrentTimeSeconds float64 `json:"current_time_seconds"` CurrentTimeSeconds float64 `json:"current_time_seconds"`
TitleOriginal string `json:"title_original"` CompletedAt sql.NullTime `json:"completed_at"`
TitleEnglish sql.NullString `json:"title_english"` CompletedAtEstimated bool `json:"completed_at_estimated"`
TitleJapanese sql.NullString `json:"title_japanese"` PlaybackCurrentEpisode sql.NullInt64 `json:"playback_current_episode"`
ImageUrl string `json:"image_url"` PlaybackCurrentTimeSeconds sql.NullFloat64 `json:"playback_current_time_seconds"`
Airing sql.NullBool `json:"airing"` PlaybackUpdatedAt sql.NullTime `json:"playback_updated_at"`
TitleOriginal string `json:"title_original"`
TitleEnglish sql.NullString `json:"title_english"`
TitleJapanese sql.NullString `json:"title_japanese"`
ImageUrl string `json:"image_url"`
Airing sql.NullBool `json:"airing"`
} }
func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) { func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) {
@@ -841,6 +925,11 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
&i.CurrentEpisode, &i.CurrentEpisode,
&i.LastEpisodeAt, &i.LastEpisodeAt,
&i.CurrentTimeSeconds, &i.CurrentTimeSeconds,
&i.CompletedAt,
&i.CompletedAtEstimated,
&i.PlaybackCurrentEpisode,
&i.PlaybackCurrentTimeSeconds,
&i.PlaybackUpdatedAt,
&i.TitleOriginal, &i.TitleOriginal,
&i.TitleEnglish, &i.TitleEnglish,
&i.TitleJapanese, &i.TitleJapanese,
@@ -861,7 +950,7 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
} }
const getWatchListEntry = `-- name: GetWatchListEntry :one const getWatchListEntry = `-- name: GetWatchListEntry :one
SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds FROM watch_list_entry SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds, completed_at, completed_at_estimated FROM watch_list_entry
WHERE user_id = ? AND anime_id = ? LIMIT 1 WHERE user_id = ? AND anime_id = ? LIMIT 1
` `
@@ -883,13 +972,15 @@ func (q *Queries) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryPa
&i.CurrentEpisode, &i.CurrentEpisode,
&i.LastEpisodeAt, &i.LastEpisodeAt,
&i.CurrentTimeSeconds, &i.CurrentTimeSeconds,
&i.CompletedAt,
&i.CompletedAtEstimated,
) )
return i, err return i, err
} }
const getWatchingAnime = `-- name: GetWatchingAnime :many const getWatchingAnime = `-- name: GetWatchingAnime :many
SELECT SELECT
e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, e.current_time_seconds, e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, e.current_time_seconds, e.completed_at, e.completed_at_estimated,
a.title_original, a.title_original,
a.title_english, a.title_english,
a.title_japanese, a.title_japanese,
@@ -902,20 +993,22 @@ ORDER BY e.updated_at DESC
` `
type GetWatchingAnimeRow struct { type GetWatchingAnimeRow struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"` AnimeID int64 `json:"anime_id"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
CurrentEpisode sql.NullInt64 `json:"current_episode"` CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"` LastEpisodeAt sql.NullTime `json:"last_episode_at"`
CurrentTimeSeconds float64 `json:"current_time_seconds"` CurrentTimeSeconds float64 `json:"current_time_seconds"`
TitleOriginal string `json:"title_original"` CompletedAt sql.NullTime `json:"completed_at"`
TitleEnglish sql.NullString `json:"title_english"` CompletedAtEstimated bool `json:"completed_at_estimated"`
TitleJapanese sql.NullString `json:"title_japanese"` TitleOriginal string `json:"title_original"`
ImageUrl string `json:"image_url"` TitleEnglish sql.NullString `json:"title_english"`
Airing sql.NullBool `json:"airing"` TitleJapanese sql.NullString `json:"title_japanese"`
ImageUrl string `json:"image_url"`
Airing sql.NullBool `json:"airing"`
} }
func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error) { func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error) {
@@ -936,6 +1029,8 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat
&i.CurrentEpisode, &i.CurrentEpisode,
&i.LastEpisodeAt, &i.LastEpisodeAt,
&i.CurrentTimeSeconds, &i.CurrentTimeSeconds,
&i.CompletedAt,
&i.CompletedAtEstimated,
&i.TitleOriginal, &i.TitleOriginal,
&i.TitleEnglish, &i.TitleEnglish,
&i.TitleJapanese, &i.TitleJapanese,
@@ -1299,14 +1394,34 @@ func (q *Queries) UpsertEpisodeProviderMapping(ctx context.Context, arg UpsertEp
} }
const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at) INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, completed_at, completed_at_estimated, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (
?1,
?2,
?3,
?4,
?5,
?6,
CASE WHEN ?4 = 'completed' THEN CURRENT_TIMESTAMP END,
0,
CURRENT_TIMESTAMP
)
ON CONFLICT (user_id, anime_id) DO UPDATE SET ON CONFLICT (user_id, anime_id) DO UPDATE SET
status = excluded.status, status = excluded.status,
current_episode = excluded.current_episode, current_episode = excluded.current_episode,
current_time_seconds = excluded.current_time_seconds, current_time_seconds = excluded.current_time_seconds,
completed_at = CASE
WHEN excluded.status != 'completed' THEN NULL
WHEN watch_list_entry.status = 'completed' THEN COALESCE(watch_list_entry.completed_at, CURRENT_TIMESTAMP)
ELSE CURRENT_TIMESTAMP
END,
completed_at_estimated = CASE
WHEN excluded.status = 'completed' AND watch_list_entry.status = 'completed'
THEN watch_list_entry.completed_at_estimated
ELSE 0
END,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
RETURNING id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds RETURNING id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds, completed_at, completed_at_estimated
` `
type UpsertWatchListEntryParams struct { type UpsertWatchListEntryParams struct {
@@ -1338,6 +1453,8 @@ func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListE
&i.CurrentEpisode, &i.CurrentEpisode,
&i.LastEpisodeAt, &i.LastEpisodeAt,
&i.CurrentTimeSeconds, &i.CurrentTimeSeconds,
&i.CompletedAt,
&i.CompletedAtEstimated,
) )
return i, err return i, err
} }

View File

@@ -9,6 +9,7 @@ import (
type Anime struct { type Anime struct {
jikan.Anime jikan.Anime
RecommendationRationale []string
} }
type Genre struct { type Genre struct {
@@ -137,6 +138,10 @@ type AnimeCatalogService interface {
GetTopPicksForYou(ctx context.Context, userID string) (CatalogSectionData, error) GetTopPicksForYou(ctx context.Context, userID string) (CatalogSectionData, error)
} }
type RecommendationInvalidator interface {
InvalidateTopPicksForUser(userID string)
}
type AnimeSearchService interface { type AnimeSearchService interface {
SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error)
GetProducerNameByID(ctx context.Context, id int) (string, error) GetProducerNameByID(ctx context.Context, id int) (string, error)
@@ -179,4 +184,5 @@ type AnimeRepository interface {
GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error)
GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error)
GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error)
GetContinueWatchingCarouselEntries(ctx context.Context, userID string, limit int64) ([]db.GetContinueWatchingEntriesRow, error)
} }

View File

@@ -3,16 +3,26 @@ package domain
import "context" import "context"
type EpisodeAvailability struct { type EpisodeAvailability struct {
Sub []int Sub []int
Dub []int Dub []int
Titles map[int]string
}
type EpisodeProvider interface {
Name() string
ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error)
} }
type EpisodeAvailabilityProvider interface { type EpisodeAvailabilityProvider interface {
Name() string EpisodeProvider
ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error)
GetEpisodeAvailabilityByProviderID(ctx context.Context, providerID string) (EpisodeAvailability, error) GetEpisodeAvailabilityByProviderID(ctx context.Context, providerID string) (EpisodeAvailability, error)
} }
type EpisodeTitleProvider interface {
EpisodeProvider
GetEpisodeTitlesByProviderID(ctx context.Context, providerID string, anime Anime, episodeCount int) (map[int]string, error)
}
type CanonicalEpisode struct { type CanonicalEpisode struct {
Number int `json:"number"` Number int `json:"number"`
Title string `json:"title"` Title string `json:"title"`
@@ -24,13 +34,28 @@ type CanonicalEpisode struct {
} }
type CanonicalEpisodeList struct { type CanonicalEpisodeList struct {
AnimeID int `json:"anime_id"` AnimeID int `json:"anime_id"`
Episodes []CanonicalEpisode `json:"episodes"` Episodes []CanonicalEpisode `json:"episodes"`
Source string `json:"source"` Source string `json:"source"`
NextRefreshAt string `json:"next_refresh_at,omitempty"` AvailabilityVersion int `json:"availability_version,omitempty"`
ClassificationChecked bool `json:"classification_checked,omitempty"`
ReleaseChecked bool `json:"release_checked,omitempty"`
NextRefreshAt string `json:"next_refresh_at,omitempty"`
RetryUntilAt string `json:"retry_until_at,omitempty"`
LastAttemptAt string `json:"last_attempt_at,omitempty"`
LastSuccessAt string `json:"last_success_at,omitempty"`
FailureCount int64 `json:"failure_count,omitempty"`
} }
type EpisodeService interface { type EpisodeService interface {
GetCanonicalEpisodes(ctx context.Context, anime Anime, forceRefresh bool) (CanonicalEpisodeList, error) GetCanonicalEpisodes(ctx context.Context, anime Anime, forceRefresh bool) (CanonicalEpisodeList, error)
RefreshTrackedDue(ctx context.Context, limit int) error RefreshTrackedDue(ctx context.Context, limit int) error
} }
type EpisodeTitleService interface {
EnrichEpisodeTitles(ctx context.Context, anime Anime) (CanonicalEpisodeList, error)
}
type EpisodeClassificationService interface {
EnrichEpisodeClassifications(ctx context.Context, anime Anime) (CanonicalEpisodeList, error)
}

View File

@@ -5,6 +5,27 @@ import (
"mal/internal/db" "mal/internal/db"
) )
type playbackSourceRefreshKey struct{}
type deferredPlaybackDataKey struct{}
func WithDeferredPlaybackData(ctx context.Context) context.Context {
return context.WithValue(ctx, deferredPlaybackDataKey{}, true)
}
func PlaybackDataDeferred(ctx context.Context) bool {
deferred, _ := ctx.Value(deferredPlaybackDataKey{}).(bool)
return deferred
}
func WithPlaybackSourceRefresh(ctx context.Context) context.Context {
return context.WithValue(ctx, playbackSourceRefreshKey{}, true)
}
func PlaybackSourceRefreshRequested(ctx context.Context) bool {
refresh, _ := ctx.Value(playbackSourceRefreshKey{}).(bool)
return refresh
}
type PlaybackService interface { type PlaybackService interface {
BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (WatchPageData, error) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (WatchPageData, error)
SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error
@@ -14,17 +35,26 @@ type PlaybackService interface {
UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error
} }
type PlaybackEpisodeTitleService interface {
EnrichEpisodeTitles(ctx context.Context, animeID int) ([]CanonicalEpisode, error)
}
type PlaybackEpisodeClassificationService interface {
EnrichEpisodeClassifications(ctx context.Context, animeID int) ([]CanonicalEpisode, error)
}
type WatchPageData struct { type WatchPageData struct {
WatchData WatchData WatchData WatchData
Anime Anime Anime Anime
Episodes []CanonicalEpisode Episodes []CanonicalEpisode
CurrentEpID string CurrentEpID string
WatchlistStatus string WatchlistStatus string
WatchlistIDs []int64 WatchlistIDs []int64
Seasons []SeasonEntry Seasons []SeasonEntry
User *User User *User
CurrentPath string CurrentPath string
Error string Error string
EpisodeAvailabilityWarning string
} }
type WatchData struct { type WatchData struct {

View File

@@ -3,6 +3,7 @@ package episodes
import ( import (
"mal/integrations/playback/allanime" "mal/integrations/playback/allanime"
"mal/integrations/tvmaze"
"mal/internal/config" "mal/internal/config"
"mal/internal/domain" "mal/internal/domain"
episodeService "mal/internal/episodes/service" episodeService "mal/internal/episodes/service"
@@ -17,6 +18,7 @@ func episodeAvailabilityEnabled(cfg config.Config) bool {
var Module = fx.Options( var Module = fx.Options(
fx.Provide( fx.Provide(
episodeAvailabilityEnabled, episodeAvailabilityEnabled,
tvmaze.NewClient,
fx.Annotate( fx.Annotate(
episodeService.NewEpisodeService, episodeService.NewEpisodeService,
), ),
@@ -24,5 +26,8 @@ var Module = fx.Options(
fx.Provide(func(p *allanime.AllAnimeProvider) []domain.EpisodeAvailabilityProvider { fx.Provide(func(p *allanime.AllAnimeProvider) []domain.EpisodeAvailabilityProvider {
return []domain.EpisodeAvailabilityProvider{p} return []domain.EpisodeAvailabilityProvider{p}
}), }),
fx.Provide(func(p *tvmaze.Client) domain.EpisodeTitleProvider {
return p
}),
fx.Invoke(RegisterWorker), fx.Invoke(RegisterWorker),
) )

View File

@@ -6,20 +6,26 @@ import (
"encoding/json" "encoding/json"
"time" "time"
"mal/integrations/jikan"
"mal/internal/db" "mal/internal/db"
"mal/internal/domain" "mal/internal/domain"
"mal/internal/observability" "mal/internal/observability"
) )
func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, source string, now time.Time, providerSuccess bool) (domain.CanonicalEpisodeList, error) { const episodeAvailabilityPayloadVersion = 4
func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, availability domain.EpisodeAvailability, source string, now time.Time) (domain.CanonicalEpisodeList, error) {
nextRefreshSQL := nextRefreshAt(anime, now) nextRefreshSQL := nextRefreshAt(anime, now)
episodes := mergeEpisodes(jikanEpisodes, availability, anime.Episodes) episodes := mergeEpisodes(nil, availability, 0)
payload := domain.CanonicalEpisodeList{ payload := domain.CanonicalEpisodeList{
AnimeID: anime.MalID, AnimeID: anime.MalID,
Episodes: episodes, Episodes: episodes,
Source: source, Source: source,
AvailabilityVersion: episodeAvailabilityPayloadVersion,
ReleaseChecked: false,
LastAttemptAt: now.Format(time.RFC3339),
FailureCount: 0,
} }
payload.LastSuccessAt = now.Format(time.RFC3339)
if nextRefreshSQL.Valid { if nextRefreshSQL.Valid {
payload.NextRefreshAt = nextRefreshSQL.Time.Format(time.RFC3339) payload.NextRefreshAt = nextRefreshSQL.Time.Format(time.RFC3339)
} }
@@ -29,7 +35,7 @@ func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpi
return domain.CanonicalEpisodeList{}, err return domain.CanonicalEpisodeList{}, err
} }
if !s.writeEpisodeAvailabilityCache(ctx, anime, source, body, now, providerSuccess, nextRefreshSQL) { if !s.writeEpisodeAvailabilityCache(ctx, anime, source, body, now, true, nextRefreshSQL) {
return payload, nil return payload, nil
} }
@@ -146,6 +152,7 @@ func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime)
if !ok { if !ok {
return domain.CanonicalEpisodeList{}, false return domain.CanonicalEpisodeList{}, false
} }
payload = enrichCachedPayload(payload, row)
observability.Info( observability.Info(
"episodes_cache_served", "episodes_cache_served",
"episodes", "episodes",
@@ -168,9 +175,27 @@ func (s *EpisodeService) getDecodedCached(ctx context.Context, anime domain.Anim
if !ok { if !ok {
return domain.CanonicalEpisodeList{}, false return domain.CanonicalEpisodeList{}, false
} }
payload = enrichCachedPayload(payload, row)
return payload, true return payload, true
} }
func enrichCachedPayload(payload domain.CanonicalEpisodeList, row db.EpisodeAvailabilityCache) domain.CanonicalEpisodeList {
if row.NextRefreshAt.Valid {
payload.NextRefreshAt = row.NextRefreshAt.Time.Format(time.RFC3339)
}
if row.RetryUntilAt.Valid {
payload.RetryUntilAt = row.RetryUntilAt.Time.Format(time.RFC3339)
}
if row.LastAttemptAt.Valid {
payload.LastAttemptAt = row.LastAttemptAt.Time.Format(time.RFC3339)
}
if row.LastSuccessAt.Valid {
payload.LastSuccessAt = row.LastSuccessAt.Time.Format(time.RFC3339)
}
payload.FailureCount = row.FailureCount
return payload
}
func (s *EpisodeService) isFreshEpisodeCache(anime domain.Anime, row db.EpisodeAvailabilityCache, now time.Time) bool { func (s *EpisodeService) isFreshEpisodeCache(anime domain.Anime, row db.EpisodeAvailabilityCache, now time.Time) bool {
if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(now) { if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(now) {
observability.Info( observability.Info(
@@ -213,6 +238,24 @@ func (s *EpisodeService) decodeCachedPayload(anime domain.Anime, raw string) (do
) )
return domain.CanonicalEpisodeList{}, false return domain.CanonicalEpisodeList{}, false
} }
if payload.Source == "jikan_fallback" || payload.Source == "legacy_disabled" {
return domain.CanonicalEpisodeList{}, false
}
if isStaleProviderEpisodePayload(payload) {
observability.Info(
"episodes_cached_payload_rejected_stale_version",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"cached_episodes": len(payload.Episodes),
"source": payload.Source,
"availability_version": payload.AvailabilityVersion,
},
)
return domain.CanonicalEpisodeList{}, false
}
if !isCanonicalEpisodePayloadValid(payload, anime.Episodes) { if !isCanonicalEpisodePayloadValid(payload, anime.Episodes) {
observability.Info( observability.Info(
@@ -230,3 +273,7 @@ func (s *EpisodeService) decodeCachedPayload(anime domain.Anime, raw string) (do
return payload, true return payload, true
} }
func isStaleProviderEpisodePayload(payload domain.CanonicalEpisodeList) bool {
return payload.Source != "" && payload.Source != "jikan_fallback" && payload.Source != "legacy_disabled" && payload.AvailabilityVersion < episodeAvailabilityPayloadVersion
}

View File

@@ -0,0 +1,81 @@
package service
import (
"context"
"errors"
"strconv"
"mal/integrations/jikan"
"mal/internal/domain"
"mal/internal/observability"
)
func (s *EpisodeService) EnrichEpisodeClassifications(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
payload, _, ok := s.cachedEpisodePayload(ctx, anime)
if !ok {
return domain.CanonicalEpisodeList{}, errors.New("episode classifications: episode availability is not cached")
}
if payload.ClassificationChecked {
return payload, nil
}
if s.jikan == nil {
return payload, errors.New("episode classifications: provider is not configured")
}
value, err, _ := s.classificationLoad.Do(strconv.Itoa(anime.MalID), func() (any, error) {
return s.loadEpisodeClassifications(ctx, anime)
})
if err != nil {
return payload, err
}
return value.(domain.CanonicalEpisodeList), nil
}
func (s *EpisodeService) loadEpisodeClassifications(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
episodes, err := s.jikan.GetAllEpisodes(ctx, anime.MalID)
if err != nil {
return domain.CanonicalEpisodeList{}, err
}
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
payload, row, ok := s.cachedEpisodePayload(ctx, anime)
if !ok {
return domain.CanonicalEpisodeList{}, errors.New("episode classifications: episode availability cache disappeared")
}
mergeEpisodeClassifications(payload.Episodes, episodes)
payload.ClassificationChecked = true
if err := s.storeEnrichedPayload(ctx, row, payload); err != nil {
return domain.CanonicalEpisodeList{}, err
}
observability.Info(
"episode_classifications_enriched",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"episodes": len(episodes),
},
)
return payload, nil
}
func mergeEpisodeClassifications(canonical []domain.CanonicalEpisode, episodes []jikan.Episode) {
byNumber := make(map[int]jikan.Episode, len(episodes))
for i, episode := range episodes {
number, ok := jikanEpisodeNumber(episode, i)
if ok {
byNumber[number] = episode
}
}
for i := range canonical {
classification, ok := byNumber[canonical[i].Number]
if !ok {
continue
}
canonical[i].Filler = classification.Filler
canonical[i].Recap = classification.Recap
}
}

View File

@@ -0,0 +1,32 @@
package service
import (
"testing"
"mal/integrations/jikan"
"mal/internal/domain"
)
func TestMergeEpisodeClassificationsRestoresFillerAndRecap(t *testing.T) {
episodes := []domain.CanonicalEpisode{
{Number: 1, Title: "First", HasSub: true},
{Number: 2, Title: "Second", HasSub: true},
{Number: 3, Title: "Third", HasSub: true},
}
mergeEpisodeClassifications(episodes, []jikan.Episode{
{Episode: "1"},
{Episode: "2", Filler: true},
{Episode: "3", Recap: true},
})
if episodes[0].Filler || episodes[0].Recap {
t.Fatalf("episode 1 = %#v", episodes[0])
}
if !episodes[1].Filler || episodes[1].Recap {
t.Fatalf("episode 2 = %#v", episodes[1])
}
if episodes[2].Filler || !episodes[2].Recap {
t.Fatalf("episode 3 = %#v", episodes[2])
}
}

View File

@@ -5,6 +5,7 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time"
"mal/integrations/jikan" "mal/integrations/jikan"
"mal/internal/domain" "mal/internal/domain"
@@ -35,6 +36,9 @@ func titleCandidates(anime domain.Anime) []string {
} }
func isCanonicalEpisodePayloadValid(payload domain.CanonicalEpisodeList, expectedCount int) bool { func isCanonicalEpisodePayloadValid(payload domain.CanonicalEpisodeList, expectedCount int) bool {
if payload.Source != "" && payload.Source != "jikan_fallback" && payload.Source != "legacy_disabled" {
return providerBackedPayloadHasAvailability(payload)
}
if expectedCount <= 0 { if expectedCount <= 0 {
return providerBackedPayloadHasAvailability(payload) return providerBackedPayloadHasAvailability(payload)
} }
@@ -62,15 +66,25 @@ func providerBackedPayloadHasAvailability(payload domain.CanonicalEpisodeList) b
} }
func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, expectedCount int) []domain.CanonicalEpisode { func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, expectedCount int) []domain.CanonicalEpisode {
return mergeEpisodeData(jikanEpisodes, availability, expectedCount, time.Now(), false, "", false)
}
func mergeEpisodesForAnime(anime domain.Anime, jikanEpisodes []jikan.Episode, now time.Time, providerVerified bool) []domain.CanonicalEpisode {
return mergeEpisodeData(jikanEpisodes, domain.EpisodeAvailability{}, anime.Episodes, now, providerVerified, anime.Aired.From, anime.Airing)
}
func mergeEpisodeData(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, expectedCount int, now time.Time, providerVerified bool, firstAired string, requireJikanAiredDates bool) []domain.CanonicalEpisode {
byNumber := map[int]episodePartial{} byNumber := map[int]episodePartial{}
providerNumbers := availableEpisodeNumbers(availability, expectedCount) providerNumbers := availableEpisodeNumbers(availability, expectedCount)
providerBacked := len(providerNumbers) > 0 providerBacked := providerVerified || len(providerNumbers) > 0
for number := range providerNumbers { for number := range providerNumbers {
mergeEpisode(&byNumber, number, func(item *episodePartial) {}) mergeEpisode(&byNumber, number, func(item *episodePartial) {
item.title = availability.Titles[number]
})
} }
mergeJikanEpisodes(&byNumber, jikanEpisodes, providerNumbers, providerBacked, expectedCount) mergeJikanEpisodes(&byNumber, jikanEpisodes, providerNumbers, providerBacked, expectedCount, now, firstAired, requireJikanAiredDates)
mergeAvailability(&byNumber, availability.Sub, expectedCount, func(item *episodePartial) { item.sub = true }) mergeAvailability(&byNumber, availability.Sub, expectedCount, func(item *episodePartial) { item.sub = true })
mergeAvailability(&byNumber, availability.Dub, expectedCount, func(item *episodePartial) { item.dub = true }) mergeAvailability(&byNumber, availability.Dub, expectedCount, func(item *episodePartial) { item.dub = true })
@@ -100,13 +114,26 @@ func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAva
return episodes return episodes
} }
func mergeJikanEpisodes(byNumber *map[int]episodePartial, episodes []jikan.Episode, providerNumbers map[int]bool, providerBacked bool, expectedCount int) { func mergeJikanEpisodes(byNumber *map[int]episodePartial, episodes []jikan.Episode, providerNumbers map[int]bool, providerBacked bool, expectedCount int, now time.Time, firstAired string, requireAiredDates bool) {
if shouldSkipJikanMerge(providerBacked, firstAired, now) {
return
}
for i, ep := range episodes { for i, ep := range episodes {
if exceedsExpectedCount(i+1, expectedCount) { if exceedsExpectedCount(i+1, expectedCount) {
break break
} }
number, ok := jikanEpisodeNumber(ep, i) number, ok := jikanEpisodeNumber(ep, i)
if !ok || exceedsExpectedCount(number, expectedCount) || (providerBacked && !providerNumbers[number]) { if !ok {
continue
}
if exceedsExpectedCount(number, expectedCount) {
continue
}
if providerBacked && !providerNumbers[number] {
continue
}
if !providerBacked && !hasEpisodeAired(ep, now, requireAiredDates) {
continue continue
} }
mergeEpisode(byNumber, number, func(item *episodePartial) { mergeEpisode(byNumber, number, func(item *episodePartial) {
@@ -117,6 +144,10 @@ func mergeJikanEpisodes(byNumber *map[int]episodePartial, episodes []jikan.Episo
} }
} }
func shouldSkipJikanMerge(providerBacked bool, firstAired string, now time.Time) bool {
return !providerBacked && !hasStartedAiring(firstAired, now)
}
func availableEpisodeNumbers(availability domain.EpisodeAvailability, expectedCount int) map[int]bool { func availableEpisodeNumbers(availability domain.EpisodeAvailability, expectedCount int) map[int]bool {
numbers := map[int]bool{} numbers := map[int]bool{}
for _, number := range availability.Sub { for _, number := range availability.Sub {
@@ -158,6 +189,28 @@ func jikanEpisodeNumber(ep jikan.Episode, index int) (int, bool) {
return index + 1, true return index + 1, true
} }
func hasStartedAiring(firstAired string, now time.Time) bool {
if strings.TrimSpace(firstAired) == "" {
return true
}
startedAt, err := time.Parse(time.RFC3339, firstAired)
if err != nil {
return true
}
return !now.Before(startedAt)
}
func hasEpisodeAired(ep jikan.Episode, now time.Time, requireAiredDate bool) bool {
if strings.TrimSpace(ep.Aired) == "" {
return !requireAiredDate
}
airedAt, err := time.Parse(time.RFC3339, ep.Aired)
if err != nil {
return !requireAiredDate
}
return !now.Before(airedAt)
}
func exceedsExpectedCount(number int, expectedCount int) bool { func exceedsExpectedCount(number int, expectedCount int) bool {
return expectedCount > 0 && number > expectedCount return expectedCount > 0 && number > expectedCount
} }

View File

@@ -13,7 +13,7 @@ import (
"mal/internal/observability" "mal/internal/observability"
) )
func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, titles []string) (string, error) { func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, provider domain.EpisodeProvider, titles []string) (string, error) {
providerID, found, err := s.cachedProviderID(ctx, anime, provider) providerID, found, err := s.cachedProviderID(ctx, anime, provider)
if found || err != nil { if found || err != nil {
return providerID, err return providerID, err
@@ -39,7 +39,7 @@ func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, pro
return providerID, nil return providerID, nil
} }
func (s *EpisodeService) cachedProviderID(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider) (string, bool, error) { func (s *EpisodeService) cachedProviderID(ctx context.Context, anime domain.Anime, provider domain.EpisodeProvider) (string, bool, error) {
row, err := s.queries.GetEpisodeProviderMapping(ctx, db.GetEpisodeProviderMappingParams{ row, err := s.queries.GetEpisodeProviderMapping(ctx, db.GetEpisodeProviderMappingParams{
AnimeID: int64(anime.MalID), AnimeID: int64(anime.MalID),
Provider: provider.Name(), Provider: provider.Name(),
@@ -81,7 +81,7 @@ func (s *EpisodeService) cachedProviderID(ctx context.Context, anime domain.Anim
return row.ProviderShowID, true, nil return row.ProviderShowID, true, nil
} }
func (s *EpisodeService) cacheProviderIDFailure(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, resolveErr error) { func (s *EpisodeService) cacheProviderIDFailure(ctx context.Context, anime domain.Anime, provider domain.EpisodeProvider, resolveErr error) {
err := s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{ err := s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{
AnimeID: int64(anime.MalID), AnimeID: int64(anime.MalID),
Provider: provider.Name(), Provider: provider.Name(),
@@ -105,7 +105,7 @@ func (s *EpisodeService) cacheProviderIDFailure(ctx context.Context, anime domai
) )
} }
func (s *EpisodeService) cacheProviderIDSuccess(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, providerID string) { func (s *EpisodeService) cacheProviderIDSuccess(ctx context.Context, anime domain.Anime, provider domain.EpisodeProvider, providerID string) {
err := s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{ err := s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{
AnimeID: int64(anime.MalID), AnimeID: int64(anime.MalID),
Provider: provider.Name(), Provider: provider.Name(),

View File

@@ -4,12 +4,15 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"sync"
"time" "time"
"mal/integrations/jikan" "mal/integrations/jikan"
"mal/internal/db" "mal/internal/db"
"mal/internal/domain" "mal/internal/domain"
"mal/internal/observability" "mal/internal/observability"
"golang.org/x/sync/singleflight"
) )
type Clock interface { type Clock interface {
@@ -21,32 +24,33 @@ type realClock struct{}
func (realClock) Now() time.Time { return time.Now() } func (realClock) Now() time.Time { return time.Now() }
type EpisodeService struct { type EpisodeService struct {
queries *db.Queries queries *db.Queries
jikan *jikan.Client jikan *jikan.Client
providers []domain.EpisodeAvailabilityProvider providers []domain.EpisodeAvailabilityProvider
clock Clock titles domain.EpisodeTitleProvider
enabled bool clock Clock
enabled bool
titleLoad singleflight.Group
classificationLoad singleflight.Group
cacheMu sync.Mutex
} }
func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool) domain.EpisodeService { func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, titles domain.EpisodeTitleProvider, enabled bool) domain.EpisodeService {
return NewEpisodeServiceWithClock(queries, jikanClient, providers, enabled, realClock{}) return NewEpisodeServiceWithClock(queries, jikanClient, providers, titles, enabled, realClock{})
} }
func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock) *EpisodeService { func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, titles domain.EpisodeTitleProvider, enabled bool, clock Clock) *EpisodeService {
return &EpisodeService{ return &EpisodeService{
queries: queries, queries: queries,
jikan: jikanClient, jikan: jikanClient,
providers: providers, providers: providers,
titles: titles,
clock: clock, clock: clock,
enabled: enabled, enabled: enabled,
} }
} }
func (s *EpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.Anime, forceRefresh bool) (domain.CanonicalEpisodeList, error) { func (s *EpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.Anime, forceRefresh bool) (domain.CanonicalEpisodeList, error) {
if !s.enabled {
return s.jikanOnly(ctx, anime, "legacy_disabled")
}
if !forceRefresh { if !forceRefresh {
if cached, ok := s.getFreshCached(ctx, anime); ok { if cached, ok := s.getFreshCached(ctx, anime); ok {
return cached, nil return cached, nil
@@ -125,19 +129,6 @@ func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domai
}, },
) )
jikanEpisodes, jikanErr := s.jikan.GetAllEpisodes(ctx, anime.MalID)
if jikanErr != nil {
observability.Warn(
"episodes_jikan_metadata_failed",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
},
jikanErr,
)
}
availability, source, providerErr := s.fetchProviderAvailability(ctx, anime) availability, source, providerErr := s.fetchProviderAvailability(ctx, anime)
if providerErr != nil { if providerErr != nil {
s.markFailure(ctx, anime, providerErr) s.markFailure(ctx, anime, providerErr)
@@ -153,19 +144,10 @@ func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domai
) )
return cached, nil return cached, nil
} }
if jikanErr == nil {
storeCtx := ctx
if ctx.Err() != nil {
var cancel context.CancelFunc
storeCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
}
return s.store(storeCtx, anime, jikanEpisodes, domain.EpisodeAvailability{}, "jikan_fallback", now, false)
}
return domain.CanonicalEpisodeList{}, providerErr return domain.CanonicalEpisodeList{}, providerErr
} }
return s.store(ctx, anime, jikanEpisodes, availability, source, now, true) return s.store(ctx, anime, availability, source, now)
} }
func (s *EpisodeService) fetchProviderAvailability(ctx context.Context, anime domain.Anime) (domain.EpisodeAvailability, string, error) { func (s *EpisodeService) fetchProviderAvailability(ctx context.Context, anime domain.Anime) (domain.EpisodeAvailability, string, error) {
@@ -215,15 +197,3 @@ func (s *EpisodeService) fetchProviderAvailability(ctx context.Context, anime do
} }
return domain.EpisodeAvailability{}, "", fmt.Errorf("no episode availability provider matched anime_id=%d", anime.MalID) return domain.EpisodeAvailability{}, "", fmt.Errorf("no episode availability provider matched anime_id=%d", anime.MalID)
} }
func (s *EpisodeService) jikanOnly(ctx context.Context, anime domain.Anime, source string) (domain.CanonicalEpisodeList, error) {
episodes, err := s.jikan.GetAllEpisodes(ctx, anime.MalID)
if err != nil {
return domain.CanonicalEpisodeList{}, err
}
return domain.CanonicalEpisodeList{
AnimeID: anime.MalID,
Episodes: mergeEpisodes(episodes, domain.EpisodeAvailability{}, anime.Episodes),
Source: source,
}, nil
}

View File

@@ -1,7 +1,10 @@
package service package service
import ( import (
"database/sql"
"encoding/json"
"mal/integrations/jikan" "mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain" "mal/internal/domain"
"testing" "testing"
"time" "time"
@@ -27,6 +30,19 @@ func TestMergeEpisodesUsesProviderAvailabilityAsSourceOfTruth(t *testing.T) {
assertEpisode(t, episodes[3], 6, "Episode 6", true, false, true, false) assertEpisode(t, episodes[3], 6, "Episode 6", true, false, true, false)
} }
func TestMergeEpisodesUsesProviderTitles(t *testing.T) {
episodes := mergeEpisodes(nil, domain.EpisodeAvailability{
Sub: []int{1, 2},
Titles: map[int]string{1: "The Apocalypse", 2: "The Battle of Loka"},
}, 0)
if len(episodes) != 2 {
t.Fatalf("len(episodes) = %d, want 2", len(episodes))
}
assertEpisode(t, episodes[0], 1, "The Apocalypse", true, false, true, false)
assertEpisode(t, episodes[1], 2, "The Battle of Loka", true, false, true, false)
}
func TestMergeEpisodesUsesJikanWhenProviderAvailabilityMissing(t *testing.T) { func TestMergeEpisodesUsesJikanWhenProviderAvailabilityMissing(t *testing.T) {
episodes := mergeEpisodes([]jikan.Episode{ episodes := mergeEpisodes([]jikan.Episode{
{MalID: 101, Episode: "1", Title: "Start"}, {MalID: 101, Episode: "1", Title: "Start"},
@@ -41,6 +57,74 @@ func TestMergeEpisodesUsesJikanWhenProviderAvailabilityMissing(t *testing.T) {
assertEpisode(t, episodes[1], 2, "Second", false, false, false, false) assertEpisode(t, episodes[1], 2, "Second", false, false, false, false)
} }
func TestMergeEpisodesSkipsFutureJikanEpisodesWithoutProviderAvailability(t *testing.T) {
now := time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC)
anime := domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
}}
episodes := mergeEpisodesForAnime(anime, decodeJikanEpisodes(t, `[
{"mal_id":1,"title":"Episode 1","episode":null,"aired":"2026-07-09T00:00:00+00:00"},
{"mal_id":2,"title":"Episode 2","episode":null,"aired":"2026-07-16T00:00:00+00:00"}
]`), now, false)
if len(episodes) != 0 {
t.Fatalf("len(episodes) = %d, want 0", len(episodes))
}
}
func TestMergeEpisodesSkipsUndatedJikanEpisodesForAiringAnimeWithoutProviderAvailability(t *testing.T) {
now := time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC)
anime := domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
}}
episodes := mergeEpisodesForAnime(anime, []jikan.Episode{
{MalID: 1, Title: "Episode 1"},
{MalID: 2, Title: "Episode 2"},
}, now, false)
if len(episodes) != 0 {
t.Fatalf("len(episodes) = %d, want 0", len(episodes))
}
}
func TestMergeEpisodesKeepsReleasedJikanEpisodesWithoutProviderAvailability(t *testing.T) {
now := time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC)
anime := domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
}}
episodes := mergeEpisodesForAnime(anime, decodeJikanEpisodes(t, `[
{"mal_id":1,"title":"Episode 1","episode":null,"aired":"2026-07-09T00:00:00+00:00"},
{"mal_id":2,"title":"Episode 2","episode":null,"aired":"2026-07-16T00:00:00+00:00"}
]`), now, false)
if len(episodes) != 1 {
t.Fatalf("len(episodes) = %d, want 1", len(episodes))
}
assertEpisode(t, episodes[0], 1, "Episode 1", false, false, false, false)
}
func TestMergeEpisodesTreatsEmptyProviderAvailabilityAsAuthoritative(t *testing.T) {
now := time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC)
anime := domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
}}
episodes := mergeEpisodesForAnime(anime, decodeJikanEpisodes(t, `[
{"mal_id":1,"title":"Episode 1","episode":null,"aired":"2026-07-09T00:00:00+00:00"}
]`), now, true)
if len(episodes) != 0 {
t.Fatalf("len(episodes) = %d, want 0", len(episodes))
}
}
func TestMergeEpisodesIgnoresInvalidJikanEpisodeNumbers(t *testing.T) { func TestMergeEpisodesIgnoresInvalidJikanEpisodeNumbers(t *testing.T) {
episodes := mergeEpisodes([]jikan.Episode{ episodes := mergeEpisodes([]jikan.Episode{
{MalID: 201, Episode: "", Title: "Missing"}, {MalID: 201, Episode: "", Title: "Missing"},
@@ -113,6 +197,19 @@ func TestIsCanonicalEpisodePayloadValidRejectsProviderEpisodesWithoutAvailabilit
} }
} }
func TestIsCanonicalEpisodePayloadValidTrustsProviderCount(t *testing.T) {
payload := domain.CanonicalEpisodeList{
Source: "AllAnime",
Episodes: []domain.CanonicalEpisode{
{Number: 13, Title: "Episode 13", HasSub: true},
},
}
if !isCanonicalEpisodePayloadValid(payload, 12) {
t.Fatal("expected provider episode count to override Jikan metadata")
}
}
func TestIsCanonicalEpisodePayloadValidAllowsJikanFallbackWithoutAvailability(t *testing.T) { func TestIsCanonicalEpisodePayloadValidAllowsJikanFallbackWithoutAvailability(t *testing.T) {
payload := domain.CanonicalEpisodeList{ payload := domain.CanonicalEpisodeList{
Source: "jikan_fallback", Source: "jikan_fallback",
@@ -127,6 +224,89 @@ func TestIsCanonicalEpisodePayloadValidAllowsJikanFallbackWithoutAvailability(t
} }
} }
func TestDecodeCachedPayloadRejectsUncheckedAiringJikanFallback(t *testing.T) {
svc := &EpisodeService{}
anime := domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
}}
raw := `{"anime_id":62076,"source":"jikan_fallback","episodes":[{"number":1,"title":"Episode 1"}]}`
if _, ok := svc.decodeCachedPayload(anime, raw); ok {
t.Fatal("expected unchecked airing jikan fallback cache to be rejected")
}
}
func TestDecodeCachedPayloadRejectsOldReleaseCheckedAiringFallback(t *testing.T) {
svc := &EpisodeService{}
anime := domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
}}
raw := `{"anime_id":62076,"source":"jikan_fallback","release_checked":true,"episodes":[{"number":1,"title":"Episode 1"}]}`
if _, ok := svc.decodeCachedPayload(anime, raw); ok {
t.Fatal("expected old release-checked jikan fallback cache to be rejected")
}
}
func TestDecodeCachedPayloadRejectsOldProviderPayload(t *testing.T) {
svc := &EpisodeService{}
anime := domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
}}
raw := `{"anime_id":62076,"source":"AllAnime","availability_version":2,"episodes":[{"number":1,"title":"Episode 1","has_sub":true}]}`
if _, ok := svc.decodeCachedPayload(anime, raw); ok {
t.Fatal("expected old airing provider cache to be rejected")
}
}
func TestDecodeCachedPayloadRejectsCurrentJikanFallback(t *testing.T) {
svc := &EpisodeService{}
anime := domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
}}
raw := `{"anime_id":62076,"source":"jikan_fallback","availability_version":2,"release_checked":true,"episodes":[{"number":1,"title":"Episode 1"}]}`
if _, ok := svc.decodeCachedPayload(anime, raw); ok {
t.Fatal("expected Jikan fallback cache to be rejected")
}
}
func TestEnrichCachedPayloadAddsRefreshMetadata(t *testing.T) {
now := time.Date(2026, time.June, 27, 11, 0, 0, 0, time.UTC)
payload := enrichCachedPayload(domain.CanonicalEpisodeList{
AnimeID: 59970,
Episodes: []domain.CanonicalEpisode{{Number: 1}},
Source: "AllAnime",
}, db.EpisodeAvailabilityCache{
NextRefreshAt: sql.NullTime{Time: now.Add(time.Hour), Valid: true},
RetryUntilAt: sql.NullTime{Time: now.Add(30 * time.Minute), Valid: true},
LastAttemptAt: sql.NullTime{Time: now.Add(-5 * time.Minute), Valid: true},
LastSuccessAt: sql.NullTime{Time: now.Add(-time.Hour), Valid: true},
FailureCount: 2,
})
if payload.NextRefreshAt != "2026-06-27T12:00:00Z" {
t.Fatalf("NextRefreshAt = %q, want RFC3339 timestamp", payload.NextRefreshAt)
}
if payload.RetryUntilAt != "2026-06-27T11:30:00Z" {
t.Fatalf("RetryUntilAt = %q, want RFC3339 timestamp", payload.RetryUntilAt)
}
if payload.LastAttemptAt != "2026-06-27T10:55:00Z" {
t.Fatalf("LastAttemptAt = %q, want RFC3339 timestamp", payload.LastAttemptAt)
}
if payload.LastSuccessAt != "2026-06-27T10:00:00Z" {
t.Fatalf("LastSuccessAt = %q, want RFC3339 timestamp", payload.LastSuccessAt)
}
if payload.FailureCount != 2 {
t.Fatalf("FailureCount = %d, want 2", payload.FailureCount)
}
}
func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) { func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) {
anime := domain.Anime{Anime: jikan.Anime{MalID: 1}} anime := domain.Anime{Anime: jikan.Anime{MalID: 1}}
anime.Broadcast.Day = "Saturdays" anime.Broadcast.Day = "Saturdays"
@@ -160,6 +340,16 @@ func TestNextRetryTimeWithinAndAfterRetryWindow(t *testing.T) {
} }
} }
func decodeJikanEpisodes(t *testing.T, raw string) []jikan.Episode {
t.Helper()
var episodes []jikan.Episode
if err := json.Unmarshal([]byte(raw), &episodes); err != nil {
t.Fatalf("json.Unmarshal episodes: %v", err)
}
return episodes
}
func assertEpisode(t *testing.T, got domain.CanonicalEpisode, number int, title string, sub bool, dub bool, subOnly bool, filler bool) { func assertEpisode(t *testing.T, got domain.CanonicalEpisode, number int, title string, sub bool, dub bool, subOnly bool, filler bool) {
t.Helper() t.Helper()
if got.Number != number || got.Title != title || got.HasSub != sub || got.HasDub != dub || got.SubOnly != subOnly || got.Filler != filler || got.Recap { if got.Number != number || got.Title != title || got.HasSub != sub || got.HasDub != dub || got.SubOnly != subOnly || got.Filler != filler || got.Recap {

View File

@@ -0,0 +1,140 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"mal/internal/db"
"mal/internal/domain"
"mal/internal/observability"
)
func (s *EpisodeService) EnrichEpisodeTitles(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
payload, _, ok := s.cachedEpisodePayload(ctx, anime)
if !ok {
return domain.CanonicalEpisodeList{}, errors.New("episode titles: episode availability is not cached")
}
if !hasPlaceholderTitles(payload.Episodes) {
return payload, nil
}
if s.titles == nil {
return payload, errors.New("episode titles: provider is not configured")
}
value, err, _ := s.titleLoad.Do(strconv.Itoa(anime.MalID), func() (any, error) {
return s.loadEpisodeTitles(ctx, anime)
})
if err != nil {
return payload, err
}
return value.(domain.CanonicalEpisodeList), nil
}
func (s *EpisodeService) loadEpisodeTitles(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
payload, _, ok := s.cachedEpisodePayload(ctx, anime)
if !ok {
return domain.CanonicalEpisodeList{}, errors.New("episode titles: episode availability cache disappeared")
}
providerID, err := s.providerID(ctx, anime, s.titles, titleCandidates(anime))
if err != nil {
return domain.CanonicalEpisodeList{}, err
}
titles, err := s.titles.GetEpisodeTitlesByProviderID(ctx, providerID, anime, len(payload.Episodes))
if err != nil {
return domain.CanonicalEpisodeList{}, err
}
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
payload, row, ok := s.cachedEpisodePayload(ctx, anime)
if !ok {
return domain.CanonicalEpisodeList{}, errors.New("episode titles: episode availability cache disappeared")
}
changed := mergeMissingTitles(payload.Episodes, titles)
if !changed {
return payload, nil
}
if err := s.storeEnrichedPayload(ctx, row, payload); err != nil {
return domain.CanonicalEpisodeList{}, err
}
observability.Info(
"episode_titles_enriched",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"provider": s.titles.Name(),
"titles": len(titles),
},
)
return payload, nil
}
func (s *EpisodeService) cachedEpisodePayload(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, db.EpisodeAvailabilityCache, bool) {
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(anime.MalID))
if err != nil {
return domain.CanonicalEpisodeList{}, db.EpisodeAvailabilityCache{}, false
}
payload, ok := s.decodeCachedPayload(anime, row.Data)
if !ok {
return domain.CanonicalEpisodeList{}, db.EpisodeAvailabilityCache{}, false
}
return enrichCachedPayload(payload, row), row, true
}
func (s *EpisodeService) storeEnrichedPayload(ctx context.Context, row db.EpisodeAvailabilityCache, payload domain.CanonicalEpisodeList) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("episode metadata: encode cache: %w", err)
}
err = s.queries.UpsertEpisodeAvailabilityCache(ctx, db.UpsertEpisodeAvailabilityCacheParams{
AnimeID: row.AnimeID,
Data: string(body),
NextRefreshAt: row.NextRefreshAt,
RetryUntilAt: row.RetryUntilAt,
LastAttemptAt: row.LastAttemptAt,
LastSuccessAt: row.LastSuccessAt,
FailureCount: row.FailureCount,
LastError: row.LastError,
})
if err != nil {
return fmt.Errorf("episode metadata: update cache: %w", err)
}
return nil
}
func hasPlaceholderTitles(episodes []domain.CanonicalEpisode) bool {
for _, episode := range episodes {
if isPlaceholderTitle(episode.Number, episode.Title) {
return true
}
}
return false
}
func mergeMissingTitles(episodes []domain.CanonicalEpisode, titles map[int]string) bool {
changed := false
for i := range episodes {
episode := &episodes[i]
title := strings.TrimSpace(titles[episode.Number])
if title == "" || !isPlaceholderTitle(episode.Number, episode.Title) || title == episode.Title {
continue
}
episode.Title = title
changed = true
}
return changed
}
func isPlaceholderTitle(number int, title string) bool {
return strings.TrimSpace(title) == fmt.Sprintf("Episode %d", number)
}

View File

@@ -0,0 +1,145 @@
package service
import (
"context"
"database/sql"
"encoding/json"
"testing"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
_ "github.com/mattn/go-sqlite3"
)
type titleProviderStub struct {
loads int
resolves int
}
func (p *titleProviderStub) Name() string { return "TVmaze" }
func (p *titleProviderStub) ResolveEpisodeProviderID(context.Context, int, []string) (string, error) {
p.resolves++
return "80712", nil
}
func (p *titleProviderStub) GetEpisodeTitlesByProviderID(context.Context, string, domain.Anime, int) (map[int]string, error) {
p.loads++
return map[int]string{1: "TVmaze title one", 2: "TVmaze title two"}, nil
}
func TestMergeMissingTitlesPreservesAllAnimeTitles(t *testing.T) {
episodes := []domain.CanonicalEpisode{
{Number: 1, Title: "AllAnime title"},
{Number: 2, Title: "Episode 2"},
{Number: 3, Title: "Episode 3"},
}
changed := mergeMissingTitles(episodes, map[int]string{
1: "TVmaze title one",
2: "TVmaze title two",
})
if !changed {
t.Fatal("expected missing title to be enriched")
}
if episodes[0].Title != "AllAnime title" {
t.Fatalf("AllAnime title was overwritten: %q", episodes[0].Title)
}
if episodes[1].Title != "TVmaze title two" {
t.Fatalf("episode 2 title = %q", episodes[1].Title)
}
if episodes[2].Title != "Episode 3" {
t.Fatalf("episode 3 title = %q", episodes[2].Title)
}
}
func TestEnrichEpisodeTitlesCachesTVmazeTitles(t *testing.T) {
ctx := context.Background()
sqlDB := newTitleTestDB(ctx, t)
provider := &titleProviderStub{}
svc := &EpisodeService{queries: db.New(sqlDB), titles: provider, clock: realClock{}}
anime := domain.Anime{Anime: jikan.Anime{
MalID: 59846,
Title: "Saigo ni Hitotsu dake Onegai shitemo Yoroshii deshou ka",
TitleEnglish: "May I Ask for One Final Thing?",
}}
for range 2 {
assertEnrichedTitleList(t, svc, anime)
}
if provider.resolves != 1 || provider.loads != 1 {
t.Fatalf("provider calls = resolves:%d loads:%d, want 1 each", provider.resolves, provider.loads)
}
assertCachedTitles(ctx, t, sqlDB)
}
func newTitleTestDB(ctx context.Context, t *testing.T) *sql.DB {
t.Helper()
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
})
sqlDB.SetMaxOpenConns(1)
for _, statement := range []string{
`CREATE TABLE episode_availability_cache (
anime_id INTEGER PRIMARY KEY, data TEXT NOT NULL, next_refresh_at DATETIME,
retry_until_at DATETIME, last_attempt_at DATETIME, last_success_at DATETIME,
failure_count INTEGER NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE episode_provider_mapping (
anime_id INTEGER NOT NULL, provider TEXT NOT NULL, provider_show_id TEXT NOT NULL,
failed_until DATETIME, last_error TEXT NOT NULL DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (anime_id, provider)
)`,
`INSERT INTO episode_availability_cache (
anime_id, data, next_refresh_at, failure_count, last_error
) VALUES (
59846,
'{"anime_id":59846,"source":"AllAnime","availability_version":4,"episodes":[{"number":1,"title":"AllAnime title","has_sub":true},{"number":2,"title":"Episode 2","has_sub":true}]}',
'2999-01-01T00:00:00Z', 2, 'preserve me'
)`,
} {
if _, err := sqlDB.ExecContext(ctx, statement); err != nil {
t.Fatal(err)
}
}
return sqlDB
}
func assertEnrichedTitleList(t *testing.T, svc *EpisodeService, anime domain.Anime) {
t.Helper()
list, err := svc.EnrichEpisodeTitles(context.Background(), anime)
if err != nil {
t.Fatal(err)
}
if list.Episodes[0].Title != "AllAnime title" || list.Episodes[1].Title != "TVmaze title two" {
t.Fatalf("episodes = %#v", list.Episodes)
}
}
func assertCachedTitles(ctx context.Context, t *testing.T, sqlDB *sql.DB) {
t.Helper()
var raw, lastError string
var failureCount int
if err := sqlDB.QueryRowContext(ctx, `SELECT data, failure_count, last_error FROM episode_availability_cache WHERE anime_id = 59846`).Scan(&raw, &failureCount, &lastError); err != nil {
t.Fatal(err)
}
var cached domain.CanonicalEpisodeList
if err := json.Unmarshal([]byte(raw), &cached); err != nil {
t.Fatal(err)
}
if cached.Episodes[1].Title != "TVmaze title two" || failureCount != 2 || lastError != "preserve me" {
t.Fatalf("cached = %#v failure_count=%d last_error=%q", cached.Episodes, failureCount, lastError)
}
}

View File

@@ -0,0 +1,29 @@
package observability
import (
"testing"
)
func TestFormatBytesBelow1KB(t *testing.T) {
if got := formatBytes(512.0); got != "512B" {
t.Fatalf("got %q, want %q", got, "512B")
}
}
func TestFormatBytesInKB(t *testing.T) {
if got := formatBytes(2048.0); got != "2.0KB" {
t.Fatalf("got %q, want %q", got, "2.0KB")
}
}
func TestFormatBytesInMB(t *testing.T) {
if got := formatBytes(5_242_880.0); got != "5.0MB" {
t.Fatalf("got %q, want %q", got, "5.0MB")
}
}
func TestFormatBytesReturnsStringAsIs(t *testing.T) {
if got := formatBytes("unknown"); got != "unknown" {
t.Fatalf("got %q, want %q", got, "unknown")
}
}

View File

@@ -0,0 +1,29 @@
package observability
import (
"testing"
)
func TestCloneFieldsReturnsNilForNil(t *testing.T) {
if got := cloneFields(nil); got != nil {
t.Fatalf("expected nil, got %#v", got)
}
}
func TestCloneFieldsReturnsNilForEmpty(t *testing.T) {
if got := cloneFields(map[string]any{}); got != nil {
t.Fatalf("expected nil, got %#v", got)
}
}
func TestCloneFieldsCopiesAllEntries(t *testing.T) {
original := map[string]any{"a": 1, "b": "two"}
cp := cloneFields(original)
if cp["a"] != 1 || cp["b"] != "two" {
t.Fatalf("clone = %#v, want %#v", cp, original)
}
cp["c"] = 3
if _, exists := original["c"]; exists {
t.Fatal("clone modified original")
}
}

View File

@@ -0,0 +1,23 @@
package observability
import (
"testing"
)
func TestFormatDurationMillisFormatsFloat(t *testing.T) {
if got := formatDurationMillis(123.456); got != "123.456ms" {
t.Fatalf("got %q, want %q", got, "123.456ms")
}
}
func TestFormatDurationMillisFormatsInt(t *testing.T) {
if got := formatDurationMillis(42); got != "42ms" {
t.Fatalf("got %q, want %q", got, "42ms")
}
}
func TestFormatDurationMillisReturnsStringAsIs(t *testing.T) {
if got := formatDurationMillis("fast"); got != "fast" {
t.Fatalf("got %q, want %q", got, "fast")
}
}

View File

@@ -0,0 +1,52 @@
package observability
import (
"testing"
)
func TestPopFieldReturnsEmptyForNil(t *testing.T) {
if got := popField(nil, "key"); got != "" {
t.Fatalf("got %q, want empty", got)
}
}
func TestPopFieldReturnsEmptyForMissingKey(t *testing.T) {
if got := popField(map[string]any{"a": 1}, "b"); got != "" {
t.Fatalf("got %q, want empty", got)
}
}
func TestPopFieldReturnsAndDeletes(t *testing.T) {
m := map[string]any{"status": 200, "other": "x"}
got := popField(m, "status")
if got != "200" {
t.Fatalf("got %q, want %q", got, "200")
}
if _, exists := m["status"]; exists {
t.Fatal("popField did not delete key")
}
if m["other"] != "x" {
t.Fatal("popField deleted other key")
}
}
func TestPopFieldFormatsDuration(t *testing.T) {
got := popField(map[string]any{"duration_ms": 123.4}, "duration_ms")
if got != "123.4ms" {
t.Fatalf("got %q, want %q", got, "123.4ms")
}
}
func TestPopFieldFormatsBytes(t *testing.T) {
got := popField(map[string]any{"bytes": 2048.0}, "bytes")
if got != "2.0KB" {
t.Fatalf("got %q, want %q", got, "2.0KB")
}
}
func TestPopFieldPassthroughString(t *testing.T) {
got := popField(map[string]any{"method": "GET"}, "method")
if got != "GET" {
t.Fatalf("got %q, want %q", got, "GET")
}
}

View File

@@ -20,13 +20,28 @@ func (fxLogger) LogEvent(event fxevent.Event) {
} }
func describeFXEventError(event fxevent.Event) (string, map[string]any, error) { func describeFXEventError(event fxevent.Event) (string, map[string]any, error) {
if eventName, fields, ok, err := describeFXBuildEventError(event); ok {
return eventName, fields, err
}
return describeFXLifecycleEventError(event)
}
func describeFXBuildEventError(event fxevent.Event) (string, map[string]any, bool, error) {
switch e := event.(type) { switch e := event.(type) {
case *fxevent.Provided: case *fxevent.Provided:
return "fx_provide_failed", map[string]any{"constructor": e.ConstructorName}, e.Err return "fx_provide_failed", map[string]any{"constructor": e.ConstructorName}, true, e.Err
case *fxevent.Invoked: case *fxevent.Invoked:
return "fx_invoke_failed", map[string]any{"function": e.FunctionName}, e.Err return "fx_invoke_failed", map[string]any{"function": e.FunctionName}, true, e.Err
case *fxevent.Run: case *fxevent.Run:
return "fx_run_failed", map[string]any{"function": e.Name, "kind": e.Kind}, e.Err return "fx_run_failed", map[string]any{"function": e.Name, "kind": e.Kind}, true, e.Err
default:
return "", nil, false, nil
}
}
func describeFXLifecycleEventError(event fxevent.Event) (string, map[string]any, error) {
switch e := event.(type) {
case *fxevent.OnStartExecuted: case *fxevent.OnStartExecuted:
return "fx_on_start_failed", map[string]any{"caller": e.CallerName, "function": e.FunctionName, "runtime": e.Runtime}, e.Err return "fx_on_start_failed", map[string]any{"caller": e.CallerName, "function": e.FunctionName, "runtime": e.Runtime}, e.Err
case *fxevent.OnStopExecuted: case *fxevent.OnStopExecuted:

View File

@@ -0,0 +1,119 @@
package observability
import (
"errors"
"reflect"
"testing"
"time"
"go.uber.org/fx/fxevent"
)
func TestDescribeFXEventErrorDescribesFailedBuildEvents(t *testing.T) {
err := errors.New("fx failed")
tests := map[string]struct {
event fxevent.Event
wantName string
wantFields map[string]any
}{
"provided": {
event: &fxevent.Provided{ConstructorName: "newServer", Err: err},
wantName: "fx_provide_failed",
wantFields: map[string]any{"constructor": "newServer"},
},
"invoked": {
event: &fxevent.Invoked{FunctionName: "startServer", Err: err},
wantName: "fx_invoke_failed",
wantFields: map[string]any{"function": "startServer"},
},
"run": {
event: &fxevent.Run{Name: "newRepo", Kind: "provide", Err: err},
wantName: "fx_run_failed",
wantFields: map[string]any{"function": "newRepo", "kind": "provide"},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
assertFXEventError(t, tt.event, tt.wantName, tt.wantFields, err)
})
}
}
func TestDescribeFXEventErrorDescribesFailedLifecycleEvents(t *testing.T) {
err := errors.New("fx failed")
runtime := 12 * time.Millisecond
tests := map[string]struct {
event fxevent.Event
wantName string
wantFields map[string]any
}{
"on start executed": {
event: &fxevent.OnStartExecuted{CallerName: "server", FunctionName: "listen", Runtime: runtime, Err: err},
wantName: "fx_on_start_failed",
wantFields: map[string]any{"caller": "server", "function": "listen", "runtime": runtime},
},
"on stop executed": {
event: &fxevent.OnStopExecuted{CallerName: "server", FunctionName: "close", Runtime: runtime, Err: err},
wantName: "fx_on_stop_failed",
wantFields: map[string]any{"caller": "server", "function": "close", "runtime": runtime},
},
"started": {
event: &fxevent.Started{Err: err},
wantName: "fx_start_failed",
},
"stopped": {
event: &fxevent.Stopped{Err: err},
wantName: "fx_stop_failed",
},
"rolling back": {
event: &fxevent.RollingBack{StartErr: err},
wantName: "fx_rollback_start",
},
"rolled back": {
event: &fxevent.RolledBack{Err: err},
wantName: "fx_rollback_failed",
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
assertFXEventError(t, tt.event, tt.wantName, tt.wantFields, err)
})
}
}
func assertFXEventError(t *testing.T, event fxevent.Event, wantName string, wantFields map[string]any, wantErr error) {
t.Helper()
gotName, gotFields, gotErr := describeFXEventError(event)
if gotName != wantName {
t.Fatalf("event name = %q, want %q", gotName, wantName)
}
if !reflect.DeepEqual(gotFields, wantFields) {
t.Fatalf("fields = %#v, want %#v", gotFields, wantFields)
}
if !errors.Is(gotErr, wantErr) {
t.Fatalf("error = %v, want %v", gotErr, wantErr)
}
}
func TestDescribeFXEventErrorIgnoresEventsWithoutErrors(t *testing.T) {
tests := map[string]fxevent.Event{
"provided": &fxevent.Provided{ConstructorName: "newServer"},
"unknown": &fxevent.Supplied{},
}
for name, event := range tests {
t.Run(name, func(t *testing.T) {
_, _, gotErr := describeFXEventError(event)
if gotErr != nil {
t.Fatalf("error = %v, want nil", gotErr)
}
})
}
}

View File

@@ -0,0 +1,27 @@
package observability
import (
"testing"
)
func TestIsLocalClientIPReturnsTrueForLoopback(t *testing.T) {
for _, ip := range []string{"127.0.0.1", "::1"} {
if !isLocalClientIP(ip) {
t.Fatalf("isLocalClientIP(%q) = false, want true", ip)
}
}
}
func TestIsLocalClientIPReturnsFalseForExternal(t *testing.T) {
for _, ip := range []string{"8.8.8.8", "192.168.1.1", ""} {
if isLocalClientIP(ip) {
t.Fatalf("isLocalClientIP(%q) = true, want false", ip)
}
}
}
func TestIsLocalClientIPReturnsFalseForInvalid(t *testing.T) {
if isLocalClientIP("not-an-ip") {
t.Fatal("isLocalClientIP('not-an-ip') = true, want false")
}
}

View File

@@ -0,0 +1,38 @@
package observability
import (
"testing"
)
func TestQuoteIfNeededReturnsEmptyQuoted(t *testing.T) {
if got := quoteIfNeeded(""); got != `""` {
t.Fatalf("got %q, want %q", got, `""`)
}
}
func TestQuoteIfNeededReturnsPlainString(t *testing.T) {
if got := quoteIfNeeded("hello"); got != "hello" {
t.Fatalf("got %q, want %q", got, "hello")
}
}
func TestQuoteIfNeededQuotesStringWithEquals(t *testing.T) {
got := quoteIfNeeded("key=value")
if got != `"key=value"` {
t.Fatalf("got %q, want %q", got, `"key=value"`)
}
}
func TestQuoteIfNeededQuotesStringWithSpace(t *testing.T) {
got := quoteIfNeeded("hello world")
if got != `"hello world"` {
t.Fatalf("got %q, want %q", got, `"hello world"`)
}
}
func TestQuoteIfNeededQuotesStringWithNewline(t *testing.T) {
got := quoteIfNeeded("line1\nline2")
if got != `"line1\nline2"` {
t.Fatalf("got %q, want %q", got, `"line1\nline2"`)
}
}

View File

@@ -0,0 +1,19 @@
package observability
import (
"testing"
)
func TestFormatHTTPStatusReturnsPlainWhenNoColor(t *testing.T) {
colorLogs = false
if got := formatHTTPStatus(200); got != "200" {
t.Fatalf("got %q, want %q", got, "200")
}
}
func TestFormatHTTPStatusReturnsFallback(t *testing.T) {
colorLogs = false
if got := formatHTTPStatus("ok"); got != "ok" {
t.Fatalf("got %q, want %q", got, "ok")
}
}

View File

@@ -0,0 +1,52 @@
package handler
import (
"bytes"
"context"
"errors"
"mal/internal/db"
"mal/internal/domain"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
type failingSkipSegmentPlaybackService struct {
domain.PlaybackService
}
func (s failingSkipSegmentPlaybackService) UpsertSkipSegmentOverride(context.Context, string, int64, int, string, float64, float64) error {
return errors.New("database constraint skip_segments_user_id_fkey failed")
}
func TestUpsertSkipSegmentRouteHidesServiceError(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("User", &domain.User{User: db.User{ID: "user-1"}})
})
h := NewPlaybackHandler(failingSkipSegmentPlaybackService{}, nil)
h.Register(router)
body := bytes.NewBufferString(`{"mal_id":123,"episode":1,"skip_type":"op","start_time":10,"end_time":90}`)
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/watch/segments", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
responseBody := rec.Body.String()
if !strings.Contains(responseBody, `"error":"invalid skip segment"`) {
t.Fatalf("expected stable public error, got %s", responseBody)
}
if strings.Contains(responseBody, "database constraint") {
t.Fatalf("expected private service error to stay out of response, got %s", responseBody)
}
}

View File

@@ -2,14 +2,14 @@
package handler package handler
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"mal/internal/domain" "mal/internal/domain"
"mal/internal/observability"
"mal/internal/playback/proxytarget"
"mal/internal/server" "mal/internal/server"
netutil "mal/pkg/net"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -21,15 +21,17 @@ type PlaybackHandler struct {
proxyClient *http.Client proxyClient *http.Client
streamingClient *http.Client streamingClient *http.Client
subtitleCache *subtitleCache subtitleCache *subtitleCache
manifestCache *manifestCache
} }
func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimePlaybackService) *PlaybackHandler { func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimePlaybackService) *PlaybackHandler {
return &PlaybackHandler{ return &PlaybackHandler{
svc: svc, svc: svc,
animeSvc: animeSvc, animeSvc: animeSvc,
proxyClient: netutil.NewClient(), proxyClient: proxytarget.NewClient(60 * time.Second),
streamingClient: netutil.NewStreamingClient(), streamingClient: proxytarget.NewStreamingClient(),
subtitleCache: newSubtitleCache(10*time.Minute, 256), subtitleCache: newSubtitleCache(10*time.Minute, 256),
manifestCache: newManifestCache(manifestCacheMaxEntries),
} }
} }
@@ -37,13 +39,84 @@ func (h *PlaybackHandler) Register(r *gin.Engine) {
r.GET("/anime/:id/watch", h.HandleWatchPage) r.GET("/anime/:id/watch", h.HandleWatchPage)
r.POST("/api/watch-progress", h.HandleSaveProgress) r.POST("/api/watch-progress", h.HandleSaveProgress)
r.POST("/api/watch-complete", h.HandleWatchComplete) r.POST("/api/watch-complete", h.HandleWatchComplete)
r.GET("/api/watch/episodes/:animeId/titles", h.HandleEpisodeTitles)
r.GET("/api/watch/episodes/:animeId/classifications", h.HandleEpisodeClassifications)
r.GET("/api/watch/episode/:animeId/:episode", h.HandleEpisodeData) r.GET("/api/watch/episode/:animeId/:episode", h.HandleEpisodeData)
r.POST("/api/watch/segments", h.HandleUpsertSkipSegment) r.POST("/api/watch/segments", h.HandleUpsertSkipSegment)
r.GET("/api/watch/thumbnails/:animeId", h.HandleEpisodeThumbnails)
r.GET("/watch/proxy/stream", h.HandleProxyStream) r.GET("/watch/proxy/stream", h.HandleProxyStream)
r.GET("/watch/proxy/subtitle", h.HandleProxySubtitle) r.GET("/watch/proxy/subtitle", h.HandleProxySubtitle)
} }
func (h *PlaybackHandler) HandleEpisodeClassifications(c *gin.Context) {
animeID, err := strconv.Atoi(c.Param("animeId"))
if err != nil || animeID <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
return
}
enricher, ok := h.svc.(domain.PlaybackEpisodeClassificationService)
if !ok {
server.RespondHTMLOrJSONError(c, http.StatusServiceUnavailable, "episode classifications are unavailable")
return
}
episodes, err := enricher.EnrichEpisodeClassifications(c.Request.Context(), animeID)
if err != nil {
server.RespondError(
c,
http.StatusBadGateway,
"watch_episode_classifications_failed",
"playback",
"episode classifications are unavailable",
map[string]any{"anime_id": animeID},
err,
)
return
}
classifications := make([]gin.H, 0, len(episodes))
for _, episode := range episodes {
classifications = append(classifications, gin.H{
"number": episode.Number,
"filler": episode.Filler,
"recap": episode.Recap,
})
}
c.JSON(http.StatusOK, classifications)
}
func (h *PlaybackHandler) HandleEpisodeTitles(c *gin.Context) {
animeID, err := strconv.Atoi(c.Param("animeId"))
if err != nil || animeID <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
return
}
enricher, ok := h.svc.(domain.PlaybackEpisodeTitleService)
if !ok {
server.RespondHTMLOrJSONError(c, http.StatusServiceUnavailable, "episode titles are unavailable")
return
}
episodes, err := enricher.EnrichEpisodeTitles(c.Request.Context(), animeID)
if err != nil {
server.RespondError(
c,
http.StatusBadGateway,
"watch_episode_titles_failed",
"playback",
"episode titles are unavailable",
map[string]any{"anime_id": animeID},
err,
)
return
}
titles := make([]gin.H, 0, len(episodes))
for _, episode := range episodes {
titles = append(titles, gin.H{"number": episode.Number, "title": episode.Title})
}
c.JSON(http.StatusOK, titles)
}
func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) { func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id")) id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 { if err != nil || id <= 0 {
@@ -56,12 +129,13 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
user := server.CurrentUser(c) user := server.CurrentUser(c)
userID := server.CurrentUserID(c) userID := server.CurrentUserID(c)
data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID) ctx := domain.WithDeferredPlaybackData(c.Request.Context())
data, err := h.svc.BuildWatchData(ctx, id, []string{}, ep, mode, userID)
if err != nil { if err != nil {
if data.Anime.MalID == 0 && data.WatchData.MalID == 0 && len(data.Episodes) == 0 { if data.Anime.MalID == 0 && data.WatchData.MalID == 0 && len(data.Episodes) == 0 {
anime, fetchErr := h.animeSvc.GetAnimeByID(c.Request.Context(), id) anime, fetchErr := h.animeSvc.GetAnimeByID(c.Request.Context(), id)
if fetchErr != nil { if fetchErr != nil {
fmt.Printf("error fetching anime for error page: %v\n", fetchErr) observability.Warn("error_page_anime_fetch_failed", "playback", "", map[string]any{"anime_id": id}, fetchErr)
} }
data = domain.WatchPageData{ data = domain.WatchPageData{
Anime: anime, Anime: anime,
@@ -79,7 +153,17 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
} }
} }
data.Error = err.Error() observability.LogContext(
c.Request.Context(),
observability.LogLevelError,
"watch_page_build_failed",
"playback",
"",
map[string]any{"anime_id": id, "episode": ep, "mode": mode, "user_id": userID, "request_path": c.Request.URL.Path},
err,
)
data.Error = "failed to load playback data"
data.User = user data.User = user
data.CurrentPath = c.Request.URL.Path data.CurrentPath = c.Request.URL.Path
@@ -109,10 +193,14 @@ func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
} }
mode := c.DefaultQuery("mode", "sub") mode := c.DefaultQuery("mode", "sub")
ctx := c.Request.Context()
if c.Query("refresh") == "1" {
ctx = domain.WithPlaybackSourceRefresh(ctx)
}
userID := server.CurrentUserID(c) userID := server.CurrentUserID(c)
data, err := h.svc.BuildWatchData(c.Request.Context(), animeID, []string{}, episode, mode, userID) data, err := h.svc.BuildWatchData(ctx, animeID, []string{}, episode, mode, userID)
if err != nil { if err != nil {
server.RespondError( server.RespondError(
c, c,
@@ -243,61 +331,17 @@ func (h *PlaybackHandler) HandleUpsertSkipSegment(c *gin.Context) {
} }
if err := h.svc.UpsertSkipSegmentOverride(c.Request.Context(), userID, req.MalID, req.Episode, req.SkipType, req.StartTime, req.EndTime); err != nil { if err := h.svc.UpsertSkipSegmentOverride(c.Request.Context(), userID, req.MalID, req.Episode, req.SkipType, req.StartTime, req.EndTime); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) server.RespondError(
c,
http.StatusBadRequest,
"skip_segment_upsert_failed",
"playback",
"invalid skip segment",
map[string]any{"mal_id": req.MalID, "episode": req.Episode, "skip_type": req.SkipType, "user_id": userID},
err,
)
return return
} }
c.Status(http.StatusOK) c.Status(http.StatusOK)
} }
func (h *PlaybackHandler) HandleEpisodeThumbnails(c *gin.Context) {
id, err := strconv.Atoi(c.Param("animeId"))
if err != nil {
c.Status(http.StatusBadRequest)
return
}
allEpisodes, err := h.animeSvc.GetAllEpisodes(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
anime, fetchErr := h.animeSvc.GetAnimeByID(c.Request.Context(), id)
if fetchErr != nil {
fmt.Printf("error fetching anime for thumbnail fill: %v\n", fetchErr)
}
if anime.Episodes > 0 && anime.Episodes > len(allEpisodes) {
epMap := make(map[int]domain.EpisodeData)
for _, ep := range allEpisodes {
epMap[ep.MalID] = ep
}
var filled []domain.EpisodeData
for i := 1; i <= anime.Episodes; i++ {
if ep, ok := epMap[i]; ok {
filled = append(filled, ep)
} else {
filled = append(filled, domain.EpisodeData{
MalID: i,
Title: fmt.Sprintf("Episode %d", i),
})
}
}
allEpisodes = filled
}
type Result struct {
MalID int `json:"mal_id"`
Title string `json:"title"`
}
results := make([]Result, len(allEpisodes))
for i, ep := range allEpisodes {
results[i] = Result{
MalID: ep.MalID,
Title: ep.Title,
}
}
c.JSON(http.StatusOK, results)
}

View File

@@ -0,0 +1,109 @@
package handler
import (
"container/list"
"net/http"
"strings"
"sync"
"time"
)
const (
manifestCacheVODTTL = 45 * time.Second
manifestCacheLiveTTL = 3 * time.Second
manifestCacheMaxEntries = 256
manifestCacheMaxBodySize = 2 << 20
)
type manifestCacheEntry struct {
key string
body []byte
headers http.Header
status int
expiresAt time.Time
}
type manifestCache struct {
mu sync.Mutex
maxEntries int
entries map[string]*list.Element
lru *list.List
}
func newManifestCache(maxEntries int) *manifestCache {
if maxEntries <= 0 {
maxEntries = manifestCacheMaxEntries
}
return &manifestCache{
maxEntries: maxEntries,
entries: make(map[string]*list.Element, maxEntries),
lru: list.New(),
}
}
func manifestCacheKey(targetURL string, referer string) string {
return targetURL + "\x00" + referer
}
func (c *manifestCache) get(key string, now time.Time) (manifestCacheEntry, bool) {
c.mu.Lock()
defer c.mu.Unlock()
el := c.entries[key]
if el == nil {
return manifestCacheEntry{}, false
}
entry, ok := el.Value.(manifestCacheEntry)
if !ok || !now.Before(entry.expiresAt) {
c.remove(el)
return manifestCacheEntry{}, false
}
c.lru.MoveToFront(el)
entry.body = append([]byte(nil), entry.body...)
entry.headers = entry.headers.Clone()
return entry, true
}
func (c *manifestCache) set(key string, status int, headers http.Header, body []byte, now time.Time) bool {
if status < http.StatusOK || status >= http.StatusMultipleChoices || len(body) > manifestCacheMaxBodySize {
return false
}
ttl := manifestCacheVODTTL
bodyText := string(body)
if strings.Contains(bodyText, "#EXTINF") && !strings.Contains(bodyText, "#EXT-X-ENDLIST") {
ttl = manifestCacheLiveTTL
}
entry := manifestCacheEntry{
key: key,
body: append([]byte(nil), body...),
headers: headers.Clone(),
status: status,
expiresAt: now.Add(ttl),
}
c.mu.Lock()
defer c.mu.Unlock()
if el := c.entries[key]; el != nil {
el.Value = entry
c.lru.MoveToFront(el)
return true
}
c.entries[key] = c.lru.PushFront(entry)
for len(c.entries) > c.maxEntries {
back := c.lru.Back()
if back == nil {
break
}
c.remove(back)
}
return true
}
func (c *manifestCache) remove(el *list.Element) {
entry, ok := el.Value.(manifestCacheEntry)
if ok {
delete(c.entries, entry.key)
}
c.lru.Remove(el)
}

View File

@@ -0,0 +1,122 @@
package handler
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
type manifestRoundTripper struct {
calls int
body string
}
func (rt *manifestRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
rt.calls++
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": {"application/vnd.apple.mpegurl"},
"Content-Length": {"48"},
},
Body: io.NopCloser(strings.NewReader(rt.body)),
}, nil
}
func TestManifestCacheTTLSizeAndLRU(t *testing.T) {
now := time.Unix(100, 0)
cache := newManifestCache(2)
headers := http.Header{"Content-Type": {"application/vnd.apple.mpegurl"}}
vod := []byte("#EXTM3U\n#EXTINF:4,\nsegment.ts\n#EXT-X-ENDLIST\n")
live := []byte("#EXTM3U\n#EXTINF:4,\nsegment.ts\n")
if !cache.set("vod", http.StatusOK, headers, vod, now) {
t.Fatal("VOD manifest was not cached")
}
if !cache.set("live", http.StatusOK, headers, live, now) {
t.Fatal("live manifest was not cached")
}
cache.get("vod", now)
cache.set("new", http.StatusOK, headers, vod, now)
if _, ok := cache.get("live", now); ok {
t.Fatal("least recently used manifest was not evicted")
}
if _, ok := cache.get("vod", now.Add(manifestCacheLiveTTL+time.Second)); !ok {
t.Fatal("VOD manifest expired at the live TTL")
}
if cache.set("large", http.StatusOK, headers, make([]byte, manifestCacheMaxBodySize+1), now) {
t.Fatal("oversized manifest was cached")
}
}
func TestReadBoundedPlaylistRejectsOversizedBody(t *testing.T) {
_, err := readBoundedPlaylist(bytes.NewReader(make([]byte, manifestCacheMaxBodySize+1)))
if err == nil {
t.Fatal("readBoundedPlaylist accepted an oversized body")
}
}
func TestCachedRawPlaylistIsRewrittenWithResponseTokens(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &rewritePlaybackService{}
h := &PlaybackHandler{svc: svc}
body := []byte("#EXTM3U\n#EXTINF:4,\nsegment.ts\n#EXT-X-ENDLIST\n")
headers := http.Header{"Content-Type": {"application/vnd.apple.mpegurl"}, "Content-Length": {"48"}}
responses := make([]string, 0, 2)
for range 2 {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
h.writeProxyPlaylist(ctx, http.StatusOK, headers, body, "https://cdn.example.test/master.m3u8", "")
responses = append(responses, recorder.Body.String())
if recorder.Header().Get("Content-Length") == "48" {
t.Fatal("rewritten playlist retained upstream Content-Length")
}
}
if responses[0] == responses[1] {
t.Fatalf("rewritten responses reused tokenized output: %q", responses[0])
}
if !strings.Contains(responses[0], "token=token-1") || !strings.Contains(responses[1], "token=token-2") {
t.Fatalf("responses did not contain separately issued tokens: %#v", responses)
}
}
func TestHandleProxyStreamServesCachedRawManifest(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &rewritePlaybackService{targetURL: "https://203.0.113.10/master.m3u8"}
rt := &manifestRoundTripper{body: "#EXTM3U\n#EXTINF:4,\nsegment.ts\n#EXT-X-ENDLIST\n"}
h := &PlaybackHandler{
svc: svc,
streamingClient: &http.Client{Transport: rt},
manifestCache: newManifestCache(2),
}
router := gin.New()
router.GET("/watch/proxy/stream", h.HandleProxyStream)
responses := make([]string, 0, 2)
for range 2 {
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/watch/proxy/stream?token=opaque", nil)
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
responses = append(responses, recorder.Body.String())
}
if rt.calls != 1 {
t.Fatalf("upstream calls = %d, want 1", rt.calls)
}
if responses[0] == responses[1] {
t.Fatalf("cached raw manifest reused rewritten tokens: %#v", responses)
}
}

View File

@@ -10,7 +10,8 @@ import (
) )
type rewritePlaybackService struct { type rewritePlaybackService struct {
targets []string targets []string
targetURL string
} }
func (s *rewritePlaybackService) BuildWatchData(context.Context, int, []string, string, string, string) (domain.WatchPageData, error) { func (s *rewritePlaybackService) BuildWatchData(context.Context, int, []string, string, string, string) (domain.WatchPageData, error) {
@@ -31,7 +32,7 @@ func (s *rewritePlaybackService) SignProxyToken(targetURL, _ string, _ string) (
} }
func (s *rewritePlaybackService) ResolveProxyToken(string, string) (string, string, error) { func (s *rewritePlaybackService) ResolveProxyToken(string, string) (string, string, error) {
return "", "", nil return s.targetURL, "", nil
} }
func (s *rewritePlaybackService) UpsertSkipSegmentOverride(context.Context, string, int64, int, string, float64, float64) error { func (s *rewritePlaybackService) UpsertSkipSegmentOverride(context.Context, string, int64, int, string, float64, float64) error {

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"mal/internal/observability" "mal/internal/observability"
"mal/internal/playback/proxytarget"
netutil "mal/pkg/net" netutil "mal/pkg/net"
"net/http" "net/http"
@@ -11,9 +12,13 @@ import (
) )
func newProxyRequest(ctx context.Context, targetURL string, referer string) (*http.Request, error) { func newProxyRequest(ctx context.Context, targetURL string, referer string) (*http.Request, error) {
if err := proxytarget.Validate(targetURL); err != nil {
return nil, fmt.Errorf("validate proxy target: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("build proxy request for %q: %w", targetURL, err) return nil, fmt.Errorf("build proxy request: %w", err)
} }
if referer != "" { if referer != "" {

View File

@@ -8,6 +8,7 @@ import (
errlog "mal/pkg" errlog "mal/pkg"
netutil "mal/pkg/net" netutil "mal/pkg/net"
"net/http" "net/http"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -17,68 +18,111 @@ func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) {
if !ok { if !ok {
return return
} }
req, err := newProxyRequest(c.Request.Context(), targetURL, referer) req, err := newProxyRequest(c.Request.Context(), targetURL, referer)
if err != nil { if err != nil {
c.Status(http.StatusBadGateway) c.Status(http.StatusBadGateway)
return return
} }
if rangeHeader := c.GetHeader("Range"); rangeHeader != "" { cacheKey := manifestCacheKey(targetURL, referer)
req.Header.Set("Range", rangeHeader) if h.writeCachedPlaylist(c, cacheKey, targetURL, referer) {
} return
if ifRangeHeader := c.GetHeader("If-Range"); ifRangeHeader != "" {
req.Header.Set("If-Range", ifRangeHeader)
} }
copyProxyRangeHeaders(req, c)
resp, err := h.streamingClient.Do(req) resp, err := h.streamingClient.Do(req)
if err != nil { if err != nil {
if !errors.Is(err, context.Canceled) { h.handleStreamRequestError(c, err)
observability.ErrorContext(c.Request.Context(), "proxy_stream_upstream_failed", "playback", "", map[string]any{"target_url": targetURL}, err)
recordPrivateGinError(c, err)
}
c.Status(http.StatusBadGateway)
return return
} }
defer func() { defer func() {
errlog.Log("failed to close proxy stream response body", resp.Body.Close()) errlog.Log("failed to close proxy stream response body", resp.Body.Close())
}() }()
h.writeStreamResponse(c, resp, cacheKey, targetURL, referer)
}
func (h *PlaybackHandler) writeCachedPlaylist(c *gin.Context, cacheKey string, targetURL string, referer string) bool {
if c.GetHeader("Range") != "" {
return false
}
cached, found := h.manifestCache.get(cacheKey, time.Now())
if !found {
return false
}
observability.Info("playback_manifest_cache_hit", "playback", "", nil)
h.writeProxyPlaylist(c, cached.status, cached.headers, cached.body, targetURL, referer)
return true
}
func copyProxyRangeHeaders(req *http.Request, c *gin.Context) {
if rangeHeader := c.GetHeader("Range"); rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
if ifRangeHeader := c.GetHeader("If-Range"); ifRangeHeader != "" {
req.Header.Set("If-Range", ifRangeHeader)
}
}
func (h *PlaybackHandler) handleStreamRequestError(c *gin.Context, err error) {
if !errors.Is(err, context.Canceled) {
safeErr := errors.New("stream upstream request failed")
observability.ErrorContext(c.Request.Context(), "proxy_stream_upstream_failed", "playback", "", nil, safeErr)
recordPrivateGinError(c, safeErr)
}
c.Status(http.StatusBadGateway)
}
func (h *PlaybackHandler) writeStreamResponse(c *gin.Context, resp *http.Response, cacheKey string, targetURL string, referer string) {
if isHLSPlaylistResponse(targetURL, resp.Header) { if isHLSPlaylistResponse(targetURL, resp.Header) {
h.writeProxyPlaylist(c, resp, targetURL, referer) observability.Info("playback_manifest_cache_miss", "playback", "", nil)
body, readErr := readBoundedPlaylist(resp.Body)
if readErr != nil {
safeErr := errors.New("stream playlist read failed")
observability.ErrorContext(c.Request.Context(), "proxy_stream_playlist_read_failed", "playback", "", nil, safeErr)
recordPrivateGinError(c, safeErr)
c.Status(http.StatusBadGateway)
return
}
h.manifestCache.set(cacheKey, resp.StatusCode, resp.Header, body, time.Now())
h.writeProxyPlaylist(c, resp.StatusCode, resp.Header, body, targetURL, referer)
return return
} }
copyProxyHeaders(c.Writer.Header(), resp.Header) copyProxyHeaders(c.Writer.Header(), resp.Header)
c.Status(resp.StatusCode) c.Status(resp.StatusCode)
copyProxyResponseBody(c, resp.Body, targetURL) copyProxyResponseBody(c, resp.Body)
} }
func copyProxyResponseBody(c *gin.Context, body io.Reader, targetURL string) { func copyProxyResponseBody(c *gin.Context, body io.Reader) {
n, err := io.Copy(c.Writer, body) n, err := io.Copy(c.Writer, body)
if err == nil || errors.Is(err, context.Canceled) || c.Request.Context().Err() != nil { if err == nil || errors.Is(err, context.Canceled) || c.Request.Context().Err() != nil {
return return
} }
observability.WarnContext(c.Request.Context(), "proxy_stream_copy_failed", "playback", "", map[string]any{"target_url": targetURL, "bytes_copied": n}, err) observability.WarnContext(c.Request.Context(), "proxy_stream_copy_failed", "playback", "", map[string]any{"bytes_copied": n}, err)
} }
func (h *PlaybackHandler) writeProxyPlaylist(c *gin.Context, resp *http.Response, targetURL string, referer string) { func readBoundedPlaylist(body io.Reader) ([]byte, error) {
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2)) data, err := io.ReadAll(io.LimitReader(body, netutil.MiB2+1))
if err != nil { if err != nil {
observability.ErrorContext(c.Request.Context(), "proxy_stream_playlist_read_failed", "playback", "", map[string]any{"target_url": targetURL}, err) return nil, err
recordPrivateGinError(c, err)
c.Status(http.StatusBadGateway)
return
} }
if len(data) > manifestCacheMaxBodySize {
return nil, errors.New("upstream playlist exceeds size limit")
}
return data, nil
}
func (h *PlaybackHandler) writeProxyPlaylist(c *gin.Context, status int, headers http.Header, body []byte, targetURL string, referer string) {
rewritten, err := h.rewriteHLSPlaylist(string(body), targetURL, referer) rewritten, err := h.rewriteHLSPlaylist(string(body), targetURL, referer)
if err != nil { if err != nil {
observability.ErrorContext(c.Request.Context(), "proxy_stream_playlist_rewrite_failed", "playback", "", map[string]any{"target_url": targetURL}, err) safeErr := errors.New("stream playlist rewrite failed")
recordPrivateGinError(c, err) observability.ErrorContext(c.Request.Context(), "proxy_stream_playlist_rewrite_failed", "playback", "", nil, safeErr)
recordPrivateGinError(c, safeErr)
c.Status(http.StatusBadGateway) c.Status(http.StatusBadGateway)
return return
} }
copyProxyHeaders(c.Writer.Header(), resp.Header) copyProxyHeaders(c.Writer.Header(), headers)
c.Writer.Header().Del("Content-Length") c.Writer.Header().Del("Content-Length")
c.Data(resp.StatusCode, "application/vnd.apple.mpegurl", []byte(rewritten)) c.Data(status, "application/vnd.apple.mpegurl", []byte(rewritten))
} }

View File

@@ -34,8 +34,9 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
resp, err := h.proxyClient.Do(req) resp, err := h.proxyClient.Do(req)
if err != nil { if err != nil {
if !errors.Is(err, context.Canceled) { if !errors.Is(err, context.Canceled) {
observability.ErrorContext(c.Request.Context(), "proxy_subtitle_upstream_failed", "playback", "", map[string]any{"target_url": targetURL}, err) safeErr := errors.New("subtitle upstream request failed")
recordPrivateGinError(c, err) observability.ErrorContext(c.Request.Context(), "proxy_subtitle_upstream_failed", "playback", "", nil, safeErr)
recordPrivateGinError(c, safeErr)
} }
c.Status(http.StatusBadGateway) c.Status(http.StatusBadGateway)
return return
@@ -46,8 +47,9 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2)) body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
if err != nil { if err != nil {
observability.ErrorContext(c.Request.Context(), "proxy_subtitle_read_failed", "playback", "", map[string]any{"target_url": targetURL}, err) safeErr := errors.New("subtitle response read failed")
recordPrivateGinError(c, err) observability.ErrorContext(c.Request.Context(), "proxy_subtitle_read_failed", "playback", "", nil, safeErr)
recordPrivateGinError(c, safeErr)
c.Status(http.StatusBadGateway) c.Status(http.StatusBadGateway)
return return
} }

View File

@@ -0,0 +1,78 @@
package handler
import (
"context"
"io"
"mal/internal/domain"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
type proxyTargetPlaybackService struct {
targetURL string
referer string
}
func (s proxyTargetPlaybackService) BuildWatchData(context.Context, int, []string, string, string, string) (domain.WatchPageData, error) {
return domain.WatchPageData{}, nil
}
func (s proxyTargetPlaybackService) SaveProgress(context.Context, string, int64, int, float64) error {
return nil
}
func (s proxyTargetPlaybackService) CompleteAnime(context.Context, string, int64) error {
return nil
}
func (s proxyTargetPlaybackService) SignProxyToken(string, string, string) (string, error) {
return "token", nil
}
func (s proxyTargetPlaybackService) ResolveProxyToken(string, string) (string, string, error) {
return s.targetURL, s.referer, nil
}
func (s proxyTargetPlaybackService) UpsertSkipSegmentOverride(context.Context, string, int64, int, string, float64, float64) error {
return nil
}
type recordingRoundTripper struct {
called bool
}
func (rt *recordingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
rt.called = true
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("WEBVTT\n")),
}, nil
}
func TestHandleProxySubtitleRejectsUnsafeTargetBeforeFetch(t *testing.T) {
rt := &recordingRoundTripper{}
h := &PlaybackHandler{
svc: proxyTargetPlaybackService{targetURL: "http://127.0.0.1/subtitle.vtt"},
proxyClient: &http.Client{Transport: rt},
subtitleCache: newSubtitleCache(0, 1),
}
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/watch/proxy/subtitle?token=token", nil)
rec := httptest.NewRecorder()
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/watch/proxy/subtitle", h.HandleProxySubtitle)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadGateway)
}
if rt.called {
t.Fatal("proxy client was called for unsafe target")
}
}

View File

@@ -17,14 +17,96 @@ import (
type watchPagePlaybackService struct { type watchPagePlaybackService struct {
domain.PlaybackService domain.PlaybackService
data domain.WatchPageData data domain.WatchPageData
err error deferred bool
err error
refreshRequested bool
titles []domain.CanonicalEpisode
classifications []domain.CanonicalEpisode
} }
func (s *watchPagePlaybackService) BuildWatchData(context.Context, int, []string, string, string, string) (domain.WatchPageData, error) { func (s *watchPagePlaybackService) BuildWatchData(ctx context.Context, _ int, _ []string, _ string, _ string, _ string) (domain.WatchPageData, error) {
s.deferred = domain.PlaybackDataDeferred(ctx)
s.refreshRequested = domain.PlaybackSourceRefreshRequested(ctx)
return s.data, s.err return s.data, s.err
} }
func (s *watchPagePlaybackService) EnrichEpisodeTitles(context.Context, int) ([]domain.CanonicalEpisode, error) {
return s.titles, s.err
}
func (s *watchPagePlaybackService) EnrichEpisodeClassifications(context.Context, int) ([]domain.CanonicalEpisode, error) {
return s.classifications, s.err
}
func TestHandleEpisodeDataRequestsForcedSourceRefresh(t *testing.T) {
data := baseWatchPageData()
data.WatchData.ModeSources = map[string]domain.ModeSource{
"sub": {Token: "opaque", Type: "m3u8"},
}
svc := &watchPagePlaybackService{data: data}
h := &PlaybackHandler{svc: svc}
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/watch/episode/:animeId/:episode", h.HandleEpisodeData)
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/watch/episode/123/1?mode=sub&refresh=1", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if !svc.refreshRequested {
t.Fatal("episode handler did not request a forced source refresh")
}
}
func TestHandleEpisodeTitlesReturnsMinimalEnrichedList(t *testing.T) {
svc := &watchPagePlaybackService{titles: []domain.CanonicalEpisode{
{Number: 1, Title: "The First Title", HasSub: true},
{Number: 2, Title: "The Second Title", HasDub: true},
}}
h := &PlaybackHandler{svc: svc}
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/watch/episodes/:animeId/titles", h.HandleEpisodeTitles)
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/watch/episodes/123/titles", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if got := strings.TrimSpace(rec.Body.String()); got != `[{"number":1,"title":"The First Title"},{"number":2,"title":"The Second Title"}]` {
t.Fatalf("body = %s", got)
}
}
func TestHandleEpisodeClassificationsReturnsMinimalList(t *testing.T) {
svc := &watchPagePlaybackService{classifications: []domain.CanonicalEpisode{
{Number: 1},
{Number: 2, Filler: true},
{Number: 3, Recap: true},
}}
h := &PlaybackHandler{svc: svc}
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/watch/episodes/:animeId/classifications", h.HandleEpisodeClassifications)
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/watch/episodes/123/classifications", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if got := strings.TrimSpace(rec.Body.String()); got != `[{"filler":false,"number":1,"recap":false},{"filler":true,"number":2,"recap":false},{"filler":false,"number":3,"recap":true}]` {
t.Fatalf("body = %s", got)
}
}
type watchPageAnimeService struct { type watchPageAnimeService struct {
domain.AnimePlaybackService domain.AnimePlaybackService
t *testing.T t *testing.T
@@ -96,8 +178,11 @@ func TestHandleWatchPagePreservesPartialDataOnPlaybackFailure(t *testing.T) {
if !strings.Contains(body, `data-episode-id="1"`) { if !strings.Contains(body, `data-episode-id="1"`) {
t.Fatalf("expected episode list in body, got:\n%s", body) t.Fatalf("expected episode list in body, got:\n%s", body)
} }
if !strings.Contains(body, `data-playback-error="no streams found"`) { if !strings.Contains(body, `data-playback-error="failed to load playback data"`) {
t.Fatalf("expected playback error data attribute in body, got:\n%s", body) t.Fatalf("expected stable playback error data attribute in body, got:\n%s", body)
}
if strings.Contains(body, "no streams found") {
t.Fatalf("expected private playback error to stay out of body, got:\n%s", body)
} }
if !strings.Contains(body, `/anime/123/watch?ep=2`) { if !strings.Contains(body, `/anime/123/watch?ep=2`) {
t.Fatalf("expected episode links to keep the anime id, got:\n%s", body) t.Fatalf("expected episode links to keep the anime id, got:\n%s", body)
@@ -106,3 +191,49 @@ func TestHandleWatchPagePreservesPartialDataOnPlaybackFailure(t *testing.T) {
t.Fatalf("expected partial episode list instead of empty state, got:\n%s", body) t.Fatalf("expected partial episode list instead of empty state, got:\n%s", body)
} }
} }
func TestHandleWatchPageDefersPlaybackData(t *testing.T) {
t.Parallel()
svc := &watchPagePlaybackService{data: baseWatchPageData()}
router := newWatchPageRouter(t, &PlaybackHandler{svc: svc})
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/anime/123/watch?ep=1", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if !svc.deferred {
t.Fatal("watch page did not defer playback data")
}
}
func TestHandleWatchPageRendersEpisodeAvailabilityWarningModal(t *testing.T) {
t.Parallel()
data := baseWatchPageData()
data.EpisodeAvailabilityWarning = "Episode availability may be incomplete or out of date. Continue only if you understand that the episode list and audio availability may be uncertain."
router := newWatchPageRouter(t, &PlaybackHandler{
svc: &watchPagePlaybackService{data: data},
})
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/anime/123/watch?ep=1", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
body := rec.Body.String()
for _, want := range []string{
`data-episode-availability-warning`,
`Episode availability is uncertain`,
`Continue anyway`,
data.EpisodeAvailabilityWarning,
} {
if !strings.Contains(body, want) {
t.Fatalf("watch page missing %q in body:\n%s", want, body)
}
}
}

View File

@@ -189,6 +189,33 @@ func TestPlaybackServiceProxyTokenDisabled(t *testing.T) {
} }
} }
func TestPlaybackServiceSignProxyTokenRejectsUnsafeTargets(t *testing.T) {
svc := &playbackService{proxyTokenKey: "secret", proxyTokens: newProxyTokenStore()}
tests := []struct {
name string
targetURL string
}{
{name: "loopback ipv4", targetURL: "http://127.0.0.1/video.m3u8"},
{name: "private ipv4", targetURL: "https://10.0.0.4/segment.ts"},
{name: "loopback ipv6", targetURL: "http://[::1]/subtitle.vtt"},
{name: "unsupported scheme", targetURL: "file:///tmp/video.m3u8"},
{name: "missing host", targetURL: "https:///segment.ts"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token, err := svc.SignProxyToken(tt.targetURL, "", "stream")
if err == nil {
t.Fatalf("SignProxyToken(%q) error = nil, token = %q", tt.targetURL, token)
}
if token != "" {
t.Fatalf("SignProxyToken(%q) token = %q, want empty", tt.targetURL, token)
}
})
}
}
type fakePlaybackRepository struct { type fakePlaybackRepository struct {
inTxCalled bool inTxCalled bool
getAnimeErr error getAnimeErr error

View File

@@ -0,0 +1,125 @@
package proxytarget
import (
"context"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
)
type ipResolver interface {
LookupIPAddr(context.Context, string) ([]net.IPAddr, error)
}
func Validate(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("parse proxy target: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("proxy target scheme must be http or https")
}
host := parsed.Hostname()
if host == "" {
return fmt.Errorf("proxy target host is required")
}
if isLocalhostName(host) {
return fmt.Errorf("proxy target host is local")
}
if addr, err := netip.ParseAddr(host); err == nil && !isAllowedAddr(addr) {
return fmt.Errorf("proxy target address is not allowed")
}
return nil
}
func NewClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: newTransport(10*time.Second, 10*time.Second, 30*time.Second),
Timeout: timeout,
CheckRedirect: checkRedirect,
}
}
func NewStreamingClient() *http.Client {
return &http.Client{
Transport: newTransport(10*time.Second, 10*time.Second, 15*time.Second),
CheckRedirect: checkRedirect,
}
}
func checkRedirect(req *http.Request, _ []*http.Request) error {
return Validate(req.URL.String())
}
func newTransport(dialTimeout, tlsTimeout, headerTimeout time.Duration) *http.Transport {
dialer := &net.Dialer{Timeout: dialTimeout}
return &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: tlsTimeout,
ResponseHeaderTimeout: headerTimeout,
ExpectContinueTimeout: 1 * time.Second,
DialContext: validatingDialContext(dialer, net.DefaultResolver),
}
}
func validatingDialContext(dialer *net.Dialer, resolver ipResolver) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network string, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("parse proxy target address: %w", err)
}
if isLocalhostName(host) {
return nil, fmt.Errorf("proxy target host is local")
}
if addr, err := netip.ParseAddr(host); err == nil {
if !isAllowedAddr(addr) {
return nil, fmt.Errorf("proxy target address is not allowed")
}
return dialer.DialContext(ctx, network, net.JoinHostPort(addr.String(), port))
}
ips, err := resolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("resolve proxy target host: %w", err)
}
for _, ip := range ips {
addr, ok := addrFromIP(ip.IP)
if !ok || !isAllowedAddr(addr) {
continue
}
return dialer.DialContext(ctx, network, net.JoinHostPort(addr.String(), port))
}
return nil, fmt.Errorf("proxy target resolved to no allowed addresses")
}
}
func isLocalhostName(host string) bool {
lower := strings.TrimSuffix(strings.ToLower(host), ".")
return lower == "localhost" || strings.HasSuffix(lower, ".localhost")
}
func addrFromIP(ip net.IP) (netip.Addr, bool) {
if v4 := ip.To4(); v4 != nil {
return netip.AddrFrom4([4]byte{v4[0], v4[1], v4[2], v4[3]}), true
}
if v6 := ip.To16(); v6 != nil {
return netip.AddrFrom16([16]byte{v6[0], v6[1], v6[2], v6[3], v6[4], v6[5], v6[6], v6[7], v6[8], v6[9], v6[10], v6[11], v6[12], v6[13], v6[14], v6[15]}), true
}
return netip.Addr{}, false
}
func isAllowedAddr(addr netip.Addr) bool {
return addr.IsValid() &&
addr.IsGlobalUnicast() &&
!addr.IsLoopback() &&
!addr.IsPrivate() &&
!addr.IsLinkLocalUnicast() &&
!addr.IsUnspecified()
}

View File

@@ -0,0 +1,68 @@
package proxytarget
import (
"context"
"net"
"net/http"
"net/url"
"strings"
"testing"
"time"
)
func TestValidateAllowsHTTPSTargetWithHostname(t *testing.T) {
if err := Validate("https://cdn.example.test/video/segment.ts"); err != nil {
t.Fatalf("Validate() error = %v, want nil", err)
}
}
func TestValidateRejectsUnsafeTargets(t *testing.T) {
tests := []string{
"ftp://cdn.example.test/video.ts",
"https:///video.ts",
"http://localhost/video.ts",
"http://127.0.0.1/video.ts",
"http://[::ffff:127.0.0.1]/video.ts",
"http://169.254.169.254/video.ts",
"https://[::1]/subtitle.vtt",
}
for _, targetURL := range tests {
t.Run(targetURL, func(t *testing.T) {
if err := Validate(targetURL); err == nil {
t.Fatal("Validate() error = nil, want error")
}
})
}
}
func TestCheckRedirectRejectsUnsafeLocation(t *testing.T) {
req := &http.Request{URL: &url.URL{Scheme: "http", Host: "127.0.0.1", Path: "/video.ts"}}
if err := checkRedirect(req, nil); err == nil {
t.Fatal("checkRedirect() error = nil, want error")
}
}
func TestValidatingDialContextRejectsPrivateResolvedAddress(t *testing.T) {
dial := validatingDialContext(&net.Dialer{Timeout: time.Millisecond}, fakeResolver{
ips: []net.IPAddr{{IP: net.ParseIP("192.168.1.20")}},
})
_, err := dial(context.Background(), "tcp", "cdn.example.test:443")
if err == nil {
t.Fatal("dial error = nil, want error")
}
if !strings.Contains(err.Error(), "no allowed addresses") {
t.Fatalf("dial error = %v, want no allowed addresses", err)
}
}
type fakeResolver struct {
ips []net.IPAddr
err error
}
func (r fakeResolver) LookupIPAddr(context.Context, string) ([]net.IPAddr, error) {
return r.ips, r.err
}

View File

@@ -2,15 +2,15 @@
package playback package playback
import ( import (
"context"
"fmt" "fmt"
"mal/integrations/jikan" "mal/integrations/jikan"
"mal/internal/domain" "mal/internal/domain"
"mal/internal/observability" "mal/internal/playback/proxytarget"
errlog "mal/pkg"
netutil "mal/pkg/net" netutil "mal/pkg/net"
"net/http" "net/http"
"time" "time"
"golang.org/x/sync/singleflight"
) )
type playbackService struct { type playbackService struct {
@@ -21,6 +21,8 @@ type playbackService struct {
httpClient *http.Client httpClient *http.Client
proxyTokenKey string proxyTokenKey string
proxyTokens *proxyTokenStore proxyTokens *proxyTokenStore
sourceCache *sourceCache
sourceFlight singleflight.Group
auditSvc domain.AuditService auditSvc domain.AuditService
} }
@@ -36,6 +38,7 @@ func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provi
httpClient: netutil.NewClient(), httpClient: netutil.NewClient(),
proxyTokenKey: string(proxyTokenKey), proxyTokenKey: string(proxyTokenKey),
proxyTokens: newProxyTokenStore(), proxyTokens: newProxyTokenStore(),
sourceCache: newSourceCache(defaultSourceCacheTTL, defaultSourceCacheStaleTTL, defaultSourceCacheMaxEntries),
} }
} }
@@ -43,6 +46,9 @@ func (s *playbackService) SignProxyToken(targetURL, referer, scope string) (stri
if s.proxyTokenKey == "" { if s.proxyTokenKey == "" {
return "", nil return "", nil
} }
if err := proxytarget.Validate(targetURL); err != nil {
return "", err
}
return s.proxyTokens.create(targetURL, referer, scope, 2*time.Hour, time.Now()) return s.proxyTokens.create(targetURL, referer, scope, 2*time.Hour, time.Now())
} }
@@ -59,27 +65,3 @@ func (s *playbackService) ResolveProxyToken(token string, scope string) (string,
} }
return target.targetURL, target.referer, nil return target.targetURL, target.referer, nil
} }
func (s *playbackService) warmStreamURL(targetURL, referer string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
return
}
if referer != "" {
req.Header.Set("Referer", referer)
}
req.Header.Set("User-Agent", netutil.Firefox121)
resp, err := s.httpClient.Do(req)
if err != nil {
if resp != nil {
errlog.Close(resp.Body, "failed to close warm stream error response body")
}
observability.LogJSON(observability.LogLevelWarn, "warm_stream_failed", "playback", err.Error(), map[string]any{"url": targetURL}, nil)
return
}
errlog.Log("failed to close warm stream response body", resp.Body.Close())
}

View File

@@ -0,0 +1,152 @@
package playback
import (
"container/list"
"fmt"
"strings"
"sync"
"time"
"mal/internal/domain"
)
const (
defaultSourceCacheTTL = 5 * time.Minute
defaultSourceCacheStaleTTL = 10 * time.Minute
defaultSourceCacheMaxEntries = 512
)
type sourceCacheKey struct {
animeID int
episode string
mode string
}
func newSourceCacheKey(animeID int, episode string, mode string) sourceCacheKey {
return sourceCacheKey{
animeID: animeID,
episode: strings.TrimSpace(episode),
mode: normalizeSourceMode(mode),
}
}
func (k sourceCacheKey) flightKey() string {
return fmt.Sprintf("%d|%q|%q", k.animeID, k.episode, k.mode)
}
func normalizeSourceMode(mode string) string {
mode = strings.ToLower(strings.TrimSpace(mode))
if mode == "" {
return "sub"
}
return mode
}
type sourceCacheEntry struct {
key sourceCacheKey
result *domain.StreamResult
freshUntil time.Time
staleUntil time.Time
}
type sourceCacheState uint8
const (
sourceCacheMiss sourceCacheState = iota
sourceCacheFresh
sourceCacheStale
)
type sourceCache struct {
mu sync.Mutex
freshTTL time.Duration
staleTTL time.Duration
maxEntries int
entries map[sourceCacheKey]*list.Element
lru *list.List
}
func newSourceCache(freshTTL, staleTTL time.Duration, maxEntries int) *sourceCache {
if freshTTL <= 0 {
freshTTL = defaultSourceCacheTTL
}
if staleTTL < 0 {
staleTTL = 0
}
if maxEntries <= 0 {
maxEntries = defaultSourceCacheMaxEntries
}
return &sourceCache{
freshTTL: freshTTL,
staleTTL: staleTTL,
maxEntries: maxEntries,
entries: make(map[sourceCacheKey]*list.Element, maxEntries),
lru: list.New(),
}
}
func (c *sourceCache) get(key sourceCacheKey, now time.Time) (*domain.StreamResult, sourceCacheState) {
c.mu.Lock()
defer c.mu.Unlock()
el := c.entries[key]
if el == nil {
return nil, sourceCacheMiss
}
entry, ok := el.Value.(sourceCacheEntry)
if !ok || !now.Before(entry.staleUntil) {
c.remove(el)
return nil, sourceCacheMiss
}
c.lru.MoveToFront(el)
if now.Before(entry.freshUntil) {
return cloneStreamResult(entry.result), sourceCacheFresh
}
return cloneStreamResult(entry.result), sourceCacheStale
}
func (c *sourceCache) set(key sourceCacheKey, result *domain.StreamResult, now time.Time) (evicted bool) {
c.mu.Lock()
defer c.mu.Unlock()
entry := sourceCacheEntry{
key: key,
result: cloneStreamResult(result),
freshUntil: now.Add(c.freshTTL),
staleUntil: now.Add(c.freshTTL + c.staleTTL),
}
if el := c.entries[key]; el != nil {
el.Value = entry
c.lru.MoveToFront(el)
return false
}
c.entries[key] = c.lru.PushFront(entry)
for len(c.entries) > c.maxEntries {
back := c.lru.Back()
if back == nil {
break
}
c.remove(back)
evicted = true
}
return evicted
}
func (c *sourceCache) remove(el *list.Element) {
entry, ok := el.Value.(sourceCacheEntry)
if ok {
delete(c.entries, entry.key)
}
c.lru.Remove(el)
}
func cloneStreamResult(result *domain.StreamResult) *domain.StreamResult {
if result == nil {
return nil
}
cloned := *result
cloned.Subtitles = append([]domain.Subtitle(nil), result.Subtitles...)
cloned.Qualities = append([]domain.StreamSource(nil), result.Qualities...)
return &cloned
}

View File

@@ -0,0 +1,147 @@
package playback
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"mal/internal/domain"
)
type sourceCacheProvider struct {
calls atomic.Int32
get func(context.Context, int, []string, string, string) (*domain.StreamResult, error)
}
func (p *sourceCacheProvider) Name() string { return "test" }
func (p *sourceCacheProvider) GetStreams(ctx context.Context, animeID int, titles []string, episode string, mode string) (*domain.StreamResult, error) {
p.calls.Add(1)
return p.get(ctx, animeID, titles, episode, mode)
}
func newSourceCacheService(provider domain.Provider) *playbackService {
return &playbackService{
providers: []domain.Provider{provider},
sourceCache: newSourceCache(time.Minute, time.Minute, 8),
}
}
func TestResolveStreamResultCachesClonedProviderResult(t *testing.T) {
provider := &sourceCacheProvider{get: func(context.Context, int, []string, string, string) (*domain.StreamResult, error) {
return &domain.StreamResult{
URL: "https://cdn.example.test/episode.m3u8",
Subtitles: []domain.Subtitle{{URL: "https://cdn.example.test/sub.vtt", Label: "English"}},
}, nil
}}
svc := newSourceCacheService(provider)
first := svc.resolveStreamResult(context.Background(), 42, []string{"Title"}, "3", "SUB", false, false)
first.Subtitles[0].Label = "changed"
second := svc.resolveStreamResult(context.Background(), 42, []string{"Different title"}, "3", "sub", false, false)
if provider.calls.Load() != 1 {
t.Fatalf("provider calls = %d, want 1", provider.calls.Load())
}
if second == nil || second.Subtitles[0].Label != "English" {
t.Fatalf("cached result was not cloned: %#v", second)
}
}
func TestResolveStreamResultCollapsesConcurrentMisses(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
provider := &sourceCacheProvider{get: func(context.Context, int, []string, string, string) (*domain.StreamResult, error) {
select {
case <-started:
default:
close(started)
}
<-release
return &domain.StreamResult{URL: "https://cdn.example.test/episode.m3u8"}, nil
}}
svc := newSourceCacheService(provider)
const requests = 8
results := make(chan *domain.StreamResult, requests)
var wg sync.WaitGroup
for range requests {
wg.Go(func() {
results <- svc.resolveStreamResult(context.Background(), 42, nil, "3", "sub", false, false)
})
}
<-started
close(release)
wg.Wait()
close(results)
for result := range results {
if result == nil {
t.Fatal("concurrent lookup returned nil")
}
}
if provider.calls.Load() != 1 {
t.Fatalf("provider calls = %d, want 1", provider.calls.Load())
}
}
func TestResolveStreamResultSeparatesKeysAndForcesRefresh(t *testing.T) {
provider := &sourceCacheProvider{get: func(_ context.Context, _ int, _ []string, episode string, mode string) (*domain.StreamResult, error) {
return &domain.StreamResult{URL: "https://cdn.example.test/" + episode + "/" + mode}, nil
}}
svc := newSourceCacheService(provider)
svc.resolveStreamResult(context.Background(), 42, nil, "1", "sub", false, false)
svc.resolveStreamResult(context.Background(), 42, nil, "2", "sub", false, false)
svc.resolveStreamResult(context.Background(), 42, nil, "2", "dub", false, false)
svc.resolveStreamResult(context.Background(), 42, nil, "2", "dub", false, true)
if provider.calls.Load() != 4 {
t.Fatalf("provider calls = %d, want 4", provider.calls.Load())
}
}
func TestResolveStreamResultUsesStaleCompletedMediaOnProviderError(t *testing.T) {
provider := &sourceCacheProvider{get: func(context.Context, int, []string, string, string) (*domain.StreamResult, error) {
return nil, errors.New("provider unavailable")
}}
svc := newSourceCacheService(provider)
svc.sourceCache.set(
newSourceCacheKey(42, "3", "sub"),
&domain.StreamResult{URL: "https://cdn.example.test/stale.m3u8"},
time.Now().Add(-90*time.Second),
)
result := svc.resolveStreamResult(context.Background(), 42, nil, "3", "sub", true, false)
if result == nil || result.URL != "https://cdn.example.test/stale.m3u8" {
t.Fatalf("result = %#v, want stale source", result)
}
if airing := svc.resolveStreamResult(context.Background(), 42, nil, "3", "sub", false, false); airing != nil {
t.Fatalf("airing result = %#v, want nil after provider failure", airing)
}
}
func TestSourceCacheExpiresAndEvictsLeastRecentlyUsed(t *testing.T) {
now := time.Unix(100, 0)
cache := newSourceCache(time.Minute, time.Minute, 2)
keyA := newSourceCacheKey(1, "1", "sub")
keyB := newSourceCacheKey(1, "2", "sub")
keyC := newSourceCacheKey(1, "3", "sub")
cache.set(keyA, &domain.StreamResult{URL: "a"}, now)
cache.set(keyB, &domain.StreamResult{URL: "b"}, now)
cache.get(keyA, now)
cache.set(keyC, &domain.StreamResult{URL: "c"}, now)
if _, state := cache.get(keyB, now); state != sourceCacheMiss {
t.Fatalf("evicted cache state = %d, want miss", state)
}
if _, state := cache.get(keyA, now.Add(90*time.Second)); state != sourceCacheStale {
t.Fatalf("stale cache state = %d, want stale", state)
}
if _, state := cache.get(keyA, now.Add(2*time.Minute)); state != sourceCacheMiss {
t.Fatalf("expired cache state = %d, want miss", state)
}
}

View File

@@ -2,29 +2,43 @@ package playback
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time"
"mal/integrations/jikan" "mal/integrations/jikan"
"mal/internal/domain" "mal/internal/domain"
"mal/internal/observability" "mal/internal/observability"
) )
const sourceResolutionTimeout = 15 * time.Second
type sourceResolutionResult struct {
result *domain.StreamResult
err error
duration time.Duration
shared bool
completed bool
}
const episodeAvailabilityUncertainWarning = "Episode availability may be incomplete or out of date. Continue only if you understand that the episode list and audio availability may be uncertain."
func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (domain.WatchPageData, error) { func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (domain.WatchPageData, error) {
anime, err := s.jikan.GetAnimeByID(ctx, animeID) animeData, err := s.watchAnime(ctx, animeID)
if err != nil { if err != nil {
return domain.WatchPageData{}, fmt.Errorf("failed to fetch anime: %w", err) return domain.WatchPageData{}, fmt.Errorf("failed to fetch anime: %w", err)
} }
animeData := domain.Anime{Anime: anime}
if err := s.ensureAnimeRow(ctx, animeData); err != nil { if err := s.ensureAnimeRow(ctx, animeData); err != nil {
observability.Warn("upsert_anime_failed", "playback", "", observability.Warn("upsert_anime_failed", "playback", "",
map[string]any{"anime_id": animeID}, map[string]any{"anime_id": animeID},
err, err,
) )
} }
anime := animeData.Anime
searchTitles := buildSearchTitles(animeData, titleCandidates) searchTitles := buildSearchTitles(animeData, titleCandidates)
eps, err := s.episodes.GetCanonicalEpisodes(ctx, animeData, false) eps, err := s.episodes.GetCanonicalEpisodes(ctx, animeData, false)
if err != nil { if err != nil {
@@ -33,24 +47,18 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
// mode fallback // mode fallback
mode, from := resolveMode(episode, mode, eps.Episodes) mode, from := resolveMode(episode, mode, eps.Episodes)
modeSources, result, resolvedMode, switchedFrom := s.resolveModeSources(ctx, animeID, searchTitles, episode, mode) deferred := domain.PlaybackDataDeferred(ctx)
if resolvedMode != "" { modeSources, result, mode, from := s.watchModeSources(
mode = resolvedMode ctx, animeID, searchTitles, episode, mode, from, !anime.Airing, deferred,
} )
if switchedFrom != "" {
from = switchedFrom
}
startTime, watchlistStatus, watchlistIDs := s.loadWatchProgress(ctx, userID, animeID, anime.Episodes, episode) startTime, watchlistStatus, watchlistIDs := s.loadWatchProgress(ctx, userID, animeID, anime.Episodes, episode)
seasons := s.loadSeasons(ctx, animeID) segments := s.watchSegments(ctx, userID, animeID, episode, deferred)
segments, err := s.fetchSkipSegments(ctx, userID, animeID, episode)
if err != nil {
observability.Warn("fetch_skip_segments_failed", "playback", "",
map[string]any{"anime_id": animeID, "episode": episode},
err,
)
}
watchData := buildWatchDataPayload(animeData, animeID, episode, startTime, eps.Episodes, modeSources, mode, from, segments) watchData := buildWatchDataPayload(animeData, animeID, episode, startTime, eps.Episodes, modeSources, mode, from, segments)
pageData := buildWatchPageData(animeData, eps.Episodes, episode, watchlistStatus, watchlistIDs, seasons, watchData) pageData := buildWatchPageData(animeData, eps.Episodes, episode, watchlistStatus, watchlistIDs, nil, watchData)
pageData.EpisodeAvailabilityWarning = episodeAvailabilityWarning(eps, time.Now())
if deferred {
return pageData, nil
}
if len(modeSources) == 0 { if len(modeSources) == 0 {
return pageData, fmt.Errorf("no streams found") return pageData, fmt.Errorf("no streams found")
} }
@@ -58,10 +66,99 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
return pageData, fmt.Errorf("no streams found for mode %s", mode) return pageData, fmt.Errorf("no streams found for mode %s", mode)
} }
go s.warmStreamURL(result.URL, result.Referer)
return pageData, nil return pageData, nil
} }
func (s *playbackService) EnrichEpisodeTitles(ctx context.Context, animeID int) ([]domain.CanonicalEpisode, error) {
anime, err := s.watchAnime(ctx, animeID)
if err != nil {
return nil, fmt.Errorf("failed to fetch anime for episode titles: %w", err)
}
enricher, ok := s.episodes.(domain.EpisodeTitleService)
if !ok {
return nil, errors.New("episode title enrichment is unavailable")
}
episodes, err := enricher.EnrichEpisodeTitles(ctx, anime)
if err != nil {
return nil, err
}
return episodes.Episodes, nil
}
func (s *playbackService) EnrichEpisodeClassifications(ctx context.Context, animeID int) ([]domain.CanonicalEpisode, error) {
anime, err := s.watchAnime(ctx, animeID)
if err != nil {
return nil, fmt.Errorf("failed to fetch anime for episode classifications: %w", err)
}
enricher, ok := s.episodes.(domain.EpisodeClassificationService)
if !ok {
return nil, errors.New("episode classification enrichment is unavailable")
}
episodes, err := enricher.EnrichEpisodeClassifications(ctx, anime)
if err != nil {
return nil, err
}
return episodes.Episodes, nil
}
func (s *playbackService) watchAnime(ctx context.Context, animeID int) (domain.Anime, error) {
row, err := s.repo.GetAnime(ctx, int64(animeID))
if err == nil && row.ID > 0 && strings.TrimSpace(row.TitleOriginal) != "" {
anime := jikan.Anime{
MalID: int(row.ID),
Title: row.TitleOriginal,
TitleEnglish: row.TitleEnglish.String,
TitleJapanese: row.TitleJapanese.String,
Airing: row.Airing.Valid && row.Airing.Bool,
Status: row.Status.String,
}
return domain.Anime{Anime: anime}, nil
}
anime, err := s.jikan.GetAnimeByID(ctx, animeID)
if err != nil {
return domain.Anime{}, err
}
return domain.Anime{Anime: anime}, nil
}
func (s *playbackService) watchModeSources(ctx context.Context, animeID int, searchTitles []string, episode, mode, from string, allowStale, deferred bool) (map[string]domain.ModeSource, *domain.StreamResult, string, string) {
if deferred {
return map[string]domain.ModeSource{}, nil, mode, from
}
modeSources, result, resolvedMode, switchedFrom := s.resolveModeSources(
ctx,
animeID,
searchTitles,
episode,
mode,
allowStale,
domain.PlaybackSourceRefreshRequested(ctx),
)
if resolvedMode != "" {
mode = resolvedMode
}
if switchedFrom != "" {
from = switchedFrom
}
return modeSources, result, mode, from
}
func (s *playbackService) watchSegments(ctx context.Context, userID string, animeID int, episode string, deferred bool) []domain.SkipSegment {
if deferred {
return []domain.SkipSegment{}
}
segments, err := s.fetchSkipSegments(ctx, userID, animeID, episode)
if err != nil {
observability.Warn("fetch_skip_segments_failed", "playback", "",
map[string]any{"anime_id": animeID, "episode": episode},
err,
)
}
return segments
}
func buildWatchDataPayload(anime domain.Anime, animeID int, episode string, startTime float64, episodes []domain.CanonicalEpisode, modeSources map[string]domain.ModeSource, mode string, modeSwitchedFrom string, segments []domain.SkipSegment) domain.WatchData { func buildWatchDataPayload(anime domain.Anime, animeID int, episode string, startTime float64, episodes []domain.CanonicalEpisode, modeSources map[string]domain.ModeSource, mode string, modeSwitchedFrom string, segments []domain.SkipSegment) domain.WatchData {
return domain.WatchData{ return domain.WatchData{
MalID: animeID, MalID: animeID,
@@ -96,6 +193,23 @@ func buildWatchPageData(anime domain.Anime, episodes []domain.CanonicalEpisode,
} }
} }
func episodeAvailabilityWarning(episodeList domain.CanonicalEpisodeList, now time.Time) string {
if episodeList.FailureCount > 0 {
return episodeAvailabilityUncertainWarning
}
if episodeList.NextRefreshAt == "" {
return ""
}
nextRefresh, err := time.Parse(time.RFC3339, episodeList.NextRefreshAt)
if err != nil {
return ""
}
if nextRefresh.After(now) {
return ""
}
return episodeAvailabilityUncertainWarning
}
func buildSearchTitles(anime domain.Anime, titleCandidates []string) []string { func buildSearchTitles(anime domain.Anime, titleCandidates []string) []string {
seen := map[string]struct{}{} seen := map[string]struct{}{}
out := make([]string, 0, 3+len(anime.TitleSynonyms)+len(titleCandidates)) out := make([]string, 0, 3+len(anime.TitleSynonyms)+len(titleCandidates))
@@ -144,15 +258,16 @@ func resolveMode(episode string, requestedMode string, episodes []domain.Canonic
return requestedMode, "" return requestedMode, ""
} }
func (s *playbackService) resolveModeSources(ctx context.Context, animeID int, searchTitles []string, episode string, requestedMode string) (map[string]domain.ModeSource, *domain.StreamResult, string, string) { func (s *playbackService) resolveModeSources(ctx context.Context, animeID int, searchTitles []string, episode string, requestedMode string, allowStale bool, forceRefresh bool) (map[string]domain.ModeSource, *domain.StreamResult, string, string) {
if res := s.resolveStreamResult(ctx, animeID, searchTitles, episode, requestedMode); res != nil { requestedMode = normalizeSourceMode(requestedMode)
if res := s.resolveStreamResult(ctx, animeID, searchTitles, episode, requestedMode, allowStale, forceRefresh); res != nil {
return map[string]domain.ModeSource{ return map[string]domain.ModeSource{
requestedMode: s.buildModeSource(res), requestedMode: s.buildModeSource(res),
}, res, requestedMode, "" }, res, requestedMode, ""
} }
for _, fallbackMode := range fallbackModes(requestedMode) { for _, fallbackMode := range fallbackModes(requestedMode) {
res := s.resolveStreamResult(ctx, animeID, searchTitles, episode, fallbackMode) res := s.resolveStreamResult(ctx, animeID, searchTitles, episode, fallbackMode, allowStale, forceRefresh)
if res == nil { if res == nil {
continue continue
} }
@@ -164,23 +279,85 @@ func (s *playbackService) resolveModeSources(ctx context.Context, animeID int, s
return map[string]domain.ModeSource{}, nil, requestedMode, "" return map[string]domain.ModeSource{}, nil, requestedMode, ""
} }
func (s *playbackService) resolveStreamResult(ctx context.Context, animeID int, searchTitles []string, episode string, mode string) *domain.StreamResult { func (s *playbackService) resolveStreamResult(ctx context.Context, animeID int, searchTitles []string, episode string, mode string, allowStale bool, forceRefresh bool) *domain.StreamResult {
for _, p := range s.providers { key := newSourceCacheKey(animeID, episode, mode)
res, err := p.GetStreams(ctx, animeID, searchTitles, episode, mode) stale, state := s.sourceCache.get(key, time.Now())
if err == nil && res != nil { if !forceRefresh && state == sourceCacheFresh {
return res observability.Info("playback_source_cache_hit", "playback", "", map[string]any{"anime_id": animeID, "episode": episode, "mode": key.mode})
} return stale
} }
observability.Info("playback_source_cache_miss", "playback", "", map[string]any{"anime_id": animeID, "episode": episode, "mode": key.mode, "forced": forceRefresh})
resolved := s.waitForSourceResult(ctx, key, animeID, searchTitles, forceRefresh)
if !resolved.completed {
return nil
}
observability.Info("playback_source_resolution", "playback", "", map[string]any{
"anime_id": animeID, "episode": episode, "mode": key.mode,
"duration_ms": resolved.duration.Milliseconds(), "shared": resolved.shared,
})
if resolved.err == nil {
return cloneStreamResult(resolved.result)
}
if allowStale && state == sourceCacheStale && stale != nil {
observability.Warn("playback_source_cache_stale_hit", "playback", "", map[string]any{"anime_id": animeID, "episode": episode, "mode": key.mode}, errors.New("provider source refresh failed"))
return stale
}
observability.Warn("playback_source_resolution_failed", "playback", "", map[string]any{"anime_id": animeID, "episode": episode, "mode": key.mode}, resolved.err)
return nil return nil
} }
func (s *playbackService) waitForSourceResult(ctx context.Context, key sourceCacheKey, animeID int, searchTitles []string, forceRefresh bool) sourceResolutionResult {
startedAt := time.Now()
resultCh := s.sourceFlight.DoChan(key.flightKey(), func() (any, error) {
return s.resolveSource(key, animeID, searchTitles, forceRefresh)
})
select {
case <-ctx.Done():
return sourceResolutionResult{err: ctx.Err(), duration: time.Since(startedAt)}
case resolved := <-resultCh:
result, _ := resolved.Val.(*domain.StreamResult)
return sourceResolutionResult{
result: result, err: resolved.Err, shared: resolved.Shared,
duration: time.Since(startedAt), completed: true,
}
}
}
func (s *playbackService) resolveSource(key sourceCacheKey, animeID int, searchTitles []string, forceRefresh bool) (*domain.StreamResult, error) {
if !forceRefresh {
if cached, state := s.sourceCache.get(key, time.Now()); state == sourceCacheFresh {
return cached, nil
}
}
resolveCtx, cancel := context.WithTimeout(context.Background(), sourceResolutionTimeout)
defer cancel()
var lastErr error
for _, provider := range s.providers {
result, err := provider.GetStreams(resolveCtx, animeID, searchTitles, key.episode, key.mode)
if err == nil && result != nil {
if s.sourceCache.set(key, result, time.Now()) {
observability.Info("playback_source_cache_eviction", "playback", "", nil)
}
return cloneStreamResult(result), nil
}
if err != nil {
lastErr = err
}
}
if lastErr == nil {
lastErr = errors.New("no provider returned a stream")
}
return nil, lastErr
}
func (s *playbackService) buildModeSource(res *domain.StreamResult) domain.ModeSource { func (s *playbackService) buildModeSource(res *domain.StreamResult) domain.ModeSource {
subtitles := make([]domain.SubtitleItem, 0, len(res.Subtitles)) subtitles := make([]domain.SubtitleItem, 0, len(res.Subtitles))
for _, sub := range res.Subtitles { for _, sub := range res.Subtitles {
token, err := s.SignProxyToken(sub.URL, res.Referer, "subtitle") token, err := s.SignProxyToken(sub.URL, res.Referer, "subtitle")
if err != nil { if err != nil {
observability.LogJSON(observability.LogLevelWarn, "sign_subtitle_token_failed", "playback", err.Error(), map[string]any{"url": sub.URL}, nil) observability.Warn("sign_subtitle_token_failed", "playback", "", nil, err)
} }
subtitles = append(subtitles, domain.SubtitleItem{ subtitles = append(subtitles, domain.SubtitleItem{
Lang: sub.Label, Lang: sub.Label,
@@ -190,7 +367,7 @@ func (s *playbackService) buildModeSource(res *domain.StreamResult) domain.ModeS
streamToken, err := s.SignProxyToken(res.URL, res.Referer, "stream") streamToken, err := s.SignProxyToken(res.URL, res.Referer, "stream")
if err != nil { if err != nil {
observability.LogJSON(observability.LogLevelWarn, "sign_stream_token_failed", "playback", err.Error(), map[string]any{"url": res.URL}, nil) observability.Warn("sign_stream_token_failed", "playback", "", nil, err)
} }
return domain.ModeSource{ return domain.ModeSource{
Token: streamToken, Token: streamToken,
@@ -199,40 +376,6 @@ func (s *playbackService) buildModeSource(res *domain.StreamResult) domain.ModeS
} }
} }
func (s *playbackService) loadSeasons(ctx context.Context, animeID int) []domain.SeasonEntry {
relations, err := s.jikan.GetFullRelations(ctx, animeID, jikan.WatchOrderModeMain)
if err != nil {
observability.Warn("fetch_relations_failed", "playback", "",
map[string]any{"anime_id": animeID},
err,
)
}
seasons := make([]domain.SeasonEntry, 0, len(relations))
tvCounter := 1
for _, rel := range relations {
animeType := strings.ToLower(rel.Anime.Type)
if animeType != "tv" && animeType != "movie" {
continue
}
season := domain.SeasonEntry{
MalID: rel.Anime.MalID,
Title: rel.Anime.DisplayTitle(),
Prefix: rel.Relation,
IsCurrent: rel.IsCurrent,
}
if rel.Relation == "TV" {
season.Prefix = fmt.Sprintf("S%d", tvCounter)
tvCounter++
}
seasons = append(seasons, season)
}
return seasons
}
func availableModes(modeSources map[string]domain.ModeSource) []string { func availableModes(modeSources map[string]domain.ModeSource) []string {
modes := make([]string, 0, len(modeSources)) modes := make([]string, 0, len(modeSources))
for mode := range modeSources { for mode := range modeSources {

View File

@@ -1,9 +1,43 @@
package playback package playback
import ( import (
"context"
"mal/internal/domain"
"testing" "testing"
"time"
) )
func TestWatchModeSourcesDefersProviderResolution(t *testing.T) {
provider := &sourceCacheProvider{get: func(context.Context, int, []string, string, string) (*domain.StreamResult, error) {
t.Fatal("deferred watch page resolved a provider source")
return nil, nil
}}
svc := newSourceCacheService(provider)
sources, result, mode, from := svc.watchModeSources(
context.Background(), 42, nil, "1", "sub", "", false, true,
)
if len(sources) != 0 || result != nil || mode != "sub" || from != "" {
t.Fatalf("deferred result = (%v, %v, %q, %q)", sources, result, mode, from)
}
if provider.calls.Load() != 0 {
t.Fatalf("provider calls = %d, want 0", provider.calls.Load())
}
}
func TestWatchAnimeUsesLocalRow(t *testing.T) {
svc := &playbackService{repo: &fakePlaybackRepository{}}
anime, err := svc.watchAnime(context.Background(), 12)
if err != nil {
t.Fatalf("watchAnime() error = %v", err)
}
if anime.MalID != 12 || anime.Title != "Anime 12" {
t.Fatalf("watchAnime() = %+v", anime.Anime)
}
}
func TestFallbackModes(t *testing.T) { func TestFallbackModes(t *testing.T) {
t.Parallel() t.Parallel()
@@ -33,3 +67,57 @@ func TestFallbackModes(t *testing.T) {
}) })
} }
} }
func TestEpisodeAvailabilityWarning(t *testing.T) {
now := time.Date(2026, time.June, 27, 11, 0, 0, 0, time.UTC)
tests := []struct {
name string
list domain.CanonicalEpisodeList
want string
}{
{
name: "fresh availability does not warn",
list: domain.CanonicalEpisodeList{
Episodes: []domain.CanonicalEpisode{{Number: 1, HasSub: true}},
LastSuccessAt: "2026-06-27T10:00:00Z",
NextRefreshAt: "2026-06-27T12:00:00Z",
},
},
{
name: "stale availability warns",
list: domain.CanonicalEpisodeList{
Episodes: []domain.CanonicalEpisode{{Number: 1, HasSub: true}},
LastSuccessAt: "2026-06-27T09:00:00Z",
NextRefreshAt: "2026-06-27T10:00:00Z",
},
want: episodeAvailabilityUncertainWarning,
},
{
name: "retrying availability warns",
list: domain.CanonicalEpisodeList{
Episodes: []domain.CanonicalEpisode{{Number: 1, HasSub: true}},
NextRefreshAt: "2026-06-27T11:05:00Z",
RetryUntilAt: "2026-06-27T11:30:00Z",
FailureCount: 1,
},
want: episodeAvailabilityUncertainWarning,
},
{
name: "failed availability warns",
list: domain.CanonicalEpisodeList{
Episodes: []domain.CanonicalEpisode{{Number: 1, HasSub: true}},
FailureCount: 3,
},
want: episodeAvailabilityUncertainWarning,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := episodeAvailabilityWarning(tt.list, now)
if got != tt.want {
t.Fatalf("episodeAvailabilityWarning() = %q, want %q", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,113 @@
package server
import (
"context"
"mal/internal/config"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestCORSAllowAllSetsOriginHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := config.Config{CORSAllowAll: true}
router := gin.New()
router.Use(CORSMiddlewareWithConfig(cfg))
router.GET("/api/test", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/test", nil)
req.Header.Set("Origin", "https://example.com")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" {
t.Fatalf("Access-Control-Allow-Origin = %q, want %q", got, "https://example.com")
}
if got := rec.Header().Get("Vary"); got != "Origin" {
t.Fatalf("Vary = %q, want %q", got, "Origin")
}
}
func TestCORSDisallowNonLocalOrigin(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := config.Config{CORSAllowAll: false}
router := gin.New()
router.Use(CORSMiddlewareWithConfig(cfg))
router.GET("/api/test", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/test", nil)
req.Header.Set("Origin", "https://example.com")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("Access-Control-Allow-Origin = %q, want empty", got)
}
}
func TestCORSAllowsLocalOrigin(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := config.Config{CORSAllowAll: false}
router := gin.New()
router.Use(CORSMiddlewareWithConfig(cfg))
router.GET("/api/test", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
for _, origin := range []string{"http://localhost:3000", "https://localhost:5173", "http://127.0.0.1:8080"} {
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/test", nil)
req.Header.Set("Origin", origin)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != origin {
t.Fatalf("origin %q: Access-Control-Allow-Origin = %q, want %q", origin, got, origin)
}
}
}
func TestCORSPreflightReturnsNoContent(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := config.Config{CORSAllowAll: true}
router := gin.New()
router.Use(CORSMiddlewareWithConfig(cfg))
req := httptest.NewRequestWithContext(context.Background(), http.MethodOptions, "/api/test", nil)
req.Header.Set("Origin", "http://localhost:3000")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
}
func TestCORSPreflightSkipsNonAPI(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := config.Config{CORSAllowAll: true}
router := gin.New()
router.Use(CORSMiddlewareWithConfig(cfg))
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequestWithContext(context.Background(), http.MethodOptions, "/test", nil)
req.Header.Set("Origin", "http://localhost:3000")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}

View File

@@ -2,6 +2,7 @@ package server
import ( import (
"mal/internal/observability" "mal/internal/observability"
"net/http"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -12,6 +13,9 @@ func RequestLogger() gin.HandlerFunc {
start := time.Now() start := time.Now()
path := c.Request.URL.Path path := c.Request.URL.Path
query := c.Request.URL.RawQuery query := c.Request.URL.RawQuery
if c.Request.URL.Path == "/watch/proxy/stream" || c.Request.URL.Path == "/watch/proxy/subtitle" {
query = ""
}
c.Next() c.Next()
@@ -22,15 +26,8 @@ func RequestLogger() gin.HandlerFunc {
duration := time.Since(start) duration := time.Since(start)
status := c.Writer.Status() status := c.Writer.Status()
fields := map[string]any{
"client_ip": c.ClientIP(),
"duration_ms": float64(duration.Microseconds()) / 1000,
"method": c.Request.Method,
"path": path,
"request_id": c.Writer.Header().Get(requestIDHeader),
"status": status,
}
privateErrors := c.Errors.ByType(gin.ErrorTypePrivate) privateErrors := c.Errors.ByType(gin.ErrorTypePrivate)
privateErrorText := privateErrors.String()
var logErr error var logErr error
if len(privateErrors) > 0 { if len(privateErrors) > 0 {
logErr = privateErrors.Last().Err logErr = privateErrors.Last().Err
@@ -38,17 +35,8 @@ func RequestLogger() gin.HandlerFunc {
if route == "/watch/proxy/stream" && status < 400 && len(privateErrors) == 0 { if route == "/watch/proxy/stream" && status < 400 && len(privateErrors) == 0 {
return return
} }
if route != path { if c.FullPath() == "" && status == http.StatusSeeOther {
fields["route"] = route return
}
if query != "" {
fields["query"] = query
}
if size := c.Writer.Size(); size >= 0 {
fields["bytes"] = size
}
if errors := privateErrors.String(); errors != "" {
fields["errors"] = errors
} }
observability.LogContext( observability.LogContext(
@@ -57,12 +45,37 @@ func RequestLogger() gin.HandlerFunc {
"http_request", "http_request",
"http", "http",
c.Request.Method+" "+path, c.Request.Method+" "+path,
fields, requestLogFields(c, path, query, route, duration, status, privateErrorText),
logErr, logErr,
) )
} }
} }
func requestLogFields(c *gin.Context, path, query, route string, duration time.Duration, status int, privateErrorText string) map[string]any {
fields := map[string]any{
"client_ip": c.ClientIP(),
"duration_ms": float64(duration.Microseconds()) / 1000,
"method": c.Request.Method,
"path": path,
"request_id": c.Writer.Header().Get(requestIDHeader),
"status": status,
}
if route != path {
fields["route"] = route
}
if query != "" {
fields["query"] = query
}
if size := c.Writer.Size(); size >= 0 {
fields["bytes"] = size
}
if privateErrorText != "" {
fields["errors"] = privateErrorText
}
return fields
}
func requestLogLevel(status int) observability.LogLevel { func requestLogLevel(status int) observability.LogLevel {
if status >= 500 { if status >= 500 {
return observability.LogLevelError return observability.LogLevelError

Some files were not shown because too many files have changed in this diff Show More