Compare commits

..

584 Commits

Author SHA1 Message Date
Gitea Action
f95b5df5f5 chore(deploy): update image to latest 2026-07-07 20:50:29 +00:00
2712775d4f update bun.lock
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 6m7s
2026-07-07 22:44:33 +02:00
80e509e2a0 Merge origin/main
Some checks failed
Build and Push Container Image / build-and-push (push) Failing after 6m5s
2026-07-07 22:34:43 +02:00
0b333fcba8 Merge upstream/main 2026-07-07 22:29:06 +02:00
7c5622ab5c fix: log actual source resolution error 2026-07-07 20:51:41 +02:00
557c46880e refactor: consolidate http and utls clients, switch to post with aa lease 2026-07-07 20:51:35 +02:00
e9b134fc9c feat: add aa lease auth 2026-07-07 20:51:26 +02:00
f03db2888f test: add test for simulcast deferred content loading 2026-07-06 22:10:29 +02:00
45341ae212 feat: lazy-load simulcast content via htmx with skeleton 2026-07-06 22:10:24 +02:00
74930976c4 feat: add filler/recap data attrs and kind markers to template 2026-07-06 20:47:17 +02:00
de5d292599 feat: wire up episode classifications in player init 2026-07-06 20:47:13 +02:00
eab4d5e302 feat: export episodeElements helper 2026-07-06 20:47:10 +02:00
528af5bf8b feat: add frontend episode classification client 2026-07-06 20:47:07 +02:00
ee7efc33b7 feat: implement episode classification in playback service 2026-07-06 20:47:03 +02:00
c36d180961 feat: add episode classifications handler test 2026-07-06 20:46:59 +02:00
d7385c01b1 feat: add episode classifications handler endpoint 2026-07-06 20:46:55 +02:00
5c64227f30 feat: add episode classification enrichment implementation 2026-07-06 20:46:52 +02:00
8b8f85bea7 feat: add classification fields to episode service 2026-07-06 20:46:48 +02:00
e75845b78a feat: add episode classification domain interfaces 2026-07-06 20:46:45 +02:00
a577567fdc test: add title search candidate expansion test 2026-07-06 19:09:40 +02:00
3626edc231 test: add split cour episode selection test 2026-07-06 19:09:35 +02:00
30be4551ee test: update episode titles test for season filtering 2026-07-06 19:09:25 +02:00
610b5029a3 test: update title provider stub signature 2026-07-06 19:09:16 +02:00
0e3d4b4b11 feat: wire anime and episode count into title loading 2026-07-06 19:09:11 +02:00
78bd003b7c feat: pass anime context to episode title provider interface 2026-07-06 19:09:03 +02:00
352f32bca7 feat: add season-aware episode title selection to tvmaze client 2026-07-06 19:08:58 +02:00
18bcaf9e31 feat: add season and airdate fields to tvmaze episode struct 2026-07-06 19:07:23 +02:00
15e08e1460 refactor: extract season show filter into helper 2026-07-06 18:36:36 +02:00
c4f276b72d fix: normalize seasonal year filtering locally instead of via api 2026-07-06 18:33:33 +02:00
c13e5541a6 test: verify seasonal shows filters normalized years locally 2026-07-06 18:33:28 +02:00
6dfca350f0 fix: replace fmt.Printf with observability warn in error page path 2026-07-06 07:38:20 +02:00
1b19bf65de feat: wire episode title hydration into player init 2026-07-06 07:37:21 +02:00
92c316f4a7 test: add episode title parsing test 2026-07-06 07:37:18 +02:00
eb90710dc6 feat: add client-side episode title hydration 2026-07-06 07:37:14 +02:00
e556b0fe09 test: add episode titles endpoint test 2026-07-06 07:37:10 +02:00
b32873fc76 feat: add episode titles api endpoint 2026-07-06 07:37:06 +02:00
bf13a4a1f8 feat: add episode title enrichment to playback service 2026-07-06 07:37:01 +02:00
73b4436f61 feat: wire tvmaze as episode title provider 2026-07-06 07:36:58 +02:00
522a99ddf8 test: add episode title enrichment tests 2026-07-06 07:36:54 +02:00
5679be866a feat: add episode title enrichment with cache update 2026-07-06 07:36:51 +02:00
51bdf5c53a feat: wire title provider and singleflight into episode service 2026-07-06 07:36:46 +02:00
f3b6489d03 refactor: accept episode provider interface in provider mapping 2026-07-06 07:36:42 +02:00
9487ff124a feat: add playback episode title service interface 2026-07-06 07:36:39 +02:00
dbda4b96a4 feat: add episode title provider and service interfaces 2026-07-06 07:36:36 +02:00
a4bc38990d test: add tvmaze client tests 2026-07-06 07:36:33 +02:00
05ede20893 feat: add tvmaze api client 2026-07-06 07:36:29 +02:00
03e1990f11 test: add player loading and source resolution tests 2026-07-06 07:10:08 +02:00
5a3797b5df test: add player accessibility assertion 2026-07-06 07:10:02 +02:00
61900a508c feat: add loading ui and aria attributes to video template 2026-07-06 07:10:00 +02:00
413759c454 feat: integrate loading state into player init and remove metadata fetch 2026-07-06 07:09:56 +02:00
7215b1a4ee feat: use loading state machine in episode navigation 2026-07-06 07:09:53 +02:00
6b6256b839 feat: wire loading state into video source loading 2026-07-06 07:09:50 +02:00
28bb28c5e5 refactor: split source module into resolve and refresh paths 2026-07-06 07:09:47 +02:00
65358aa809 feat: add aria-labels to player controls 2026-07-06 07:09:44 +02:00
e39e73bc6b feat: add player loading state machine with perf tracking 2026-07-06 07:09:40 +02:00
f5b38c519b test: add tests for deferred mode sources and local anime lookup 2026-07-06 07:09:32 +02:00
a595ade533 refactor: split watch data build into focused helper functions 2026-07-06 07:09:27 +02:00
8ec647943e test: replace episode metadata tests with deferred playback test 2026-07-06 07:09:23 +02:00
722f936734 feat: defer provider resolution in watch page handler 2026-07-06 07:09:18 +02:00
8e11afb3b5 test: update episode service tests for provider-only flow 2026-07-06 07:09:14 +02:00
80b5aec448 feat: remove jikan fallback from episode refresh 2026-07-06 07:09:10 +02:00
d06d6de4c4 feat: bump availability version to 4 and drop jikan-only cache paths 2026-07-06 07:09:06 +02:00
1110cb1809 feat: merge provider titles into canonical episodes 2026-07-06 07:09:02 +02:00
0d2459fa3c feat: fetch episode titles from allanime provider 2026-07-06 07:08:57 +02:00
9f370448ed feat: add deferred playback data context helpers 2026-07-06 07:08:54 +02:00
6a7cd55b47 feat: add title field to episode availability domain 2026-07-06 07:08:48 +02:00
217478c894 refactor: reduce cyclomatic complexity in template tests 2026-07-05 23:27:11 +02:00
137c6ab644 test: use wg.Go in concurrent resolve test 2026-07-05 23:25:17 +02:00
539dd917f8 chore: remove stale audit fix documents 2026-07-05 23:24:26 +02:00
1317bbb532 chore: move static assets from assets/source to static/assets 2026-07-05 23:22:45 +02:00
d3ddf5d228 chore: remove audit screenshots and report, keep fix documents 2026-07-05 23:17:16 +02:00
3c82c32e87 feat: prefetch next episode and hover-preload in player 2026-07-05 23:09:04 +02:00
7d59674649 test: add forced source refresh and manifest cache integration tests 2026-07-05 23:08:50 +02:00
7278676a2e refactor: sanitize proxy error messages and observability 2026-07-05 23:08:44 +02:00
0abee703a1 feat: wire source cache and manifest cache into playback pipeline 2026-07-05 23:08:39 +02:00
8c2562496a feat: add HLS manifest cache with TTL-based expiry 2026-07-05 23:08:34 +02:00
a0d11e3a7b feat: add source cache with stale-while-revalidate 2026-07-05 23:08:26 +02:00
a9bb16d8b5 style: use range-over-int in sequel test 2026-07-05 22:08:34 +02:00
a31d71bca6 refactor: use strings.SplitSeq in static delivery 2026-07-05 22:08:29 +02:00
11ec256e18 refactor: use strings.SplitSeq in cookie Forwarded parsing 2026-07-05 22:08:25 +02:00
7dfc54b3e3 refactor: use slices.Contains for season validation 2026-07-05 22:08:21 +02:00
d2d94b919d refactor: use maps.Copy for watchlist map clone 2026-07-05 22:08:17 +02:00
a3cfae33f2 test: add shared cache test for top pick/top picks 2026-07-05 22:06:06 +02:00
04a9b0b0bd feat: share cache between top pick and top picks endpoints 2026-07-05 22:06:00 +02:00
57a6aa8f7a test: fix port default test to match behavior 2026-07-04 05:20:36 +02:00
9c19f0da1e test: add formatHTTPStatus tests 2026-07-04 05:17:30 +02:00
d1eafdd6de test: add isLocalClientIP tests 2026-07-04 05:17:30 +02:00
a527981447 test: add formatBytes tests 2026-07-04 05:17:30 +02:00
43fb5509f1 test: add formatDurationMillis tests 2026-07-04 05:17:29 +02:00
b796560f77 test: add quoteIfNeeded tests 2026-07-04 05:16:25 +02:00
e1de04f72e test: add popField and field formatting tests 2026-07-04 05:16:25 +02:00
f4cacd7390 test: add cloneFields tests 2026-07-04 05:16:25 +02:00
42fcc94773 test: add respond helper tests 2026-07-04 05:15:19 +02:00
992705a2b6 test: add cors middleware tests 2026-07-04 05:14:36 +02:00
54f92725d9 test: add config loading and helper tests 2026-07-04 05:13:18 +02:00
9955c7b000 test: add episode transition tests for in-page navigation 2026-07-04 05:10:34 +02:00
9ac41d3f9f feat: add aria-current to episode links for accessibility 2026-07-04 05:09:45 +02:00
489d025426 feat: always render prev/next buttons with aria and data attrs 2026-07-04 05:08:28 +02:00
85e0dd5913 feat: add data-episode-navigation attribute to watch layout 2026-07-04 05:07:33 +02:00
8ababab46b feat: wire episode navigation into player init 2026-07-04 05:03:59 +02:00
2952bd0e89 feat: add in-page episode transition system 2026-07-04 05:03:35 +02:00
66fa70e6ad feat: add updateEpisodeNavigation and focus restore in updateEpisodeHighlight 2026-07-04 05:03:10 +02:00
72c5c99737 feat: save current episode progress on transition 2026-07-04 05:02:45 +02:00
3ed092c5c5 feat: add preservePosition option to loadVideoSource 2026-07-04 05:02:19 +02:00
660ae022ea docs: update logo path in readme 2026-07-03 16:16:13 +02:00
f4bad31290 test: update cache policy test for versioned static assets 2026-07-03 16:16:13 +02:00
1af0ec351d feat: add immutable cache policy for versioned static assets 2026-07-03 16:16:13 +02:00
11bdd9751b test: add manifest icon and first-load asset size validation 2026-07-03 16:16:12 +02:00
22f61a8c98 test: add brand asset rendering test 2026-07-03 16:16:12 +02:00
b14aac130d feat: update navigation logo to use assetURL helper 2026-07-03 16:16:08 +02:00
bf38ca59b7 feat: use assetURL helper for link tags in base template 2026-07-03 16:16:02 +02:00
fda5620a54 feat: update manifest to reference versioned icons 2026-07-03 16:15:58 +02:00
021b39dc0d feat: remove oversized brand assets 2026-07-03 16:15:49 +02:00
6681f535ac feat: add pwa app icons 2026-07-03 16:15:45 +02:00
904483da15 feat: add 166x128 navigation logo 2026-07-03 16:15:42 +02:00
e43e8edaca feat: add 180x180 apple touch icon 2026-07-03 16:15:38 +02:00
c7fceda2a8 feat: add sized favicon pngs 2026-07-03 16:15:35 +02:00
5c77d90010 feat: add source brand asset files 2026-07-03 16:15:31 +02:00
4516417c6b test: add integration and size tracking tests 2026-07-03 03:02:01 +02:00
289ad25064 test: add edge case tests for compression 2026-07-03 03:01:40 +02:00
8561e0dca0 test: add compression middleware tests 2026-07-03 03:01:25 +02:00
2476a21b86 test: add compression test case definitions 2026-07-03 03:00:57 +02:00
2d4e4c6ae3 test: add static cache middleware tests 2026-07-03 03:00:32 +02:00
22b26da0dc feat: wire compression and cache middlewares into router 2026-07-03 02:59:53 +02:00
37717fec69 feat: add compression middleware 2026-07-03 02:59:28 +02:00
40eb3a146e feat: add flush and finish for compression writer 2026-07-03 02:58:16 +02:00
db9ac7c28c feat: add compression writer write method 2026-07-03 02:57:53 +02:00
fbbee45650 feat: add compression feasibility check 2026-07-03 02:57:39 +02:00
54fb72e001 feat: add gzip initiation and content length helpers 2026-07-03 02:57:25 +02:00
89447cbc15 feat: add compression writer struct and basic methods 2026-07-03 02:57:07 +02:00
ba0e5c6d21 feat: add compression types and negotiation helpers 2026-07-03 02:55:59 +02:00
ca67562a77 feat: add cache control writer and static cache middleware 2026-07-03 02:55:34 +02:00
32fed1eee2 feat: add static cache policy function 2026-07-03 02:55:10 +02:00
01776f4eef feat: add content type and path helpers 2026-07-03 02:54:56 +02:00
58abaaceb0 feat: add vary header helper 2026-07-03 02:54:40 +02:00
372c93ab52 feat: add dev ts build recipe to justfile 2026-07-03 02:36:49 +02:00
025e926482 feat: add dev variant of ts build script 2026-07-03 02:36:47 +02:00
49abffb21a feat: add dev/production modes to ts build script 2026-07-03 02:36:42 +02:00
1800335719 chore: add codex audit reports 2026-07-03 02:32:03 +02:00
fed8bf76a0 fix: remove border from anime sidebar elements 2026-07-01 19:14:33 +02:00
08f58cf377 feat: show release info on anime detail page 2026-07-01 18:53:35 +02:00
d61ed31d1e feat: reject stale caches and filter future episodes 2026-07-01 18:53:31 +02:00
0bf78f1ea3 feat: add version and release fields to canonical episode list 2026-07-01 18:53:28 +02:00
886e412be5 feat: add aired field to jikan episode 2026-07-01 18:53:24 +02:00
2918fa5051 feat: enforce strict mal id in allanime getstreams 2026-07-01 18:53:19 +02:00
223c0fa89e feat: add dedicated watchlist download endpoint 2026-07-01 01:43:57 +02:00
72ee744161 chore: remove go version from mise tools 2026-07-01 01:40:52 +02:00
df9fd64123 fix: remove bottom border from header 2026-07-01 01:40:50 +02:00
3a824c9c7e feat: refine simulcast card layout with smaller hover title and wider grid 2026-06-30 21:24:24 +02:00
59756095c2 feat: map allanime description to anime synopsis in simulcast 2026-06-30 21:24:19 +02:00
5e12a496e7 feat: add description field to allanime provider show 2026-06-30 21:24:14 +02:00
86ba058825 feat: add simulcast page with season picker and navigation 2026-06-30 19:29:18 +02:00
4afa0b46f9 feat: add season navigation helpers 2026-06-30 19:29:11 +02:00
078dfe9a58 feat: add season discovery service 2026-06-30 19:29:03 +02:00
7f74e4bc55 feat: add allanime seasonal queries and provider show model 2026-06-30 19:28:56 +02:00
c6afc2bc53 refactor: make allanime base url configurable 2026-06-30 19:28:44 +02:00
ed7dfea834 docs: document public watchlist 2026-06-29 13:39:42 +02:00
be71ec91c8 feat: add public watchlist export 2026-06-29 13:39:35 +02:00
1e6ac5e854 feat: track watchlist completions 2026-06-29 13:39:27 +02:00
748c823172 fix: style top picks back link as button 2026-06-28 12:52:57 +02:00
44d4b7c342 fix: remove top picks rationale badges 2026-06-28 12:51:46 +02:00
37c5e66217 fix: add anime details link to continue watching card 2026-06-28 12:50:09 +02:00
cdcf6a5a2a chore: stop redacting token from request log query params 2026-06-28 12:45:46 +02:00
094d560c30 test: cover error sanitization for metadata and skip segment endpoints 2026-06-27 22:43:42 +02:00
c5ae4e7d69 test: update watch page test for stable error message 2026-06-27 22:43:37 +02:00
02dcde5786 fix: sanitize internal error messages from api responses 2026-06-27 22:43:31 +02:00
4bae78ca56 test: add tests for session cookie hardening and POST logout 2026-06-27 21:05:52 +02:00
e9a167b2c9 refactor: use centralized session cookie helpers 2026-06-27 21:05:49 +02:00
a74087aa2a fix: change logout route from GET to POST 2026-06-27 21:05:41 +02:00
6edb93d5af feat: add HTTPS-aware session cookie helpers 2026-06-27 21:05:09 +02:00
63221d72dc fix: reduce RequestLogger cyclomatic complexity 2026-06-27 20:59:47 +02:00
b59ad79117 refactor: add requestLogFields helper 2026-06-27 20:59:32 +02:00
ae88b92151 fix: redact token query logs 2026-06-27 20:53:55 +02:00
f661c2ed6c test: cover proxy token log redaction 2026-06-27 20:53:45 +02:00
d81f59de05 chore: remove plans folder 2026-06-27 19:05:05 +02:00
b648afa471 feat: warn on uncertain episode availability 2026-06-27 19:04:13 +02:00
34923212c5 feat: expose episode cache metadata 2026-06-27 19:03:49 +02:00
4ded4e789b docs: remove completed recommendation plan 2026-06-27 17:56:02 +02:00
42fc505068 test: add recommendation rationale coverage 2026-06-27 17:55:47 +02:00
6d5e9d0a2c feat: surface recommendation rationale 2026-06-27 17:55:38 +02:00
1d9840f2b9 chore: remove completed plans 009 and 010 2026-06-27 17:54:13 +02:00
177081edd0 chore: format plans 2026-06-27 17:46:05 +02:00
549f964e8e fix: refresh stale proxy token on video error 2026-06-27 17:46:01 +02:00
83dd4510df test: simplify browser test module loading 2026-06-27 17:45:54 +02:00
6465dc137c docs: remove resolved plan for data-fix characterization tests 2026-06-27 16:31:29 +02:00
ae00c04cf9 test: add registered data-fix effect tests 2026-06-27 16:31:05 +02:00
531566291b test: add data-fix runner characterization tests 2026-06-27 16:31:02 +02:00
1010dd06d2 refactor: extract runDataFixList internal seam 2026-06-27 16:30:58 +02:00
9acb9218c2 fix: skip logging unmatched 303 redirects 2026-06-27 16:30:39 +02:00
88c066398e feat: add robots.txt and sitemap.xml 2026-06-27 16:30:33 +02:00
87bc6228e7 docs: remove resolved plan 2026-06-27 16:18:36 +02:00
1272800ca2 test: add browser player flow characterization tests 2026-06-27 16:18:33 +02:00
1e4dcd2208 docs: remove resolved plan 2026-06-27 15:46:46 +02:00
490b2e3ed5 fix: reduce watchlist update complexity 2026-06-27 15:46:18 +02:00
ae1b323732 feat: invalidate top-picks on watchlist changes 2026-06-27 15:43:56 +02:00
aaf9cf41f3 feat: register invalidator in anime module 2026-06-27 15:43:52 +02:00
e502056f2f feat: cache top-picks with stale-while-refresh 2026-06-27 15:43:48 +02:00
cbf084e935 feat: add recommendation invalidator interface 2026-06-27 15:43:07 +02:00
75520aa1a3 refactor: rename player thumbnail module to metadata 2026-06-27 15:34:02 +02:00
0480b70dfc test: add episode metadata route test 2026-06-27 15:33:42 +02:00
b4c759103b refactor: rename api endpoint from thumbnails to metadata 2026-06-27 15:33:37 +02:00
eeb85d4aca docs: remove resolved plan 2026-06-27 15:27:13 +02:00
e54b9cef5e fix: limit continue watching carousel 2026-06-27 15:26:45 +02:00
538a3f0138 feat: add continue watching carousel query 2026-06-27 15:26:31 +02:00
2ad8605c9c docs: remove resolved plan 2026-06-27 15:23:05 +02:00
3c4f5375ed test: add proxy target rejection tests 2026-06-26 23:56:29 +02:00
5338c61a5d feat: validate proxy targets during signing and proxy fetch 2026-06-26 23:56:23 +02:00
f8e3f89e42 feat: add proxy target validation package 2026-06-26 23:56:20 +02:00
ff4998f3f1 docs: remove resolved plan for unauthenticated pprof routes 2026-06-26 23:56:17 +02:00
c1475dd71c test: assert pprof routes return 404 2026-06-26 23:45:47 +02:00
8345143bb0 fix: remove unauthenticated pprof routes 2026-06-26 23:45:43 +02:00
34d2b37e94 chore: remove resolved plan for JSON attribute escaping 2026-06-26 23:41:04 +02:00
07eae2d971 fix: stop marking JSON attributes as trusted HTML 2026-06-26 23:40:59 +02:00
b2f00e879a docs: add product direction plans (import, rationale, availability state) 2026-06-26 19:24:11 +02:00
17a65c4926 docs: add plan to add CI for existing verification command 2026-06-26 19:24:11 +02:00
0ed4ab3f82 docs: add test coverage improvement plans (player, data fixes) 2026-06-26 19:24:10 +02:00
cf5462312e docs: add performance improvement plans (carousel limit, recommendation caching) 2026-06-26 19:24:09 +02:00
d08961efbc docs: add security-focused improvement plans (XSS, pprof, proxy target validation) 2026-06-26 19:24:08 +02:00
379e2ff313 docs: add plan to fix broken Go lint baseline 2026-06-26 19:24:08 +02:00
33c91e91ca docs: add plans index with status table and recommended order 2026-06-26 19:24:04 +02:00
0d31d46fae feat: add custom 404 page with playful inline artwork and prose 2026-06-26 15:12:10 +02:00
3aa8ce4638 fix: restore desktop sidebar alongside mobile drawer 2026-06-25 21:55:11 +02:00
adcae02c18 feat: add swipeable sidebar drawer for anime details on mobile 2026-06-25 21:47:21 +02:00
e9917730b8 feat: add responsive hamburger menu for mobile navigation 2026-06-25 21:41:39 +02:00
b171df07f2 fix: replace background-surface with background-button in character cards 2026-06-25 21:32:24 +02:00
aba83d1109 test: refactor login template test to use substring checks 2026-06-25 21:29:21 +02:00
b67c4f211b chore: remove playwright e2e test infrastructure 2026-06-25 21:29:17 +02:00
Gitea Action
ecab93de84 chore(deploy): update image to latest 2026-06-25 00:44:47 +00:00
32b08245ca test(e2e): add global setup, sign-in helpers and authenticated page tests 2026-06-24 16:23:20 +02:00
a732e13ee8 test: add recommendation scoring profile tests for weights, taste, and ranking 2026-06-24 16:12:02 +02:00
4bb31e6f00 test: add playback progress service tests for save, complete and load 2026-06-24 16:11:36 +02:00
998a1d212a test: add episodes service tests for merge, validation and refresh scheduling 2026-06-24 16:10:32 +02:00
243ea85dbf test: add playwright e2e test setup with smoke tests 2026-06-24 16:10:07 +02:00
b0dee41eb8 test: add login template text snapshot test 2026-06-24 16:08:55 +02:00
c6f24e07f0 test: add watchlist handler and service unit tests 2026-06-24 16:08:50 +02:00
c68ee462e9 test: add auth handler middleware and service unit tests 2026-06-24 16:08:45 +02:00
306d209701 test: add player validate unit tests for isRecord, parseModeSources and parseSegments 2026-06-24 16:08:38 +02:00
860c8df639 test: add vtt parser tests for invalid timestamps, cue settings and multiline cues 2026-06-24 16:08:03 +02:00
c7252f7297 fix: guard parseVttTime against NaN inputs and strip cue settings from end time 2026-06-24 16:08:00 +02:00
ad69ac7e6c refactor: simplify test stubs with interface embedding 2026-06-24 12:08:43 +02:00
7b59435f33 refactor: consolidate fx event error description into single function 2026-06-24 12:03:15 +02:00
894c1adf97 refactor: adjust watch page button sizing and alignment 2026-06-23 17:48:51 +02:00
2463d88c53 refactor: tweak video player settings panel sizing 2026-06-23 17:48:49 +02:00
f062395eac refactor: shorten function names in allanime 2026-06-23 17:40:32 +02:00
ac8e583e23 refactor: shorten function names in jikan search 2026-06-23 17:40:26 +02:00
9f973c178a refactor: shorten function names in allanime 2026-06-23 17:38:13 +02:00
734b39efd7 refactor: shorten function names in jikan relations 2026-06-23 17:38:07 +02:00
e1da6ddb19 refactor: inline imageurl in go callers 2026-06-23 17:32:27 +02:00
c0dbe6be2a refactor: use formatnumber in templates 2026-06-23 17:32:24 +02:00
01af5b60bf feat: add formatnumber template function 2026-06-23 17:32:18 +02:00
7e76c28396 refactor: remove display methods from anime type 2026-06-23 17:32:13 +02:00
0c3be8a8d3 refactor: move producer type and method out of studio.go 2026-06-23 17:27:40 +02:00
f2822f068f refactor: move top anime and genres to anime.go 2026-06-23 17:25:45 +02:00
6e7e1fc001 refactor: shorten verbose variable names across codebase 2026-06-23 17:23:27 +02:00
f6ed2c673d refactor: inline single-use getcached wrapper 2026-06-23 17:16:56 +02:00
c777de9f06 refactor: inline single-use haswatchordertable 2026-06-23 17:16:51 +02:00
2213293b57 refactor: inline single-use helpers in allanime 2026-06-23 17:16:46 +02:00
1dd37a5aa2 refactor: inline single-use helpers in jikan relations 2026-06-23 17:16:41 +02:00
1e5fe7f879 refactor: inline single-use helpers in jikan client 2026-06-23 17:16:36 +02:00
87b67744be refactor: rename constants.go to cache_policy.go 2026-06-23 17:09:00 +02:00
fbc4b3c90c refactor: rename more.go to anime_resources.go 2026-06-23 17:06:06 +02:00
697fa38577 refactor: replace type switch with reflect map in isemptyresult 2026-06-23 17:03:48 +02:00
a9fa4d8d90 refactor: lazy load audio availability via htmx 2026-06-23 15:32:24 +02:00
42017681f5 fix: capture jikan api error body in api error struct 2026-06-23 15:12:46 +02:00
69c1c9d507 refactor: remove metrics from server and database 2026-06-23 15:09:01 +02:00
b0aec3eaa9 refactor: remove metrics from episode service 2026-06-23 15:08:54 +02:00
fe92997160 refactor: remove metrics from jikan client 2026-06-23 15:08:46 +02:00
43019f2771 refactor: remove prometheus metrics subsystem 2026-06-23 15:08:41 +02:00
c5035eb2a0 docs: simplify playback secret command 2026-06-22 21:10:49 +02:00
a981028c5d refactor: defer provider episode count to async load 2026-06-22 21:09:39 +02:00
7010f72a26 Merge branch 'integrate-codex-work' 2026-06-21 17:22:24 +02:00
a5875de32e Merge branch 'codex/edb6' into integrate-codex-work
# Conflicts:
#	integrations/jikan/client_test.go
2026-06-21 17:20:46 +02:00
52b04b17d1 Merge branch 'codex/df1b' into integrate-codex-work 2026-06-21 17:20:14 +02:00
812371dca4 Merge branch 'codex/ae48' into integrate-codex-work 2026-06-21 17:20:06 +02:00
c1574160e8 Merge branch 'codex/a6d6' into integrate-codex-work 2026-06-21 17:20:02 +02:00
6bd74a18f7 Merge branch 'codex/78a9' into integrate-codex-work 2026-06-21 17:19:59 +02:00
726861f933 Merge branch 'codex/2195' into integrate-codex-work 2026-06-21 17:19:56 +02:00
8792cdc457 feat: observe jikan cache stats 2026-06-21 17:19:01 +02:00
d3ec9d9f6e feat: expose jikan cache metrics 2026-06-21 17:18:52 +02:00
0fe3a71627 feat: add jikan cache stats query 2026-06-21 17:18:43 +02:00
577f289f81 refactor: share episode cache decoding 2026-06-21 17:18:36 +02:00
3d855ab1eb fix: bound stale jikan cache reads 2026-06-21 17:18:29 +02:00
a0fff22e90 fix: limit sqlite connection pool 2026-06-21 17:18:23 +02:00
dbe029e15e fix: ignore expired anime in random pool cache 2026-06-21 17:18:11 +02:00
b87f743615 feat: add expired failed mapping cleanup query 2026-06-21 17:18:05 +02:00
61ff4a4fa2 feat: add jikan cache cleanup worker 2026-06-21 17:17:57 +02:00
07f6080451 chore: exclude README from formatter 2026-06-21 16:58:37 +02:00
d420b9b741 docs: avoid README heading borders 2026-06-21 16:57:07 +02:00
b216a74abf docs: remove README section dividers 2026-06-21 16:55:39 +02:00
582c8d841c docs: restore README note alert syntax 2026-06-21 16:55:06 +02:00
a2e99dc4ec fix: remove constant episode test parameter 2026-06-21 16:53:41 +02:00
da62913a93 fix: reduce episode merge complexity 2026-06-21 16:53:00 +02:00
952bc0b9c0 docs: wrap security policy prose 2026-06-21 16:52:19 +02:00
b6668015e3 docs: align README markdown tables 2026-06-21 16:52:19 +02:00
cf4348e27e docs: wrap code of conduct prose 2026-06-21 16:52:18 +02:00
73939d3ba3 fix: clarify anime episode summary 2026-06-21 16:49:47 +02:00
cb353083aa fix: source anime episode counts from availability 2026-06-21 16:49:38 +02:00
40e74bd793 fix: use provider availability for episodes 2026-06-21 16:49:30 +02:00
68086904a7 fix: remove blocking detail fetches from anime page load 2026-06-21 15:55:37 +02:00
8351d850c5 docs: remove Project Documents section from README 2026-06-21 02:27:29 +02:00
426453612f docs: reorganize and trim README 2026-06-21 02:25:14 +02:00
9472cb5362 docs: expand README with detailed project documentation 2026-06-21 02:19:25 +02:00
5584dcff12 docs: add SECURITY.md 2026-06-21 02:19:22 +02:00
f6ea4d65d6 docs: add CODE_OF_CONDUCT.md 2026-06-21 02:19:20 +02:00
698ee33227 chore: update bun.lock after package rename 2026-06-21 02:10:53 +02:00
57c92f43c9 style: format static/watchlist.ts 2026-06-21 02:05:10 +02:00
5ded3f9bd4 style: format static/top_pick_carousel.ts 2026-06-21 02:05:09 +02:00
abfda4d1db style: format static/toast.ts 2026-06-21 02:05:08 +02:00
70cdbf3ceb style: format static/theme.ts 2026-06-21 02:05:07 +02:00
4c340aed14 style: format static/search/state.ts 2026-06-21 02:05:06 +02:00
949618aecd style: format static/search/render.ts 2026-06-21 02:05:05 +02:00
36d8d48e90 style: format static/search/overlay.ts 2026-06-21 02:05:04 +02:00
0a44dfb474 style: format static/search/fetch.ts 2026-06-21 02:05:03 +02:00
25abee8930 style: format static/search/actions.ts 2026-06-21 02:05:02 +02:00
54eebe5245 style: format static/q.ts 2026-06-21 02:05:01 +02:00
e10e6a47c9 style: format static/player/video.ts 2026-06-21 02:05:00 +02:00
3b695e5d19 style: format static/player/validate.ts 2026-06-21 02:04:59 +02:00
6c665f1624 style: format static/player/types.ts 2026-06-21 02:04:58 +02:00
75e5f69b33 style: format static/player/timeline.ts 2026-06-21 02:04:56 +02:00
4fc9d22ef7 style: format static/player/subtitles/vtt.ts 2026-06-21 02:04:55 +02:00
216f9063bf style: format static/player/subtitles/vtt.test.ts 2026-06-21 02:04:54 +02:00
40d57fd7ee style: format static/player/subtitles/index.ts 2026-06-21 02:04:53 +02:00
0cb7ac19e4 style: format static/player/storage.ts 2026-06-21 02:04:52 +02:00
b3dec5ebf7 style: format static/player/state.ts 2026-06-21 02:04:51 +02:00
54708b93a4 style: format static/player/source.ts 2026-06-21 02:04:50 +02:00
abe8f8e867 style: format static/player/skip/segments.ts 2026-06-21 02:04:49 +02:00
e8d5da949c style: format static/player/skip/index.ts 2026-06-21 02:04:48 +02:00
a672ee861d style: format static/player/skip/editor.ts 2026-06-21 02:04:47 +02:00
180ab2bc6a style: format static/player/quality.ts 2026-06-21 02:04:46 +02:00
b620b68eec style: format static/player/progress.ts 2026-06-21 02:04:45 +02:00
1d51e5f86d style: format static/player/mode.ts 2026-06-21 02:04:44 +02:00
62f6d1cf0f style: format static/player/main.ts 2026-06-21 02:04:43 +02:00
cbbfcc9828 style: format static/player/keyboard.ts 2026-06-21 02:04:42 +02:00
56642c4150 style: format static/player/hls_profile.ts 2026-06-21 02:04:40 +02:00
2b30c06582 style: format static/player/episodes/ui.ts 2026-06-21 02:04:39 +02:00
6ba16dd08e style: format static/player/episodes/thumbnails.ts 2026-06-21 02:04:38 +02:00
1b412798ef style: format static/player/episodes/nav.ts 2026-06-21 02:04:37 +02:00
de22cc6971 style: format static/player/episodes/complete.ts 2026-06-21 02:04:36 +02:00
306468b4fd style: format static/player/controls.ts 2026-06-21 02:04:35 +02:00
c040b29819 style: format static/login.ts 2026-06-21 02:04:34 +02:00
1240af74fc style: format static/htmx.ts 2026-06-21 02:04:33 +02:00
08c92a940a style: format static/dropdown.ts 2026-06-21 02:04:31 +02:00
d4ca95de1a style: format static/dedupe.ts 2026-06-21 02:04:30 +02:00
ce8c618eba style: format static/continue_watching_carousel.ts 2026-06-21 02:04:29 +02:00
3a33df5d9b style: format static/anime.ts 2026-06-21 02:04:28 +02:00
a617feb269 style: format data fix script 2026-06-21 02:04:20 +02:00
2dccc72f5c style: format go fix script 2026-06-21 02:04:19 +02:00
d7f844563b style: format build script 2026-06-21 02:04:18 +02:00
2eadc2835c style: sort package scripts 2026-06-21 02:04:18 +02:00
6c3a379786 chore: tune frontend lint and type target 2026-06-21 02:04:09 +02:00
ee715162ac chore: tune lint rules for node scripts 2026-06-21 01:52:06 +02:00
02059ea607 style: format dev docs and hook config 2026-06-21 01:48:48 +02:00
69c9f87080 chore: configure oxlint with strict rule set 2026-06-21 01:41:39 +02:00
b5c58f062d chore: configure oxfmt with explicit formatting options 2026-06-21 01:41:37 +02:00
c8bcabe804 docs: update README with mise and dev workflow instructions 2026-06-21 01:28:28 +02:00
8881491e18 refactor: add setup, build-dev, dev, and run targets to justfile 2026-06-21 01:28:23 +02:00
f6ac3ed818 chore: add mise tool versions config 2026-06-21 01:28:19 +02:00
cba35c6a02 chore: add air hot-reload config 2026-06-21 01:28:15 +02:00
ec7abdd760 chore: remove knip dependency and config 2026-06-21 01:19:13 +02:00
2c5cc957c8 chore: add knip configuration to package.json 2026-06-21 01:17:12 +02:00
1810d11983 refactor: migrate new-data-fix script from bash to TypeScript 2026-06-21 01:16:48 +02:00
f261a88e6e refactor: migrate fix-all script from bash to TypeScript 2026-06-21 01:16:42 +02:00
a5de6de778 refactor: migrate justfile fix scripts from bash to bun 2026-06-21 01:16:37 +02:00
e1e39d365b chore: remove entrypoint.sh, no longer needed 2026-06-21 01:16:32 +02:00
d20d7a683c refactor: switch Dockerfile entrypoint to main_server binary 2026-06-21 01:16:25 +02:00
8f754d9467 refactor: make isStringArray and isSubtitleItemArray private 2026-06-21 01:12:18 +02:00
f0633dba46 refactor: make SubtitleItem interface private 2026-06-21 01:12:15 +02:00
aad3b775cd refactor: make timelineBounds and getBufferedEnd private 2026-06-21 01:12:12 +02:00
5e1b577b34 refactor: make parseVttCue private 2026-06-21 01:12:09 +02:00
4151525acf refactor: use replaceChildren in subtitle options 2026-06-21 01:12:06 +02:00
c6427bf866 refactor: use replaceChildren instead of innerHTML assignment 2026-06-21 01:12:03 +02:00
7b33a6d977 refactor: make switchQuality private, use replaceChildren 2026-06-21 01:11:59 +02:00
5d32173d7a refactor: make switchMode private 2026-06-21 01:11:56 +02:00
480ce55117 refactor: make syncVolumeUI private 2026-06-21 01:11:53 +02:00
7aa8aca4b5 refactor: make fetchNextSearchPage private 2026-06-21 01:11:50 +02:00
f88353f725 refactor: remove unused search state helpers 2026-06-21 01:11:47 +02:00
420817a849 refactor: remove unused parseClassList utility 2026-06-21 01:11:44 +02:00
ed1677e5ec refactor: make dedupe function private 2026-06-21 01:11:41 +02:00
595c5e1eaa refactor: replace bash build script with TypeScript 2026-06-21 01:11:38 +02:00
8fed96c6cf chore: update bun.lock for knip dependency 2026-06-21 01:11:35 +02:00
264314bd17 chore: update just lint command to match package.json 2026-06-21 01:11:33 +02:00
85afbe8e69 chore: replace jiti with knip, update build and lint scripts 2026-06-21 01:11:31 +02:00
89bf8f90c3 chore: remove prettier config 2026-06-21 01:11:28 +02:00
bb2dc447ac chore: remove dist/ from .gitignore 2026-06-21 01:11:25 +02:00
6efd7dca8c chore: add more ignore patterns to .dockerignore 2026-06-21 01:11:19 +02:00
91641b9a95 style: add bottom border to header navigation 2026-06-21 00:48:46 +02:00
c47ac97c2b docs: update README to reflect search page replacement 2026-06-21 00:37:40 +02:00
fe4a200927 refactor: update search template with new DOM attributes and IDs 2026-06-21 00:37:38 +02:00
2a24565848 refactor: simplify render module by removing compact items and continue watching 2026-06-21 00:37:35 +02:00
73b59856bf refactor: rename overlay module to search page module 2026-06-21 00:37:32 +02:00
c5f36887a1 refactor: update fetch module to use search API endpoint and types 2026-06-21 00:37:28 +02:00
328bc6229b refactor: rename command palette types and DOM selectors to search 2026-06-21 00:37:25 +02:00
8eb4c6156d refactor: rename search entry point init function 2026-06-21 00:37:22 +02:00
34a9946959 feat: add dedicated search API handler 2026-06-21 00:37:17 +02:00
6406b85a7e refactor: swap command palette route for search API 2026-06-21 00:37:13 +02:00
bd6bd22478 refactor: remove command palette methods from watchlist service 2026-06-21 00:37:10 +02:00
cf87a6770d refactor: remove command palette methods from watchlist repository 2026-06-21 00:37:06 +02:00
10992a61a3 refactor: remove command palette methods from domain interfaces 2026-06-21 00:37:02 +02:00
75fb9b020a refactor: remove command palette methods from Querier interface 2026-06-21 00:36:58 +02:00
44dfc1a122 refactor: remove command palette DB tests 2026-06-21 00:36:56 +02:00
38f9d70df2 refactor: remove command palette DB queries 2026-06-21 00:36:53 +02:00
090af0e97c refactor: remove command palette handler 2026-06-21 00:36:51 +02:00
02d936e798 fix: keep create-user as prod wrapper 2026-06-20 19:46:29 +02:00
2a5dc21afa refactor: use tailwind theme utilities 2026-06-20 19:39:34 +02:00
2acf980da0 refactor: move shared styles to tailwind utilities 2026-06-20 19:39:22 +02:00
8675b9c20b feat: add user creation CLI 2026-06-20 19:30:26 +02:00
796ce42576 refactor: remove user command 2026-06-20 19:20:59 +02:00
649c754a78 refactor: remove dbtx package 2026-06-20 19:14:36 +02:00
be0dfaf734 refactor: move app wiring to internal root 2026-06-20 19:14:27 +02:00
ed59c9f8b5 refactor: inject data fix dependencies 2026-06-20 19:14:14 +02:00
3fa6f76de0 refactor: remove nested errlog package 2026-06-20 19:04:06 +02:00
0de1a4d01b refactor: update utls errlog import 2026-06-20 19:04:01 +02:00
9f50a145aa refactor: update net document errlog import 2026-06-20 19:03:56 +02:00
eb6c74f3a3 refactor: update skip segments errlog import 2026-06-20 19:03:50 +02:00
d649989fc2 refactor: update playback service errlog import 2026-06-20 19:03:37 +02:00
b6319af4e0 refactor: update proxy subtitle errlog import 2026-06-20 19:03:30 +02:00
90d1296d78 refactor: update proxy stream errlog import 2026-06-20 19:03:23 +02:00
663e29113d refactor: update watchlist ids errlog import 2026-06-20 19:03:19 +02:00
ec349f3714 refactor: update skip override errlog import 2026-06-20 19:03:14 +02:00
6cecd9bac8 refactor: update command palette errlog import 2026-06-20 19:03:11 +02:00
420c97704f refactor: update duration backfill errlog import 2026-06-20 19:03:07 +02:00
ac45080fd9 refactor: update avatar backfill errlog import 2026-06-20 19:03:03 +02:00
0ed3968f99 refactor: update database fixes errlog import 2026-06-20 19:02:58 +02:00
eaa8574c03 refactor: update watchorder errlog import 2026-06-20 19:02:45 +02:00
e22c2e7647 refactor: update allanime extractor errlog import 2026-06-20 19:02:40 +02:00
8b905f0080 refactor: update allanime client errlog import 2026-06-20 19:02:36 +02:00
ec26913e65 refactor: update jikan transport errlog import 2026-06-20 19:02:31 +02:00
120c0ce801 refactor: update user command errlog import 2026-06-20 19:02:25 +02:00
4fc8688837 refactor: use root errlog in graphql 2026-06-20 19:02:21 +02:00
29e684a65a refactor: add errlog helpers to pkg 2026-06-20 19:02:17 +02:00
6f26a59ca3 Handle watchlist async errors 2026-06-20 18:30:02 +02:00
f34ecf29e2 Handle subtitle HTTP failures 2026-06-20 18:30:01 +02:00
bc75dcb138 Handle autoskip progress save errors 2026-06-20 18:30:00 +02:00
fbb4e90bd5 Handle progress save failures 2026-06-20 18:29:59 +02:00
9c75282b59 Handle player mode fetch errors 2026-06-20 18:29:58 +02:00
07929eeeb5 Handle player init async errors 2026-06-20 18:29:57 +02:00
fee7653386 Handle keyboard progress save errors 2026-06-20 18:29:56 +02:00
ff33221867 Handle episode navigation async errors 2026-06-20 18:29:55 +02:00
4df0de7ce2 Handle anime completion retry errors 2026-06-20 18:29:54 +02:00
1b70e6500c Handle control progress save errors 2026-06-20 18:29:53 +02:00
3609c80cbc Handle AniSkip response close errors 2026-06-20 18:29:51 +02:00
37db709b64 Handle warm stream close errors 2026-06-20 18:29:49 +02:00
fe92fefe5c Handle proxy subtitle errors 2026-06-20 18:29:37 +02:00
c0746b7300 Handle proxy stream errors 2026-06-20 18:29:35 +02:00
47d3c17570 Handle Gin private error recording 2026-06-20 18:29:34 +02:00
fcfabd0980 Validate playback route parameters 2026-06-20 18:29:32 +02:00
f50b5564fb Handle metrics test cleanup errors 2026-06-20 18:29:31 +02:00
92812c7f42 Return metrics write errors 2026-06-20 18:29:29 +02:00
0074669d50 Return rollback errors 2026-06-20 18:29:29 +02:00
46c82ef0b7 Handle watchlist ID test cleanup errors 2026-06-20 18:29:28 +02:00
ecf1ae1f32 Handle watchlist ID row close errors 2026-06-20 18:29:27 +02:00
5b4ed5facc Handle skip segment test cleanup errors 2026-06-20 18:29:25 +02:00
9e5bc8cdfc Handle skip segment row close errors 2026-06-20 18:29:24 +02:00
ab1ee0c26c Handle command palette test cleanup errors 2026-06-20 18:29:23 +02:00
cfd767e772 Handle command palette row close errors 2026-06-20 18:29:12 +02:00
4512bbdbe5 Handle duration backfill row close errors 2026-06-20 18:29:11 +02:00
f578f2b495 Handle avatar backfill row close errors 2026-06-20 18:29:11 +02:00
d9c4363205 Handle data fix row close errors 2026-06-20 18:29:09 +02:00
a02f45260b Handle database test cleanup errors 2026-06-20 18:29:08 +02:00
6bff7bfaa3 Handle logout errors 2026-06-20 18:29:07 +02:00
616e98dbd7 Handle audit test cleanup errors 2026-06-20 18:29:05 +02:00
6241b27959 Handle watch order response close errors 2026-06-20 18:29:04 +02:00
da8c75f1aa Handle AllAnime source assertions 2026-06-20 18:29:02 +02:00
b5f48389b7 Handle AllAnime test copy errors 2026-06-20 18:29:01 +02:00
347f88ce0a Handle AllAnime extractor errors 2026-06-20 18:29:00 +02:00
8a9295c03a Handle AllAnime response close errors 2026-06-20 18:28:58 +02:00
8cc4e42ff1 Handle Jikan transport errors 2026-06-20 18:28:47 +02:00
43ec2d106e Handle relation refresh errors 2026-06-20 18:28:45 +02:00
4291c4999e Handle Jikan test cleanup errors 2026-06-20 18:28:44 +02:00
68b7426fe9 Handle Jikan cache refresh errors 2026-06-20 18:28:43 +02:00
274b0b055d Handle anime refresh errors 2026-06-20 18:28:41 +02:00
30c9de86f1 Handle user CLI errors 2026-06-20 18:28:39 +02:00
a613862b9e Log env file load errors 2026-06-20 18:28:36 +02:00
07725c01c9 Handle GraphQL body close errors 2026-06-20 18:28:35 +02:00
54434e8066 Handle HTML response errors 2026-06-20 18:28:34 +02:00
3123fd374e Handle uTLS close errors 2026-06-20 18:28:32 +02:00
608d9e6598 Add error logging helper 2026-06-20 18:28:32 +02:00
9380e27a8f Fallback to local skip segment overrides 2026-06-19 22:55:01 +02:00
2d79c3e1ad fix: extract requestLogLevel to reduce cyclomatic complexity 2026-06-19 13:40:05 +02:00
068ff8ec54 fix: extract copyProxyResponseBody to reduce cyclomatic complexity 2026-06-19 13:39:43 +02:00
b22aa29547 feat: add watchlist toggle to search results 2026-06-18 20:59:29 +02:00
f11bc865b5 fix: reduce hls playback churn 2026-06-18 20:50:44 +02:00
6d3773794d fix: stop stale request retries 2026-06-18 20:25:30 +02:00
93d567710f feat: fetch actual episode count for airing anime 2026-06-17 18:54:58 +02:00
bc230f753e fix: use light-dark() for header nav hover colors 2026-06-17 18:45:56 +02:00
909f43ff75 refactor: split fetchRelationResults to satisfy funlen 2026-06-16 21:05:16 +02:00
1ef16a42ff fix: fall back to ipv4 when ipv6 is unreachable 2026-06-16 20:02:39 +02:00
e94d6a4e67 feat: add browse link to navigation 2026-06-16 19:50:25 +02:00
eeb9e796fa feat: estimate released episode count for airing anime 2026-06-16 18:36:53 +02:00
302683b7ac feat: sort selected genres first in filter dropdown 2026-06-16 17:33:40 +02:00
2b3d6cc38a fix: scope browse param sync to browse form and sync genres 2026-06-16 17:29:48 +02:00
362d53f4c7 fix: swap entire browse-content on filter change 2026-06-16 17:29:45 +02:00
44fa4bbe35 feat: add sfw to browse links across templates 2026-06-16 17:25:22 +02:00
c70fb28d2d fix: pass error data to video player template 2026-06-16 17:25:21 +02:00
d4883d9228 fix: include sfw in browseURL generation 2026-06-16 17:25:20 +02:00
5ae2a41c1b fix: always include sfw hidden input in filter bar 2026-06-16 17:25:20 +02:00
37356851c2 fix: add variant to watchlist toast type 2026-06-16 17:25:15 +02:00
3588582e85 feat: show playback error toast on player init 2026-06-16 17:25:14 +02:00
1ae42bf3fd fix: sync sfw parameter on htmx config requests 2026-06-16 17:25:13 +02:00
e109194340 fix: sync all sfw hidden inputs on checkbox toggle 2026-06-16 17:25:09 +02:00
d2d14a27c2 feat: add destructive variant to toast component 2026-06-16 17:25:08 +02:00
a8a179185d refactor: populate watch page data before error return 2026-06-16 17:25:04 +02:00
58d63fd30a feat: redirect browse to canonical sfw url 2026-06-16 17:20:49 +02:00
c9dcec9a44 fix: allow empty search results from jikan 2026-06-16 17:20:39 +02:00
45a7a18b0d fix: update segment editor modal styling and accessibility 2026-06-16 16:32:12 +02:00
a7822611a2 fix: remove redundant py-1 from dropdown content containers 2026-06-16 16:26:21 +02:00
27bda397f9 feat: restore preferred audio mode on player init 2026-06-16 15:35:32 +02:00
ec4ea435c9 fix: preserve player position only when switching away from existing playback 2026-06-16 15:28:21 +02:00
c529c3354f fix: set sqlite txlock=immediate to prevent mid-transaction lock upgrades 2026-06-16 15:28:17 +02:00
6cbb6209c4 feat: compile typescript in docker build 2026-06-16 15:18:48 +02:00
653f8f85e1 feat: add error handling to search functions 2026-06-16 14:00:41 +02:00
c2b92ad5a4 feat: add error handling to player core functions 2026-06-16 14:00:38 +02:00
e24d832cf0 feat: add error handling to player episode functions 2026-06-16 14:00:35 +02:00
6545e898bb fix: resolve syntax error in watchlist.ts 2026-06-16 13:59:55 +02:00
99133f5d3e fix: update justfile to use correct script files 2026-06-16 13:48:50 +02:00
c9e4925850 chore: remove old TypeScript build scripts 2026-06-16 13:45:05 +02:00
f46999a2f6 feat: rewrite build-ts script in shell 2026-06-16 13:41:22 +02:00
7f6256c7bd feat: rewrite new-data-fix script in shell 2026-06-16 13:40:10 +02:00
2cd270e7e3 fix: apply go fix updates (any, range loop, slices, maps) 2026-06-16 13:36:45 +02:00
23852e53a6 feat: add fix-all script for recursive go fix 2026-06-16 13:35:46 +02:00
b32c8d632d build: remove catch-all entry point build, use app.ts only 2026-06-16 11:06:46 +02:00
1d211dd118 refactor: move toast container to base template 2026-06-16 11:05:16 +02:00
57a4272a92 fix: defer resp.Body.Close in handleResponseRetry 2026-06-16 11:03:42 +02:00
b856af7b7e fix: log discarded io.Copy error in proxy stream handler 2026-06-16 11:02:16 +02:00
3893718c39 fix: scope htmx:beforeSwap and clear searchDebounce on teardown 2026-06-16 11:01:33 +02:00
a95a339c0c feat: add http roundtripper mock and deterministic integration tests for allanime 2026-06-16 10:59:26 +02:00
5fe34eb1c9 refactor: extract anime template sections into components 2026-06-16 10:47:52 +02:00
7f67ebcf8c feat: add anime_themes component template 2026-06-16 10:47:35 +02:00
2dae9fdd37 feat: add anime_synopsis component template 2026-06-16 10:47:31 +02:00
98ea153d0b feat: add anime_statistics component template 2026-06-16 10:47:31 +02:00
217aabb1f5 feat: add anime_recommendations component template 2026-06-16 10:47:30 +02:00
05bf1616be feat: add anime_characters component template 2026-06-16 10:47:26 +02:00
32274d79f3 refactor: wrap bare errors with context in database package 2026-06-16 10:42:43 +02:00
cda8bd55c2 refactor: wrap bare errors with context in playback package 2026-06-16 10:42:37 +02:00
a6054ccf88 refactor: wrap bare errors with context in anime package 2026-06-16 10:42:30 +02:00
abe91b71e6 refactor: group episode state 2026-06-16 10:38:16 +02:00
ec205ebe24 refactor: group media state 2026-06-16 10:38:08 +02:00
2816a85457 refactor: group player state 2026-06-16 10:37:55 +02:00
4773b2e406 refactor: deduplicate rollback via WatchlistStore 2026-06-16 10:28:06 +02:00
e945d1952c chore: remove dead search dialog overlay code 2026-06-16 10:24:49 +02:00
c81d01d592 chore: remove dead sort_filter code 2026-06-16 10:20:23 +02:00
9311933b48 chore: remove dead timezone conversion code 2026-06-16 10:19:10 +02:00
e9bb8ee05c fix: check exists from c.Get 2026-06-16 10:17:18 +02:00
f0d95f7b82 fix: log genre fetch failure instead of silencing 2026-06-16 10:16:32 +02:00
c8cbaf58a9 fix: log error when fetching relations fails instead of silencing 2026-06-16 10:15:55 +02:00
8cee6af44a fix: log anime fetch errors in watch page and thumbnail handlers 2026-06-16 10:13:58 +02:00
e82e0b0bec fix: handle db errors in watchlist update entry 2026-06-16 10:08:15 +02:00
29b139fb03 chore: remove unused static/images directory 2026-06-16 09:31:29 +02:00
55929d0c3c refactor: deduplicate runtime validation into shared module 2026-06-16 09:30:09 +02:00
f5e4048158 feat: profile hls playback 2026-06-16 01:34:42 +02:00
e13df784e0 feat: time database queries 2026-06-16 01:34:36 +02:00
71ec3d3548 feat: add profiling recipes 2026-06-16 01:34:30 +02:00
2e9eaf8fef fix: index related anime lookup 2026-06-16 01:28:49 +02:00
1c17d6334f fix: unblock jikan limiter waits 2026-06-16 01:27:28 +02:00
579373642c fix: limit recommendation scoring 2026-06-16 01:25:46 +02:00
95264a371d fix: surface search failures 2026-06-16 01:23:41 +02:00
b8eee45c9c fix: harden player vtt handling 2026-06-16 01:23:37 +02:00
5b4c7f3729 test: cover skip segment overrides 2026-06-16 01:19:26 +02:00
3ccbcd2023 test: cover hls playlist response 2026-06-16 01:18:49 +02:00
fa465630ab test: harden allanime crypto tests 2026-06-16 01:18:33 +02:00
108f037818 fix: replace empty catch blocks with error logging 2026-06-16 01:14:31 +02:00
894f2166ae fix: log and skip per-seed jikan failures in collaborative candidates 2026-06-16 01:11:55 +02:00
a39dbed851 fix: log errors from expired session cleanup and token usage tracking 2026-06-16 01:10:42 +02:00
3c9eb791b9 fix: return errors from fetchAniSkipSegments instead of swallowing them 2026-06-16 01:09:22 +02:00
7fb34525cc fix: remove redundant defer rows.Close() in sqlc queries 2026-06-16 01:05:29 +02:00
367a4592c5 feat: deduplicate proxy token creation 2026-06-16 01:04:21 +02:00
c3a80af469 fix: replace package-level traceEnabled with per-client field 2026-06-16 01:01:54 +02:00
3dccde9e7e refactor: encapsulate search state, bound cache 2026-06-16 01:00:32 +02:00
b8bbee6a33 refactor: derive availableModes from modeSources 2026-06-16 00:56:33 +02:00
c083e35672 refactor: split playback proxy logic into separate handler files 2026-06-16 00:53:52 +02:00
316ed27938 refactor: split jikan client into transport/cache/rate subpackages 2026-06-16 00:50:12 +02:00
a85327bd25 refactor: split recommendation engine into subpackage 2026-06-16 00:45:57 +02:00
9ca8e9e8bf refactor: drop custom dns cache, use net.Dialer directly 2026-06-16 00:40:06 +02:00
93a2f181e5 fix: log provider mapping cache write failures instead of silently discarding 2026-06-16 00:35:50 +02:00
9dac4cbc44 fix: log jikan cache set failures instead of silently discarding 2026-06-16 00:35:16 +02:00
d87b3a79e5 fix: check PRAGMA errors instead of silently ignoring 2026-06-16 00:34:33 +02:00
dd0a8bd306 fix: propagate ensureAnimeRow error instead of silently discarding it 2026-06-16 00:34:02 +02:00
bfae7745a6 fix: log errors from sign proxy token calls instead of discarding them 2026-06-16 00:32:52 +02:00
6187afad71 fix: log audit record failures instead of silently discarding 2026-06-16 00:31:33 +02:00
6d1322b2b7 feat: wrap service-layer errors with context 2026-06-16 00:29:50 +02:00
a1890e22cb auth: replace opaque invalid credentials with sentinel errors 2026-06-16 00:28:27 +02:00
480d780701 refactor: use errors.New for static error strings 2026-06-16 00:26:43 +02:00
bc7fe27eb5 fix: log silent gaps in fetchRelationResults 2026-06-16 00:19:38 +02:00
c67797e72d fix: guard nil resp in warmStreamURL 2026-06-16 00:18:42 +02:00
159 changed files with 11472 additions and 1207 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

159
bun.lock
View File

@@ -9,7 +9,6 @@
"htmx.org": "1.9.12", "htmx.org": "1.9.12",
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/cli": "^4.3.0", "@tailwindcss/cli": "^4.3.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"lefthook": "^2.1.6", "lefthook": "^2.1.6",
@@ -82,111 +81,111 @@
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.23.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ=="], "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.23.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.67.0", "", { "os": "android", "cpu": "arm" }, "sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw=="], "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.73.0", "", { "os": "android", "cpu": "arm" }, "sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.67.0", "", { "os": "android", "cpu": "arm64" }, "sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ=="], "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.73.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.67.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg=="], "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.73.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3de96NdtXhxERMjIz7wsp2HYMY6pMQycGxFWac2mFecAx6VeARF/IqFb1QIaqiCRIdfzBwzTed+pCTCoiS+CYA=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.67.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg=="], "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.73.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-5zx/uPW32TiaOeVY1dQ/H5iOf0K1HOdFKOJhLqGl4o63+i1fpzoqqu/mKtd7OFgFjNCdhlyTGgjVkQTZm1ELcg=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.67.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg=="], "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.73.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-qNe4gKHaGnLuZJ8toUg90JAa0S2vTVvDw+0bRi3q1avXZXDT4u5mMeECf3nD4HYrbdn1O7dXqWut4onY/yx/Xg=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g=="], "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.73.0", "", { "os": "linux", "cpu": "arm" }, "sha512-cCehYh5hTbfShm/fxTD6wwrGUWIpvX+N5OxmAMhFhDeTGXvw+BeNj889tpxsFQ9ZLatQ6wImuY8tsKLZ+FMz7w=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw=="], "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.73.0", "", { "os": "linux", "cpu": "arm" }, "sha512-d5j5GDU/2dMgjVhw7TQT9ITrsIr1Y02KEXKyVGIXUkD+KiaxE9TP65FS2ZdgTBemQvoRL+gSBdbrIm3cQIeacg=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg=="], "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.73.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Eyf1SrP3+yR1DI3OJgOY2Pvrr9dWP9TK37xPaDYycwTtlGlI45erJAVIfH5/m/xosDt6BupJYEFi47bvbTuuyw=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w=="], "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.73.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IlT/OJApEDKaMmCooHuncgJZbbCe7T5QIWmTZBEtYscWvzPQuuEinVcid6kwQRVQOUdb7PUCz4jQHnaYXdfJXw=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.67.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug=="], "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.73.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-L+JYcb/vdg5fmcH08V6o0YYLU28cTH1SPNulwJdvK9NK49aXSkYy6oNpKBmddArVOXYqNepriDGiZ04G54kh1Q=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.67.0", "", { "os": "linux", "cpu": "none" }, "sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ=="], "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.73.0", "", { "os": "linux", "cpu": "none" }, "sha512-Qtk0g3bKV6OwWjIm7R8kQN1uOZRKQt/MODK2a8QfkwhTpXBD53ozx5XLVWLGDQAVyp2otLW4D2wB98XfAfMPGA=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.67.0", "", { "os": "linux", "cpu": "none" }, "sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA=="], "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.73.0", "", { "os": "linux", "cpu": "none" }, "sha512-wX0NQKZVxltkAOVmzFcpOaMpdaUvsq1Eqpx9tkAfl71UdkTlSo1R4AdAnGccR1Fm2+TzFgZ22CyyGuZ41RDr/A=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.67.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q=="], "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.73.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-vPe7UGBMWyiLTtnqS4xxgMQFSFGmtQwhwCxuiw6lXygaO6bVt0D8dFVg8Xv05eaiN3ybC0HXXHUAohFMFvqoCQ=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA=="], "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.73.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2CwIWr9cemFC/CbRBWZvuk5mffz6ObmfFkfcC/9rTQ7f+icNhYr2kOjf9Rt8lLvugvkdGDOmkoVoFFHh6ClCTw=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg=="], "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.73.0", "", { "os": "linux", "cpu": "x64" }, "sha512-nDadfJgg7NBBxG0N560wOe7LLX5QiYp6qBaI7viuk5EUORFBktU/NfV0MbTqU3gTqQDCh4VyxKdo5VADxk9w8Q=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.67.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g=="], "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.73.0", "", { "os": "none", "cpu": "arm64" }, "sha512-wGjJC+NLH9xP+IKGn9RDW94ojJR/wPbg5WCnQjj/oReaOtCQthr8ws1zICe77JFmo4ouUdeTHHZL/ESGiF6Pmw=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.67.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA=="], "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.73.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-I7X47GPGljw225YUQ5SbC/rb1Kkdrd0yQf0x+hYxeKS6DpfjMbo9ccQPQ6LNY6BoJQ1sHhgDUGuMn5Vg5gHT6w=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.67.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg=="], "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.73.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-5lWj+3h+74Fm1jYOO9qkJA4xkAlZA099DkXppuXsk7UpnpZLttsefrZU469vChGaG6hcSqrkKXQOvMTZtbjeNg=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.67.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ=="], "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.73.0", "", { "os": "win32", "cpu": "x64" }, "sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw=="],
"@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], "@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="], "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
"@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="], "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw=="],
"@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="], "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg=="],
"@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="], "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ=="],
"@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="], "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA=="],
"@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="], "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="],
"@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="], "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="],
"@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="], "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="],
"@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="], "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="],
"@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="], "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="],
"@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="], "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw=="],
"@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="], "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ=="],
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
"@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="], "@tailwindcss/cli": ["@tailwindcss/cli@4.3.2", "", { "dependencies": { "@parcel/watcher": "2.5.1", "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "enhanced-resolve": "5.21.6", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.3.2" }, "bin": { "tailwindcss": "./dist/index.mjs" } }, "sha512-Fzt+HrIZHDlkRYKdLMBeufaroaPvwCBG70sMLdmurdeadNMO/LxbmT8Sbb+P83ep0iAlAImettb7Y+rO+37rXw=="],
"@tailwindcss/cli": ["@tailwindcss/cli@4.3.0", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "enhanced-resolve": "^5.21.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.3.0" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-X9kdlqyMopO9fewbgHsEeuy31YzMHbdZ9VsKt004tB+mxSg1CNbyhZYCzvhciN0AM4R4b5lvIprPjtNq7iQxpQ=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.2" } }, "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.2", "@tailwindcss/oxide-darwin-arm64": "4.3.2", "@tailwindcss/oxide-darwin-x64": "4.3.2", "@tailwindcss/oxide-freebsd-x64": "4.3.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", "@tailwindcss/oxide-linux-x64-musl": "4.3.2", "@tailwindcss/oxide-wasm32-wasi": "4.3.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="], "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.2", "", { "os": "android", "cpu": "arm64" }, "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="], "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="], "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="], "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="], "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="], "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="], "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="], "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="], "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="], "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.2", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="], "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="], "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="], "@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="],
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="], "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@@ -198,29 +197,31 @@
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"lefthook": ["lefthook@2.1.6", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.6", "lefthook-darwin-x64": "2.1.6", "lefthook-freebsd-arm64": "2.1.6", "lefthook-freebsd-x64": "2.1.6", "lefthook-linux-arm64": "2.1.6", "lefthook-linux-x64": "2.1.6", "lefthook-openbsd-arm64": "2.1.6", "lefthook-openbsd-x64": "2.1.6", "lefthook-windows-arm64": "2.1.6", "lefthook-windows-x64": "2.1.6" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-w9sBoR0mdN+kJc3SB85VzpiAAl451/rxdCRcZlwW71QLjkeH3EBQFgc4VMj5apePychYDHAlqEWTB8J8JK/j1Q=="], "lefthook": ["lefthook@2.1.9", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.9", "lefthook-darwin-x64": "2.1.9", "lefthook-freebsd-arm64": "2.1.9", "lefthook-freebsd-x64": "2.1.9", "lefthook-linux-arm64": "2.1.9", "lefthook-linux-x64": "2.1.9", "lefthook-openbsd-arm64": "2.1.9", "lefthook-openbsd-x64": "2.1.9", "lefthook-windows-arm64": "2.1.9", "lefthook-windows-x64": "2.1.9" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-bwDaIOViTktE8kJLf9jP0p+H2/RDTlFFlc43Am2YgUsX22hI6Sq4RbzsrecwzY5y+MHTipOH7WsmWSEniePHWQ=="],
"lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ=="], "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg=="],
"lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-5Ka6cFxiH83krt+OMRQtmS6zqoZR5SLXSudLjTbZA1c3ZqF0+dqkeb4XcB6plx6WR0GFizabuc6Bi3iXPIe1eQ=="], "lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-dwo5Tke2XcQCM56DGHgFKBfRbJIL6xs2wZ0zG1TUVZgl4t4mQUt6LiZ4V/ZQfYHTZF9qywvXoIlR5N35qOaiVQ=="],
"lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.6", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-VswyOg5CVN3rMaOJ2HtnkltiMKgFHW/wouWxXsV8RxSa4tgWOKxM0EmSXi8qc2jX+LRga6B0uOY6toXS01zWxA=="], "lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.9", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+09PVap6nl6xsaHch5JLtq7WvIR++U1Q2MzA2ai0M4uB/VP3AqrvKqHw6+9hjyKnIH+HHL83uqi77EAY+LaxLA=="],
"lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.1.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vXsCUFYuVwrVWwcypB7Zt2Hf+5pl1V1la7ZfvGYZaTRURu0zF/XUnMF/nOz/PebGv0f4x/iOWXWwP7E42xRWsg=="], "lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.1.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-8XresjKIYpkE9ARgCtBEZgJZxAU3T4MIqzj4zNy15XRT59I1Us+QdqXTNm+pkZ41Yd2X/nxs2Pkvbq3NWWlIGw=="],
"lefthook-linux-arm64": ["lefthook-linux-arm64@2.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-WDJiQhJdZOvKORZd+kF/ms2l6NSsXzdA9ahflyr65V90AC4jES223W8VtEMbGPUtHuGWMEZ/v/XvwlWv0Ioz9g=="], "lefthook-linux-arm64": ["lefthook-linux-arm64@2.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-1oNIQfwrPe6rgU2KcDM3aF6+hpZDCKx1TmawQKpXUY5gVsbZ7MqX0Sk/1lnnWxqPm+kQQ5f6J2dpFWd+4xH8jg=="],
"lefthook-linux-x64": ["lefthook-linux-x64@2.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-C18nCd7nTX1AVL4TcvwMmLAO1VI1OuGluIOTjiPkBQ746Ls1HhL5rl//jMPACmT28YmxIQJ2ZcLPNmhvEVBZvw=="], "lefthook-linux-x64": ["lefthook-linux-x64@2.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-fT+7Q+BJyGp+CslFQkNXmdFRgyVXsPHPi9NAsDX0a6QOyNnoORByAsvx6zeAKuF5rL3BBgNfho1/v2RuGxGy9w=="],
"lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.1.6", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-mZOMxM8HiPxVFXDO3PtCUbH4GB8rkveXhsgXF27oAZTYVzQ3gO9vT6r/pxit6msqRXz3fvcwimLVJgb8eRsa8A=="], "lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.1.9", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-4bVuafBk3dddVNo0+3hMbjcJs4mqYAstxpPMmX2ufkudSTYFNIhWoqwuGVQV/SS/xdcOKJAldW4qayAzed2ysw=="],
"lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.1.6", "", { "os": "openbsd", "cpu": "x64" }, "sha512-sG9ALLZSnnMOfXu+B7SmxFhJhuoAh4bqi5En5aaHJET48TqrLOcWWZuH+7ArFM6gr/U5KfSUvdmHFmY8WqCcIg=="], "lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.1.9", "", { "os": "openbsd", "cpu": "x64" }, "sha512-PmPoMmLP/wQQWcQ9u2YH86bTZ3UCfBsxuEmVTEyPU2U8R1qSTp5r/Gs3G8cN5Mxo91XB9oBERtF1n+xD3W6aVA=="],
"lefthook-windows-arm64": ["lefthook-windows-arm64@2.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-lD8yFWY4Csuljd0Rqs7EQaySC0VvDf7V3rN1FhRMUISTRDHutebIom1Loc8ckQPvKYGC6mftT9k0GvipsS+Brw=="], "lefthook-windows-arm64": ["lefthook-windows-arm64@2.1.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-KphfkBKmwBnmolyrdhIl3lrBaOyTcCgXBT2AB/9OHnEXhOLvv5uTCUkrD4YRAxXPtFKq6UvnapIeoL3GZq0bdA=="],
"lefthook-windows-x64": ["lefthook-windows-x64@2.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-q4z2n3xucLscoWiyMwFViEj3N8MDSkPulMwcJYuCYFHoPhP1h+icqNu7QRLGYj6AnVrCQweiUJY3Tb2X+GbD/A=="], "lefthook-windows-x64": ["lefthook-windows-x64@2.1.9", "", { "os": "win32", "cpu": "x64" }, "sha512-2qlUtkJHZ3MyUxgV5XTEmcrIoNZA07iwaquoswAcqv/1MeBFXlD+O+koFRfrzWng2O5WYEbpJnd8tvaYnV8fTA=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -248,46 +249,48 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"oxfmt": ["oxfmt@0.52.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.52.0", "@oxfmt/binding-android-arm64": "0.52.0", "@oxfmt/binding-darwin-arm64": "0.52.0", "@oxfmt/binding-darwin-x64": "0.52.0", "@oxfmt/binding-freebsd-x64": "0.52.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.52.0", "@oxfmt/binding-linux-arm-musleabihf": "0.52.0", "@oxfmt/binding-linux-arm64-gnu": "0.52.0", "@oxfmt/binding-linux-arm64-musl": "0.52.0", "@oxfmt/binding-linux-ppc64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-musl": "0.52.0", "@oxfmt/binding-linux-s390x-gnu": "0.52.0", "@oxfmt/binding-linux-x64-gnu": "0.52.0", "@oxfmt/binding-linux-x64-musl": "0.52.0", "@oxfmt/binding-openharmony-arm64": "0.52.0", "@oxfmt/binding-win32-arm64-msvc": "0.52.0", "@oxfmt/binding-win32-ia32-msvc": "0.52.0", "@oxfmt/binding-win32-x64-msvc": "0.52.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug=="], "oxfmt": ["oxfmt@0.52.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.52.0", "@oxfmt/binding-android-arm64": "0.52.0", "@oxfmt/binding-darwin-arm64": "0.52.0", "@oxfmt/binding-darwin-x64": "0.52.0", "@oxfmt/binding-freebsd-x64": "0.52.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.52.0", "@oxfmt/binding-linux-arm-musleabihf": "0.52.0", "@oxfmt/binding-linux-arm64-gnu": "0.52.0", "@oxfmt/binding-linux-arm64-musl": "0.52.0", "@oxfmt/binding-linux-ppc64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-musl": "0.52.0", "@oxfmt/binding-linux-s390x-gnu": "0.52.0", "@oxfmt/binding-linux-x64-gnu": "0.52.0", "@oxfmt/binding-linux-x64-musl": "0.52.0", "@oxfmt/binding-openharmony-arm64": "0.52.0", "@oxfmt/binding-win32-arm64-msvc": "0.52.0", "@oxfmt/binding-win32-ia32-msvc": "0.52.0", "@oxfmt/binding-win32-x64-msvc": "0.52.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug=="],
"oxlint": ["oxlint@1.67.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.67.0", "@oxlint/binding-android-arm64": "1.67.0", "@oxlint/binding-darwin-arm64": "1.67.0", "@oxlint/binding-darwin-x64": "1.67.0", "@oxlint/binding-freebsd-x64": "1.67.0", "@oxlint/binding-linux-arm-gnueabihf": "1.67.0", "@oxlint/binding-linux-arm-musleabihf": "1.67.0", "@oxlint/binding-linux-arm64-gnu": "1.67.0", "@oxlint/binding-linux-arm64-musl": "1.67.0", "@oxlint/binding-linux-ppc64-gnu": "1.67.0", "@oxlint/binding-linux-riscv64-gnu": "1.67.0", "@oxlint/binding-linux-riscv64-musl": "1.67.0", "@oxlint/binding-linux-s390x-gnu": "1.67.0", "@oxlint/binding-linux-x64-gnu": "1.67.0", "@oxlint/binding-linux-x64-musl": "1.67.0", "@oxlint/binding-openharmony-arm64": "1.67.0", "@oxlint/binding-win32-arm64-msvc": "1.67.0", "@oxlint/binding-win32-ia32-msvc": "1.67.0", "@oxlint/binding-win32-x64-msvc": "1.67.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ=="], "oxlint": ["oxlint@1.73.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.73.0", "@oxlint/binding-android-arm64": "1.73.0", "@oxlint/binding-darwin-arm64": "1.73.0", "@oxlint/binding-darwin-x64": "1.73.0", "@oxlint/binding-freebsd-x64": "1.73.0", "@oxlint/binding-linux-arm-gnueabihf": "1.73.0", "@oxlint/binding-linux-arm-musleabihf": "1.73.0", "@oxlint/binding-linux-arm64-gnu": "1.73.0", "@oxlint/binding-linux-arm64-musl": "1.73.0", "@oxlint/binding-linux-ppc64-gnu": "1.73.0", "@oxlint/binding-linux-riscv64-gnu": "1.73.0", "@oxlint/binding-linux-riscv64-musl": "1.73.0", "@oxlint/binding-linux-s390x-gnu": "1.73.0", "@oxlint/binding-linux-x64-gnu": "1.73.0", "@oxlint/binding-linux-x64-musl": "1.73.0", "@oxlint/binding-openharmony-arm64": "1.73.0", "@oxlint/binding-win32-arm64-msvc": "1.73.0", "@oxlint/binding-win32-ia32-msvc": "1.73.0", "@oxlint/binding-win32-x64-msvc": "1.73.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.24.0", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ=="],
"oxlint-tsgolint": ["oxlint-tsgolint@0.23.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.23.0", "@oxlint-tsgolint/darwin-x64": "0.23.0", "@oxlint-tsgolint/linux-arm64": "0.23.0", "@oxlint-tsgolint/linux-x64": "0.23.0", "@oxlint-tsgolint/win32-arm64": "0.23.0", "@oxlint-tsgolint/win32-x64": "0.23.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA=="], "oxlint-tsgolint": ["oxlint-tsgolint@0.23.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.23.0", "@oxlint-tsgolint/darwin-x64": "0.23.0", "@oxlint-tsgolint/linux-arm64": "0.23.0", "@oxlint-tsgolint/linux-x64": "0.23.0", "@oxlint-tsgolint/win32-arm64": "0.23.0", "@oxlint-tsgolint/win32-x64": "0.23.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
"playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], "tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
} }
} }

View File

@@ -17,4 +17,4 @@ namespace: mal
images: images:
- name: main - name: main
newName: reg.milasholsting.dk/apps/mal newName: reg.milasholsting.dk/apps/mal
newTag: sha-8fd7c11 newTag: sha-2712775

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

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