Compare commits

..

1318 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
7701ec5a7e test(e2e): add global setup, sign-in helpers and authenticated page tests
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 8m45s
2026-06-25 02:36:19 +02:00
9141fe4f09 test: add recommendation scoring profile tests for weights, taste, and ranking 2026-06-25 02:36:19 +02:00
9026f96b04 test: add playback progress service tests for save, complete and load 2026-06-25 02:36:19 +02:00
0c413782e6 test: add episodes service tests for merge, validation and refresh scheduling 2026-06-25 02:36:19 +02:00
4ecd9599c7 test: add playwright e2e test setup with smoke tests 2026-06-25 02:36:19 +02:00
dbc675d79b test: add login template text snapshot test 2026-06-25 02:36:19 +02:00
6040e3254e test: add watchlist handler and service unit tests 2026-06-25 02:36:19 +02:00
b16b3edf4d test: add auth handler middleware and service unit tests 2026-06-25 02:36:19 +02:00
2bfeb6325c test: add player validate unit tests for isRecord, parseModeSources and parseSegments 2026-06-25 02:36:19 +02:00
76cee8ce21 test: add vtt parser tests for invalid timestamps, cue settings and multiline cues 2026-06-25 02:36:19 +02:00
2565cdfcc7 fix: guard parseVttTime against NaN inputs and strip cue settings from end time 2026-06-25 02:36:19 +02:00
2c6e03eee6 refactor: simplify test stubs with interface embedding 2026-06-25 02:36:19 +02:00
5da2769288 refactor: consolidate fx event error description into single function 2026-06-25 02:36:19 +02:00
6ad6d8b197 refactor: adjust watch page button sizing and alignment 2026-06-25 02:36:19 +02:00
775ca09389 refactor: tweak video player settings panel sizing 2026-06-25 02:36:19 +02:00
5c8f1d6359 refactor: shorten function names in allanime 2026-06-25 02:36:19 +02:00
ce91822a25 refactor: shorten function names in jikan search 2026-06-25 02:36:19 +02:00
d55a9087eb refactor: shorten function names in allanime 2026-06-25 02:36:19 +02:00
496aea9d0d refactor: shorten function names in jikan relations 2026-06-25 02:36:19 +02:00
f940c678d6 refactor: inline imageurl in go callers 2026-06-25 02:36:19 +02:00
63a404bf48 refactor: use formatnumber in templates 2026-06-25 02:36:19 +02:00
201d3479cd feat: add formatnumber template function 2026-06-25 02:36:19 +02:00
3c50fc5d53 refactor: remove display methods from anime type 2026-06-25 02:36:19 +02:00
3dfbcdb815 refactor: move producer type and method out of studio.go 2026-06-25 02:36:19 +02:00
6a039dc9ac refactor: move top anime and genres to anime.go 2026-06-25 02:36:19 +02:00
3515476374 refactor: shorten verbose variable names across codebase 2026-06-25 02:36:19 +02:00
4c7abea589 refactor: inline single-use getcached wrapper 2026-06-25 02:36:19 +02:00
3b53bde103 refactor: inline single-use haswatchordertable 2026-06-25 02:36:19 +02:00
648eb568ff refactor: inline single-use helpers in allanime 2026-06-25 02:36:19 +02:00
2724f0f7ed refactor: inline single-use helpers in jikan relations 2026-06-25 02:36:19 +02:00
e40e657d60 refactor: inline single-use helpers in jikan client 2026-06-25 02:36:19 +02:00
7e26f2ee77 refactor: rename constants.go to cache_policy.go 2026-06-25 02:36:19 +02:00
9a0a6d74bb refactor: rename more.go to anime_resources.go 2026-06-25 02:36:19 +02:00
454b5a0cb3 refactor: replace type switch with reflect map in isemptyresult 2026-06-25 02:36:19 +02:00
e48c719a68 refactor: lazy load audio availability via htmx 2026-06-25 02:36:19 +02:00
fe2f5be812 fix: capture jikan api error body in api error struct 2026-06-25 02:36:19 +02:00
18861593f8 refactor: remove metrics from server and database 2026-06-25 02:36:19 +02:00
a014ad40a9 refactor: remove metrics from episode service 2026-06-25 02:36:19 +02:00
0d53d5efdc refactor: remove metrics from jikan client 2026-06-25 02:36:19 +02:00
546ab66b1a refactor: remove prometheus metrics subsystem 2026-06-25 02:36:19 +02:00
c1e8cf63b4 docs: simplify playback secret command 2026-06-25 02:36:19 +02:00
e333ae36e8 refactor: defer provider episode count to async load 2026-06-25 02:36:19 +02:00
01564ffd52 feat: observe jikan cache stats 2026-06-25 02:36:19 +02:00
1250c591b7 feat: expose jikan cache metrics 2026-06-25 02:36:19 +02:00
3d76046762 feat: add jikan cache stats query 2026-06-25 02:36:19 +02:00
66cd131756 refactor: share episode cache decoding 2026-06-25 02:35:53 +02:00
a1aa5d2540 fix: bound stale jikan cache reads 2026-06-25 02:35:53 +02:00
b5281df6a5 fix: limit sqlite connection pool 2026-06-25 02:35:53 +02:00
e87af49dff fix: ignore expired anime in random pool cache 2026-06-25 02:35:53 +02:00
a9a00dbf3b feat: add expired failed mapping cleanup query 2026-06-25 02:35:53 +02:00
86d0c2b5c0 feat: add jikan cache cleanup worker 2026-06-25 02:35:53 +02:00
963f6e925b chore: exclude README from formatter 2026-06-25 02:35:53 +02:00
216febc02d docs: avoid README heading borders 2026-06-25 02:35:53 +02:00
680d2a1a33 docs: remove README section dividers 2026-06-25 02:35:53 +02:00
de2488216c docs: restore README note alert syntax 2026-06-25 02:35:53 +02:00
7e850ec740 fix: remove constant episode test parameter 2026-06-25 02:35:53 +02:00
dacd1b300a fix: reduce episode merge complexity 2026-06-25 02:35:53 +02:00
cdf322602f docs: wrap security policy prose 2026-06-25 02:35:53 +02:00
fb8433a435 docs: align README markdown tables 2026-06-25 02:35:53 +02:00
4bb9caa972 docs: wrap code of conduct prose 2026-06-25 02:35:53 +02:00
34c1cfa084 fix: clarify anime episode summary 2026-06-25 02:35:53 +02:00
45e69dd38d fix: source anime episode counts from availability 2026-06-25 02:35:53 +02:00
b0bebec656 fix: use provider availability for episodes 2026-06-25 02:35:53 +02:00
cf641ce79b fix: remove blocking detail fetches from anime page load 2026-06-25 02:35:52 +02:00
81e1b861b5 docs: remove Project Documents section from README 2026-06-25 02:35:52 +02:00
ce64efaf5f docs: reorganize and trim README 2026-06-25 02:35:52 +02:00
8ebaac758c docs: expand README with detailed project documentation 2026-06-25 02:35:52 +02:00
b793566221 docs: add SECURITY.md 2026-06-25 02:35:52 +02:00
23fa885053 docs: add CODE_OF_CONDUCT.md 2026-06-25 02:35:52 +02:00
4ca27faf08 chore: update bun.lock after package rename 2026-06-25 02:35:52 +02:00
c0e2e7f8fb style: format static/watchlist.ts 2026-06-25 02:35:52 +02:00
ebb5e59134 style: format static/top_pick_carousel.ts 2026-06-25 02:35:52 +02:00
379ade5fd4 style: format static/toast.ts 2026-06-25 02:35:52 +02:00
f59aca5e92 style: format static/theme.ts 2026-06-25 02:35:52 +02:00
7ad0b74730 style: format static/search/state.ts 2026-06-25 02:35:52 +02:00
c3bd8840b7 style: format static/search/render.ts 2026-06-25 02:35:52 +02:00
0afb4e4c6d style: format static/search/overlay.ts 2026-06-25 02:35:52 +02:00
b7e06810c6 style: format static/search/fetch.ts 2026-06-25 02:35:52 +02:00
2cc6eb3224 style: format static/search/actions.ts 2026-06-25 02:35:52 +02:00
cc071ce9a7 style: format static/q.ts 2026-06-25 02:35:52 +02:00
65d5d1774c style: format static/player/video.ts 2026-06-25 02:35:52 +02:00
a602fa085b style: format static/player/validate.ts 2026-06-25 02:35:52 +02:00
3b39b1abce style: format static/player/types.ts 2026-06-25 02:35:52 +02:00
bb83966491 style: format static/player/timeline.ts 2026-06-25 02:35:52 +02:00
cffaa143a9 style: format static/player/subtitles/vtt.ts 2026-06-25 02:35:52 +02:00
ce3571c88b style: format static/player/subtitles/vtt.test.ts 2026-06-25 02:35:52 +02:00
14d08e93b3 style: format static/player/subtitles/index.ts 2026-06-25 02:35:52 +02:00
7050ef3cb7 style: format static/player/storage.ts 2026-06-25 02:35:52 +02:00
4d4ee7bd58 style: format static/player/state.ts 2026-06-25 02:35:52 +02:00
ff710a354c style: format static/player/source.ts 2026-06-25 02:35:52 +02:00
292f779ee8 style: format static/player/skip/segments.ts 2026-06-25 02:35:52 +02:00
69d7cad5c1 style: format static/player/skip/index.ts 2026-06-25 02:35:52 +02:00
4815080ec9 style: format static/player/skip/editor.ts 2026-06-25 02:35:52 +02:00
445e37c2d8 style: format static/player/quality.ts 2026-06-25 02:35:52 +02:00
b1cbc5d3fe style: format static/player/progress.ts 2026-06-25 02:35:52 +02:00
d1d6ea9f24 style: format static/player/mode.ts 2026-06-25 02:35:52 +02:00
d3e294b7c9 style: format static/player/main.ts 2026-06-25 02:35:52 +02:00
0d343dfff9 style: format static/player/keyboard.ts 2026-06-25 02:35:52 +02:00
967c897300 style: format static/player/hls_profile.ts 2026-06-25 02:35:52 +02:00
9054f43a11 style: format static/player/episodes/ui.ts 2026-06-25 02:35:52 +02:00
ffc08ccb9c style: format static/player/episodes/thumbnails.ts 2026-06-25 02:35:52 +02:00
e66432ac0a style: format static/player/episodes/nav.ts 2026-06-25 02:35:52 +02:00
4b739ac149 style: format static/player/episodes/complete.ts 2026-06-25 02:35:52 +02:00
239dd501aa style: format static/player/controls.ts 2026-06-25 02:35:52 +02:00
9c8c9c9d3c style: format static/login.ts 2026-06-25 02:35:52 +02:00
9d82a6dce8 style: format static/htmx.ts 2026-06-25 02:35:52 +02:00
9ca3eb0a27 style: format static/dropdown.ts 2026-06-25 02:35:52 +02:00
8cd9ac94e9 style: format static/dedupe.ts 2026-06-25 02:35:52 +02:00
a1582226ef style: format static/continue_watching_carousel.ts 2026-06-25 02:35:52 +02:00
3228b5cfa6 style: format static/anime.ts 2026-06-25 02:35:52 +02:00
327af5f75a style: format data fix script 2026-06-25 02:35:52 +02:00
6832625260 style: format go fix script 2026-06-25 02:35:52 +02:00
3404dfe511 style: format build script 2026-06-25 02:35:52 +02:00
8f1fae8141 style: sort package scripts 2026-06-25 02:35:52 +02:00
a91d0cd87b chore: tune frontend lint and type target 2026-06-25 02:35:52 +02:00
fc56734a74 chore: tune lint rules for node scripts 2026-06-25 02:35:52 +02:00
7ab263ae2d style: format dev docs and hook config 2026-06-25 02:35:52 +02:00
8219e83135 chore: configure oxlint with strict rule set 2026-06-25 02:35:52 +02:00
4c5d52dfee chore: configure oxfmt with explicit formatting options 2026-06-25 02:35:52 +02:00
5234d567c3 docs: update README with mise and dev workflow instructions 2026-06-25 02:35:52 +02:00
6df9c1b9f6 refactor: add setup, build-dev, dev, and run targets to justfile 2026-06-25 02:35:52 +02:00
c5bd09623e chore: add mise tool versions config 2026-06-25 02:35:52 +02:00
193fede0ab chore: add air hot-reload config 2026-06-25 02:35:52 +02:00
55eeb052e5 chore: remove knip dependency and config 2026-06-25 02:35:52 +02:00
7d68095d87 chore: add knip configuration to package.json 2026-06-25 02:35:52 +02:00
41b5246111 refactor: migrate new-data-fix script from bash to TypeScript 2026-06-25 02:35:52 +02:00
95db00f389 refactor: migrate fix-all script from bash to TypeScript 2026-06-25 02:35:52 +02:00
85d986039b refactor: migrate justfile fix scripts from bash to bun 2026-06-25 02:35:52 +02:00
3aa25aeef3 chore: remove entrypoint.sh, no longer needed 2026-06-25 02:35:52 +02:00
f91d9733a1 refactor: switch Dockerfile entrypoint to main_server binary 2026-06-25 02:35:52 +02:00
64eb94f128 refactor: make isStringArray and isSubtitleItemArray private 2026-06-25 02:35:52 +02:00
32bcb1a188 refactor: make SubtitleItem interface private 2026-06-25 02:35:52 +02:00
72facaad68 refactor: make timelineBounds and getBufferedEnd private 2026-06-25 02:35:52 +02:00
9b251d5191 refactor: make parseVttCue private 2026-06-25 02:35:52 +02:00
077499cf9e refactor: use replaceChildren in subtitle options 2026-06-25 02:35:52 +02:00
4835cf9835 refactor: use replaceChildren instead of innerHTML assignment 2026-06-25 02:35:52 +02:00
77b9802751 refactor: make switchQuality private, use replaceChildren 2026-06-25 02:35:52 +02:00
e64ce1dc47 refactor: make switchMode private 2026-06-25 02:35:52 +02:00
c732d86018 refactor: make syncVolumeUI private 2026-06-25 02:35:52 +02:00
82cee146de refactor: make fetchNextSearchPage private 2026-06-25 02:35:52 +02:00
171a45c015 refactor: remove unused search state helpers 2026-06-25 02:35:52 +02:00
3fe8059e77 refactor: remove unused parseClassList utility 2026-06-25 02:35:52 +02:00
1de75db825 refactor: make dedupe function private 2026-06-25 02:35:52 +02:00
c0808fe5f3 refactor: replace bash build script with TypeScript 2026-06-25 02:35:52 +02:00
30a23dae5e chore: update bun.lock for knip dependency 2026-06-25 02:35:52 +02:00
c29f6fad92 chore: update just lint command to match package.json 2026-06-25 02:35:52 +02:00
511bf8338d chore: replace jiti with knip, update build and lint scripts 2026-06-25 02:35:52 +02:00
d319be4492 chore: remove prettier config 2026-06-25 02:35:52 +02:00
64de95cdee chore: remove dist/ from .gitignore 2026-06-25 02:35:52 +02:00
8e9d2586e1 chore: add more ignore patterns to .dockerignore 2026-06-25 02:35:52 +02:00
9b3f972766 style: add bottom border to header navigation 2026-06-25 02:35:52 +02:00
d2b8649af2 docs: update README to reflect search page replacement 2026-06-25 02:35:52 +02:00
76373faf8f refactor: update search template with new DOM attributes and IDs 2026-06-25 02:35:52 +02:00
d3241fc146 refactor: simplify render module by removing compact items and continue watching 2026-06-25 02:35:52 +02:00
363a125e31 refactor: rename overlay module to search page module 2026-06-25 02:35:52 +02:00
9d8b09a9a7 refactor: update fetch module to use search API endpoint and types 2026-06-25 02:35:52 +02:00
9a961d9815 refactor: rename command palette types and DOM selectors to search 2026-06-25 02:35:52 +02:00
c20a22b2a8 refactor: rename search entry point init function 2026-06-25 02:35:52 +02:00
81cc3e2d0b feat: add dedicated search API handler 2026-06-25 02:35:52 +02:00
e91120dd63 refactor: swap command palette route for search API 2026-06-25 02:35:52 +02:00
f0f9337c31 refactor: remove command palette methods from watchlist service 2026-06-25 02:35:52 +02:00
c045e00b40 refactor: remove command palette methods from watchlist repository 2026-06-25 02:35:52 +02:00
20ee50c2b9 refactor: remove command palette methods from domain interfaces 2026-06-25 02:35:52 +02:00
8af1808d4a refactor: remove command palette methods from Querier interface 2026-06-25 02:35:52 +02:00
ee90a78adf refactor: remove command palette DB tests 2026-06-25 02:35:52 +02:00
e7aca4afb8 refactor: remove command palette DB queries 2026-06-25 02:35:52 +02:00
c88833feb1 refactor: remove command palette handler 2026-06-25 02:35:52 +02:00
4cb1bc1179 fix: keep create-user as prod wrapper 2026-06-25 02:35:52 +02:00
2593a45cc3 refactor: use tailwind theme utilities 2026-06-25 02:35:52 +02:00
2dca69c9f4 refactor: move shared styles to tailwind utilities 2026-06-25 02:35:52 +02:00
6e29cb59ef feat: add user creation CLI 2026-06-25 02:35:52 +02:00
b39add4362 refactor: remove user command 2026-06-25 02:35:52 +02:00
6f6d09e24b refactor: remove dbtx package 2026-06-25 02:35:52 +02:00
584754c0ca refactor: move app wiring to internal root 2026-06-25 02:35:52 +02:00
87eb4c6403 refactor: inject data fix dependencies 2026-06-25 02:35:52 +02:00
9ae57ad2b1 refactor: remove nested errlog package 2026-06-25 02:35:52 +02:00
734b59f760 refactor: update utls errlog import 2026-06-25 02:35:52 +02:00
353adb3eed refactor: update net document errlog import 2026-06-25 02:35:52 +02:00
39e96ec073 refactor: update skip segments errlog import 2026-06-25 02:35:52 +02:00
5397759192 refactor: update playback service errlog import 2026-06-25 02:35:52 +02:00
921c476b5b refactor: update proxy subtitle errlog import 2026-06-25 02:35:52 +02:00
d696981821 refactor: update proxy stream errlog import 2026-06-25 02:35:52 +02:00
60fd2fe90c refactor: update watchlist ids errlog import 2026-06-25 02:35:52 +02:00
74d3a6d7e7 refactor: update skip override errlog import 2026-06-25 02:35:52 +02:00
f1573ce802 refactor: update command palette errlog import 2026-06-25 02:35:52 +02:00
31308b20ab refactor: update duration backfill errlog import 2026-06-25 02:35:52 +02:00
31010ed51c refactor: update avatar backfill errlog import 2026-06-25 02:35:52 +02:00
f31dc1dc9e refactor: update database fixes errlog import 2026-06-25 02:35:52 +02:00
51f1c60c81 refactor: update watchorder errlog import 2026-06-25 02:35:52 +02:00
57604d5be6 refactor: update allanime extractor errlog import 2026-06-25 02:35:52 +02:00
e38760d62d refactor: update allanime client errlog import 2026-06-25 02:35:52 +02:00
5ddfd78240 refactor: update jikan transport errlog import 2026-06-25 02:35:52 +02:00
dcf506f94d refactor: update user command errlog import 2026-06-25 02:35:52 +02:00
b634c950d0 refactor: use root errlog in graphql 2026-06-25 02:35:52 +02:00
c1125ee44c refactor: add errlog helpers to pkg 2026-06-25 02:35:52 +02:00
54439bccd1 Handle watchlist async errors 2026-06-25 02:35:52 +02:00
8380f32228 Handle subtitle HTTP failures 2026-06-25 02:35:52 +02:00
9549fda1b1 Handle autoskip progress save errors 2026-06-25 02:35:52 +02:00
41ee7a1d72 Handle progress save failures 2026-06-25 02:35:52 +02:00
890ab5e3f3 Handle player mode fetch errors 2026-06-25 02:35:52 +02:00
3c7c22310d Handle player init async errors 2026-06-25 02:35:52 +02:00
e784d7d2a8 Handle keyboard progress save errors 2026-06-25 02:35:52 +02:00
3430541aef Handle episode navigation async errors 2026-06-25 02:35:52 +02:00
a00d854062 Handle anime completion retry errors 2026-06-25 02:35:52 +02:00
226bb69709 Handle control progress save errors 2026-06-25 02:35:52 +02:00
ec78f11b2e Handle AniSkip response close errors 2026-06-25 02:35:52 +02:00
8d1c1640ce Handle warm stream close errors 2026-06-25 02:35:52 +02:00
e11a15383c Handle proxy subtitle errors 2026-06-25 02:35:52 +02:00
2f035ebdd9 Handle proxy stream errors 2026-06-25 02:35:52 +02:00
fed837f868 Handle Gin private error recording 2026-06-25 02:35:52 +02:00
98f6b1c6cf Validate playback route parameters 2026-06-25 02:35:52 +02:00
1828306c27 Handle metrics test cleanup errors 2026-06-25 02:35:52 +02:00
04521675ed Return metrics write errors 2026-06-25 02:35:52 +02:00
1917b22e77 Return rollback errors 2026-06-25 02:35:52 +02:00
7930ece337 Handle watchlist ID test cleanup errors 2026-06-25 02:35:52 +02:00
7eb51e853f Handle watchlist ID row close errors 2026-06-25 02:35:52 +02:00
0957329c41 Handle skip segment test cleanup errors 2026-06-25 02:35:52 +02:00
b99acf719b Handle skip segment row close errors 2026-06-25 02:35:52 +02:00
9571310cfc Handle command palette test cleanup errors 2026-06-25 02:35:52 +02:00
30ba627016 Handle command palette row close errors 2026-06-25 02:35:52 +02:00
2705244dcb Handle duration backfill row close errors 2026-06-25 02:35:51 +02:00
b73f96fa0b Handle avatar backfill row close errors 2026-06-25 02:35:51 +02:00
c85977c728 Handle data fix row close errors 2026-06-25 02:35:51 +02:00
c6d11d83b9 Handle database test cleanup errors 2026-06-25 02:35:51 +02:00
bcf9d48d8e Handle logout errors 2026-06-25 02:35:51 +02:00
8909fb9229 Handle audit test cleanup errors 2026-06-25 02:35:51 +02:00
1c24dc221f Handle watch order response close errors 2026-06-25 02:35:51 +02:00
0acefe636e Handle AllAnime source assertions 2026-06-25 02:35:51 +02:00
0c685e6c09 Handle AllAnime test copy errors 2026-06-25 02:35:51 +02:00
10fafcc848 Handle AllAnime extractor errors 2026-06-25 02:35:51 +02:00
690bd6a82e Handle AllAnime response close errors 2026-06-25 02:35:51 +02:00
d994647e62 Handle Jikan transport errors 2026-06-25 02:35:51 +02:00
1c21474ff6 Handle relation refresh errors 2026-06-25 02:35:51 +02:00
c668914edd Handle Jikan test cleanup errors 2026-06-25 02:35:51 +02:00
087ff429ab Handle Jikan cache refresh errors 2026-06-25 02:35:51 +02:00
6bf91f293b Handle anime refresh errors 2026-06-25 02:35:51 +02:00
f137e6be58 Handle user CLI errors 2026-06-25 02:35:51 +02:00
2ccb23abf1 Log env file load errors 2026-06-25 02:35:51 +02:00
69a1fe3707 Handle GraphQL body close errors 2026-06-25 02:35:51 +02:00
ce41785ffa Handle HTML response errors 2026-06-25 02:35:51 +02:00
9e8e49691c Handle uTLS close errors 2026-06-25 02:35:51 +02:00
86206127d6 Add error logging helper 2026-06-25 02:35:51 +02:00
6248cd75e9 Fallback to local skip segment overrides 2026-06-25 02:35:51 +02:00
3dcfc6157e fix: extract requestLogLevel to reduce cyclomatic complexity 2026-06-25 02:35:51 +02:00
bb37b8e18a fix: extract copyProxyResponseBody to reduce cyclomatic complexity 2026-06-25 02:35:51 +02:00
e1ab6e714e feat: add watchlist toggle to search results 2026-06-25 02:35:51 +02:00
bda3c58a98 fix: reduce hls playback churn 2026-06-25 02:35:51 +02:00
9e0f2231b5 fix: stop stale request retries 2026-06-25 02:35:51 +02:00
aed61b8b61 feat: fetch actual episode count for airing anime 2026-06-25 02:35:51 +02:00
dcefb08cdb fix: use light-dark() for header nav hover colors 2026-06-25 02:35:51 +02:00
16ba3d25ba refactor: split fetchRelationResults to satisfy funlen 2026-06-25 02:35:51 +02:00
ff1ce6588a fix: fall back to ipv4 when ipv6 is unreachable 2026-06-25 02:35:51 +02:00
99d5d89fe1 feat: add browse link to navigation 2026-06-25 02:35:51 +02:00
ac91bd945e feat: estimate released episode count for airing anime 2026-06-25 02:35:51 +02:00
59e25d414c feat: sort selected genres first in filter dropdown 2026-06-25 02:35:51 +02:00
8b4963e1c2 fix: scope browse param sync to browse form and sync genres 2026-06-25 02:35:51 +02:00
ab268ab698 fix: swap entire browse-content on filter change 2026-06-25 02:35:51 +02:00
7c636455c1 feat: add sfw to browse links across templates 2026-06-25 02:35:51 +02:00
1c286e0194 fix: pass error data to video player template 2026-06-25 02:35:51 +02:00
2f41e95864 fix: include sfw in browseURL generation 2026-06-25 02:35:51 +02:00
d8f51a74f8 fix: always include sfw hidden input in filter bar 2026-06-25 02:35:51 +02:00
1f159edf07 fix: add variant to watchlist toast type 2026-06-25 02:35:51 +02:00
ff24e85cd8 feat: show playback error toast on player init 2026-06-25 02:35:51 +02:00
f478de537e fix: sync sfw parameter on htmx config requests 2026-06-25 02:35:51 +02:00
7fb6309a25 fix: sync all sfw hidden inputs on checkbox toggle 2026-06-25 02:35:51 +02:00
cdcc21c6c6 feat: add destructive variant to toast component 2026-06-25 02:35:51 +02:00
2eae804dad refactor: populate watch page data before error return 2026-06-25 02:35:51 +02:00
eaabb28b23 feat: redirect browse to canonical sfw url 2026-06-25 02:35:51 +02:00
bb8aac06eb fix: allow empty search results from jikan 2026-06-25 02:35:51 +02:00
d4e6de9e98 fix: update segment editor modal styling and accessibility 2026-06-25 02:35:51 +02:00
ed3c50f452 fix: remove redundant py-1 from dropdown content containers 2026-06-25 02:35:51 +02:00
5788216bb6 feat: restore preferred audio mode on player init 2026-06-25 02:35:51 +02:00
4557d8552c fix: preserve player position only when switching away from existing playback 2026-06-25 02:35:51 +02:00
795bbe825f fix: set sqlite txlock=immediate to prevent mid-transaction lock upgrades 2026-06-25 02:35:51 +02:00
43a1fff446 feat: compile typescript in docker build 2026-06-25 02:35:51 +02:00
2ec1cdec38 feat: add error handling to search functions 2026-06-25 02:35:51 +02:00
2a8294c405 feat: add error handling to player core functions 2026-06-25 02:35:51 +02:00
3a1a2129d9 feat: add error handling to player episode functions 2026-06-25 02:35:51 +02:00
0cd47ab0fe fix: resolve syntax error in watchlist.ts 2026-06-25 02:35:51 +02:00
262dc6e406 fix: update justfile to use correct script files 2026-06-25 02:35:51 +02:00
34d26c7ecb chore: remove old TypeScript build scripts 2026-06-25 02:35:51 +02:00
bb5ec87654 feat: rewrite build-ts script in shell 2026-06-25 02:35:51 +02:00
8c146fa06e feat: rewrite new-data-fix script in shell 2026-06-25 02:35:51 +02:00
bc7a3f58cf fix: apply go fix updates (any, range loop, slices, maps) 2026-06-25 02:35:51 +02:00
8f0549b290 feat: add fix-all script for recursive go fix 2026-06-25 02:35:51 +02:00
a83377671e build: remove catch-all entry point build, use app.ts only 2026-06-25 02:35:51 +02:00
ac33f1c0dd refactor: move toast container to base template 2026-06-25 02:35:51 +02:00
656ddbd005 fix: defer resp.Body.Close in handleResponseRetry 2026-06-25 02:35:51 +02:00
dc2366cbcc fix: log discarded io.Copy error in proxy stream handler 2026-06-25 02:35:51 +02:00
1d531ab181 fix: scope htmx:beforeSwap and clear searchDebounce on teardown 2026-06-25 02:35:51 +02:00
06b50509e8 feat: add http roundtripper mock and deterministic integration tests for allanime 2026-06-25 02:35:51 +02:00
0d1ae305b5 refactor: extract anime template sections into components 2026-06-25 02:35:51 +02:00
e545ef1a06 feat: add anime_themes component template 2026-06-25 02:35:51 +02:00
a5a8df096a feat: add anime_synopsis component template 2026-06-25 02:35:51 +02:00
5f531aa771 feat: add anime_statistics component template 2026-06-25 02:35:51 +02:00
5be8bce461 feat: add anime_recommendations component template 2026-06-25 02:35:51 +02:00
966eced0f8 feat: add anime_characters component template 2026-06-25 02:35:51 +02:00
e170d81652 refactor: wrap bare errors with context in database package 2026-06-25 02:35:51 +02:00
ca08af2dbb refactor: wrap bare errors with context in playback package 2026-06-25 02:35:51 +02:00
290dc36298 refactor: wrap bare errors with context in anime package 2026-06-25 02:35:51 +02:00
ff54e9c5db refactor: group episode state 2026-06-25 02:35:51 +02:00
7aaead6c67 refactor: group media state 2026-06-25 02:35:51 +02:00
b569b06591 refactor: group player state 2026-06-25 02:35:51 +02:00
4d8486e6ea refactor: deduplicate rollback via WatchlistStore 2026-06-25 02:35:51 +02:00
1770492b00 chore: remove dead search dialog overlay code 2026-06-25 02:35:51 +02:00
a37e609880 chore: remove dead sort_filter code 2026-06-25 02:35:51 +02:00
510549c6ec chore: remove dead timezone conversion code 2026-06-25 02:35:51 +02:00
99fa808d30 fix: check exists from c.Get 2026-06-25 02:35:51 +02:00
8e43731d1f fix: log genre fetch failure instead of silencing 2026-06-25 02:35:51 +02:00
51d26943df fix: log error when fetching relations fails instead of silencing 2026-06-25 02:35:51 +02:00
f0ad92a8f9 fix: log anime fetch errors in watch page and thumbnail handlers 2026-06-25 02:35:51 +02:00
f39fcacadc fix: handle db errors in watchlist update entry 2026-06-25 02:35:51 +02:00
f4486655d1 chore: remove unused static/images directory 2026-06-25 02:35:51 +02:00
9d8a497c48 refactor: deduplicate runtime validation into shared module 2026-06-25 02:35:51 +02:00
c3b3c606db feat: profile hls playback 2026-06-25 02:35:51 +02:00
c70ec383c5 feat: time database queries 2026-06-25 02:35:51 +02:00
50e74326c5 feat: add profiling recipes 2026-06-25 02:35:51 +02:00
71ab6a3abd fix: index related anime lookup 2026-06-25 02:35:51 +02:00
c9bdc4a75e fix: unblock jikan limiter waits 2026-06-25 02:35:51 +02:00
7c25907c92 fix: limit recommendation scoring 2026-06-25 02:35:51 +02:00
c1e313d684 fix: surface search failures 2026-06-25 02:35:51 +02:00
d2a3b0ccda fix: harden player vtt handling 2026-06-25 02:35:51 +02:00
e7fb4264f7 test: cover skip segment overrides 2026-06-25 02:35:51 +02:00
a2d16caea0 test: cover hls playlist response 2026-06-25 02:35:51 +02:00
e836d464cb test: harden allanime crypto tests 2026-06-25 02:35:51 +02:00
22f05580df fix: replace empty catch blocks with error logging 2026-06-25 02:35:51 +02:00
641f97fb8e fix: log and skip per-seed jikan failures in collaborative candidates 2026-06-25 02:35:51 +02:00
12b72b227d fix: log errors from expired session cleanup and token usage tracking 2026-06-25 02:35:51 +02:00
eaabdc5475 fix: return errors from fetchAniSkipSegments instead of swallowing them 2026-06-25 02:35:51 +02:00
941a282d3f fix: remove redundant defer rows.Close() in sqlc queries 2026-06-25 02:35:51 +02:00
622418f96c feat: deduplicate proxy token creation 2026-06-25 02:35:51 +02:00
ec10fa56b4 fix: replace package-level traceEnabled with per-client field 2026-06-25 02:35:51 +02:00
31a8da10b4 refactor: encapsulate search state, bound cache 2026-06-25 02:35:51 +02:00
3c30688058 refactor: derive availableModes from modeSources 2026-06-25 02:35:51 +02:00
2a04876754 refactor: split playback proxy logic into separate handler files 2026-06-25 02:35:51 +02:00
9e25745804 refactor: split jikan client into transport/cache/rate subpackages 2026-06-25 02:35:51 +02:00
4f73b0ca97 refactor: split recommendation engine into subpackage 2026-06-25 02:35:51 +02:00
1e4a5612e8 refactor: drop custom dns cache, use net.Dialer directly 2026-06-25 02:35:51 +02:00
2146876f24 fix: log provider mapping cache write failures instead of silently discarding 2026-06-25 02:35:50 +02:00
b88a859b66 fix: log jikan cache set failures instead of silently discarding 2026-06-25 02:35:50 +02:00
aa9375eff2 fix: check PRAGMA errors instead of silently ignoring 2026-06-25 02:35:50 +02:00
0a483ad2a2 fix: propagate ensureAnimeRow error instead of silently discarding it 2026-06-25 02:35:50 +02:00
8224934046 fix: log errors from sign proxy token calls instead of discarding them 2026-06-25 02:35:50 +02:00
57a2ff874a fix: log audit record failures instead of silently discarding 2026-06-25 02:35:50 +02:00
5a0c8b7476 feat: wrap service-layer errors with context 2026-06-25 02:35:50 +02:00
82e850070c auth: replace opaque invalid credentials with sentinel errors 2026-06-25 02:35:50 +02:00
a1c5726eee refactor: use errors.New for static error strings 2026-06-25 02:35:50 +02:00
fda2346d9a fix: log silent gaps in fetchRelationResults 2026-06-25 02:35:50 +02:00
0bde5ac778 fix: guard nil resp in warmStreamURL 2026-06-25 02:35:50 +02: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
Gitea Action
84e4ddefa2 chore(deploy): update image to latest 2026-06-15 19:47:31 +00:00
8fd7c1104c Merge branch 'upstream/main' into main
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 9m21s
2026-06-15 21:37:41 +02:00
6841f5c55a ci: drop sqlc from Docker build
sqlc-generated code is committed, no need to install and run it
during docker image builds
2026-06-14 21:58:04 +02:00
3e100c1a97 feat: ensure anime row exists before saving progress 2026-06-14 21:53:41 +02:00
4a74fdcf31 feat: add cache busting and hls query param 2026-06-14 21:51:02 +02:00
f9f3322797 feat: add hls.js for m3u8 stream playback 2026-06-14 21:37:55 +02:00
c891382efb feat: add type field to ModeSource and pass to loadVideoSource 2026-06-14 21:37:49 +02:00
ef36578c4b feat: propagate stream source type from provider to client 2026-06-14 21:37:38 +02:00
20aadd36f8 feat: preload alternate mode source on episode load 2026-06-14 21:19:59 +02:00
5dcf39c401 test: add fallbackModes unit tests 2026-06-14 21:17:33 +02:00
7b56f587e5 test: add parseOKRUSources unit test 2026-06-14 21:17:30 +02:00
43d31865ed test: add test for embed source skipping in resolveDirectSource 2026-06-14 21:17:27 +02:00
3668ccb541 refactor: wire mode fallback into resolveModeSources 2026-06-14 21:17:11 +02:00
7bf0ffbd06 feat: add fallbackModes helper 2026-06-14 21:17:09 +02:00
08a16f3302 feat: detect embeds in source references and route to extraction 2026-06-14 21:16:38 +02:00
dcebe90620 feat: add embed video parsing helpers for allanime 2026-06-14 21:16:28 +02:00
d28b187ac0 chore: update allanime site url and referer constants 2026-06-14 21:16:10 +02:00
c57ecf3d4b fix: skip error log on client disconnect in proxy handlers 2026-06-13 22:38:51 +02:00
d2528ba4f1 refactor: reduce search.ts to entry point 2026-06-13 22:29:34 +02:00
c8112e5062 feat: add search/overlay.ts 2026-06-13 22:29:30 +02:00
0d7c572f2c feat: add search/actions.ts 2026-06-13 22:29:26 +02:00
5dbb04dbdd feat: add search/fetch.ts 2026-06-13 22:29:23 +02:00
ff1cd7ce4a feat: add search/render.ts 2026-06-13 22:29:20 +02:00
4ac155c8cc feat: add search/state.ts 2026-06-13 22:29:16 +02:00
e3d82389e4 trim: keep only entrypoint in client.go 2026-06-13 22:24:10 +02:00
f99b30bf43 extract: add stream source resolution 2026-06-13 22:24:06 +02:00
21a1965fdd extract: add availability parsing 2026-06-13 22:24:02 +02:00
fdb79633df extract: add search and provider-id resolution 2026-06-13 22:23:58 +02:00
4876995652 extract: add decrypt and deobfuscation helpers 2026-06-13 22:23:53 +02:00
40be6d3132 refactor: add moved recommendation types to recommendations.go 2026-06-13 22:14:57 +02:00
6a256a20c5 refactor: strip recommendation code from service.go 2026-06-13 22:14:52 +02:00
9e8fb5c033 extract: add cache store, trim to orchestration 2026-06-13 22:12:08 +02:00
84a967856b extract: add provider mapping cache 2026-06-13 22:12:04 +02:00
639f8f424f extract: add refresh and broadcast policy 2026-06-13 22:12:01 +02:00
9fcdd36c5e extract: add merge/validation functions 2026-06-13 22:11:57 +02:00
04c0b8d601 refactor: extract progress and completion 2026-06-13 22:07:52 +02:00
b578bd661e refactor: extract skip segments handling 2026-06-13 22:07:21 +02:00
e2d9ecfb03 refactor: extract watch data building 2026-06-13 22:06:38 +02:00
d6f1c37ac3 refactor: extract proxy token store 2026-06-13 22:06:07 +02:00
837b99bc58 refactor: extract anime reviews handler 2026-06-13 21:54:05 +02:00
e1ddd59417 refactor: extract anime details handlers 2026-06-13 21:54:01 +02:00
ec5a17c392 refactor: extract browse and search handlers 2026-06-13 21:53:57 +02:00
19c5f7ef1f refactor: extract catalog and search handlers 2026-06-13 21:53:52 +02:00
5a703bc323 style: clean up top picks header and page 2026-06-13 21:39:19 +02:00
1a65ef2a9c style: remove extra newline 2026-06-13 21:32:51 +02:00
263bfafd04 style: use http status constant 2026-06-13 21:32:23 +02:00
7523215a71 style: fix linter nits 2026-06-13 21:31:42 +02:00
ea411e5feb perf: preallocate fetchedAnimes in fetchBaselineAnime 2026-06-13 21:30:49 +02:00
aced7bb5d9 refactor: replace wrapper lambda with direct function reference 2026-06-13 21:29:21 +02:00
195d8c0e60 refactor: replace inline lambda with NewPlaybackService 2026-06-13 21:28:42 +02:00
bcc75467f0 style: replace len(status) == 0 with status == '' 2026-06-13 21:27:51 +02:00
a922953776 refactor: replace wrapper lambda with direct function reference 2026-06-13 21:27:20 +02:00
bcd4106dce refactor: replace wrapper lambda with direct function reference 2026-06-13 21:26:32 +02:00
b519706429 refactor: simplify fx.WithLogger call 2026-06-13 21:25:38 +02:00
6ac9e38423 refactor: update top picks link from /discover/top-picks to /top-picks 2026-06-13 21:23:23 +02:00
e13022c7d4 refactor: add top picks link to header, remove discover and schedule nav 2026-06-13 21:23:20 +02:00
c82d25d9c8 feat: add standalone top picks template 2026-06-13 21:23:17 +02:00
55bd2a2582 refactor: remove schedule page template 2026-06-13 21:23:15 +02:00
443dbeda77 refactor: remove discover page template 2026-06-13 21:23:12 +02:00
b53a58905d refactor: remove discover and schedule script imports 2026-06-13 21:23:09 +02:00
e393d2759b refactor: remove schedule board client code 2026-06-13 21:23:05 +02:00
df4867c60d refactor: remove discover tab and surprise me client code 2026-06-13 21:23:01 +02:00
b281acdf88 refactor: remove schedule caching and ISO week helpers 2026-06-13 21:22:59 +02:00
ef1cd20f0b refactor: remove animeschedule.net integration 2026-06-13 21:22:56 +02:00
66faa1a13f refactor: replace schedule nav item with top picks 2026-06-13 21:22:52 +02:00
a0bfe9f889 refactor: remove AnimeDiscoverService fx registration 2026-06-13 21:22:47 +02:00
e44d64a651 refactor: remove AnimeDiscoverService and DiscoverSectionData 2026-06-13 21:22:44 +02:00
4256480e0c refactor: remove discover and schedule service methods 2026-06-13 21:22:40 +02:00
a976769cdd refactor: remove discover and schedule routes and handlers 2026-06-13 21:22:36 +02:00
8304c0a338 chore: enable additional golangci-lint linters 2026-06-13 21:22:29 +02:00
6918f0bf48 fix: prevent pre-commit hook leaks 2026-06-13 20:58:01 +02:00
997957a232 style: reformat isClosableDropdown guard 2026-06-13 20:52:12 +02:00
bd268ead10 fix: close more dropdown before opening segment modal 2026-06-13 20:51:52 +02:00
18f9ec2a95 refactor: switch watch layout to CSS grid 2026-06-13 20:48:26 +02:00
168fea8ab5 feat: add fullscreen overrides to video player 2026-06-13 20:48:23 +02:00
b4e2930112 docs: rewrite readme as minimal prose 2026-06-13 20:14:17 +02:00
370dec5f3b docs: update readme logo markup 2026-06-13 19:22:48 +02:00
b11af766f2 chore: update manifest for png icons and theme colors 2026-06-13 19:22:42 +02:00
7d55aa7837 chore: remove svg icon assets 2026-06-13 19:22:36 +02:00
fb03d73e66 chore: remove mobile menu shell module 2026-06-13 19:22:29 +02:00
012bfee03d refactor: replace sidebar with top header nav in base layout 2026-06-13 19:22:23 +02:00
c47ffcb5be feat: add png icon assets 2026-06-13 19:22:15 +02:00
b1afd2ef82 feat: add header navigation bar templates 2026-06-13 19:22:08 +02:00
f2213bd4aa feat: revamp schedule and home pages 2026-06-13 17:04:09 +02:00
70a6e9a6b5 refactor: remove discover page 2026-06-13 17:04:01 +02:00
9b7a2cac8f feat: add standalone search page 2026-06-13 16:27:14 +02:00
bf85c3b018 feat: add poster retry and dedupe to search 2026-06-13 16:26:44 +02:00
26a8878fc2 refactor: extract dedupeByID utility 2026-06-13 16:25:21 +02:00
a09ff85ff8 style: update player segment color to amber 2026-06-13 12:28:35 +02:00
c4a7151d99 chore: fix node_modules path exclusion in golangci config 2026-06-12 13:46:02 +02:00
4ba1944f70 style: apply formatter to search 2026-06-12 13:40:23 +02:00
45b4a01801 fix: handle nil response request in fetch document 2026-06-12 13:40:19 +02:00
ffe42a352b refactor: pass watch order mode in playback service 2026-06-12 13:40:06 +02:00
1f2fd4f53d refactor: add mode param to GetRelations interface 2026-06-12 13:40:03 +02:00
35a367d569 refactor: pass watch order mode and paginate command palette 2026-06-12 13:39:58 +02:00
36c0e87ae8 feat: add watch order mode toggle 2026-06-12 13:39:50 +02:00
18ed806fc0 fix: prefer original over japanese in db displaytitle 2026-06-12 13:17:55 +02:00
164232cf0d fix: prefer original over japanese in jikan displaytitle 2026-06-12 13:17:51 +02:00
ea587665f2 feat: colorize http status logs 2026-06-12 13:09:49 +02:00
fa88badc69 style: apply formatter changes 2026-06-12 11:39:00 +02:00
4c4c10b154 feat: redesign search overlay 2026-06-12 11:38:28 +02:00
97814b7223 refactor: streamline command palette results 2026-06-12 11:38:15 +02:00
c509144b30 refactor: use css variable for player segment color 2026-06-12 11:38:03 +02:00
ab9d585a1f fix: make build script typecheck 2026-06-12 11:37:54 +02:00
de9bcb5e40 refactor: make schedule board responsive with stacked layout 2026-06-12 11:01:55 +02:00
b607b091d5 feat: add section_action component 2026-06-12 11:01:53 +02:00
15ad54a847 refactor: polish dark theme colors and add border accents 2026-06-12 10:48:55 +02:00
3ae09d4014 refactor: remove browse link from navigation 2026-06-12 10:37:54 +02:00
90ae58b99e fix: change browse sort from desc to asc 2026-06-12 10:37:50 +02:00
c252739610 refactor: split LogEvent into smaller functions 2026-06-11 17:12:22 +02:00
3c2e6a6984 refactor: extract helpers to reduce formatHTTPRequestLog complexity 2026-06-11 17:12:19 +02:00
25471e0bd5 fix: replace nil context with context.TODO 2026-06-11 17:11:47 +02:00
ed90b5c7aa fix: remove nil error return 2026-06-11 14:51:57 +02:00
1485800c32 fix: use request context in server 2026-06-11 14:51:56 +02:00
faae7bc719 fix: use request context in metrics 2026-06-11 14:51:55 +02:00
acabd50970 fix: use execcontext in db 2026-06-11 14:49:57 +02:00
6ba387bb6a fix: use QueryRowContext in test to fix noctx lint 2026-06-11 14:48:51 +02:00
3d13cf9be8 fix: use context-aware db calls in cmd/user 2026-06-11 14:48:05 +02:00
7f05f026e9 refactor: split fetchSkipSegments and fix warmStreamURL noctx 2026-06-11 14:46:22 +02:00
2ed03a667b refactor: split BuildWatchData into focused helpers 2026-06-11 14:45:15 +02:00
2e79c32afe refactor: split getTopPicksForYou into focused helpers 2026-06-11 14:38:40 +02:00
7968fb57f6 refactor: split parseProviderResponse into smaller helpers 2026-06-11 14:35:31 +02:00
ba578d969a refactor: split seedRandomPool to reduce gocognit 2026-06-11 14:33:29 +02:00
5998b59e81 refactor: extract helpers from fetchWithRetry to reduce gocognit 2026-06-11 14:32:02 +02:00
c5acc63370 refactor: extract helpers from FetchWeek to reduce gocognit 2026-06-11 14:29:59 +02:00
b0769ddce7 refactor: shorten ProvideRenderer to satisfy funlen 2026-06-11 14:26:35 +02:00
1c86f802b4 chore: remove stale comment about sqlc name conflict in skip segment overrides 2026-06-11 14:24:18 +02:00
97dcb19b7d refactor: split long functions in episode service to fix funlen linter 2026-06-11 14:23:18 +02:00
8e4ce81232 refactor: extract helpers to reduce funlen in command_palette 2026-06-11 13:08:44 +02:00
f360e22beb refactor: extract scanContinueWatchingEntry helper 2026-06-11 13:06:35 +02:00
27c84a9603 style: gofmt alignment in init 2026-06-11 13:05:25 +02:00
a925cc069e refactor: shorten init below funlen threshold 2026-06-11 13:04:51 +02:00
76af597f4d refactor: shorten TestBuildSourceReferences below funlen threshold 2026-06-11 13:03:19 +02:00
0227c8688b refactor: extract duplicate table-test loop into helper 2026-06-11 13:02:06 +02:00
188eec58a2 refactor: reduce cyclomatic complexity of UpsertSkipSegmentOverride 2026-06-11 12:59:58 +02:00
233472b14d refactor: reduce cyclomatic complexity of mergeEpisodes 2026-06-11 12:58:47 +02:00
7265dec446 refactor: reduce cyclomatic complexity of AuthMiddleware 2026-06-11 12:57:26 +02:00
1ad3be5160 refactor: extract helpers to reduce cyclomatic complexity in audit test 2026-06-11 12:56:19 +02:00
01ee9b1022 refactor: reduce cyclomatic complexity of GetAiringSchedule 2026-06-11 12:53:59 +02:00
e77debb085 refactor: extract candidate score adjustments into helpers 2026-06-11 12:48:58 +02:00
c575bfae47 refactor: extract section path in anime details handler 2026-06-11 12:47:31 +02:00
0262f22876 refactor: reduce cyclomatic complexity of HandleBrowse 2026-06-11 12:45:54 +02:00
e04b11f97f refactor: reduce cyclomatic complexity of HandleProducers 2026-06-11 12:42:35 +02:00
55095791c7 refactor: reduce cyclomatic complexity of parseM3U8 2026-06-11 12:40:41 +02:00
983d805240 refactor: extract string slice helper in allanime client 2026-06-11 12:38:38 +02:00
0beec5fd56 refactor: reduce cyclomatic complexity of resolveSourceReferences 2026-06-11 12:36:35 +02:00
650fac1c90 refactor: reduce graphqlRequestWithHash complexity 2026-06-11 12:35:11 +02:00
870f8086e2 refactor: extract show resolution helpers from GetStreams 2026-06-11 12:33:12 +02:00
7af597d8fc refactor: reduce DurationSeconds complexity with token parsing 2026-06-11 12:31:33 +02:00
b72bace16a refactor: use url.Values in proxy token url 2026-06-11 12:29:29 +02:00
de939cc5f3 refactor: use url.Values in avatar url 2026-06-11 12:29:28 +02:00
4d6736a439 refactor: use url.Values in command palette search url 2026-06-11 12:29:27 +02:00
02bbc6c4d4 refactor: use url.Values in allanime graphql request 2026-06-11 12:29:23 +02:00
5ada1f72e4 feat: add shared query param helpers for jikan 2026-06-11 12:27:56 +02:00
4b95f85d4d chore: remove stray blank line in test 2026-06-11 12:19:59 +02:00
c36f02862d refactor: split getFullRelations into smaller helpers 2026-06-11 12:19:43 +02:00
704058a512 refactor: extract helpers from TestGetWithCacheReturnsStaleAndRefreshesAsync
Split setup (newTestCacheDB, insertCachedResponse) and async
polling (waitForFreshCache) out of the test to reduce its
cyclomatic complexity below 10. Switch DB calls to ExecContext
/ QueryRowContext to fix noctx lint.
2026-06-11 12:17:40 +02:00
9b19661fa3 refactor: extract skip/level helpers from logJikanCache 2026-06-11 12:15:00 +02:00
ca957b5cdc refactor: reduce cyclomatic complexity in fetchWeekAPI 2026-06-11 12:13:22 +02:00
03ccd54c85 refactor: extract parseAirType from parseMeta 2026-06-11 12:09:57 +02:00
c70adbd0ec fix: only report new lint issues with --new-from-rev 2026-06-11 12:06:04 +02:00
5f346d8dec fix: run linter at package level, not file level 2026-06-11 12:05:38 +02:00
3ade952653 fix: scope pre-commit hooks to staged files 2026-06-11 12:05:18 +02:00
37d7e0f6f0 chore: scope pre-commit hooks to staged files 2026-06-11 11:28:26 +02:00
f32bcf1288 fix: close response body in FetchHTMLDocument 2026-06-11 11:25:51 +02:00
7f98fbfa7a chore: remove unused CORSMiddleware wrapper 2026-06-11 11:18:05 +02:00
827b77cb20 fix: remove leading space in class attributes 2026-06-09 19:11:48 +02:00
b67727c21c test: add template function and renderer tests 2026-06-09 19:10:25 +02:00
470039d9e9 refactor: use posterURL in templates 2026-06-09 19:10:16 +02:00
ea518a7d0a refactor: simplify browseURL, add posterURL helper 2026-06-09 19:10:10 +02:00
bd89715ea0 chore: remove unused template files
- delete dropdown.gohtml (unused — codebase uses <ui-dropdown> directly)
- delete footer.gohtml (never referenced)
- update components/README.md to reflect actual files
2026-06-09 19:09:10 +02:00
49512a6708 refactor: replace scrollbar hacks with scrollbar-hidden class 2026-06-09 18:21:27 +02:00
070375eaa5 refactor: reorder head and use non-blocking font loading 2026-06-09 18:21:16 +02:00
1d4364d63e refactor: deduplicate sidebar navigation into data-driven loop 2026-06-09 18:21:08 +02:00
15876a4f86 refactor: consolidate css token system and add base utilities 2026-06-09 18:21:02 +02:00
1a35bd81bd fix: preserve schedule source items 2026-06-09 12:36:26 +02:00
Gitea Action
12076f4cbb chore(deploy): update image to latest 2026-06-09 07:21:33 +00:00
30a00eb348 remove kustomize install as it should be in the actor container
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 10m55s
2026-06-09 08:45:35 +02:00
21fd1110d4 feat: populate duration_seconds on anime upsert and add backfill fix 2026-06-08 02:26:56 +02:00
f8cf4579af test: add HLS playlist rewrite and detection tests 2026-06-08 02:13:41 +02:00
1a1189d035 feat: add HLS playlist rewriting to proxy stream 2026-06-08 02:13:32 +02:00
db4dc20603 refactor: replace HMAC proxy tokens with in-memory store 2026-06-08 02:13:21 +02:00
a4fa0beff5 refactor: update playback domain types and interfaces 2026-06-08 02:13:12 +02:00
39df0ff99a style: migrate watch page to v4 syntax 2026-06-07 17:45:56 +02:00
80a3481ebe style: migrate schedule page to v4 syntax 2026-06-07 17:45:48 +02:00
6efea21632 style: migrate index page to v4 syntax 2026-06-07 17:45:40 +02:00
4c90f759c9 style: migrate filter_bar and video_player to v4 syntax 2026-06-07 17:45:32 +02:00
470f9e3532 style: migrate anime page, watchlist_actions, and watchlist to v4 syntax 2026-06-07 17:45:24 +02:00
e355933ba8 style: migrate z-index/scrollbar in continue_watching, size shorthands in login 2026-06-07 17:45:08 +02:00
102317c9b0 style: migrate shadow variable syntax in dropdown component 2026-06-07 17:44:59 +02:00
cd7fab7fbd style: migrate z-index syntax in toast 2026-06-07 17:44:49 +02:00
7f6d2c82cb style: migrate important modifier syntax in browse and discover 2026-06-07 17:44:25 +02:00
bc9820c536 chore(deps): bump tailwindcss from 4.2.4 to 4.3.0 2026-06-07 17:43:51 +02:00
f90ff2e4c7 fix: update anime page layout 2026-06-06 17:54:56 +02:00
79be865989 fix: handle edge cases in continue watching carousel 2026-06-06 17:26:41 +02:00
18a335fd74 feat: add continue watching carousel 2026-06-06 17:26:22 +02:00
082219d2d4 test: add tests for mergeEpisodes capping and cache validation 2026-06-06 17:22:14 +02:00
b661b577dd feat: cap episode numbers to expected count and validate cached payload 2026-06-06 17:22:06 +02:00
fb6e48cf92 feat: add visual filler/recap indicator in episode list 2026-06-06 17:21:56 +02:00
a6cb71c65b refactor: move video source construction from inline script to initPlayer 2026-06-06 16:54:35 +02:00
e70574ac08 refactor: update anime page scripts 2026-06-06 16:54:27 +02:00
f9064b3b6c refactor: simplify dedupe module 2026-06-06 16:54:19 +02:00
4b1b4266d9 refactor: streamline mobile menu with event delegation 2026-06-06 16:54:11 +02:00
f7e7dfd161 feat: improve command palette focus management and aria 2026-06-06 16:54:03 +02:00
651db05cd0 feat: add htmx error toast on error class swap 2026-06-06 16:53:56 +02:00
470e5b092b refactor: read watchlist IDs from JSON script tag instead of global var 2026-06-06 16:53:48 +02:00
e06a20b5d0 refactor: switch watchlist IDs from global to JSON script tag 2026-06-06 16:53:40 +02:00
fe46dd9c48 refactor: replace inline theme dialog script with data attributes 2026-06-06 16:53:32 +02:00
cb8ef29cde refactor: replace inline scripts with module scripts block 2026-06-06 16:53:24 +02:00
03e741c561 refactor: use browseURL helper and simplify filter bar templates 2026-06-06 16:53:16 +02:00
9cb3e8fe27 test: add tests for browseURL helper 2026-06-06 16:53:08 +02:00
b9ca82dbd9 refactor: add browseURL template helper for filter URLs 2026-06-06 16:53:00 +02:00
5441b14737 feat: improve dropdown accessibility with aria and focus management 2026-06-06 16:52:52 +02:00
5cc03579b2 refactor: consolidate scripts into single app.js entry point 2026-06-06 16:52:22 +02:00
b5fc2dfe4e feat: add app entry point, password toggle, and schedule modules 2026-06-06 16:52:16 +02:00
78b36452ae refactor: migrate from htmx:afterSwap to onHtmxLoad 2026-06-06 16:51:12 +02:00
392bc10b99 refactor: replace DOMContentLoaded with onReady utility 2026-06-06 16:51:07 +02:00
5019e9fcb7 feat: add onHtmxLoad and closestFocusable utilities 2026-06-06 16:50:03 +02:00
4bcfc8fdb7 refactor: remove docs folder 2026-06-06 15:54:10 +02:00
b85b29aa13 feat: add top picks for you page 2026-06-06 13:34:18 +02:00
ede17ce8aa test: verify diversity reranker spreads repeated genres 2026-06-05 16:38:27 +02:00
9d964824dc feat: add multi-feature diversity reranker for recommendations 2026-06-05 16:38:19 +02:00
620434f61b feat: dedupe after htmx swap on swap target 2026-06-05 16:32:31 +02:00
6aeb887830 refactor: scope dedupe to parent container 2026-06-05 16:32:21 +02:00
24bc63e8e2 refactor: remove theme toggle from navigation 2026-06-05 16:24:17 +02:00
4791eebf48 refactor: remove theme toggle from footer 2026-06-05 16:24:12 +02:00
6b43fa7ce5 feat: add inline theme script to prevent FOUC 2026-06-05 16:24:07 +02:00
60ba1a4fb5 refactor: follow system color scheme via matchMedia listener 2026-06-05 16:23:59 +02:00
3ea5ea68ff refactor: remove unused htmx global type declaration 2026-06-05 16:23:53 +02:00
97623aad4d style: add color-scheme for light and dark themes 2026-06-05 16:23:50 +02:00
9587dd5a71 feat: add top pick for you section to homepage 2026-06-05 16:15:13 +02:00
8b26e5f036 test: add weighted taste profile and search query tests 2026-06-05 16:15:00 +02:00
b4061bc9b1 feat: integrate profile search into top pick service 2026-06-05 16:14:38 +02:00
e326f89d62 feat: add profile search query builders and weighted scoring 2026-06-05 16:14:28 +02:00
55ee13d4eb feat: timezone-aware schedule with browser tz and JST client conversion 2026-06-05 15:42:23 +02:00
356ac99c64 feat: show audio availability on anime detail page 2026-06-05 13:20:21 +02:00
9d58adea9c refactor: try sub and dub modes in allanime resolution, drop fallback 2026-06-05 13:20:12 +02:00
a8a53d2677 fix: polish watch page layout and button consistency 2026-06-04 16:37:06 +02:00
51ee38bb57 refactor: use recommendation engine in discover for-you 2026-06-04 16:10:15 +02:00
8ae79c301a feat: add recommendation scoring and reranking engine 2026-06-04 16:10:08 +02:00
c725d96035 docs: add recommendation architecture document 2026-06-04 16:09:53 +02:00
ede479c3e1 feat: add loading fragment templates and optimize section triggers 2026-06-04 11:28:34 +02:00
390f6386af feat: wire background warming for detail sections 2026-06-04 11:28:27 +02:00
3fe1135203 feat: warm anime recommendations in background 2026-06-04 11:28:20 +02:00
342bd096da feat: stale-while-revalidate cache for watch order 2026-06-04 11:28:13 +02:00
404fa3c406 feat: add htmx type declarations and process on ready 2026-06-04 11:28:06 +02:00
8b3bd30b6c feat: bundle htmx.org locally instead of loading from unpkg 2026-06-04 11:28:01 +02:00
0c4b35cc4b refactor: replace discover for-you swap with targeted htmx fragment 2026-06-04 11:00:40 +02:00
b639e933ff redesign: schedule page layout with scrollable calendar grid 2026-06-04 10:36:29 +02:00
59d903d400 refactor: consolidate skeleton styles into global css 2026-06-04 10:06:22 +02:00
4316ce3f1d test: add skip segment overrides table check 2026-06-03 09:10:28 +02:00
5604432187 refactor: share jst helpers 2026-06-01 22:32:12 +02:00
0483bc5cc1 refactor: dedupe scrub seek 2026-06-01 22:29:42 +02:00
983981a186 refactor: dedupe next nav 2026-06-01 22:28:49 +02:00
55bf11d8be refactor: share stream url 2026-06-01 22:26:57 +02:00
455490f07d refactor: share dom ready 2026-06-01 22:25:47 +02:00
36435b6eb5 refactor: dedupe html fetch 2026-06-01 22:24:27 +02:00
340daeadc6 refactor: dedupe html headers 2026-06-01 22:23:10 +02:00
625c3bbe25 refactor: dedupe repo tx 2026-06-01 22:22:14 +02:00
d5406a6857 refactor: dedupe jikan refresh 2026-06-01 22:21:19 +02:00
9f754012eb test: dedupe jikan bool cases 2026-06-01 22:20:12 +02:00
58036bea5a refactor: dedupe season fetch 2026-06-01 22:19:10 +02:00
25a8167461 refactor: dedupe anime warnings 2026-06-01 22:18:23 +02:00
70be78fd7b refactor: dedupe allanime requests 2026-06-01 22:17:02 +02:00
fbd2c5b602 refactor: dedupe watchlist ids 2026-06-01 22:15:21 +02:00
bfe23276ba refactor: dedupe proxy handlers 2026-06-01 22:14:15 +02:00
208281aee7 refactor: dedupe browse render 2026-06-01 22:12:49 +02:00
7943822194 refactor: dedupe allanime sources 2026-06-01 22:11:29 +02:00
d9ed4287a5 fix: hide scrollbar on studio and genre dropdowns 2026-06-01 19:04:03 +02:00
e907c7ae07 fix: hide episode list scrollbar on desktop 2026-06-01 19:00:11 +02:00
957905299e fix: give toggle inactive state a solid background 2026-06-01 18:58:55 +02:00
a865da79d4 fix: open More dropdown upward on watch page 2026-06-01 18:53:57 +02:00
156cb92fbe docs: add package comments to public and template packages 2026-06-01 12:55:53 +02:00
1861e20e2a docs: add package comments to server and watchlist packages 2026-06-01 12:55:48 +02:00
e146b0320a docs: add package comments to playback packages 2026-06-01 12:55:43 +02:00
fdd09bc004 docs: add package comments to anime and episodes packages 2026-06-01 12:55:38 +02:00
10c3923352 docs: add package comments to data layer packages 2026-06-01 12:55:33 +02:00
b862b6e08b docs: add package comments to auth and audit packages 2026-06-01 12:55:28 +02:00
5e553ceecc docs: add package comments to core infrastructure packages 2026-06-01 12:55:23 +02:00
475625de35 docs: add package comments to integrations 2026-06-01 12:55:20 +02:00
f4b3d1bccb feat: refacotr cmd/user/main.go 2026-05-31 19:17:05 +02:00
7f45e62dce refactor: extract generic graphql client 2026-05-31 19:02:35 +02:00
2b761127a0 chore: cleanup 2026-05-31 00:39:20 +02:00
1da19d500e feat: extract video module and add mode-switch fallback 2026-05-31 00:39:01 +02:00
2e3650b77b fix: sort scraped schedule entries by time within each day 2026-05-30 13:12:46 +02:00
9321f36a0f style: format cmd/readme table alignment 2026-05-29 21:24:27 +02:00
77acc627dc docs: improve readmes for cmd and template components 2026-05-29 21:24:00 +02:00
6929124ee3 fix: episode refresh resilience and allanime fallback 2026-05-29 21:12:53 +02:00
0b27974258 docs: clarify animeschedule api key is optional 2026-05-29 19:10:58 +02:00
dd38b1f7ba fix: remove forgejo ci/cd 2026-05-29 19:07:29 +02:00
c94e0699f3 feat: add create-user cli to image 2026-05-29 13:01:04 +02:00
cfb0ea724d feat: add end-state detection and prevent airing auto-complete 2026-05-29 00:04:17 +02:00
32586d6b08 feat: add airing status and end-state helpers to player 2026-05-29 00:04:05 +02:00
aebdd75942 fix: preserve watchlist progress on complete and status update 2026-05-29 00:03:47 +02:00
f89012f23c refactor: redesign schedule with responsive grid and expanded spacing 2026-05-28 23:24:50 +02:00
1242297742 feat: prefer english titles from animeschedule api 2026-05-28 23:24:39 +02:00
e8dcf1466b refactor: decompose anime handler and parallelize for-you fetches 2026-05-28 17:45:56 +02:00
54b03f85a2 refactor: remove CONFLICTS.md and inline avatar URL from migration 2026-05-28 12:52:10 +02:00
5dd49e585a refactor: extract CurrentUser and CurrentUserID helpers 2026-05-28 12:51:11 +02:00
04b241392c refactor: remove unused watchlist partial template 2026-05-28 12:47:38 +02:00
fd36b97908 refactor: replace regex parser with JSON walker in allanime extractor 2026-05-28 12:40:51 +02:00
f9a2649bec refactor: update template embed to remove anime subdirectory 2026-05-28 12:40:35 +02:00
dc09dcc547 refactor: update backfill migration to use internal.DefaultAvatarURL 2026-05-28 12:40:27 +02:00
271a24dbbe refactor: update user CLI to use internal.DefaultAvatarURL 2026-05-28 12:40:19 +02:00
363121465b refactor: update audit middleware to use flattened audit package 2026-05-28 12:40:11 +02:00
cf9c60ba70 refactor: update watchlist module imports for flattened package structure 2026-05-28 12:40:03 +02:00
cdebd407e4 refactor: update playback module imports for flattened package structure 2026-05-28 12:39:55 +02:00
82543d39fb refactor: update auth module imports for flattened package structure 2026-05-28 12:39:44 +02:00
c000e7c778 refactor: update audit module imports for flattened package structure 2026-05-28 12:39:35 +02:00
65a1d15383 refactor: update anime module imports for flattened package structure 2026-05-28 12:39:26 +02:00
7122a5d34d refactor: move reviews template from subdirectory 2026-05-28 12:39:07 +02:00
0b115e583d refactor: move watchlist service from subdirectory 2026-05-28 12:38:58 +02:00
589bf53597 refactor: move watchlist repository from subdirectory 2026-05-28 12:38:50 +02:00
6e2ba51c28 refactor: move watchlist handler from subdirectory 2026-05-28 12:38:42 +02:00
8b405845a1 refactor: move playback service from subdirectory 2026-05-28 12:38:34 +02:00
ceec637a43 refactor: move playback repository from subdirectory 2026-05-28 12:38:26 +02:00
21b84d7440 refactor: move anime service from subdirectory 2026-05-28 12:38:18 +02:00
7cdbcd7c04 refactor: move anime repository from subdirectory 2026-05-28 12:38:09 +02:00
68462d5591 refactor: move anime handler from subdirectory 2026-05-28 12:38:00 +02:00
9a0506913c refactor: move auth service from subdirectory 2026-05-28 12:37:51 +02:00
221155bed3 refactor: move auth repository from subdirectory 2026-05-28 12:37:42 +02:00
f9543d0d79 refactor: move auth middleware from subdirectory 2026-05-28 12:37:33 +02:00
d0d115cc93 refactor: move auth handler from subdirectory 2026-05-28 12:37:25 +02:00
f392610b4e refactor: move audit service test from internal/audit/service to internal/audit 2026-05-28 12:37:17 +02:00
3bbcc71460 refactor: move audit service from internal/audit/service to internal/audit 2026-05-28 12:37:09 +02:00
cb51800ae3 refactor: move audit context from internal/auditctx to internal/audit 2026-05-28 12:37:01 +02:00
b29fb5a3d6 refactor: move avatar from internal/users to internal 2026-05-28 12:36:50 +02:00
9695d7772d refactor: update playback handler imports for flattened pkg/net 2026-05-28 12:36:31 +02:00
866d293419 refactor: update watchorder imports for flattened pkg/net 2026-05-28 12:36:21 +02:00
91f6ba9db8 refactor: update allanime client imports for flattened pkg/net 2026-05-28 12:36:04 +02:00
3c1e4d34a9 refactor: update jikan imports for flattened pkg/net 2026-05-28 12:35:56 +02:00
186ea65545 refactor: update animeschedule imports for flattened pkg/net 2026-05-28 12:35:47 +02:00
3b930c5b79 refactor: flatten pkg/net/utls into pkg/net 2026-05-28 12:35:26 +02:00
0f96ec0c18 refactor: flatten pkg/net/useragent into pkg/net 2026-05-28 12:35:17 +02:00
0dbe4e75bc refactor: flatten pkg/net/proxytransport into pkg/net 2026-05-28 12:35:08 +02:00
05b9dfd216 refactor: flatten pkg/net/limits into pkg/net 2026-05-28 12:35:00 +02:00
f7e5f46234 refactor: move utls client from package var to provider field 2026-05-28 12:18:52 +02:00
fe0de5a214 refactor: centralize avatar URL generation and backfill existing users 2026-05-28 12:18:03 +02:00
dd4c7f80f3 feat: add transactional InTx to playback and watchlist repos 2026-05-28 12:17:19 +02:00
4329bce4a7 refactor: decouple domain types from jikan 2026-05-28 12:14:10 +02:00
6cc25af18a refactor: switch playback to AnimePlaybackService interface 2026-05-28 12:12:09 +02:00
3e67602e92 refactor: wire anime handler to use new service interfaces via fx 2026-05-28 12:12:00 +02:00
794eb8da27 refactor: split AnimeService into segregated interfaces 2026-05-28 12:11:53 +02:00
b52cd311a5 chore: format player main 2026-05-28 11:30:01 +02:00
a48d48f5ad chore: format player controls 2026-05-28 11:29:52 +02:00
8a21dadf21 chore: format player skip editor 2026-05-28 11:29:43 +02:00
a0c5005937 chore: format player skip index and segments 2026-05-28 11:29:33 +02:00
606df97eae chore: format player subtitles 2026-05-28 11:29:23 +02:00
fab242736d chore: format player episode nav and ui 2026-05-28 11:29:14 +02:00
15d311ace6 chore: format player episode complete and thumbnails 2026-05-28 11:29:04 +02:00
47b96107a5 chore: format player mode and state 2026-05-28 11:28:56 +02:00
2f88c14620 chore: format player progress quality keyboard 2026-05-28 11:28:46 +02:00
4a3e2e19d8 chore: format player storage and timeline 2026-05-28 11:28:36 +02:00
a4cf0375b7 chore: format watchlist 2026-05-28 11:28:25 +02:00
9f88e48786 chore: format search 2026-05-28 11:28:15 +02:00
8578bdb9e3 chore: format schedule_board 2026-05-28 11:28:02 +02:00
4b883c6572 chore: format toast and sort_filter 2026-05-28 11:27:52 +02:00
9375cf68b4 chore: format theme and timezone 2026-05-28 11:27:43 +02:00
c2931f941a chore: format htmx and shell 2026-05-28 11:27:32 +02:00
41128bd632 chore: format discover and dropdown 2026-05-28 11:27:22 +02:00
2823c6f026 chore: format anime and dedupe 2026-05-28 11:27:12 +02:00
fa7fe2f178 chore: format style.css 2026-05-28 11:27:01 +02:00
40204f04a1 chore: format small utility files 2026-05-28 11:26:51 +02:00
6868722061 chore: format scripts/new-data-fix.ts 2026-05-28 11:26:36 +02:00
049c78ac06 chore: update bun.lock for oxlint and oxfmt 2026-05-28 11:26:02 +02:00
eaeb2d09ee ci: replace prettier and eslint with oxfmt and oxlint 2026-05-28 11:25:36 +02:00
650a415c2d chore: replace eslint and prettier with oxlint and oxfmt 2026-05-28 11:25:26 +02:00
a9b20dff4c chore: update lefthook hooks for oxlint and oxfmt 2026-05-28 11:25:02 +02:00
28dc915a8d chore: remove eslint config 2026-05-28 11:24:53 +02:00
7b23d3f4c1 feat: add oxfmt configuration 2026-05-28 11:24:39 +02:00
843b98db5b feat: add oxlint configuration 2026-05-28 11:24:27 +02:00
3233894e6a ci: gracefully skip docker build if unavailable 2026-05-28 09:32:44 +02:00
dd482da9aa docs: remove ci section from readme 2026-05-28 09:11:47 +02:00
ef52daf3fa ci: use golangci-lint v2 install path 2026-05-28 09:08:38 +02:00
98e6ca64d1 ci: add forgejo actions workflows 2026-05-27 20:51:28 +02:00
4aa12e9fe5 chore: formatting 2026-05-27 14:05:35 +02:00
bb1eb8cb10 fix: pre push is no more 2026-05-27 12:08:52 +02:00
1076fa58b7 chore: formatting 2026-05-27 11:08:19 +02:00
69cfac8c9f fix: remove redundant type declaration 2026-05-27 11:03:11 +02:00
0ebe6e5963 docs: document ANIMESCHEDULE_API_TOKEN in readme 2026-05-27 11:02:16 +02:00
7e77f57a6f refine: adjust schedule board spacing and grid layout 2026-05-27 11:00:32 +02:00
ab37268e8b chore: remove debug logging from animeschedule integration 2026-05-27 11:00:25 +02:00
5dd6eedc3f feat: wire scraped schedule into handler with caching and week nav 2026-05-27 10:56:37 +02:00
c044ebdda0 feat: add schedule board client logic 2026-05-27 10:56:28 +02:00
c8e0c673ca feat: add animeschedule integration 2026-05-27 10:56:21 +02:00
342 changed files with 27745 additions and 10348 deletions

34
.air.toml Normal file
View File

@@ -0,0 +1,34 @@
root = "."
tmp_dir = "tmp"
[build]
cmd = "just build-dev"
entrypoint = "./tmp/server"
full_bin = "./tmp/server"
delay = 300
exclude_dir = [".git", ".mise", "dist", "node_modules", "tmp"]
exclude_file = ["mal.db", "mal.db-shm", "mal.db-wal"]
exclude_regex = ["_test\\.go"]
exclude_unchanged = true
follow_symlink = false
include_ext = ["css", "go", "gohtml", "html", "sql", "toml", "ts"]
kill_delay = "500ms"
log = "air-build.log"
send_interrupt = true
stop_on_error = true
[color]
app = "white"
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = true
[misc]
clean_on_exit = true
startup_banner = ""

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

@@ -2,7 +2,13 @@ node_modules
dist
.env
*.db
*.db-shm
*.db-journal
*.db-wal
server
main_server
create_user
*.log
*.pid
.DS_Store
.git

View File

@@ -25,7 +25,6 @@ jobs:
http = false
insecure = true
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
@@ -53,11 +52,6 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Install Kustomize
run: |
curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
sudo mv kustomize /usr/local/bin/
- name: Update Kustomize
run: |
IMAGE_TAG=$(echo '${{ steps.meta.outputs.json }}' | jq -r '.tags[] | select(startswith("reg.milasholsting.dk/apps/mal:sha-"))' | cut -d: -f2)

4
.gitignore vendored
View File

@@ -5,11 +5,13 @@ node_modules
out
dist
*.tgz
dist/
# code coverage
coverage
*.lcov
playwright-report/
test-results/
blob-report/
# logs
logs

View File

@@ -3,15 +3,58 @@ version: "2"
linters:
default: none
enable:
- bodyclose
- copyloopvar
- cyclop
- dogsled
- dupl
- errcheck
- funlen
- gocognit
- gocritic
- gocyclo
- govet
- ineffassign
- maintidx
- makezero
- nakedret
- nilerr
- noctx
- prealloc
- predeclared
- revive
- staticcheck
- unconvert
- unparam
- unused
- usestdlibvars
- wastedassign
- whitespace
settings:
gocritic:
disable-all: true
enabled-checks:
- appendCombine
- boolExprSimplify
- commentedOutCode
- commentedOutImport
- deferUnlambda
- dupBranchBody
- dupImport
- dupSubExpr
- emptyDecl
- emptyFallthrough
- emptyStringTest
- equalFold
- redundantSprint
- regexpPattern
- stringConcatSimplify
- typeUnparen
- underef
- unlambda
- unnecessaryBlock
- unnecessaryDefer
- unslice
revive:
enable-all-rules: false
rules:
@@ -39,7 +82,7 @@ linters:
- third_party$
- builtin$
- examples$
- node_modules$
- node_modules/
issues:
max-issues-per-linter: 0

5
.mise.toml Normal file
View File

@@ -0,0 +1,5 @@
[tools]
bun = "1.3.14"
just = "1.53.0"
golangci-lint = "2.12.2"
"go:github.com/air-verse/air" = "latest"

View File

@@ -1,4 +1,56 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": []
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"endOfLine": "lf",
"htmlWhitespaceSensitivity": "css",
"ignorePatterns": ["dist/**", "node_modules/**", "README.md", "static/assets/manifest.json"],
"insertFinalNewline": true,
"jsdoc": true,
"jsxSingleQuote": false,
"objectWrap": "collapse",
"printWidth": 100,
"proseWrap": "always",
"quoteProps": "as-needed",
"semi": true,
"singleAttributePerLine": true,
"singleQuote": false,
"sortImports": {
"groups": [
"side_effect_style",
"side_effect",
{ "newlinesBetween": true },
"type",
"builtin",
"external",
["internal", "subpath"],
["parent", "sibling", "index"],
"style",
"unknown"
],
"ignoreCase": false,
"internalPattern": ["~/**", "@/**", "#/**"],
"newlinesBetween": true,
"order": "asc",
"partitionByComment": false,
"partitionByNewline": false,
"sortSideEffects": false
},
"sortPackageJson": { "sortScripts": true },
"sortTailwindcss": {
"attributes": ["class"],
"functions": ["clsx", "cn", "cva", "tw"],
"preserveDuplicates": false,
"preserveWhitespace": false,
"stylesheet": "./static/assets/style.css"
},
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false,
"overrides": [
{ "files": ["*.md", "**/*.md"], "options": { "proseWrap": "always" } },
{ "files": ["*.json", "**/*.json"], "options": { "printWidth": 120 } }
]
}

View File

@@ -1,15 +1,208 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "unicorn", "oxc"],
"plugins": ["eslint", "import", "typescript", "unicorn", "oxc", "promise", "node"],
"categories": {
"correctness": "error"
"correctness": "error",
"nursery": "error",
"pedantic": "error",
"perf": "error",
"restriction": "off",
"style": "error",
"suspicious": "error"
},
"options": {
"denyWarnings": true,
"maxWarnings": 0,
"reportUnusedDisableDirectives": "error",
"respectEslintDisableDirectives": true,
"typeAware": true,
"typeCheck": true
},
"ignorePatterns": ["dist/**", "node_modules/**", "static/assets/**"],
"env": { "browser": true, "builtin": true, "es2026": true, "node": true },
"rules": {
"typescript/unbound-method": "off",
"typescript/no-base-to-string": "off",
"typescript/no-floating-promises": "off"
"import/exports-last": "off",
"import/group-exports": "off",
"import/no-default-export": "off",
"import/no-mutable-exports": "error",
"import/no-named-export": "off",
"import/no-named-default": "error",
"import/no-self-import": "error",
"import/no-unassigned-import": "off",
"import/no-relative-parent-imports": "off",
"capitalized-comments": "off",
"curly": "error",
"id-length": "off",
"max-lines": "off",
"max-lines-per-function": "off",
"max-statements": "off",
"no-console": "error",
"no-debugger": "error",
"no-empty-function": "error",
"no-eval": "error",
"no-implicit-coercion": "error",
"no-magic-numbers": "off",
"no-negated-condition": "off",
"no-param-reassign": "error",
"no-plusplus": "off",
"no-process-exit": "error",
"no-restricted-globals": [
"error",
{ "name": "event", "message": "Use the event parameter instead of the legacy global." },
{ "name": "name", "message": "Avoid the ambiguous window.name global." }
],
"no-ternary": "off",
"no-undefined": "off",
"no-use-before-define": "off",
"no-warning-comments": "warn",
"oxc/no-async-await": "off",
"oxc/no-barrel-file": "off",
"oxc/no-optional-chaining": "off",
"oxc/no-rest-spread-properties": "off",
"sort-imports": "off",
"sort-keys": "off",
"typescript/array-type": ["error", { "default": "array-simple" }],
"typescript/consistent-type-definitions": ["error", "type"],
"typescript/consistent-type-exports": "error",
"typescript/consistent-type-imports": [
"error",
{ "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports", "prefer": "type-imports" }
],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-member-accessibility": "error",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-base-to-string": "error",
"typescript/no-confusing-non-null-assertion": "error",
"typescript/no-explicit-any": "error",
"typescript/no-floating-promises": "error",
"typescript/no-inferrable-types": "error",
"typescript/no-invalid-void-type": "error",
"typescript/no-misused-promises": "error",
"typescript/no-non-null-assertion": "error",
"typescript/no-unsafe-type-assertion": "off",
"typescript/no-unnecessary-condition": "error",
"typescript/no-unsafe-argument": "error",
"typescript/no-unsafe-assignment": "error",
"typescript/no-unsafe-call": "error",
"typescript/no-unsafe-member-access": "error",
"typescript/no-unsafe-return": "error",
"typescript/no-var-requires": "error",
"typescript/prefer-readonly": "error",
"typescript/prefer-readonly-parameter-types": "off",
"typescript/require-await": "error",
"typescript/restrict-plus-operands": "error",
"typescript/restrict-template-expressions": "error",
"typescript/strict-boolean-expressions": "error",
"typescript/strict-void-return": "off",
"typescript/switch-exhaustiveness-check": "error",
"typescript/unbound-method": "error",
"unicorn/filename-case": "off",
"unicorn/no-array-for-each": "off",
"unicorn/no-array-reduce": "off",
"unicorn/no-null": "off",
"unicorn/no-useless-undefined": "off",
"unicorn/prefer-global-this": "off",
"unicorn/prefer-module": "error",
"unicorn/prefer-query-selector": "error",
"unicorn/prefer-string-replace-all": "error"
},
"env": {
"builtin": true
}
"overrides": [
{
"files": ["static/**/*.ts"],
"rules": {
"curly": "off",
"eqeqeq": "off",
"import/first": "off",
"import/max-dependencies": "off",
"import/no-duplicates": "off",
"import/no-named-as-default-member": "off",
"import/prefer-default-export": "off",
"init-declarations": "off",
"max-params": "off",
"no-console": "off",
"no-continue": "off",
"no-duplicate-imports": "off",
"no-useless-assignment": "off",
"no-inline-comments": "off",
"no-negated-condition": "off",
"no-underscore-dangle": "off",
"no-useless-return": "off",
"prefer-const": "off",
"prefer-destructuring": "off",
"require-await": "off",
"require-unicode-regexp": "off",
"promise/always-return": "off",
"promise/avoid-new": "off",
"promise/param-names": "off",
"promise/prefer-await-to-callbacks": "off",
"promise/prefer-await-to-then": "off",
"oxc/no-map-spread": "off",
"typescript/consistent-type-definitions": "off",
"typescript/explicit-member-accessibility": "off",
"typescript/no-base-to-string": "off",
"typescript/no-floating-promises": "off",
"typescript/no-inferrable-types": "off",
"typescript/no-misused-promises": "off",
"typescript/no-unnecessary-condition": "off",
"typescript/no-unnecessary-type-assertion": "off",
"typescript/no-unnecessary-type-conversion": "off",
"typescript/no-unnecessary-type-parameters": "off",
"typescript/no-unsafe-argument": "off",
"typescript/no-unsafe-assignment": "off",
"typescript/no-unsafe-call": "off",
"typescript/no-unsafe-member-access": "off",
"typescript/no-unsafe-return": "off",
"typescript/prefer-nullish-coalescing": "off",
"typescript/prefer-optional-chain": "off",
"typescript/strict-boolean-expressions": "off",
"typescript/unbound-method": "off",
"unicorn/consistent-function-scoping": "off",
"unicorn/no-array-callback-reference": "off",
"unicorn/no-lonely-if": "off",
"unicorn/no-negated-condition": "off",
"unicorn/prefer-at": "off",
"unicorn/prefer-dom-node-append": "off",
"unicorn/prefer-query-selector": "off",
"unicorn/prefer-spread": "off",
"unicorn/prefer-string-replace-all": "off",
"unicorn/require-module-specifiers": "off"
}
},
{
"files": ["static/**/*.test.ts", "static/**/*.spec.ts"],
"env": { "node": true },
"rules": { "import/no-nodejs-modules": "off" }
},
{
"files": ["**/*.test.ts", "**/*.spec.ts"],
"env": { "vitest": true },
"rules": { "typescript/no-explicit-any": "off" }
},
{
"files": ["tests/e2e/**/*.ts"],
"env": { "browser": false, "node": true },
"rules": {
"import/no-nodejs-modules": "off",
"no-console": "off",
"no-duplicate-imports": "off",
"no-process-exit": "off",
"promise/prefer-await-to-then": "off"
}
},
{
"files": ["scripts/**/*.ts"],
"env": { "browser": false, "node": true },
"rules": {
"import/no-nodejs-modules": "off",
"no-console": "off",
"no-process-exit": "off",
"promise/prefer-await-to-callbacks": "off",
"promise/prefer-await-to-then": "off",
"typescript/no-unnecessary-condition": "off",
"unicorn/no-array-sort": "off",
"unicorn/prefer-string-replace-all": "off",
"unicorn/prefer-top-level-await": "off"
}
}
]
}

View File

@@ -1,11 +0,0 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf"
}

55
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,55 @@
# Code of Conduct
## Our Standard
This project should be a respectful, constructive place to discuss code, design decisions, issues,
and improvements. Contributions and conversations are expected to be professional, specific, and
generous in intent.
Examples of positive behavior include:
- giving feedback that is clear, actionable, and focused on the work;
- assuming good intent while still naming problems directly;
- welcoming questions from people with different experience levels;
- crediting ideas, reports, and contributions accurately;
- disagreeing without making the conversation personal.
Examples of unacceptable behavior include:
- harassment, insults, threats, or discriminatory language;
- sexualized language or imagery in project spaces;
- personal attacks, trolling, or repeated disruptive comments;
- publishing private information without explicit permission;
- pressuring maintainers or contributors outside the scope of the project.
## Scope
This code of conduct applies to project spaces such as issues, pull requests, discussions, commits,
reviews, and any other forum used to coordinate work on this repository. It also applies when
someone is representing the project in public.
## Reporting
If you notice behavior that violates this code of conduct, please contact the maintainer privately.
Include the relevant context, links, screenshots, or timestamps when possible so the report can be
reviewed fairly.
Reports will be handled with care and discretion. The goal is to protect contributors, keep the
project healthy, and respond proportionally to the situation.
## Enforcement
The maintainer may take any action needed to keep the project environment constructive, including:
- clarifying expectations in a thread;
- editing or removing inappropriate comments;
- closing or locking conversations;
- declining contributions;
- limiting or blocking future participation.
Enforcement decisions are based on the behavior, its impact, and the needs of the project community.
## Attribution
This code of conduct is adapted from common open source community standards and tailored for this
repository.

View File

@@ -18,9 +18,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
# Install sqlc for code generation
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0
ENV GOPROXY=direct
COPY go.mod go.sum ./
RUN go mod download
@@ -32,14 +29,11 @@ RUN bun install --frozen-lockfile
COPY . .
# Ensure dist is clean at build time (belt + suspenders)
RUN rm -rf dist/ && bun run build:assets
# Generate sqlc code
RUN sqlc generate
RUN rm -rf dist/ && bun run build:assets && bun run build:ts
# Build the server and CLI tools
RUN go build -ldflags="-s -w" -o main_server ./cmd/server
RUN go build -ldflags="-s -w" -o create-user ./cmd/user
RUN go build -ldflags="-s -w" -o user_admin ./cmd/user
FROM debian:bookworm-slim
@@ -55,13 +49,15 @@ RUN mkdir -p /app/data
ENV DATABASE_FILE=/app/data/mal.db
COPY --from=builder /app/main_server .
COPY --from=builder /app/create-user .
COPY --from=builder /app/user_admin .
COPY --from=builder /app/templates ./templates
COPY --from=builder /app/static ./static
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/internal/database/migrations ./migrations
COPY entrypoint.sh ./entrypoint.sh
RUN printf '%s\n' '#!/bin/sh' 'set -e' 'exec /app/user_admin "$@"' > /app/create-user \
&& chmod +x /app/create-user
EXPOSE 3000
ENTRYPOINT ["./entrypoint.sh"]
ENTRYPOINT ["/app/main_server"]

207
README.md
View File

@@ -1,71 +1,200 @@
# MyAnimeList
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/static/assets/readme-logo-dark.svg" />
<img src="/static/assets/readme-logo-light.svg" alt="MyAnimeList logo" width="120" />
</picture>
<img src="/static/assets/logo-128.png" alt="MyAnimeList logo" width="120" />
</p>
<p align="center">
<strong>A local-first anime catalog, watchlist, recommendation, and playback app.</strong>
</p>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/go-1.25-00ADD8?style=flat-square&logo=go" />
<img alt="SQLite" src="https://img.shields.io/badge/database-sqlite-003B57?style=flat-square&logo=sqlite" />
<img alt="Bun" src="https://img.shields.io/badge/runtime-bun-000000?style=flat-square&logo=bun" />
<img alt="Tailwind" src="https://img.shields.io/badge/tailwind-4-06D6D4?style=flat-square&logo=tailwindcss" />
<img alt="HTMX" src="https://img.shields.io/badge/htmx-partial--updates-3366CC?style=flat-square" />
<img alt="License" src="https://img.shields.io/badge/license-MIT-green?style=flat-square" />
</p>
---
MyAnimeList is a self-hosted media app for browsing anime, managing a watchlist, resuming episodes,
and playing streams through a browser-based player. It collects the parts of an anime workflow that
usually live across several products and keeps them in one small Go application backed by SQLite.
I built this because nothing else felt right. Every tracker I tried had decent pieces but the whole never clicked — awkward UI, missing features, or it just got in the way of actually watching anime. So I built one that fits how I work.
I built it as a portfolio project, but the goal was never to make a disposable demo. The interesting
part of the project is the product shape: server-rendered pages, a local database, provider
integrations, playback proxying, recommendations, migrations, tests, and a TypeScript player that
only appears where browser state actually earns its place.
It is a self-hosted Go server that streams anime through a proxy layer, catalogs metadata, and tracks your progress.
> [!NOTE]
> This is a personal, local-first project. It is written to demonstrate product engineering choices,
> not to present itself as an official MyAnimeList client or a hosted streaming platform.
The frontend is Tailwind CSS v4 with HTMX handling pagination, infinite scroll, search, and watchlist interactions. TypeScript only steps in where HTMX cannot — the video player, command palette bound to Cmd+K, skip segment editor, theme toggling with system preference detection, and custom UI components. Everything lives in one process, one SQLite database, one deployment.
### Contents
## Repository structure
- [What This Project Is](#what-this-project-is)
- [What It Includes](#what-it-includes)
- [How It Is Built](#how-it-is-built)
- [Working Locally](#working-locally)
- [Repository Map](#repository-map)
| Path | Purpose |
| ----------------- | ------------------------------------------------ |
| `api/*` | Feature routes: anime, auth, playback, watchlist |
| `cmd/server` | Application entrypoint and CLI commands |
| `cmd/user` | User management CLI (create, update, delete) |
| `integrations/*` | External API clients and scraping |
| `internal/*` | Core services: db, middleware, server, worker |
| `pkg/middleware` | Generic HTTP middleware |
| `templates/*` | Server-rendered HTML templates |
| `migrations` | Schema evolution (20 migrations) |
| `static` / `dist` | Frontend assets |
### What This Project Is
## Running locally
This project started from a simple idea: anime tracking becomes more interesting when catalog data,
personal progress, and playback live in the same interface. A user should be able to discover a
title, inspect its metadata, add it to a watchlist, watch an episode, come back later, and continue
from the right place without stitching that flow together manually.
Requires Go `1.25+`, Bun, and [just](https://github.com/casey/just). Migrations run on startup. Configuration lives in environment variables — see `cmd/server/main.go` for the full list.
That makes the app a useful playground for real application concerns. It has authentication,
long-lived user state, external APIs, background refresh behavior, migrations, data fixes, cache
boundaries, provider-specific code, and enough frontend complexity to justify TypeScript without
turning the whole product into a single-page app.
An optional API key from [animeschedule.net](https://animeschedule.net) can be used for the schedule board to enable English titles and improve performance. Create an account, generate a token under your profile, and set it as `ANIMESCHEDULE_API_TOKEN`.
The project is also intentionally modest. It uses a single Go server and a SQLite database because
those choices make the system easy to run, inspect, and reason about. The architecture is more about
clear ownership than novelty: feature packages own their handlers and services, integrations stay at
the edges, and the UI is mostly rendered by the server.
### What It Includes
| Area | What it does |
| --------------- | ------------------------------------------------------------------------------------------------------------ |
| Catalog | Browse, search, and inspect anime metadata from external catalog sources. |
| Details | Render synopsis, reviews, characters, statistics, relations, themes, and watch-order data. |
| Watchlist | Store local user state for saved titles, statuses, and progress-driven flows. |
| Playback | Serve watch pages, proxy streams/subtitles, rewrite playlists, and track progress. |
| Player | Handle HLS playback, quality selection, subtitles, keyboard controls, episode navigation, and skip segments. |
| Recommendations | Generate personal top picks from watchlist signals and recommendation data. |
| Maintenance | Run migrations, startup fixes, local user commands, and data repair scripts. |
<details>
<summary><strong>Implementation notes</strong></summary>
The backend is written in Go with Gin for HTTP routing and Fx for module wiring. SQLite is used for
local persistence, with migrations and data fixes committed alongside the application. Templates are
rendered on the server, HTMX handles small partial updates, and TypeScript powers the interactive
parts of the browser experience.
The most stateful frontend code lives under `static/player`, where the app handles playback mode,
source loading, progress storage, subtitles, timelines, quality changes, keyboard shortcuts, skip
segments, episode completion, and thumbnail navigation.
</details>
### How It Is Built
The application is organized around product boundaries rather than framework layers.
`internal/anime` owns catalog-facing behavior, `internal/watchlist` owns saved user state,
`internal/playback` owns watch data and proxy behavior, and `integrations` contains provider
clients. This keeps the core app from depending directly on the details of a specific metadata or
playback source.
Server-rendered templates are the default because most pages are content-heavy and benefit from
simple request-response rendering. TypeScript is used where the browser has real ongoing state:
search interactions, theme handling, carousels, watchlist actions, toast messages, and especially
the video player.
The result is a codebase that behaves like a small product rather than a tutorial project: it has a
repeatable toolchain, database evolution, local maintenance commands, focused tests, and a clear
split between app code and external integrations.
### Working Locally
The local workflow assumes [`mise`](https://mise.jdx.dev/) for tool versions and `just` for common
commands.
```bash
mise install
bun install
just dev
```
## Quality checks
The development server runs on `http://localhost:3000` by default. `just dev` uses Air to rebuild
the Go server and frontend assets when relevant files change.
Playback proxying requires a local `PLAYBACK_PROXY_SECRET` so the server can mint stream and
subtitle proxy tokens. Generate a strong value and add it to `.env` before using playback:
```bash
gofmt -l .
go test ./...
go build -o server ./cmd/server
golangci-lint run ./...
go mod tidy
go test -race ./...
bunx oxfmt --check
bun run lint:ts
bun run typecheck
bun run build:assets
docker build -t mal:ci .
echo "PLAYBACK_PROXY_SECRET=$(openssl rand -base64 32)" >> .env
```
## Contributing
Create a local user with:
Bug reports and pull requests are welcome. This is a personal project, so there is no strict roadmap or issue triage cycle. If something is broken or missing, open an issue or send a PR.
```bash
go run ./cmd/user <username> <password>
```
## License
#### Commands
MIT. See `LICENSE`.
| Command | Use it for |
| ------------------------------- | --------------------------------------------------- |
| `just setup` | Install pinned tools and Bun dependencies. |
| `just dev` | Run the app locally with live rebuilds. |
| `just build` | Build the Go binary, CSS, and TypeScript assets. |
| `just test` | Run the Go test suite. |
| `just check` | Run linting, tests, typechecking, and a full build. |
| `just lint-go` / `just lint-ts` | Run backend or frontend linting separately. |
| `just typecheck` | Run TypeScript without emitting files. |
| `just run` | Build and run the compiled server. |
| `just clean` | Remove generated build output. |
<details>
<summary><strong>Configuration</strong></summary>
Configuration is loaded from environment variables, and a local `.env` file is read automatically.
| Variable | Default | Purpose |
| --------------------------- | --------------- | -------------------------------------------------------------------------- |
| `PORT` | `3000` | HTTP port for the server. |
| `DATABASE_FILE` | `mal.db` | SQLite database path. |
| `GIN_MODE` | release default | Gin runtime mode. |
| `MAL_CORS_ALLOW_ALL` | disabled | Allows any origin when set to `1`; intended for local/proxy setups. |
| `PLAYBACK_PROXY_SECRET` | empty | Secret used to mint playback proxy tokens; required for playback proxying. |
| `EPISODE_AVAILABILITY_MODE` | `auto` | Episode availability strategy: `auto`, `legacy`, or `jikan`. |
| `MAL_JIKAN_TRACE` | disabled | Enables optional Jikan client tracing when truthy. |
</details>
<details>
<summary><strong>Maintenance commands</strong></summary>
| Command | Use it for |
| ------------------------ | ---------------------------------------------------------- |
| `just new-data-fix name` | Scaffold a new data-fix file. |
| `just run-fixes` | Run registered data fixes through `cmd/user`. |
| `just fix-all` | Run the Bun maintenance script for data fixes. |
| `bun run format` | Format TypeScript and related frontend files with `oxfmt`. |
</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
| Path | Responsibility |
| -------------------------------- | --------------------------------------------------------------- |
| `cmd/server` | Web server entry point. |
| `cmd/user` | Local user and maintenance commands. |
| `internal/anime` | Catalog, details, browse, search, reviews, and recommendations. |
| `internal/auth` | Authentication, middleware, and local user handling. |
| `internal/watchlist` | Watchlist handlers, service logic, and persistence. |
| `internal/playback` | Watch data, progress, proxy tokens, and skip segments. |
| `internal/episodes` | Episode refresh and provider mapping. |
| `internal/database` | SQLite setup, migrations, and startup data fixes. |
| `integrations/jikan` | Jikan API client and catalog types. |
| `integrations/playback/allanime` | Playback provider client and extraction logic. |
| `templates` | Server-rendered pages and reusable components. |
| `static` | TypeScript source for client-side behavior. |
| `scripts` | Bun-powered development and maintenance scripts. |
Released under the [MIT License](LICENSE).

71
SECURITY.md Normal file
View File

@@ -0,0 +1,71 @@
# Security Policy
## Supported Versions
This is a personal portfolio project, so there is no formal long-term support schedule. Security
fixes are applied to the current main branch when issues are confirmed and within the practical
maintenance capacity of the project.
## Reporting A Vulnerability
Please do not open a public issue for a security vulnerability.
Report security concerns privately to the repository maintainer. Include as much detail as you can:
- a description of the vulnerability;
- steps to reproduce the issue;
- affected routes, commands, files, or configuration;
- the potential impact;
- any suggested fix or mitigation, if you have one.
You can expect a best-effort response acknowledging the report, followed by validation and a fix
when the issue is reproducible and in scope.
## Security Scope
The most important security areas for this project are:
- local authentication and session handling;
- watchlist and playback progress data;
- playback proxy tokens and signed stream access;
- subtitle and playlist proxying;
- external provider integration boundaries;
- SQLite database access and migrations;
- configuration loaded from environment variables or `.env` files.
Reports involving these areas are especially useful.
## Out Of Scope
The following are generally out of scope unless they expose a direct application vulnerability:
- issues that require full local machine access;
- denial-of-service reports against a local development server;
- vulnerabilities in third-party services outside this repository;
- missing production hardening for deployments that are not documented or supported by the project;
- social engineering or physical attacks.
## Operational Notes
This application is designed to be self-hosted and local-first. If you deploy it beyond a private
local environment, you are responsible for the surrounding production controls, including TLS,
network access, backups, secrets management, reverse proxy configuration, logging retention, and
dependency monitoring.
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.
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
Dependencies are managed through Go modules and Bun. When updating dependencies, run the normal
local checks before merging:
```bash
just check
```
Security-related dependency updates should be kept small and reviewed separately when possible.

160
bun.lock
View File

@@ -3,14 +3,14 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "myanimelist-ui",
"name": "mal",
"dependencies": {
"hls.js": "^1.6.16",
"htmx.org": "1.9.12",
},
"devDependencies": {
"@tailwindcss/cli": "^4.3.0",
"@types/node": "^24.0.0",
"jiti": "^2.7.0",
"lefthook": "^2.1.6",
"oxfmt": "^0.52.0",
"oxlint": "^1.67.0",
@@ -81,139 +81,147 @@
"@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=="],
"@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/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/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/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/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": ["@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-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="],
"@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-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w=="],
"@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-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw=="],
"@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-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA=="],
"@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-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw=="],
"@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-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-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ=="],
"@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=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ=="],
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
"@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
"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.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
"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=="],
"hls.js": ["hls.js@1.6.16", "", {}, "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA=="],
"htmx.org": ["htmx.org@1.9.12", "", {}, "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"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=="],
"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=="],
@@ -241,42 +249,48 @@
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
}
}

View File

@@ -1,14 +0,0 @@
# cmd
Application entrypoints.
| binary | purpose |
| ------------ | -------------------------------- |
| `cmd/server` | HTTP server and worker processes |
| `cmd/user` | User management CLI |
## Conventions
- Each subdirectory is a `package main` that compiles to a standalone binary.
- Shared logic lives in `internal/` or `pkg/`, not in `cmd/`.
- Configuration is read from environment variables — see each binary's `main.go` for the full list.

View File

@@ -2,14 +2,17 @@
package main
import (
"mal/internal/app"
"mal/internal"
"mal/internal/observability"
"github.com/joho/godotenv"
)
func main() {
_ = godotenv.Load()
if err := godotenv.Load(); err != nil {
observability.Warn("env_file_load_failed", "server", "", nil, err)
}
application := app.NewApp()
application := internal.NewApp()
application.Run()
}

View File

@@ -1,250 +1,195 @@
// Package main provides small CLI utilities for local admin tasks.
// Package main provides local user administration commands.
package main
import (
"bufio"
"context"
"database/sql"
"errors"
"fmt"
"os"
"strings"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"io"
"mal/internal"
"mal/internal/config"
"mal/internal/database"
"mal/internal/db"
"mal/internal/observability"
"os"
"strings"
"time"
"github.com/google/uuid"
"github.com/joho/godotenv"
"golang.org/x/crypto/bcrypt"
"golang.org/x/term"
)
func main() {
cfg, err := config.Load()
if err != nil {
observability.Error("cli_config_load_failed", "cmd/user", "", nil, err)
if err := godotenv.Load(); err != nil {
observability.Warn("env_file_load_failed", "user", "", nil, err)
}
if err := run(os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
os.Exit(1)
}
dbConn, err := db.Open(cfg.DatabaseFile)
if err != nil {
observability.Error("cli_db_open_failed", "cmd/user", "", map[string]any{"db_file": cfg.DatabaseFile}, err)
os.Exit(1)
}
defer func() { _ = dbConn.Close() }()
os.Exit(run(dbConn, os.Args))
}
func run(dbConn *sql.DB, args []string) int {
cmd, err := parseArgs(args)
if err != nil {
observability.Warn("cli_usage", "cmd/user", "invalid arguments", map[string]any{"argc": len(args)}, err)
_, _ = fmt.Fprintln(os.Stderr, usage())
return 2
func run(args []string) error {
if len(args) == 1 && args[0] == "run-fixes" {
return runFixes()
}
switch cmd.kind {
case commandUpdateAvatar:
updateAvatars(dbConn)
return 0
case commandRunFixes:
runFixes(dbConn)
return 0
case commandCreateOrUpdateUser:
if err := createOrUpdateUser(dbConn, cmd.username, cmd.password); err != nil {
return 1
}
return 0
default:
observability.Error("cli_command_unreachable", "cmd/user", "", map[string]any{"kind": cmd.kind}, errors.New("unhandled command"))
return 1
if len(args) != 1 && len(args) != 2 {
return errors.New("usage: create-user <username> [password]")
}
}
type commandKind string
const (
commandUpdateAvatar commandKind = "update-avatar"
commandRunFixes commandKind = "run-fixes"
commandCreateOrUpdateUser commandKind = "create-or-update-user"
)
type command struct {
kind commandKind
username string
password string
}
func parseArgs(args []string) (command, error) {
username := strings.TrimSpace(args[0])
password := ""
if len(args) == 2 {
switch args[1] {
case string(commandUpdateAvatar):
return command{kind: commandUpdateAvatar}, nil
case string(commandRunFixes):
return command{kind: commandRunFixes}, nil
}
password = args[1]
}
if username == "" {
return errors.New("username must not be empty")
}
if len(args) == 3 {
return command{
kind: commandCreateOrUpdateUser,
username: args[1],
password: args[2],
}, nil
}
return command{}, errors.New("invalid arguments")
}
func usage() string {
return "Usage: go run cmd/user/main.go <username> <password>\n go run cmd/user/main.go update-avatar\n go run cmd/user/main.go run-fixes"
}
func createOrUpdateUser(dbConn *sql.DB, username string, password string) error {
existingID, err := lookupUserID(dbConn, username)
sqlDB, err := openDatabase()
if err != nil {
observability.Error("cli_user_lookup_failed", "cmd/user", "", map[string]any{"username": username}, err)
return err
}
defer sqlDB.Close()
if existingID != "" {
if !promptConfirmOverwrite(username) {
fmt.Println("Operation cancelled.")
return nil
}
if err := updateUserPassword(dbConn, existingID, username, password); err != nil {
return err
}
fmt.Printf("Password for '%s' updated successfully!\n", username)
if err := internal.RunMigrationsAndFixes(sqlDB); err != nil {
return fmt.Errorf("prepare database: %w", err)
}
return createOrUpdateUser(sqlDB, username, password)
}
func runFixes() error {
sqlDB, err := openDatabase()
if err != nil {
return err
}
defer sqlDB.Close()
if err := internal.RunMigrationsAndFixes(sqlDB); err != nil {
return fmt.Errorf("run migrations and fixes: %w", err)
}
fmt.Println("Database migrations and fixes complete")
return nil
}
func openDatabase() (*sql.DB, error) {
cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("load config: %w", err)
}
sqlDB, err := db.Open(cfg.DatabaseFile)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
return sqlDB, nil
}
func createOrUpdateUser(sqlDB *sql.DB, username, password string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var userID string
err := sqlDB.QueryRowContext(ctx, `SELECT id FROM user WHERE username = ? LIMIT 1`, username).Scan(&userID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("check user: %w", err)
}
userExists := err == nil
if !userExists {
return createUser(ctx, sqlDB, username, password)
}
update, err := confirmPasswordUpdate(username)
if err != nil {
return err
}
if !update {
fmt.Println("No changes made")
return nil
}
if err := createUser(dbConn, username, password); err != nil {
return err
}
fmt.Printf("User '%s' was created successfully!\n", username)
return nil
return updateUserPassword(ctx, sqlDB, userID, username, password)
}
func lookupUserID(dbConn *sql.DB, username string) (string, error) {
var id string
err := dbConn.QueryRow("SELECT id FROM user WHERE username = ?", username).Scan(&id)
if err == nil {
return id, nil
}
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return "", err
}
func promptConfirmOverwrite(username string) bool {
fmt.Printf("User '%s' already exists. Do you want to overwrite their password? [y/N]: ", username)
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
return response == "y" || response == "yes"
}
func updateUserPassword(dbConn *sql.DB, userID string, username string, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
func createUser(ctx context.Context, sqlDB *sql.DB, username, password string) error {
password, err := resolvePassword(password)
if err != nil {
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
return err
}
_, err = dbConn.Exec("UPDATE user SET password_hash = ? WHERE id = ?", string(hash), userID)
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
observability.Error("cli_user_password_update_failed", "cmd/user", "", map[string]any{"username": username}, err)
return err
}
return nil
}
func createUser(dbConn *sql.DB, username string, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
return err
return fmt.Errorf("hash password: %w", err)
}
id := uuid.New().String()
avatarURL := internal.DefaultAvatarURL(username)
_, err = dbConn.Exec(
"INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)",
id,
username,
string(hash),
avatarURL,
_, err = sqlDB.ExecContext(
ctx,
`INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)`,
uuid.NewString(), username, string(passwordHash), internal.DefaultAvatarURL(username),
)
if err != nil {
observability.Error("cli_user_create_failed", "cmd/user", "", map[string]any{"username": username}, err)
return err
return fmt.Errorf("create user: %w", err)
}
fmt.Printf("Created user %q\n", username)
return nil
}
func updateAvatars(dbConn *sql.DB) {
rows, err := dbConn.Query("SELECT id, username FROM user")
func updateUserPassword(ctx context.Context, sqlDB *sql.DB, userID, username, password string) error {
password, err := resolvePassword(password)
if err != nil {
observability.Error("cli_users_list_failed", "cmd/user", "", nil, err)
os.Exit(1)
}
defer func() { _ = rows.Close() }()
count := 0
for rows.Next() {
var id, username string
if err := rows.Scan(&id, &username); err != nil {
observability.Error("cli_user_scan_failed", "cmd/user", "", nil, err)
os.Exit(1)
}
avatarURL := internal.DefaultAvatarURL(username)
_, err := dbConn.Exec("UPDATE user SET avatar_url = ? WHERE id = ?", avatarURL, id)
if err != nil {
observability.Error("cli_user_avatar_update_failed", "cmd/user", "", map[string]any{"username": username}, err)
os.Exit(1)
}
count++
return err
}
if err := rows.Err(); err != nil {
observability.Error("cli_users_iter_failed", "cmd/user", "", nil, err)
os.Exit(1)
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
fmt.Printf("Updated avatars for %d user(s)\n", count)
if _, err := sqlDB.ExecContext(ctx, `UPDATE user SET password_hash = ? WHERE id = ?`, string(passwordHash), userID); err != nil {
return fmt.Errorf("update password: %w", err)
}
fmt.Printf("Updated password for user %q\n", username)
return nil
}
func runFixes(dbConn *sql.DB) {
if err := database.RunMigrationsAndFixes(dbConn); err != nil {
observability.Error("cli_run_migrations_and_fixes_failed", "cmd/user", "", nil, err)
os.Exit(1)
func resolvePassword(password string) (string, error) {
if password != "" {
return password, nil
}
rows, err := dbConn.Query("SELECT id, applied_at FROM data_fixes ORDER BY id ASC")
fmt.Print("Password: ")
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
observability.Error("cli_data_fixes_list_failed", "cmd/user", "", nil, err)
os.Exit(1)
return "", fmt.Errorf("read password: %w", err)
}
defer func() { _ = rows.Close() }()
count := 0
for rows.Next() {
var id string
var appliedAt string
if err := rows.Scan(&id, &appliedAt); err != nil {
observability.Error("cli_data_fix_scan_failed", "cmd/user", "", nil, err)
os.Exit(1)
}
fmt.Printf("%s applied_at=%s\n", id, appliedAt)
count++
if len(passwordBytes) == 0 {
return "", errors.New("password must not be empty")
}
return string(passwordBytes), nil
}
func confirmPasswordUpdate(username string) (bool, error) {
fmt.Printf("User %q already exists. Change password? [Y/n] ", username)
answer, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return false, fmt.Errorf("read confirmation: %w", err)
}
switch strings.ToLower(strings.TrimSpace(answer)) {
case "", "y", "yes":
return true, nil
case "n", "no":
return false, nil
default:
return false, errors.New("invalid response; enter y or n")
}
if err := rows.Err(); err != nil {
observability.Error("cli_data_fixes_iter_failed", "cmd/user", "", nil, err)
os.Exit(1)
}
fmt.Printf("Applied fixes: %d\n", count)
}

4
create-user Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -e
exec go run ./cmd/user "$@"

View File

@@ -9,4 +9,4 @@ spec:
destination:
name: mal
create: true
refreshAfter: 1h
refreshAfter: 1h

View File

@@ -8,7 +8,7 @@ spec:
app: mal
type: ClusterIP
ports:
- name: mal
protocol: TCP
port: 3000
targetPort: 3000
- name: mal
protocol: TCP
port: 3000
targetPort: 3000

View File

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

View File

@@ -1,12 +0,0 @@
#!/bin/sh
set -e
: "${DATABASE_FILE:=/app/data/mal.db}"
if [ ! -x /app/main_server ]; then
echo "ERROR: /app/main_server not found or not executable" >&2
exit 1
fi
exec /app/main_server

3
go.mod
View File

@@ -16,6 +16,7 @@ require (
github.com/gin-gonic/gin v1.12.0
github.com/pressly/goose/v3 v3.27.1
go.uber.org/fx v1.24.0
golang.org/x/term v0.43.0
)
require (
@@ -56,6 +57,6 @@ require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/klauspost/compress v1.18.5 // indirect
golang.org/x/sync v0.20.0 // direct
golang.org/x/sys v0.43.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.36.0 // indirect
)

6
go.sum
View File

@@ -158,8 +158,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -169,6 +169,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=

View File

@@ -358,7 +358,7 @@ func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*g
return nil, url, err
}
return document, response.Request.URL.String(), nil
return document, response, nil
}
type timetableAnimeAPI struct {

View File

@@ -3,6 +3,9 @@ package jikan
import (
"context"
"fmt"
"mal/internal/observability"
"net/url"
"strconv"
"time"
)
@@ -38,10 +41,48 @@ func (c *Client) WarmAnimeRecommendations(id int) {
c.runAsyncRefresh(func(ctx context.Context) {
var resp RecommendationsResponse
_ = c.getWithCache(ctx, cacheKey, 24*time.Hour, url, &resp)
if err := c.getWithCache(ctx, cacheKey, 24*time.Hour, url, &resp); err != nil {
c.EnqueueAnimeFetchRetry(ctx, id, err)
}
})
}
// GetTopAnime returns the top-rated anime list for a given page.
func (c *Client) GetTopAnime(ctx context.Context, page int) (TopAnimeResult, error) {
if page < 1 {
page = 1
}
cacheKey := fmt.Sprintf("top:%d", page)
var result TopAnimeResponse
params := url.Values{}
params.Set("page", strconv.Itoa(page))
reqURL := buildRequestURL(c.baseURL, "/top/anime", params)
if err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result); err != nil {
return TopAnimeResult{}, err
}
return TopAnimeResult{
Animes: result.Data,
HasNextPage: result.Pagination.HasNextPage,
}, nil
}
// GetAnimeGenres returns list of all anime genres, cached long-term.
func (c *Client) GetAnimeGenres(ctx context.Context) ([]Genre, error) {
const cacheKey = "anime_genres"
var result GenresResponse
reqURL := fmt.Sprintf("%s/genres/anime", c.baseURL)
if err := c.getWithCache(ctx, cacheKey, longCacheTTL, reqURL, &result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetAnimeByID returns full anime details; finished series cached 30 days, airing cached 1 day.
func (c *Client) GetAnimeByID(ctx context.Context, id int) (Anime, error) {
cacheKey := fmt.Sprintf("anime:%d", id)
@@ -71,7 +112,7 @@ func (c *Client) GetAnimeByID(ctx context.Context, id int) (Anime, error) {
func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
cacheKey := fmt.Sprintf("anime:%d", id)
value, err, _ := c.sf.Do("refresh:"+cacheKey, func() (any, error) {
value, err, shared := c.sf.Do("refresh:"+cacheKey, func() (any, error) {
var cached Anime
if c.getCache(ctx, cacheKey, &cached) && cached.MalID != 0 {
return cached, nil
@@ -95,6 +136,14 @@ func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
if err != nil {
return Anime{}, err
}
if shared {
observability.Info(
"jikan_anime_refresh_shared",
"jikan",
"",
map[string]any{"anime_id": id, "cache_key": cacheKey},
)
}
if anime, ok := value.(Anime); ok && anime.MalID != 0 {
return anime, nil
@@ -105,6 +154,8 @@ func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
func (c *Client) refreshAnimeByIDAsync(id int) {
c.runAsyncRefresh(func(ctx context.Context) {
_, _ = c.refreshAnimeByID(ctx, id)
if _, err := c.refreshAnimeByID(ctx, id); err != nil {
c.EnqueueAnimeFetchRetry(ctx, id, err)
}
})
}

View File

@@ -3,6 +3,8 @@ package jikan
import (
"context"
"fmt"
"net/url"
"strconv"
)
func (c *Client) GetAnimeStaff(ctx context.Context, id int) ([]StaffEntry, error) {
@@ -46,7 +48,9 @@ func (c *Client) GetAnimeReviews(ctx context.Context, id int, page int) ([]Revie
page = 1
}
url := fmt.Sprintf("%s/anime/%d/reviews?page=%d", c.baseURL, id, page)
params := url.Values{}
params.Set("page", strconv.Itoa(page))
url := buildRequestURL(c.baseURL, fmt.Sprintf("/anime/%d/reviews", id), params)
cacheKey := fmt.Sprintf("anime:reviews:%d:%d", id, page)
var resp ReviewsResponse

79
integrations/jikan/cache/store.go vendored Normal file
View File

@@ -0,0 +1,79 @@
package cache
import (
"context"
"encoding/json"
"time"
"mal/internal/db"
"mal/internal/observability"
)
type Store struct {
db db.Querier
}
func NewStore(queries db.Querier) *Store {
return &Store{db: queries}
}
// Get retrieves a fresh cached value by key.
func (s *Store) Get(parentCtx context.Context, key string, out any) bool {
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()
data, err := s.db.GetJikanCache(ctx, key)
if err != nil {
return false
}
if err := json.Unmarshal([]byte(data), out); err != nil {
return false
}
return true
}
// GetStale retrieves an expired-but-available cached value by key.
func (s *Store) GetStale(parentCtx context.Context, key string, out any) bool {
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()
data, err := s.db.GetJikanCacheStale(ctx, key)
if err != nil {
return false
}
if err := json.Unmarshal([]byte(data), out); err != nil {
return false
}
return true
}
// Set stores data in cache with the specified TTL.
func (s *Store) Set(parentCtx context.Context, key string, data any, ttl time.Duration) {
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()
bytes, err := json.Marshal(data)
if err != nil {
return
}
err = s.db.SetJikanCache(ctx, db.SetJikanCacheParams{
Key: key,
Data: string(bytes),
ExpiresAt: time.Now().Add(ttl),
})
if err != nil {
observability.LogJSON(
observability.LogLevelError,
"jikan_cache_set",
"jikan",
"",
map[string]any{"cache_key": key},
err,
)
}
}

View File

@@ -1,8 +1,8 @@
// Package jikan provides a client for the Jikan v4 API.
package jikan
import "time"
// Cache TTLs used by the Jikan client for endpoint responses.
const shortCacheTTL = time.Hour // 1 hour - for frequently changing data
const longCacheTTL = time.Hour * 24 // 24 hours - for stable data like genres
const producerCacheTTL = time.Hour * 24 * 30

View File

@@ -5,34 +5,29 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"reflect"
"strconv"
"strings"
"sync"
"time"
jcache "mal/integrations/jikan/cache"
"mal/integrations/jikan/rate"
jtransport "mal/integrations/jikan/transport"
"mal/internal/config"
"mal/internal/db"
"mal/internal/observability"
netutil "mal/pkg/net"
"golang.org/x/sync/singleflight"
)
var traceEnabled bool
type Client struct {
httpClient *http.Client
baseURL string
db db.Querier
retrySignal chan struct{} // signals retry worker to process queued retries
mu sync.Mutex
lastReqTime time.Time // rate limiting: last request timestamp
sf singleflight.Group
refreshSem chan struct{}
metrics *observability.Metrics
baseURL string
db db.Querier
retrySignal chan struct{} // signals retry worker to process queued retries
sf singleflight.Group
refreshSem chan struct{}
cache *jcache.Store
fetcher *jtransport.Client
traceEnabled bool
// Random anime pool for DDoS-proof truly random "Surprise Me"
randomPool []Anime
@@ -42,132 +37,78 @@ type Client struct {
const jikanSlowLogThreshold = 750 * time.Millisecond
func NewClient(cfg config.Config, queries *db.Queries, metrics *observability.Metrics) *Client {
traceEnabled = cfg.JikanTrace
return &Client{
httpClient: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
DisableKeepAlives: false,
TLSHandshakeTimeout: 5 * time.Second,
},
},
baseURL: "https://api.jikan.moe/v4",
db: queries,
metrics: metrics,
retrySignal: make(chan struct{}, 1),
refreshSem: make(chan struct{}, 4),
randomPool: make([]Anime, 0),
type APIError = jtransport.APIError
func NewClient(cfg config.Config, queries *db.Queries) *Client {
limiter := rate.NewLimiter(400 * time.Millisecond)
client := &Client{
baseURL: "https://api.jikan.moe/v4",
db: queries,
retrySignal: make(chan struct{}, 1),
refreshSem: make(chan struct{}, 4),
cache: jcache.NewStore(queries),
traceEnabled: cfg.JikanTrace,
randomPool: make([]Anime, 0),
}
}
client.fetcher = jtransport.NewClient(jtransport.Config{
HTTPClient: jtransport.NewHTTPClient(),
Limiter: limiter,
TraceEnabled: client.jikanTraceEnabled,
})
type APIError struct {
StatusCode int
URL string
}
func (e *APIError) Error() string {
return fmt.Sprintf("jikan api returned status %d", e.StatusCode)
return client
}
// IsRetryableError returns true if the error should trigger a retry.
func IsRetryableError(err error) bool {
if err == nil {
return jtransport.IsRetryableError(err)
}
func (c *Client) jikanTraceEnabled() bool {
return c.traceEnabled
}
func (c *Client) shouldSkipJikanCacheLog(source string, duration time.Duration, err error) bool {
if c.jikanTraceEnabled() || err != nil {
return false
}
var apiErr *APIError
if errors.As(err, &apiErr) {
return isRetryableStatus(apiErr.StatusCode)
if source == "fresh" {
return duration < 50*time.Millisecond
}
var netErr net.Error
if errors.As(err, &netErr) {
return true
}
if errors.Is(err, context.DeadlineExceeded) {
return true
if source == "refresh" {
return duration < jikanSlowLogThreshold
}
return false
}
func isRetryableStatus(statusCode int) bool {
if statusCode == http.StatusTooManyRequests {
return true
}
return statusCode >= 500 && statusCode <= 504
}
// retryDelay returns exponential backoff delay: 500ms, 1s, 2s, 4s, 8s (capped).
func retryDelay(attempt int) time.Duration {
base := 500 * time.Millisecond
delay := base * time.Duration(1<<attempt)
if delay > 8*time.Second {
return 8 * time.Second
}
return delay
}
// parseRetryAfter parses Retry-After header value (seconds) into duration.
func parseRetryAfter(value string) (time.Duration, bool) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return 0, false
}
seconds, err := strconv.Atoi(trimmed)
func jikanCacheLogLevel(source string, err error) observability.LogLevel {
if err != nil {
return 0, false
return observability.LogLevelError
}
if seconds <= 0 {
return 0, false
}
return time.Duration(seconds) * time.Second, true
}
func waitForRetry(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return fmt.Errorf("request canceled while retrying jikan request: %w", ctx.Err())
}
}
func jikanTraceEnabled() bool {
return traceEnabled
}
func logJikanCache(cacheKey string, source string, startedAt time.Time, err error) {
duration := time.Since(startedAt)
if !jikanTraceEnabled() && err == nil && source == "fresh" && duration < 50*time.Millisecond {
return
}
if !jikanTraceEnabled() && err == nil && source == "refresh" && duration < jikanSlowLogThreshold {
return
}
level := observability.LogLevelInfo
if err != nil {
level = observability.LogLevelError
} else if source != "fresh" && source != "refresh" {
if source != "fresh" && source != "refresh" {
// Stale reads are expected sometimes, but worth tracking in logs.
level = observability.LogLevelWarn
return observability.LogLevelWarn
}
return observability.LogLevelInfo
}
func (c *Client) logJikanCache(cacheKey string, source string, startedAt time.Time, err error) {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
duration := time.Since(startedAt)
if c.shouldSkipJikanCacheLog(source, duration, err) {
return
}
observability.LogJSON(
level,
jikanCacheLogLevel(source, err),
"jikan_cache",
"jikan",
"",
@@ -180,43 +121,6 @@ func logJikanCache(cacheKey string, source string, startedAt time.Time, err erro
)
}
func logJikanUpstream(urlStr string, statusCode int, attempts int, startedAt time.Time, err error) {
duration := time.Since(startedAt)
if !jikanTraceEnabled() && err == nil && statusCode < http.StatusBadRequest && duration < jikanSlowLogThreshold {
return
}
level := observability.LogLevelInfo
if err != nil || statusCode >= http.StatusInternalServerError {
level = observability.LogLevelError
} else if statusCode == http.StatusTooManyRequests || statusCode >= http.StatusBadRequest {
level = observability.LogLevelWarn
}
observability.LogJSON(
level,
"jikan_upstream",
"jikan",
"",
map[string]any{
"url": urlStr,
"endpoint": metricsEndpoint(urlStr),
"status": statusCode,
"attempts": attempts,
"duration_ms": float64(duration.Microseconds()) / 1000,
},
err,
)
}
func truncateErrorMessage(message string) string {
if len(message) <= 400 {
return message
}
return message[:400]
}
// notifyRetryWorker signals the retry worker, non-blocking.
func (c *Client) notifyRetryWorker() {
select {
@@ -239,121 +143,76 @@ func (c *Client) EnqueueAnimeFetchRetry(parentCtx context.Context, animeID int,
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()
message := cause.Error()
if len(message) > 400 {
message = message[:400]
}
err := c.db.EnqueueAnimeFetchRetry(ctx, db.EnqueueAnimeFetchRetryParams{
AnimeID: int64(animeID),
LastError: truncateErrorMessage(cause.Error()),
LastError: message,
})
if err != nil {
observability.Warn(
"jikan_retry_enqueue_failed",
"jikan",
"",
map[string]any{"anime_id": animeID},
err,
)
return
}
c.notifyRetryWorker()
}
// waitRateLimit enforces Jikan's 3 req/sec rate limit with 400ms spacing.
func (c *Client) waitRateLimit(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
// Jikan has a 3 req/sec limit AND a 60 req/min limit.
// 400ms base delay keeps us safely under the 3/sec limit.
nextAllowed := c.lastReqTime.Add(400 * time.Millisecond)
if now.Before(nextAllowed) {
timer := time.NewTimer(nextAllowed.Sub(now))
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
return fmt.Errorf("request canceled while waiting for rate limit: %w", ctx.Err())
}
c.lastReqTime = time.Now()
} else {
c.lastReqTime = now
}
return nil
}
// getCache retrieves cached data by key, returns true on cache hit.
func (c *Client) getCache(parentCtx context.Context, key string, out any) bool {
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()
data, err := c.db.GetJikanCache(ctx, key)
if err != nil {
c.metrics.ObserveCache("jikan", "miss")
return false
}
err = json.Unmarshal([]byte(data), out)
if err != nil {
c.metrics.ObserveCache("jikan", "miss")
return false
}
c.metrics.ObserveCache("jikan", "hit")
return true
return c.cache.Get(parentCtx, key, out)
}
// getStaleCache retrieves expired-but-available cache by key.
func (c *Client) getStaleCache(parentCtx context.Context, key string, out any) bool {
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()
data, err := c.db.GetJikanCacheStale(ctx, key)
if err != nil {
c.metrics.ObserveCache("jikan_stale", "miss")
return false
}
err = json.Unmarshal([]byte(data), out)
if err != nil {
c.metrics.ObserveCache("jikan_stale", "miss")
return false
}
c.metrics.ObserveCache("jikan_stale", "hit")
return true
return c.cache.GetStale(parentCtx, key, out)
}
// setCache stores data in cache with specified TTL.
func (c *Client) setCache(parentCtx context.Context, key string, data any, ttl time.Duration) {
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()
c.cache.Set(parentCtx, key, data, ttl)
}
bytes, err := json.Marshal(data)
if err != nil {
return
}
func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) error {
return c.fetcher.FetchWithRetry(ctx, urlStr, out)
}
_ = c.db.SetJikanCache(ctx, db.SetJikanCacheParams{
Key: key,
Data: string(bytes),
ExpiresAt: time.Now().Add(ttl),
})
var emptyResultChecks = map[reflect.Type]func(any) bool{
reflect.TypeFor[*TopAnimeResponse](): func(out any) bool {
return len(out.(*TopAnimeResponse).Data) == 0
},
reflect.TypeFor[*AnimeResponse](): func(out any) bool {
return out.(*AnimeResponse).Data.MalID == 0
},
reflect.TypeFor[*EpisodesResponse](): func(out any) bool {
return len(out.(*EpisodesResponse).Data) == 0
},
reflect.TypeFor[*StaffResponse](): func(out any) bool {
return len(out.(*StaffResponse).Data) == 0
},
reflect.TypeFor[*StatisticsResponse](): func(out any) bool {
return out.(*StatisticsResponse).Data.Total == 0
},
reflect.TypeFor[*ThemesResponse](): func(out any) bool {
themes := out.(*ThemesResponse).Data
return len(themes.Openings) == 0 && len(themes.Endings) == 0
},
}
// isEmptyResult detects if response contains no meaningful data.
func isEmptyResult(out any) bool {
switch v := out.(type) {
case *TopAnimeResponse:
return len(v.Data) == 0
case *SearchResponse:
return len(v.Data) == 0
case *AnimeResponse:
return v.Data.MalID == 0
case *EpisodesResponse:
return len(v.Data) == 0
case *StaffResponse:
return len(v.Data) == 0
case *StatisticsResponse:
return v.Data.Total == 0
case *ThemesResponse:
return len(v.Data.Openings) == 0 && len(v.Data.Endings) == 0
case *ReviewsResponse:
return false // empty reviews is a valid state
if out == nil {
return true
}
outType := reflect.TypeOf(out)
if check, ok := emptyResultChecks[outType]; ok {
return check(out)
}
return false
}
@@ -372,7 +231,7 @@ func cloneResponseTarget(out any) (any, bool) {
}
func (c *Client) refreshWithCache(ctx context.Context, cacheKey string, ttl time.Duration, url string, out any) error {
value, err, _ := c.sf.Do("refresh:"+cacheKey, func() (any, error) {
value, err, shared := c.sf.Do("refresh:"+cacheKey, func() (any, error) {
if c.getCache(ctx, cacheKey, out) {
if !isEmptyResult(out) {
return json.Marshal(out)
@@ -383,7 +242,7 @@ func (c *Client) refreshWithCache(ctx context.Context, cacheKey string, ttl time
return nil, err
}
// Don't cache empty results to avoid caching failures
// Don't cache empty results to avoid caching failures.
if isEmptyResult(out) {
return nil, fmt.Errorf("jikan: empty response for %s", cacheKey)
}
@@ -394,6 +253,14 @@ func (c *Client) refreshWithCache(ctx context.Context, cacheKey string, ttl time
if err != nil {
return err
}
if shared {
observability.Info(
"jikan_cache_refresh_shared",
"jikan",
"",
map[string]any{"cache_key": cacheKey, "url": url},
)
}
if bytes, ok := value.([]byte); ok {
if err := json.Unmarshal(bytes, out); err == nil && !isEmptyResult(out) {
@@ -411,7 +278,15 @@ func (c *Client) refreshWithCacheAsync(cacheKey string, ttl time.Duration, url s
}
c.runAsyncRefresh(func(ctx context.Context) {
_ = c.refreshWithCache(ctx, cacheKey, ttl, url, target)
if err := c.refreshWithCache(ctx, cacheKey, ttl, url, target); err != nil {
observability.Warn(
"jikan_async_cache_refresh_failed",
"jikan",
"",
map[string]any{"cache_key": cacheKey, "url": url},
err,
)
}
})
}
@@ -437,149 +312,26 @@ func (c *Client) getWithCache(ctx context.Context, cacheKey string, ttl time.Dur
startedAt := time.Now()
if c.getCache(ctx, cacheKey, out) {
if !isEmptyResult(out) {
logJikanCache(cacheKey, "fresh", startedAt, nil)
c.logJikanCache(cacheKey, "fresh", startedAt, nil)
return nil
}
}
if c.getStaleCache(ctx, cacheKey, out) && !isEmptyResult(out) {
logJikanCache(cacheKey, "stale", startedAt, nil)
c.logJikanCache(cacheKey, "stale", startedAt, nil)
c.refreshWithCacheAsync(cacheKey, ttl, url, out)
return nil
}
if err := c.refreshWithCache(ctx, cacheKey, ttl, url, out); err != nil {
if c.getStaleCache(ctx, cacheKey, out) && !isEmptyResult(out) {
logJikanCache(cacheKey, "stale_after_error", startedAt, err)
c.logJikanCache(cacheKey, "stale_after_error", startedAt, err)
return nil
}
logJikanCache(cacheKey, "miss", startedAt, err)
c.logJikanCache(cacheKey, "miss", startedAt, err)
return err
}
logJikanCache(cacheKey, "refresh", startedAt, nil)
c.logJikanCache(cacheKey, "refresh", startedAt, nil)
return nil
}
// fetchWithRetry makes HTTP request with exponential backoff retry on transient failures.
func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) error {
maxRetries := 5
startedAt := time.Now()
attempts := 0
endpoint := metricsEndpoint(urlStr)
logAndReturn := func(statusCode int, err error) error {
c.metrics.ObserveJikanRequest(endpoint, statusCode, time.Since(startedAt), err)
logJikanUpstream(urlStr, statusCode, attempts, startedAt, err)
return err
}
for attempt := range maxRetries {
attempts = attempt + 1
select {
case <-ctx.Done():
return logAndReturn(0, fmt.Errorf("request canceled while retrying jikan request: %w", ctx.Err()))
default:
}
if err := c.waitRateLimit(ctx); err != nil {
return logAndReturn(0, err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return logAndReturn(0, fmt.Errorf("failed to create jikan request: %w", err))
}
req.Header.Set("User-Agent", netutil.Generic)
resp, err := c.httpClient.Do(req)
if err != nil {
if errors.Is(err, context.Canceled) {
return logAndReturn(0, fmt.Errorf("request canceled while retrying jikan request: %w", err))
}
if attempt < maxRetries-1 && IsRetryableError(err) {
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
return logAndReturn(0, retryErr)
}
continue
}
return logAndReturn(0, fmt.Errorf("jikan api error: %w", err))
}
if resp.StatusCode != http.StatusOK {
apiErr := &APIError{StatusCode: resp.StatusCode, URL: urlStr}
retryable := isRetryableStatus(resp.StatusCode)
retryAfter := time.Duration(0)
if parsed, ok := parseRetryAfter(resp.Header.Get("Retry-After")); ok {
retryAfter = parsed
}
if retryable && attempt < maxRetries-1 {
_ = resp.Body.Close()
delay := max(retryAfter, retryDelay(attempt))
if retryErr := waitForRetry(ctx, delay); retryErr != nil {
return logAndReturn(resp.StatusCode, retryErr)
}
continue
}
// Best-effort decode (often useful for debugging), but still treat non-200 as error.
_ = json.NewDecoder(resp.Body).Decode(out)
_ = resp.Body.Close()
return logAndReturn(resp.StatusCode, apiErr)
}
err = json.NewDecoder(resp.Body).Decode(out)
_ = resp.Body.Close()
if err == nil {
return logAndReturn(resp.StatusCode, nil)
}
if attempt < maxRetries-1 {
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
return logAndReturn(resp.StatusCode, retryErr)
}
continue
}
return logAndReturn(resp.StatusCode, fmt.Errorf("failed to decode jikan response: %w", err))
}
return logAndReturn(0, fmt.Errorf("max retries exceeded for %s", urlStr))
}
func metricsEndpoint(urlStr string) string {
trimmed := strings.TrimSpace(urlStr)
if trimmed == "" {
return "unknown"
}
prefix := "https://api.jikan.moe/v4"
trimmed = strings.TrimPrefix(trimmed, prefix)
if idx := strings.Index(trimmed, "?"); idx >= 0 {
trimmed = trimmed[:idx]
}
parts := strings.Split(trimmed, "/")
out := make([]string, 0, len(parts))
for _, part := range parts {
if part == "" {
continue
}
if _, err := strconv.Atoi(part); err == nil {
out = append(out, "{id}")
continue
}
out = append(out, part)
}
if len(out) == 0 {
return "/"
}
return "/" + strings.Join(out, "/")
}

View File

@@ -7,7 +7,6 @@ import (
"io"
"mal/internal/config"
"mal/internal/db"
"mal/internal/observability"
"net/http"
"strings"
"testing"
@@ -23,44 +22,19 @@ func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
}
func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer sqlDB.Close()
sqlDB.SetMaxOpenConns(1)
_, err = sqlDB.Exec(`
CREATE TABLE jikan_cache (
key TEXT PRIMARY KEY,
data TEXT NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`)
if err != nil {
t.Fatalf("create cache table: %v", err)
}
sqlDB := newTestCacheDB(t)
defer func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
}()
queries := db.New(sqlDB)
client := NewClient(config.Config{}, queries, observability.NewMetrics())
client := NewClient(config.Config{}, queries)
stale := TopAnimeResponse{Data: []Anime{{MalID: 1, Title: "stale"}}}
staleBytes, err := json.Marshal(stale)
if err != nil {
t.Fatalf("marshal stale response: %v", err)
}
insertCachedResponse(t, sqlDB, "top:1", stale, time.Now().Add(-time.Hour))
_, err = sqlDB.Exec(
`INSERT INTO jikan_cache (key, data, expires_at) VALUES (?, ?, ?)`,
"top:1",
string(staleBytes),
time.Now().Add(-time.Hour),
)
if err != nil {
t.Fatalf("insert stale cache: %v", err)
}
client.httpClient = &http.Client{
client.fetcher.HTTPClient = &http.Client{
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
body := `{"data":[{"mal_id":2,"title":"fresh"}]}`
return &http.Response{
@@ -78,11 +52,169 @@ func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
if len(got.Data) != 1 || got.Data[0].Title != "stale" {
t.Fatalf("got %+v, want stale cache response", got.Data)
}
waitForFreshCache(t, sqlDB, client, "top:1")
}
func TestGetWithCacheAllowsEmptySearchResults(t *testing.T) {
sqlDB := newTestCacheDB(t)
defer func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
}()
queries := db.New(sqlDB)
client := NewClient(config.Config{}, queries)
client.fetcher.HTTPClient = &http.Client{
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
body := `{"pagination":{"has_next_page":false},"data":[]}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}),
}
var got SearchResponse
if err := client.getWithCache(context.Background(), "search::::::12:0:true:1:24", time.Hour, "https://example.test/anime?genres=12", &got); err != nil {
t.Fatalf("getWithCache() returned error for empty search response: %v", err)
}
if len(got.Data) != 0 {
t.Fatalf("getWithCache() data length = %d, want 0", len(got.Data))
}
}
func TestLoadCachedRandomPoolIgnoresExpiredAnimeCache(t *testing.T) {
sqlDB := newTestCacheDB(t)
defer func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
}()
queries := db.New(sqlDB)
client := NewClient(config.Config{}, queries)
insertCachedAnime(t, sqlDB, "anime:1", Anime{MalID: 1, Title: "fresh"}, time.Now().Add(time.Hour))
insertCachedAnime(t, sqlDB, "anime:2", Anime{MalID: 2, Title: "expired"}, time.Now().Add(-time.Hour))
client.loadCachedRandomPool(context.Background())
client.poolMu.RLock()
defer client.poolMu.RUnlock()
if len(client.randomPool) != 1 {
t.Fatalf("randomPool length = %d, want 1", len(client.randomPool))
}
if client.randomPool[0].MalID != 1 || client.randomPool[0].Title != "fresh" {
t.Fatalf("randomPool[0] = %+v, want fresh anime", client.randomPool[0])
}
}
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 {
t.Helper()
ctx := context.Background()
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlDB.SetMaxOpenConns(1)
_, err = sqlDB.ExecContext(ctx, `
CREATE TABLE jikan_cache (
key TEXT PRIMARY KEY,
data TEXT NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`)
if err != nil {
if closeErr := sqlDB.Close(); closeErr != nil {
t.Fatalf("create cache table: %v; close sqlite: %v", err, closeErr)
}
t.Fatalf("create cache table: %v", err)
}
return sqlDB
}
func insertCachedResponse(t *testing.T, sqlDB *sql.DB, key string, value TopAnimeResponse, expiresAt time.Time) {
t.Helper()
ctx := context.Background()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal cached response: %v", err)
}
_, err = sqlDB.ExecContext(
ctx,
`INSERT INTO jikan_cache (key, data, expires_at) VALUES (?, ?, ?)`,
key,
string(encoded),
expiresAt,
)
if err != nil {
t.Fatalf("insert cached response: %v", err)
}
}
func insertCachedAnime(t *testing.T, sqlDB *sql.DB, key string, value Anime, expiresAt time.Time) {
t.Helper()
ctx := context.Background()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal cached anime: %v", err)
}
_, err = sqlDB.ExecContext(
ctx,
`INSERT INTO jikan_cache (key, data, expires_at) VALUES (?, ?, ?)`,
key,
string(encoded),
expiresAt,
)
if err != nil {
t.Fatalf("insert cached anime: %v", err)
}
}
func waitForFreshCache(t *testing.T, sqlDB *sql.DB, client *Client, key string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
var refreshed TopAnimeResponse
if client.getCache(context.Background(), "top:1", &refreshed) && len(refreshed.Data) == 1 && refreshed.Data[0].Title == "fresh" {
if client.getCache(context.Background(), key, &refreshed) && len(refreshed.Data) == 1 && refreshed.Data[0].Title == "fresh" {
return
}
time.Sleep(10 * time.Millisecond)
@@ -90,6 +222,8 @@ func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
var rawData string
var rawExpires string
_ = sqlDB.QueryRow(`SELECT data, expires_at FROM jikan_cache WHERE key = ?`, "top:1").Scan(&rawData, &rawExpires)
if err := sqlDB.QueryRowContext(context.Background(), `SELECT data, expires_at FROM jikan_cache WHERE key = ?`, key).Scan(&rawData, &rawExpires); err != nil {
t.Fatalf("query cached refresh result: %v", err)
}
t.Fatalf("cache was not refreshed asynchronously; data=%s expires_at=%s", rawData, rawExpires)
}

View File

@@ -3,6 +3,8 @@ package jikan
import (
"context"
"fmt"
"net/url"
"strconv"
"sync"
"time"
)
@@ -15,7 +17,9 @@ func (c *Client) GetEpisodes(ctx context.Context, animeID int, page int) (Episod
cacheKey := fmt.Sprintf("anime:%d:episodes:%d", animeID, page)
var result EpisodesResponse
reqURL := fmt.Sprintf("%s/anime/%d/episodes?page=%d", c.baseURL, animeID, page)
params := url.Values{}
params.Set("page", strconv.Itoa(page))
reqURL := buildRequestURL(c.baseURL, fmt.Sprintf("/anime/%d/episodes", animeID), params)
err := c.getWithCache(ctx, cacheKey, 12*time.Hour, reqURL, &result)
return result, err

View File

@@ -27,6 +27,21 @@ type ProducerListResult struct {
HasNextPage bool
}
func (c *Client) GetProducerByID(ctx context.Context, id int) (ProducerResponse, error) {
if id <= 0 {
return ProducerResponse{}, fmt.Errorf("invalid producer id")
}
cacheKey := fmt.Sprintf("producer:%d", id)
reqURL := fmt.Sprintf("%s/producers/%d", c.baseURL, id)
var result ProducerResponse
if err := c.getWithCache(ctx, cacheKey, producerCacheTTL, reqURL, &result); err != nil {
return ProducerResponse{}, err
}
return result, nil
}
func (c *Client) GetProducers(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) {
if page < 1 {
page = 1
@@ -56,10 +71,11 @@ func (c *Client) GetProducers(ctx context.Context, query string, page int, limit
func (c *Client) fetchProducersPage(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) {
q := strings.TrimSpace(query)
cacheKey := fmt.Sprintf("producers:%s:%d:%d", q, page, limit)
reqURL := fmt.Sprintf("%s/producers?page=%d&limit=%d", c.baseURL, page, limit)
if q != "" {
reqURL += "&q=" + url.QueryEscape(q)
}
params := url.Values{}
params.Set("page", strconv.Itoa(page))
params.Set("limit", strconv.Itoa(limit))
setQueryValue(params, "q", q)
reqURL := buildRequestURL(c.baseURL, "/producers", params)
var result ProducersResponse
if err := c.getWithCache(ctx, cacheKey, producerCacheTTL, reqURL, &result); err != nil {

View File

@@ -0,0 +1,43 @@
package jikan
import (
"fmt"
"net/url"
"strconv"
)
func buildRequestURL(baseURL, path string, params url.Values) string {
encoded := params.Encode()
if encoded == "" {
return fmt.Sprintf("%s%s", baseURL, path)
}
return fmt.Sprintf("%s%s?%s", baseURL, path, encoded)
}
func setQueryValue(values url.Values, key, value string) {
if value == "" {
values.Del(key)
return
}
values.Set(key, value)
}
func setPositiveInt(values url.Values, key string, value int) {
if value <= 0 {
values.Del(key)
return
}
values.Set(key, strconv.Itoa(value))
}
func setTrueQueryValue(values url.Values, key string, enabled bool) {
if !enabled {
values.Del(key)
return
}
values.Set(key, "true")
}

View File

@@ -0,0 +1,66 @@
package rate
import (
"context"
"sync"
"time"
)
type Limiter struct {
mu sync.Mutex
nextReqTime time.Time
interval time.Duration
}
func NewLimiter(interval time.Duration) *Limiter {
return &Limiter{interval: interval}
}
// Wait enforces minimum spacing between upstream Jikan requests.
func (l *Limiter) Wait(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
waitUntil := l.reserve(time.Now())
if waitUntil.IsZero() {
return nil
}
timer := time.NewTimer(time.Until(waitUntil))
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
l.release(waitUntil)
return ctx.Err()
}
}
func (l *Limiter) reserve(now time.Time) time.Time {
l.mu.Lock()
defer l.mu.Unlock()
if l.nextReqTime.IsZero() || now.After(l.nextReqTime) {
l.nextReqTime = now.Add(l.interval)
return time.Time{}
}
waitUntil := l.nextReqTime
l.nextReqTime = l.nextReqTime.Add(l.interval)
return waitUntil
}
func (l *Limiter) release(waitUntil time.Time) {
l.mu.Lock()
defer l.mu.Unlock()
reservationEnd := waitUntil.Add(l.interval)
if l.nextReqTime.Equal(reservationEnd) {
l.nextReqTime = waitUntil
}
}

View File

@@ -0,0 +1,40 @@
package rate
import (
"context"
"testing"
"time"
)
func TestLimiterDoesNotHoldLockWhileWaiting(t *testing.T) {
limiter := NewLimiter(250 * time.Millisecond)
if err := limiter.Wait(context.Background()); err != nil {
t.Fatalf("initial wait: %v", err)
}
firstCtx, cancelFirst := context.WithCancel(context.Background())
defer cancelFirst()
firstDone := make(chan error, 1)
go func() {
firstDone <- limiter.Wait(firstCtx)
}()
time.Sleep(20 * time.Millisecond)
secondCtx, cancelSecond := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancelSecond()
startedAt := time.Now()
err := limiter.Wait(secondCtx)
elapsed := time.Since(startedAt)
if err == nil {
t.Fatal("second wait succeeded, want context timeout")
}
if elapsed > 150*time.Millisecond {
t.Fatalf("second wait took %s, want it to observe context timeout without waiting behind first caller", elapsed)
}
cancelFirst()
<-firstDone
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"time"
@@ -20,6 +21,22 @@ const chiakiWatchOrderURL = "https://chiaki.site/?/tools/watch_order/id/%d"
const watchOrderCacheTTL = time.Hour * 24
const maxWatchOrderEntries = 120 // cap to prevent huge relation chains
type WatchOrderMode string
const (
WatchOrderModeMain WatchOrderMode = "main"
WatchOrderModeComplete WatchOrderMode = "complete"
)
func NormalizeWatchOrderMode(value string) WatchOrderMode {
switch WatchOrderMode(strings.ToLower(strings.TrimSpace(value))) {
case WatchOrderModeComplete:
return WatchOrderModeComplete
default:
return WatchOrderModeMain
}
}
// watchOrderTypeLabel normalizes watch order type to display-friendly labels.
func watchOrderTypeLabel(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
@@ -28,31 +45,25 @@ func watchOrderTypeLabel(value string) string {
return "TV"
case "movie":
return "Movie"
case "ona":
return "ONA"
case "ova":
return "OVA"
default:
return strings.TrimSpace(value)
}
}
// isAllowedWatchOrderType returns true only for TV and Movie types (filters out specials, etc).
func isAllowedWatchOrderType(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
return normalized == "tv" || normalized == "movie"
}
func relationCacheKey(id int) string {
return fmt.Sprintf("relations:watch-order:%d", id)
}
func (c *Client) refreshWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
cacheKey := relationCacheKey(id)
cacheKey := fmt.Sprintf("relations:watch-order:%d", id)
watchOrderURL := fmt.Sprintf(chiakiWatchOrderURL, id)
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
result, err := watchorder.FetchWatchOrder(requestCtx, c.httpClient, watchOrderURL)
result, err := watchorder.FetchWatchOrder(requestCtx, c.fetcher.HTTPClient, watchOrderURL)
if err != nil {
var statusError *watchorder.HTTPStatusError
if errors.As(err, &statusError) && statusError.StatusCode == 404 {
if errors.As(err, &statusError) && statusError.StatusCode == http.StatusNotFound {
return watchorder.WatchOrderResult{}, watchorder.ErrWatchOrderNotFound
}
if errors.Is(err, watchorder.ErrWatchOrderMarkupNotFound) {
@@ -104,13 +115,21 @@ func (c *Client) refreshWatchOrder(ctx context.Context, id int) (watchorder.Watc
func (c *Client) refreshWatchOrderAsync(id int) {
c.runAsyncRefresh(func(ctx context.Context) {
_, _ = c.refreshWatchOrder(ctx, id)
if _, err := c.refreshWatchOrder(ctx, id); err != nil {
observability.Warn(
"relations_watch_order_async_refresh_failed",
"jikan",
"",
map[string]any{"anime_id": id},
err,
)
}
})
}
// getWatchOrder fetches watch order from chiaki, caches result for 24h.
func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
cacheKey := relationCacheKey(id)
cacheKey := fmt.Sprintf("relations:watch-order:%d", id)
var cached watchorder.WatchOrderResult
if c.getCache(ctx, cacheKey, &cached) {
@@ -148,84 +167,134 @@ func (c *Client) currentOnlyRelation(ctx context.Context, id int) ([]RelationEnt
}}, nil
}
// GetFullRelations returns related anime based on watch order, with parallel fetching (3 concurrent).
func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, error) {
result, err := c.getWatchOrder(ctx, id)
if err != nil {
if errors.Is(err, watchorder.ErrWatchOrderNotFound) {
return c.currentOnlyRelation(ctx, id)
}
observability.Warn(
"relations_watch_order_fallback_current_only",
"jikan",
"",
map[string]any{
"anime_id": id,
},
err,
)
func (c *Client) handleWatchOrderError(ctx context.Context, id int, err error) ([]RelationEntry, error) {
if errors.Is(err, watchorder.ErrWatchOrderNotFound) {
return c.currentOnlyRelation(ctx, id)
}
type fetchResult struct {
index int
anime Anime
entry watchorder.WatchOrderEntry
}
observability.Warn(
"relations_watch_order_fallback_current_only",
"jikan",
"",
map[string]any{
"anime_id": id,
},
err,
)
var allowedEntries []watchorder.WatchOrderEntry
return c.currentOnlyRelation(ctx, id)
}
// relation filter
func allowedWatchOrder(result watchorder.WatchOrderResult, mode WatchOrderMode) ([]watchorder.WatchOrderEntry, map[int]bool) {
allowedEntries := make([]watchorder.WatchOrderEntry, 0, len(result.WatchOrder))
seen := make(map[int]bool)
hasTVEntry := false
for _, entry := range result.WatchOrder {
if strings.EqualFold(strings.TrimSpace(entry.Type), "tv") {
hasTVEntry = true
break
}
}
allTypes := mode == WatchOrderModeComplete || !hasTVEntry
for _, entry := range result.WatchOrder {
if len(allowedEntries) >= maxWatchOrderEntries {
break
}
if !isAllowedWatchOrderType(entry.Type) || seen[entry.ID] {
if seen[entry.ID] {
continue
}
typ := strings.ToLower(strings.TrimSpace(entry.Type))
if !allTypes && typ != "tv" && typ != "movie" {
continue
}
seen[entry.ID] = true
allowedEntries = append(allowedEntries, entry)
}
return allowedEntries, seen
}
func (c *Client) fetchEntries(ctx context.Context, entries []watchorder.WatchOrderEntry) chan fetchResult {
g, gCtx := errgroup.WithContext(ctx)
g.SetLimit(3)
results := make(chan fetchResult, len(allowedEntries))
results := make(chan fetchResult, len(entries))
for i, entry := range allowedEntries {
for i, entry := range entries {
g.Go(func() error {
anime, err := c.GetAnimeByID(gCtx, entry.ID)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil
}
observability.Warn(
"relations_fetch_entry_failed",
"jikan",
"",
map[string]any{
"anime_id": entry.ID,
"index": i,
},
err,
)
c.EnqueueAnimeFetchRetry(gCtx, entry.ID, err)
return nil
}
select {
case results <- fetchResult{index: i, anime: anime, entry: entry}:
case <-gCtx.Done():
}
return nil
})
}
go func() {
_ = g.Wait()
if err := g.Wait(); err != nil {
observability.Warn("relations_fetch_group_failed", "jikan", "", nil, err)
}
close(results)
}()
fetched := make([]fetchResult, 0, len(allowedEntries))
return results
}
func (c *Client) fetchResults(ctx context.Context, entries []watchorder.WatchOrderEntry) []fetchResult {
results := c.fetchEntries(ctx, entries)
fetched := make([]fetchResult, 0, len(entries))
for res := range results {
fetched = append(fetched, res)
}
// Re-sort because they might have finished out of order
if len(fetched) < len(entries) {
observability.Warn(
"relations_fetch_incomplete",
"jikan",
"",
map[string]any{
"expected": len(entries),
"fetched": len(fetched),
"missing": len(entries) - len(fetched),
},
nil,
)
}
sort.Slice(fetched, func(i, j int) bool {
return fetched[i].index < fetched[j].index
})
relations := make([]RelationEntry, 0, len(fetched)+1)
for _, res := range fetched {
return fetched
}
func buildRelations(results []fetchResult, id int) []RelationEntry {
relations := make([]RelationEntry, 0, len(results)+1)
for _, res := range results {
relations = append(relations, RelationEntry{
Anime: res.anime,
Relation: watchOrderTypeLabel(res.entry.Type),
@@ -234,18 +303,46 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
})
}
if !seen[id] {
currentAnime, err := c.GetAnimeByID(ctx, id)
if err != nil {
return nil, err
}
return relations
}
relations = append([]RelationEntry{{
Anime: currentAnime,
Relation: "Current",
IsCurrent: true,
IsExtra: false,
}}, relations...)
func (c *Client) ensureCurrent(ctx context.Context, id int, seen map[int]bool, relations []RelationEntry) ([]RelationEntry, error) {
if seen[id] {
return relations, nil
}
currentAnime, err := c.GetAnimeByID(ctx, id)
if err != nil {
return nil, err
}
return append([]RelationEntry{{
Anime: currentAnime,
Relation: "Current",
IsCurrent: true,
IsExtra: false,
}}, relations...), nil
}
type fetchResult struct {
index int
anime Anime
entry watchorder.WatchOrderEntry
}
// GetFullRelations returns related anime based on watch order, with parallel fetching (3 concurrent).
func (c *Client) GetFullRelations(ctx context.Context, id int, mode WatchOrderMode) ([]RelationEntry, error) {
result, err := c.getWatchOrder(ctx, id)
if err != nil {
return c.handleWatchOrderError(ctx, id, err)
}
allowedEntries, seen := allowedWatchOrder(result, mode)
fetched := c.fetchResults(ctx, allowedEntries)
relations := buildRelations(fetched, id)
relations, err = c.ensureCurrent(ctx, id, seen, relations)
if err != nil {
return nil, err
}
if len(relations) == 0 {
@@ -257,6 +354,14 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
func (c *Client) WarmFullRelations(id int) {
c.runAsyncRefresh(func(ctx context.Context) {
_, _ = c.GetFullRelations(ctx, id)
if _, err := c.GetFullRelations(ctx, id, WatchOrderModeMain); err != nil {
observability.Warn(
"relations_warm_full_failed",
"jikan",
"",
map[string]any{"anime_id": id},
err,
)
}
})
}

View File

@@ -1,39 +1,106 @@
package jikan
import "testing"
import (
"mal/integrations/watchorder"
"testing"
)
func runBoolCases(t *testing.T, tests []struct {
name string
input string
want bool
}, fn func(string) bool) {
t.Helper()
func TestNormalizeWatchOrderMode(t *testing.T) {
tests := []struct {
name string
input string
want WatchOrderMode
}{
{name: "empty defaults main", input: "", want: WatchOrderModeMain},
{name: "main", input: "main", want: WatchOrderModeMain},
{name: "complete", input: "complete", want: WatchOrderModeComplete},
{name: "case and whitespace", input: " COMPLETE ", want: WatchOrderModeComplete},
{name: "unknown defaults main", input: "everything", want: WatchOrderModeMain},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
got := fn(testCase.input)
got := NormalizeWatchOrderMode(testCase.input)
if got != testCase.want {
t.Fatalf("expected %v, got %v", testCase.want, got)
t.Fatalf("expected %q, got %q", testCase.want, got)
}
})
}
}
func TestIsAllowedWatchOrderType(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{name: "tv", input: "tv", want: true},
{name: "movie", input: "movie", want: true},
{name: "case and whitespace", input: " TV ", want: true},
{name: "tv special", input: "tv special", want: false},
{name: "ova", input: "ova", want: false},
{name: "empty", input: "", want: false},
func TestBuildAllowedWatchOrderEntriesKeepsDefaultTypesWhenTVExists(t *testing.T) {
result := watchorder.WatchOrderResult{
WatchOrder: []watchorder.WatchOrderEntry{
{ID: 1, Type: "TV"},
{ID: 2, Type: "Special"},
{ID: 3, Type: " Movie "},
{ID: 4, Type: "ONA"},
},
}
runBoolCases(t, tests, isAllowedWatchOrderType)
entries, seen := allowedWatchOrder(result, WatchOrderModeMain)
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
if entries[0].ID != 1 || entries[1].ID != 3 {
t.Fatalf("unexpected entries: %+v", entries)
}
if !seen[1] || !seen[3] || seen[2] || seen[4] {
t.Fatalf("unexpected seen map: %+v", seen)
}
}
func TestBuildAllowedWatchOrderEntriesIncludesAllTypesWhenNoTVExists(t *testing.T) {
result := watchorder.WatchOrderResult{
WatchOrder: []watchorder.WatchOrderEntry{
{ID: 1, Type: "ONA"},
{ID: 2, Type: "Special"},
{ID: 3, Type: "Movie"},
{ID: 1, Type: "ONA"},
},
}
entries, seen := allowedWatchOrder(result, WatchOrderModeMain)
if len(entries) != 3 {
t.Fatalf("expected 3 entries, got %d", len(entries))
}
if entries[0].ID != 1 || entries[1].ID != 2 || entries[2].ID != 3 {
t.Fatalf("unexpected entries: %+v", entries)
}
if !seen[1] || !seen[2] || !seen[3] {
t.Fatalf("unexpected seen map: %+v", seen)
}
}
func TestBuildAllowedWatchOrderEntriesIncludesAllTypesInCompleteMode(t *testing.T) {
result := watchorder.WatchOrderResult{
WatchOrder: []watchorder.WatchOrderEntry{
{ID: 1, Type: "TV"},
{ID: 2, Type: "Special"},
{ID: 3, Type: "ONA"},
{ID: 4, Type: "Movie"},
},
}
entries, seen := allowedWatchOrder(result, WatchOrderModeComplete)
if len(entries) != 4 {
t.Fatalf("expected 4 entries, got %d", len(entries))
}
for index, entry := range entries {
wantID := index + 1
if entry.ID != wantID {
t.Fatalf("expected entry %d to have id %d, got %+v", index, wantID, entry)
}
}
if !seen[1] || !seen[2] || !seen[3] || !seen[4] {
t.Fatalf("unexpected seen map: %+v", seen)
}
}
func TestWatchOrderTypeLabel(t *testing.T) {
@@ -44,6 +111,8 @@ func TestWatchOrderTypeLabel(t *testing.T) {
}{
{name: "tv", input: "tv", want: "TV"},
{name: "movie", input: "movie", want: "Movie"},
{name: "ona", input: "ona", want: "ONA"},
{name: "ova", input: "ova", want: "OVA"},
{name: "trimmed passthrough", input: " tv special ", want: "tv special"},
}
@@ -56,17 +125,3 @@ func TestWatchOrderTypeLabel(t *testing.T) {
})
}
}
func TestAllowedWatchOrderTypeFromDataset(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{name: "label tv", input: "TV", want: true},
{name: "label movie", input: "Movie", want: true},
{name: "label special", input: "Special", want: false},
}
runBoolCases(t, tests, isAllowedWatchOrderType)
}

View File

@@ -8,8 +8,7 @@ import (
"strings"
)
// SearchAdvanced performs a filtered anime search with type, status, ordering, genre filters, and studio (producer) filters.
func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (SearchResult, error) {
func normalizePage(page, limit int) (int, int) {
if page < 1 {
page = 1
}
@@ -17,46 +16,47 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o
limit = 0
}
genresParam := ""
if len(genres) > 0 {
ids := make([]string, len(genres))
for i, g := range genres {
ids[i] = strconv.Itoa(g)
}
genresParam = strings.Join(ids, ",")
return page, limit
}
func joinGenreIDs(genres []int) string {
if len(genres) == 0 {
return ""
}
ids := make([]string, len(genres))
for i, g := range genres {
ids[i] = strconv.Itoa(g)
}
return strings.Join(ids, ",")
}
func advancedURL(baseURL, query, animeType, status, orderBy, sort, genres string, studioID int, sfw bool, page, limit int) string {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
setTrueQueryValue(params, "sfw", sfw)
setQueryValue(params, "q", query)
setQueryValue(params, "type", animeType)
setQueryValue(params, "status", status)
setPositiveInt(params, "producers", studioID)
setQueryValue(params, "order_by", orderBy)
setQueryValue(params, "sort", sort)
setQueryValue(params, "genres", genres)
setPositiveInt(params, "limit", limit)
return buildRequestURL(baseURL, "/anime", params)
}
// SearchAdvanced performs a filtered anime search with type, status, ordering, genre filters, and studio (producer) filters.
func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (SearchResult, error) {
page, limit = normalizePage(page, limit)
genresParam := joinGenreIDs(genres)
cacheKey := fmt.Sprintf("search:%s:%s:%s:%s:%s:%s:%d:%v:%d:%d", query, animeType, status, orderBy, sort, genresParam, studioID, sfw, page, limit)
var result SearchResponse
reqURL := fmt.Sprintf("%s/anime?page=%d", c.baseURL, page)
if sfw {
reqURL += "&sfw=true"
}
if query != "" {
reqURL += "&q=" + url.QueryEscape(query)
}
if animeType != "" {
reqURL += "&type=" + url.QueryEscape(animeType)
}
if status != "" {
reqURL += "&status=" + url.QueryEscape(status)
}
if studioID > 0 {
reqURL += "&producers=" + strconv.Itoa(studioID)
}
if orderBy != "" {
reqURL += "&order_by=" + url.QueryEscape(orderBy)
}
if sort != "" {
reqURL += "&sort=" + url.QueryEscape(sort)
}
if genresParam != "" {
reqURL += "&genres=" + genresParam
}
if limit > 0 {
reqURL += fmt.Sprintf("&limit=%d", limit)
}
reqURL := advancedURL(c.baseURL, query, animeType, status, orderBy, sort, genresParam, studioID, sfw, page, limit)
if err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result); err != nil {
return SearchResult{}, err
@@ -67,37 +67,3 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o
HasNextPage: result.Pagination.HasNextPage,
}, nil
}
// GetTopAnime returns the top-rated anime list for a given page.
func (c *Client) GetTopAnime(ctx context.Context, page int) (TopAnimeResult, error) {
if page < 1 {
page = 1
}
cacheKey := fmt.Sprintf("top:%d", page)
var result TopAnimeResponse
reqURL := fmt.Sprintf("%s/top/anime?page=%d", c.baseURL, page)
if err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result); err != nil {
return TopAnimeResult{}, err
}
return TopAnimeResult{
Animes: result.Data,
HasNextPage: result.Pagination.HasNextPage,
}, nil
}
// GetAnimeGenres returns list of all anime genres, cached long-term.
func (c *Client) GetAnimeGenres(ctx context.Context) ([]Genre, error) {
const cacheKey = "anime_genres"
var result GenresResponse
reqURL := fmt.Sprintf("%s/genres/anime", c.baseURL)
if err := c.getWithCache(ctx, cacheKey, longCacheTTL, reqURL, &result); err != nil {
return nil, err
}
return result.Data, nil
}

View File

@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"math/rand"
"net/url"
"strconv"
"time"
)
@@ -30,7 +32,9 @@ func (c *Client) getSeasonList(ctx context.Context, page int, season string) (To
cacheKey := fmt.Sprintf("seasons_%s:%d", season, page)
var result TopAnimeResponse
reqURL := fmt.Sprintf("%s/seasons/%s?page=%d", c.baseURL, season, page)
params := url.Values{}
params.Set("page", strconv.Itoa(page))
reqURL := buildRequestURL(c.baseURL, fmt.Sprintf("/seasons/%s", season), params)
err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result)
if err != nil {
@@ -44,76 +48,119 @@ func (c *Client) getSeasonList(ctx context.Context, page int, season string) (To
}
// seedRandomPool seeds the in-memory pool of random anime
func (c *Client) seedRandomPool(ctx context.Context) error {
func (c *Client) seedRandomPool(ctx context.Context) {
if !c.markRandomPoolInitialized() {
return
}
c.loadCachedRandomPool(ctx)
// Fetch a solid baseline in the background, then start refreshing.
go c.seedRandomPoolBaseline()
}
func (c *Client) markRandomPoolInitialized() bool {
c.poolMu.Lock()
defer c.poolMu.Unlock()
if c.poolInitialized {
c.poolMu.Unlock()
return false
}
c.poolInitialized = true
return true
}
func (c *Client) loadCachedRandomPool(ctx context.Context) {
cachedJSONs, err := c.db.GetAllCachedAnime(ctx)
if err != nil || len(cachedJSONs) == 0 {
return
}
loadedAnimes := decodeCachedAnime(cachedJSONs)
if len(loadedAnimes) == 0 {
return
}
c.poolMu.Lock()
c.randomPool = append(c.randomPool, loadedAnimes...)
c.poolMu.Unlock()
}
func decodeCachedAnime(cachedJSONs []string) []Anime {
loadedAnimes := make([]Anime, 0, len(cachedJSONs))
for _, dataStr := range cachedJSONs {
var anime Anime
if err := json.Unmarshal([]byte(dataStr), &anime); err != nil || anime.MalID == 0 {
continue
}
loadedAnimes = append(loadedAnimes, anime)
}
return loadedAnimes
}
func (c *Client) seedRandomPoolBaseline() {
bgCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
fetchedAnimes := c.fetchBaselineAnime(bgCtx)
if len(fetchedAnimes) > 0 {
c.appendUniqueRandomPool(fetchedAnimes)
}
// Start background refresher once seeding completes
c.startPoolRefresher()
}
func (c *Client) fetchBaselineAnime(ctx context.Context) []Anime {
topPageOne := c.fetchTopAnimePage(ctx, 1)
topPageTwo := c.fetchTopAnimePage(ctx, 2)
currentSeason := c.fetchCurrentSeasonAnime(ctx)
fetchedAnimes := make([]Anime, 0, len(topPageOne)+len(topPageTwo)+len(currentSeason))
fetchedAnimes = append(fetchedAnimes, topPageOne...)
fetchedAnimes = append(fetchedAnimes, topPageTwo...)
fetchedAnimes = append(fetchedAnimes, currentSeason...)
return fetchedAnimes
}
func (c *Client) fetchTopAnimePage(ctx context.Context, page int) []Anime {
top, err := c.GetTopAnime(ctx, page)
if err != nil {
return nil
}
c.poolInitialized = true
c.poolMu.Unlock()
// 1. Try to load all cached anime from the database
cachedJSONs, err := c.db.GetAllCachedAnime(ctx)
if err == nil && len(cachedJSONs) > 0 {
var loadedAnimes []Anime
for _, dataStr := range cachedJSONs {
var anime Anime
if err := json.Unmarshal([]byte(dataStr), &anime); err == nil && anime.MalID > 0 {
loadedAnimes = append(loadedAnimes, anime)
}
}
return top.Animes
}
if len(loadedAnimes) > 0 {
c.poolMu.Lock()
c.randomPool = append(c.randomPool, loadedAnimes...)
c.poolMu.Unlock()
}
func (c *Client) fetchCurrentSeasonAnime(ctx context.Context) []Anime {
now, err := c.GetSeasonsNow(ctx, 1)
if err != nil {
return nil
}
// 2. Fetch Top Anime page 1 & 2 to ensure we have a robust baseline of high-quality popular anime
go func() {
bgCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return now.Animes
}
var fetchedAnimes []Anime
func (c *Client) appendUniqueRandomPool(animes []Anime) {
c.poolMu.Lock()
defer c.poolMu.Unlock()
top, err := c.GetTopAnime(bgCtx, 1)
if err == nil && len(top.Animes) > 0 {
fetchedAnimes = append(fetchedAnimes, top.Animes...)
seen := make(map[int]bool, len(c.randomPool)+len(animes))
for _, anime := range c.randomPool {
seen[anime.MalID] = true
}
for _, anime := range animes {
if seen[anime.MalID] {
continue
}
top2, err := c.GetTopAnime(bgCtx, 2)
if err == nil && len(top2.Animes) > 0 {
fetchedAnimes = append(fetchedAnimes, top2.Animes...)
}
now, err := c.GetSeasonsNow(bgCtx, 1)
if err == nil && len(now.Animes) > 0 {
fetchedAnimes = append(fetchedAnimes, now.Animes...)
}
if len(fetchedAnimes) > 0 {
c.poolMu.Lock()
// Use map to de-duplicate any anime
seen := make(map[int]bool)
for _, a := range c.randomPool {
seen[a.MalID] = true
}
for _, a := range fetchedAnimes {
if !seen[a.MalID] {
c.randomPool = append(c.randomPool, a)
seen[a.MalID] = true
}
}
c.poolMu.Unlock()
}
// Start background refresher once seeding completes
c.startPoolRefresher()
}()
return nil
c.randomPool = append(c.randomPool, anime)
seen[anime.MalID] = true
}
}
// startPoolRefresher runs in the background to slowly mix in true random anime
@@ -162,7 +209,7 @@ func (c *Client) GetRandomAnime(ctx context.Context) (Anime, error) {
c.poolMu.Unlock()
if !initialized {
_ = c.seedRandomPool(ctx)
c.seedRandomPool(ctx)
}
c.poolMu.RLock()

View File

@@ -1,44 +0,0 @@
package jikan
import (
"context"
"fmt"
)
type ProducerResponse struct {
Data struct {
MalID int `json:"mal_id"`
Titles []struct {
Type string `json:"type"`
Title string `json:"title"`
} `json:"titles"`
Images struct {
Jpg struct {
ImageURL string `json:"image_url"`
} `json:"jpg"`
} `json:"images"`
Favorites int `json:"favorites"`
Established string `json:"established"`
About string `json:"about"`
Count int `json:"count"`
External []struct {
Name string `json:"name"`
URL string `json:"url"`
} `json:"external"`
} `json:"data"`
}
func (c *Client) GetProducerByID(ctx context.Context, id int) (ProducerResponse, error) {
if id <= 0 {
return ProducerResponse{}, fmt.Errorf("invalid producer id")
}
cacheKey := fmt.Sprintf("producer:%d", id)
reqURL := fmt.Sprintf("%s/producers/%d", c.baseURL, id)
var result ProducerResponse
if err := c.getWithCache(ctx, cacheKey, producerCacheTTL, reqURL, &result); err != nil {
return ProducerResponse{}, err
}
return result, nil
}

View File

@@ -0,0 +1,352 @@
package transport
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
"mal/integrations/jikan/rate"
"mal/internal/observability"
errlog "mal/pkg"
netutil "mal/pkg/net"
)
const slowLogThreshold = 750 * time.Millisecond
type Client struct {
HTTPClient *http.Client
Limiter *rate.Limiter
TraceEnabled func() bool
}
type Config struct {
HTTPClient *http.Client
Limiter *rate.Limiter
TraceEnabled func() bool
}
type APIError struct {
StatusCode int
URL string
Body json.RawMessage
}
func (e *APIError) Error() string {
return fmt.Sprintf("jikan api returned status %d", e.StatusCode)
}
func NewHTTPClient() *http.Client {
return &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
DisableKeepAlives: false,
TLSHandshakeTimeout: 5 * time.Second,
},
}
}
func NewClient(cfg Config) *Client {
return &Client{
HTTPClient: cfg.HTTPClient,
Limiter: cfg.Limiter,
TraceEnabled: cfg.TraceEnabled,
}
}
// IsRetryableError returns true if the error should trigger a retry.
func IsRetryableError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) {
return false
}
var apiErr *APIError
if errors.As(err, &apiErr) {
return isRetryableStatus(apiErr.StatusCode)
}
var netErr net.Error
if errors.As(err, &netErr) {
return true
}
if errors.Is(err, context.DeadlineExceeded) {
return true
}
return false
}
// FetchWithRetry makes an HTTP request with exponential backoff on transient failures.
func (c *Client) FetchWithRetry(ctx context.Context, urlStr string, out any) error {
maxRetries := 5
startedAt := time.Now()
attempts := 0
logAndReturn := func(statusCode int, err error) error {
if isDoneContextError(ctx, err) {
return err
}
c.logUpstream(urlStr, statusCode, attempts, startedAt, err)
return err
}
for attempt := range maxRetries {
attempts = attempt + 1
if err := c.prepareRetryAttempt(ctx); err != nil {
return logAndReturn(0, err)
}
resp, err := c.doRequest(ctx, urlStr)
if err != nil {
retry, requestErr := handleRequestRetry(ctx, err, attempt, maxRetries)
if retry {
continue
}
return logAndReturn(0, requestErr)
}
statusCode, retry, err := func() (int, bool, error) {
defer func() {
errlog.Log("failed to close jikan response body", resp.Body.Close())
}()
return handleResponseRetry(ctx, resp, urlStr, out, attempt, maxRetries)
}()
if retry {
continue
}
return logAndReturn(statusCode, err)
}
return logAndReturn(0, fmt.Errorf("max retries exceeded for %s", urlStr))
}
func (c *Client) prepareRetryAttempt(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
return c.Limiter.Wait(ctx)
}
func (c *Client) doRequest(ctx context.Context, urlStr string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return nil, fmt.Errorf("failed to create jikan request: %w", err)
}
req.Header.Set("User-Agent", netutil.Generic)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
return resp, nil
}
func handleRequestRetry(ctx context.Context, err error, attempt int, maxRetries int) (bool, error) {
if ctx.Err() != nil {
return false, ctx.Err()
}
if errors.Is(err, context.Canceled) {
return false, err
}
if attempt >= maxRetries-1 || !IsRetryableError(err) {
return false, fmt.Errorf("jikan api error: %w", err)
}
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
return false, retryErr
}
return true, nil
}
func handleResponseRetry(ctx context.Context, resp *http.Response, urlStr string, out any, attempt int, maxRetries int) (int, bool, error) {
if resp.StatusCode != http.StatusOK {
return handleStatusRetry(ctx, resp, urlStr, attempt, maxRetries)
}
err := json.NewDecoder(resp.Body).Decode(out)
if err == nil {
return resp.StatusCode, false, nil
}
if attempt < maxRetries-1 {
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
return resp.StatusCode, false, retryErr
}
return resp.StatusCode, true, nil
}
return resp.StatusCode, false, fmt.Errorf("failed to decode jikan response: %w", err)
}
func handleStatusRetry(ctx context.Context, resp *http.Response, urlStr string, attempt int, maxRetries int) (int, bool, error) {
statusCode := resp.StatusCode
apiErr := &APIError{StatusCode: statusCode, URL: urlStr}
retryAfter := time.Duration(0)
if parsed, ok := parseRetryAfter(resp.Header.Get("Retry-After")); ok {
retryAfter = parsed
}
if isRetryableStatus(statusCode) && attempt < maxRetries-1 {
if retryErr := waitForRetry(ctx, max(retryAfter, retryDelay(attempt))); retryErr != nil {
return statusCode, false, retryErr
}
return statusCode, true, nil
}
apiErr.Body = readErrorBody(resp)
return statusCode, false, apiErr
}
func readErrorBody(resp *http.Response) json.RawMessage {
if resp.Body == nil {
return nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil
}
body = []byte(strings.TrimSpace(string(body)))
if len(body) == 0 || !json.Valid(body) {
return nil
}
return json.RawMessage(body)
}
func isRetryableStatus(statusCode int) bool {
if statusCode == http.StatusTooManyRequests {
return true
}
return statusCode >= 500 && statusCode <= 504
}
// retryDelay returns exponential backoff delay: 500ms, 1s, 2s, 4s, 8s (capped).
func retryDelay(attempt int) time.Duration {
base := 500 * time.Millisecond
delay := base * time.Duration(1<<attempt)
if delay > 8*time.Second {
return 8 * time.Second
}
return delay
}
// parseRetryAfter parses Retry-After header value (seconds) into duration.
func parseRetryAfter(value string) (time.Duration, bool) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return 0, false
}
seconds, err := strconv.Atoi(trimmed)
if err != nil {
return 0, false
}
if seconds <= 0 {
return 0, false
}
return time.Duration(seconds) * time.Second, true
}
func waitForRetry(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func isDoneContextError(ctx context.Context, err error) bool {
return err != nil && ctx.Err() != nil && errors.Is(err, ctx.Err())
}
func (c *Client) logUpstream(urlStr string, statusCode int, attempts int, startedAt time.Time, err error) {
duration := time.Since(startedAt)
traceEnabled := c.TraceEnabled != nil && c.TraceEnabled()
if !traceEnabled && err == nil && statusCode < http.StatusBadRequest && duration < slowLogThreshold {
return
}
level := observability.LogLevelInfo
if err != nil || statusCode >= http.StatusInternalServerError {
level = observability.LogLevelError
} else if statusCode == http.StatusTooManyRequests || statusCode >= http.StatusBadRequest {
level = observability.LogLevelWarn
}
observability.LogJSON(
level,
"jikan_upstream",
"jikan",
"",
map[string]any{
"url": urlStr,
"endpoint": endpointLabel(urlStr),
"status": statusCode,
"attempts": attempts,
"duration_ms": float64(duration.Microseconds()) / 1000,
},
err,
)
}
func endpointLabel(urlStr string) string {
trimmed := strings.TrimSpace(urlStr)
if trimmed == "" {
return "unknown"
}
prefix := "https://api.jikan.moe/v4"
trimmed = strings.TrimPrefix(trimmed, prefix)
if idx := strings.Index(trimmed, "?"); idx >= 0 {
trimmed = trimmed[:idx]
}
parts := strings.Split(trimmed, "/")
out := make([]string, 0, len(parts))
for _, part := range parts {
if part == "" {
continue
}
if _, err := strconv.Atoi(part); err == nil {
out = append(out, "{id}")
continue
}
out = append(out, part)
}
if len(out) == 0 {
return "/"
}
return "/" + strings.Join(out, "/")
}

View File

@@ -0,0 +1,55 @@
package transport
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"testing"
)
func TestHandleStatusRetryLeavesOutputUntouched(t *testing.T) {
out := struct {
Data []struct {
MalID int `json:"mal_id"`
} `json:"data"`
}{
Data: []struct {
MalID int `json:"mal_id"`
}{{MalID: 123}},
}
resp := &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(strings.NewReader(`{"data":[{"mal_id":999}]}`)),
Header: make(http.Header),
}
statusCode, retry, err := handleResponseRetry(context.Background(), resp, "https://example.test/anime/1", &out, 0, 1)
if statusCode != http.StatusNotFound {
t.Fatalf("statusCode = %d, want %d", statusCode, http.StatusNotFound)
}
if retry {
t.Fatal("retry = true, want false")
}
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err = %v, want APIError", err)
}
if len(out.Data) != 1 || out.Data[0].MalID != 123 {
t.Fatalf("out = %+v, want original value", out)
}
var body struct {
Data []struct {
MalID int `json:"mal_id"`
} `json:"data"`
}
if err := json.Unmarshal(apiErr.Body, &body); err != nil {
t.Fatalf("unmarshal APIError body: %v", err)
}
if len(body.Data) != 1 || body.Data[0].MalID != 999 {
t.Fatalf("APIError body = %+v, want decoded error body", body)
}
}

View File

@@ -22,6 +22,29 @@ type StudioAnimeResult struct {
StudioName string
}
type ProducerResponse struct {
Data struct {
MalID int `json:"mal_id"`
Titles []struct {
Type string `json:"type"`
Title string `json:"title"`
} `json:"titles"`
Images struct {
Jpg struct {
ImageURL string `json:"image_url"`
} `json:"jpg"`
} `json:"images"`
Favorites int `json:"favorites"`
Established string `json:"established"`
About string `json:"about"`
Count int `json:"count"`
External []struct {
Name string `json:"name"`
URL string `json:"url"`
} `json:"external"`
} `json:"data"`
}
type NamedEntity struct {
MalID int `json:"mal_id"`
Name string `json:"name"`
@@ -33,12 +56,18 @@ type Aired struct {
String string `json:"string"`
}
type TitleEntry struct {
Type string `json:"type"`
Title string `json:"title"`
}
type Anime struct {
MalID int `json:"mal_id"`
Title string `json:"title"`
TitleEnglish string `json:"title_english"`
TitleJapanese string `json:"title_japanese"`
TitleSynonyms []string `json:"title_synonyms"`
MalID int `json:"mal_id"`
Title string `json:"title"`
TitleEnglish string `json:"title_english"`
TitleJapanese string `json:"title_japanese"`
TitleSynonyms []string `json:"title_synonyms"`
Titles []TitleEntry `json:"titles"`
Images struct {
Jpg struct {
LargeImageURL string `json:"large_image_url"`
@@ -156,40 +185,6 @@ type RecommendationsResponse struct {
Data []RecommendationEntry `json:"data"`
}
// ScoredByFormatted returns formatted count (e.g. "1 234 567").
func (a Anime) ScoredByFormatted() string {
return formatNumber(a.ScoredBy)
}
// MembersFormatted returns formatted count (e.g. "1 234 567").
func (a Anime) MembersFormatted() string {
return formatNumber(a.Members)
}
// FavoritesFormatted returns formatted count (e.g. "1 234 567").
func (a Anime) FavoritesFormatted() string {
return formatNumber(a.Favorites)
}
// formatNumber adds space separators to a number (1234567 -> "1 234 567").
func formatNumber(n int) string {
if n == 0 {
return ""
}
s := fmt.Sprintf("%d", n)
var res []string
for i := len(s); i > 0; i -= 3 {
start := max(i-3, 0)
res = append([]string{s[start:i]}, res...)
}
return strings.Join(res, " ")
}
// ImageURL returns the webp large image URL for the anime.
func (a Anime) ImageURL() string {
return a.Images.Webp.LargeImageURL
}
// ShortRating extracts just the rating code (e.g. "PG-13") from full rating string.
func (a Anime) ShortRating() string {
if a.Rating == "" {
@@ -230,35 +225,34 @@ func (a Anime) DurationSeconds() float64 {
return 0
}
var hours, minutes int
var isHours bool
var currentNum string
var currentValue int
hasValue := false
for _, c := range a.Duration {
if c >= '0' && c <= '9' {
currentNum += string(c)
} else if c == ' ' && currentNum != "" {
val, _ := strconv.Atoi(currentNum)
if isHours {
hours = val
} else {
minutes = val
}
currentNum = ""
} else if len(currentNum) > 0 && (c == 'h' || c == 'H') {
isHours = true
val, _ := strconv.Atoi(currentNum)
hours = val
currentNum = ""
for token := range strings.FieldsSeq(strings.ToLower(a.Duration)) {
value, err := strconv.Atoi(token)
if err == nil {
currentValue = value
hasValue = true
continue
}
if !hasValue {
continue
}
switch {
case strings.HasPrefix(token, "h"):
hours = currentValue
hasValue = false
case strings.HasPrefix(token, "m"):
minutes = currentValue
hasValue = false
}
}
if currentNum != "" {
val, _ := strconv.Atoi(currentNum)
if isHours {
hours = val
} else {
minutes = val
}
if hasValue {
minutes = currentValue
}
return float64(hours*60+minutes) * 60
}
@@ -327,6 +321,7 @@ type Episode struct {
MalID int `json:"mal_id"`
Title string `json:"title"`
Episode string `json:"episode"`
Aired string `json:"aired"`
Filler bool `json:"filler"`
Recap bool `json:"recap"`
Images *EpisodeImages `json:"images,omitempty"`
@@ -455,13 +450,16 @@ type ReviewsResponse struct {
Pagination Pagination `json:"pagination"`
}
// DisplayTitle returns English title if available, otherwise Japanese, then default.
// DisplayTitle returns English title if available, otherwise default title, titles[0], then Japanese.
func (a Anime) DisplayTitle() string {
if a.TitleEnglish != "" {
return a.TitleEnglish
}
if a.TitleJapanese != "" {
return a.TitleJapanese
if a.Title != "" {
return a.Title
}
return a.Title
if len(a.Titles) > 0 && a.Titles[0].Title != "" {
return a.Titles[0].Title
}
return a.TitleJapanese
}

View File

@@ -0,0 +1,27 @@
package jikan
import "testing"
func TestAnimeDisplayTitlePrefersTitleBeforeJapanese(t *testing.T) {
anime := Anime{
Title: "Cyberpunk: Edgerunners",
TitleJapanese: "サイバーパンク エッジランナーズ",
}
if got := anime.DisplayTitle(); got != "Cyberpunk: Edgerunners" {
t.Fatalf("DisplayTitle() = %q, want default title", got)
}
}
func TestAnimeDisplayTitleFallsBackToFirstTitleEntryBeforeJapanese(t *testing.T) {
anime := Anime{
TitleJapanese: "サイバーパンク エッジランナーズ",
Titles: []TitleEntry{
{Type: "Default", Title: "Cyberpunk: Edgerunners"},
},
}
if got := anime.DisplayTitle(); got != "Cyberpunk: Edgerunners" {
t.Fatalf("DisplayTitle() = %q, want first title entry", got)
}
}

View File

@@ -0,0 +1,127 @@
package allanime
import (
"context"
"errors"
"mal/internal/domain"
"strconv"
"strings"
)
type AvailableEpisodes struct {
Sub []string
Dub []string
Raw []string
Titles map[int]string
}
func (c *AllAnimeProvider) GetEpisodeAvailability(ctx context.Context, animeID int, titleCandidates []string) (domain.EpisodeAvailability, error) {
showID, err := c.ResolveEpisodeProviderID(ctx, animeID, titleCandidates)
if err != nil {
return domain.EpisodeAvailability{}, err
}
return c.GetEpisodeAvailabilityByProviderID(ctx, showID)
}
func (c *AllAnimeProvider) GetEpisodeAvailabilityByProviderID(ctx context.Context, showID string) (domain.EpisodeAvailability, error) {
available, err := c.GetAvailableEpisodes(ctx, showID)
if err != nil {
return domain.EpisodeAvailability{}, err
}
sub := episodeNums(append(available.Sub, available.Raw...))
dub := episodeNums(available.Dub)
return domain.EpisodeAvailability{Sub: sub, Dub: dub, Titles: available.Titles}, nil
}
func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) {
graphqlQuery := `query($showId: String!, $start: Float!, $end: Float!) {
show(_id: $showId) {
availableEpisodesDetail
}
episodeInfos(showId: $showId, episodeNumStart: $start, episodeNumEnd: $end) {
episodeIdNum
notes
}
}`
result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{
"showId": showID,
"start": 1,
"end": 100000,
})
if err != nil {
return AvailableEpisodes{}, err
}
data, ok := result["data"].(map[string]any)
if !ok {
return AvailableEpisodes{}, errors.New("invalid response")
}
show, ok := data["show"].(map[string]any)
if !ok || show == nil {
return AvailableEpisodes{}, errors.New("show not found")
}
detail, ok := show["availableEpisodesDetail"].(map[string]any)
if !ok {
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{
Sub: stringsFrom(detail["sub"]),
Dub: stringsFrom(detail["dub"]),
Raw: stringsFrom(detail["raw"]),
Titles: titles,
}, nil
}
// episode ids
func episodeNums(raw []string) []int {
seen := make(map[int]bool, len(raw))
out := make([]int, 0, len(raw))
for _, value := range raw {
n, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || n <= 0 || seen[n] {
continue
}
seen[n] = true
out = append(out, n)
}
return out
}
// graphql list
func stringsFrom(value any) []string {
items, ok := value.([]any)
if !ok {
return nil
}
values := make([]string, 0, len(items))
for _, item := range items {
str, ok := item.(string)
if !ok {
continue
}
values = append(values, str)
}
return values
}

View File

@@ -6,10 +6,10 @@ import (
)
func TestParseEpisodeNumbersKeepsOnlyPositiveIntegers(t *testing.T) {
got := parseEpisodeNumbers([]string{"1", " 2 ", "2", "0", "-1", "12.5", "SP1", "6"})
got := episodeNums([]string{"1", " 2 ", "2", "0", "-1", "12.5", "SP1", "6"})
want := []int{1, 2, 6}
if !reflect.DeepEqual(got, want) {
t.Fatalf("parseEpisodeNumbers() = %v, want %v", got, want)
t.Fatalf("episodeNums() = %v, want %v", got, want)
}
}

View File

@@ -1,53 +1,32 @@
// Package allanime provides an integration with the AllAnime API for episode playback.
package allanime
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mal/internal/domain"
"mal/pkg"
errlog "mal/pkg"
netutil "mal/pkg/net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
allAnimeBaseURL = "https://api.allanime.day"
allAnimeReferer = "https://allmanga.to/"
allAnimeSiteURL = "https://allanime.day"
allAnimeReferer = "https://youtu-chan.com"
allAnimeOrigin = "https://youtu-chan.com"
defaultUserAgent = netutil.Firefox121
)
var (
aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"}
)
type searchResult struct {
ID string
MalID string
Name string
}
type AvailableEpisodes struct {
Sub []string
Dub []string
Raw []string
}
type AllAnimeProvider struct {
httpClient *http.Client
utlsClient *http.Client
extractor *providerExtractor
baseURL string
}
func NewAllAnimeProvider() *AllAnimeProvider {
@@ -60,6 +39,7 @@ func NewAllAnimeProvider() *AllAnimeProvider {
Timeout: 30 * time.Second,
},
extractor: newProviderExtractor(),
baseURL: allAnimeBaseURL,
}
}
@@ -67,139 +47,30 @@ func (c *AllAnimeProvider) Name() string {
return "AllAnime"
}
const searchQuery = `query(
$search: SearchInput
$translationType: VaildTranslationTypeEnumType
$limit: Int = 40
$page: Int = 1
$countryOrigin: VaildCountryOriginEnumType = ALL
) {
shows(
search: $search
limit: $limit
page: $page
translationType: $translationType
countryOrigin: $countryOrigin
) {
edges {
_id
malId
name
}
}
}`
func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
type searchData struct {
Shows struct {
Edges []struct {
ID string `json:"_id"`
MalID string `json:"malId"`
Name string `json:"name"`
} `json:"edges"`
} `json:"shows"`
func (c *AllAnimeProvider) apiBaseURL() string {
if c.baseURL != "" {
return c.baseURL
}
type searchInput struct {
AllowAdult bool `json:"allowAdult"`
AllowUnknown bool `json:"allowUnknown"`
Query string `json:"query"`
}
type searchVariables struct {
Search searchInput `json:"search"`
TranslationType string `json:"translationType"`
}
vars := searchVariables{
Search: searchInput{
AllowAdult: false,
AllowUnknown: false,
Query: query,
},
TranslationType: mode,
}
data, err := graphql.Post[searchData](ctx, c.httpClient, allAnimeBaseURL+"/api", searchQuery, vars, graphql.PostOptions{
Headers: map[string]string{
"Referer": allAnimeReferer,
"User-Agent": defaultUserAgent,
},
BodyMax: netutil.MiB2,
})
if err != nil {
return nil, err
}
out := make([]searchResult, 0, len(data.Shows.Edges))
for _, edge := range data.Shows.Edges {
id := edge.ID
malID := edge.MalID
name := edge.Name
if unquoted, err := strconv.Unquote("\"" + name + "\""); err == nil {
name = unquoted
}
name = strings.TrimSpace(name)
if id == "" {
continue
}
out = append(out, searchResult{ID: id, MalID: malID, Name: name})
}
return out, nil
return allAnimeBaseURL
}
func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string) (*domain.StreamResult, error) {
// 1. Search for the show to get its AllAnime ID
// Try each title candidate, preferring results with matching malId
targetMalIDStr := strconv.Itoa(animeID)
var showID string
var firstAvailableShowID string
for _, title := range titleCandidates {
searchResults, err := c.Search(ctx, title, mode)
if err != nil || len(searchResults) == 0 {
continue
}
for _, res := range searchResults {
if res.MalID == targetMalIDStr {
showID = res.ID
break
}
}
if showID != "" {
break
}
if firstAvailableShowID == "" {
firstAvailableShowID = searchResults[0].ID
}
}
if showID == "" {
showID = firstAvailableShowID
}
if showID == "" {
showID, err := c.strictShowID(ctx, animeID, titleCandidates, mode)
if err != nil {
return nil, fmt.Errorf("allanime: show not found for malID %d", animeID)
}
// 2. Get sources
sources, err := c.GetEpisodeSources(ctx, showID, episode, mode)
if err != nil || len(sources) == 0 {
return nil, fmt.Errorf("allanime: no sources for show %s", showID)
}
// 3. Return the first usable source
primary := sources[0]
result := &domain.StreamResult{
URL: primary.URL,
Referer: primary.Referer,
Type: primary.Type,
}
for _, sub := range primary.Subtitles {
@@ -212,65 +83,6 @@ func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCan
return result, nil
}
func (c *AllAnimeProvider) GetEpisodeAvailability(ctx context.Context, animeID int, titleCandidates []string) (domain.EpisodeAvailability, error) {
showID, err := c.ResolveEpisodeProviderID(ctx, animeID, titleCandidates)
if err != nil {
return domain.EpisodeAvailability{}, err
}
return c.GetEpisodeAvailabilityByProviderID(ctx, showID)
}
func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error) {
for _, mode := range []string{"sub", "dub"} {
showID, err := c.resolveShowIDStrict(ctx, animeID, titleCandidates, mode)
if err == nil {
return showID, nil
}
}
return "", fmt.Errorf("allanime: no exact mal id match for %d", animeID)
}
func (c *AllAnimeProvider) GetEpisodeAvailabilityByProviderID(ctx context.Context, showID string) (domain.EpisodeAvailability, error) {
available, err := c.GetAvailableEpisodes(ctx, showID)
if err != nil {
return domain.EpisodeAvailability{}, err
}
sub := parseEpisodeNumbers(append(available.Sub, available.Raw...))
dub := parseEpisodeNumbers(available.Dub)
return domain.EpisodeAvailability{Sub: sub, Dub: dub}, nil
}
func (c *AllAnimeProvider) resolveShowIDStrict(ctx context.Context, animeID int, titleCandidates []string, mode string) (string, error) {
targetMalIDStr := strconv.Itoa(animeID)
for _, title := range titleCandidates {
searchResults, err := c.Search(ctx, title, mode)
if err != nil {
continue
}
for _, res := range searchResults {
if res.MalID == targetMalIDStr {
return res.ID, nil
}
}
}
return "", fmt.Errorf("allanime: no exact mal id match for %d in %s search", animeID, mode)
}
func parseEpisodeNumbers(raw []string) []int {
seen := make(map[int]bool, len(raw))
out := make([]int, 0, len(raw))
for _, value := range raw {
n, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || n <= 0 || seen[n] {
continue
}
seen[n] = true
out = append(out, n)
}
return out
}
func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, variables map[string]any) (map[string]any, error) {
if mode, ok := variables["translationType"].(string); ok {
variables["translationType"] = strings.ToLower(mode)
@@ -286,7 +98,7 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
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 {
return nil, fmt.Errorf("create graphql request: %w", err)
}
@@ -295,13 +107,13 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
req.Header.Set("Referer", allAnimeReferer)
req.Header.Set("User-Agent", defaultUserAgent)
resp, respBody, err := executeAndReadResponse(c.httpClient, req, "execute graphql request", "read graphql response")
statusCode, respBody, err := executeAndReadResponse(c.httpClient, req, "execute graphql request", "read graphql response")
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("graphql status %d", resp.StatusCode)
if statusCode != http.StatusOK {
return nil, fmt.Errorf("graphql status %d", statusCode)
}
var parsed map[string]any
@@ -316,487 +128,19 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
return parsed, nil
}
const episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"
func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) {
mode = strings.ToLower(mode)
varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, mode, episode)
extJSON := fmt.Sprintf(`{"persistedQuery":{"version":1,"sha256Hash":"%s"}}`, episodeQueryHash)
apiURL := fmt.Sprintf("%s/api?variables=%s&extensions=%s",
allAnimeBaseURL,
url.QueryEscape(varsJSON),
url.QueryEscape(extJSON))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return nil, fmt.Errorf("create GET request: %w", err)
}
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("Origin", allAnimeOrigin)
req.Header.Set("Sec-Fetch-Dest", "empty")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Site", "cross-site")
resp, respBody, err := executeAndReadResponse(c.utlsClient, req, "execute GET request", "read response")
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET status %d: %s", resp.StatusCode, string(respBody))
}
var parsed map[string]any
if err := json.Unmarshal(respBody, &parsed); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 {
return nil, fmt.Errorf("graphql error: %v", errs[0])
}
data, ok := parsed["data"].(map[string]any)
if !ok {
return nil, fmt.Errorf("no data in response")
}
var toBeParsed string
if s, ok := data["tobeparsed"].(string); ok && s != "" {
toBeParsed = s
} else if episodeData, ok := data["episode"].(map[string]any); ok {
if s, ok := episodeData["tobeparsed"].(string); ok {
toBeParsed = s
}
}
if toBeParsed != "" {
decrypted, err := decryptTobeparsed(toBeParsed)
if err != nil {
return nil, fmt.Errorf("decrypt tobeparsed: %w", err)
}
var ep map[string]any
if jerr := json.Unmarshal(decrypted, &ep); jerr != nil {
return nil, fmt.Errorf("unmarshal decrypted: %w", jerr)
}
var sourceURLs []any
if srcs, ok := ep["sourceUrls"].([]any); ok {
sourceURLs = srcs
} else if epInner, ok := ep["episode"].(map[string]any); ok {
if srcs, ok := epInner["sourceUrls"].([]any); ok {
sourceURLs = srcs
}
}
if len(sourceURLs) > 0 {
return map[string]any{
"episode": map[string]any{
"sourceUrls": sourceURLs,
},
}, nil
}
}
if episodeData, ok := data["episode"].(map[string]any); ok {
if srcs, ok := episodeData["sourceUrls"].([]any); ok && len(srcs) > 0 {
return parsed, nil
}
}
return nil, fmt.Errorf("no usable data in response")
}
// GetEpisodeSources fetches stream URLs for a given show, episode, and mode (dub/sub).
func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) {
episodeQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) {
sourceUrls
}
}`
result, err := c.graphqlRequestWithHash(ctx, showID, episode, mode)
if err == nil {
sources := c.extractSourceURLsFromData(ctx, result)
if len(sources) > 0 {
return sources, nil
}
}
result, err = c.graphqlRequest(ctx, episodeQuery, map[string]any{
"showId": showID,
"translationType": mode,
"episodeString": episode,
})
if err != nil {
return nil, err
}
data, ok := result["data"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid source response")
}
rawSourceURLs, ok := data["episode"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid episode response")
}
sourceURLs, ok := rawSourceURLs["sourceUrls"].([]any)
if !ok || len(sourceURLs) == 0 {
return nil, fmt.Errorf("no source urls")
}
references := buildSourceReferences(sourceURLs)
if len(references) == 0 {
return nil, fmt.Errorf("no source references")
}
out := c.resolveSourceReferences(ctx, references)
if len(out) == 0 {
return nil, fmt.Errorf("no playable sources extracted")
}
return out, nil
}
func (c *AllAnimeProvider) extractSourceURLsFromData(ctx context.Context, data map[string]any) []StreamSource {
episodeData, ok := data["episode"].(map[string]any)
if !ok {
return nil
}
sourceURLs, ok := episodeData["sourceUrls"].([]any)
if !ok || len(sourceURLs) == 0 {
return nil
}
references := buildSourceReferences(sourceURLs)
if len(references) == 0 {
return nil
}
return c.resolveSourceReferences(ctx, references)
}
func (c *AllAnimeProvider) resolveSourceReferences(ctx context.Context, references []sourceReference) []StreamSource {
out := make([]StreamSource, 0, len(references))
for _, ref := range references {
target := strings.TrimSpace(ref.URL)
if target == "" {
continue
}
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
sourceType := detectStreamType(target)
if sourceType == "unknown" {
sourceType = detectEmbedType(target)
}
out = append(out, buildStreamSource(target, sourceType, ref.Name))
continue
}
decoded := decodeSourceURL(target)
if decoded == "" {
continue
}
if strings.HasPrefix(decoded, "http://") || strings.HasPrefix(decoded, "https://") {
sourceType := detectStreamType(decoded)
if sourceType == "unknown" {
sourceType = detectEmbedType(decoded)
}
out = append(out, buildStreamSource(decoded, sourceType, ref.Name))
continue
}
if !strings.HasPrefix(decoded, "/") {
decoded = "/" + decoded
}
extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded)
if err != nil {
continue
}
out = append(out, extracted...)
}
return out
}
func executeAndReadResponse(client *http.Client, req *http.Request, executeErrPrefix string, readErrPrefix string) (*http.Response, []byte, error) {
func executeAndReadResponse(client *http.Client, req *http.Request, executeErrPrefix string, readErrPrefix string) (int, []byte, error) {
resp, err := client.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", executeErrPrefix, err)
return 0, nil, fmt.Errorf("%s: %w", executeErrPrefix, err)
}
defer func() { _ = resp.Body.Close() }()
defer func() {
errlog.Log("failed to close allanime response body", resp.Body.Close())
}()
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", readErrPrefix, err)
return 0, nil, fmt.Errorf("%s: %w", readErrPrefix, err)
}
return resp, body, nil
}
func buildStreamSource(url, sourceType, provider string) StreamSource {
return StreamSource{
URL: url,
Provider: provider,
Type: sourceType,
Referer: allAnimeReferer,
}
}
type sourceReference struct {
URL string
Name string
}
// buildSourceReferences orders source URLs by provider priority, deduplicating entries.
func buildSourceReferences(rawSourceURLs []any) []sourceReference {
priorityOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
prioritySet := map[string]struct{}{"default": {}, "yt-mp4": {}, "s-mp4": {}, "luf-mp4": {}}
prioritized := make(map[string]sourceReference)
fallback := make([]sourceReference, 0, len(rawSourceURLs))
seen := make(map[string]struct{})
for _, source := range rawSourceURLs {
item, ok := source.(map[string]any)
if !ok {
continue
}
sourceURL, _ := item["sourceUrl"].(string)
sourceName, _ := item["sourceName"].(string)
sourceURL = strings.TrimSpace(sourceURL)
sourceName = strings.TrimSpace(sourceName)
if sourceURL == "" {
continue
}
if _, exists := seen[sourceURL]; exists {
continue
}
seen[sourceURL] = struct{}{}
ref := sourceReference{URL: sourceURL, Name: sourceName}
normalized := strings.ToLower(sourceName)
// separate prioritized providers from fallback
if _, prioritizedProvider := prioritySet[normalized]; prioritizedProvider {
if _, exists := prioritized[normalized]; !exists {
prioritized[normalized] = ref
}
continue
}
fallback = append(fallback, ref)
}
// output: prioritized in order, then fallback
ordered := make([]sourceReference, 0, len(prioritized)+len(fallback))
for _, provider := range priorityOrder {
if ref, ok := prioritized[provider]; ok {
ordered = append(ordered, ref)
}
}
ordered = append(ordered, fallback...)
return ordered
}
func decryptTobeparsed(encoded string) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("base64 decode failed: %w", err)
}
if len(raw) < 29 {
return nil, fmt.Errorf("encrypted payload too short")
}
version := raw[0]
iv := raw[1:13]
cipherText := raw[13 : len(raw)-16]
for _, keyStr := range aesKeys {
key := sha256.Sum256([]byte(keyStr))
block, err := aes.NewCipher(key[:])
if err != nil {
continue
}
if version == 1 {
plainText := tryDecryptCTR(block, iv, cipherText)
if json.Valid(plainText) {
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, fmt.Errorf("decryption failed")
}
func tryDecryptCTR(block cipher.Block, iv []byte, cipherText []byte) []byte {
ctrIV := append([]byte{}, iv...)
ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02)
ctr := cipher.NewCTR(block, ctrIV)
plainText := make([]byte, len(cipherText))
ctr.XORKeyStream(plainText, cipherText)
return plainText
}
// GetAvailableEpisodes returns the count of sub/dub/raw episodes available for a show.
func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) {
graphqlQuery := `query($showId: String!) {
show(_id: $showId) {
availableEpisodesDetail
lastEpisodeInfo
}
}`
result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{"showId": showID})
if err != nil {
return AvailableEpisodes{}, err
}
data, ok := result["data"].(map[string]any)
if !ok {
return AvailableEpisodes{}, fmt.Errorf("invalid response")
}
show, ok := data["show"].(map[string]any)
if !ok || show == nil {
return AvailableEpisodes{}, fmt.Errorf("show not found")
}
detail, ok := show["availableEpisodesDetail"].(map[string]any)
if !ok {
return AvailableEpisodes{}, fmt.Errorf("invalid detail")
}
var count AvailableEpisodes
if sub, ok := detail["sub"].([]any); ok {
for _, s := range sub {
if str, ok := s.(string); ok {
count.Sub = append(count.Sub, str)
}
}
}
if dub, ok := detail["dub"].([]any); ok {
for _, s := range dub {
if str, ok := s.(string); ok {
count.Dub = append(count.Dub, str)
}
}
}
if raw, ok := detail["raw"].([]any); ok {
for _, s := range raw {
if str, ok := s.(string); ok {
count.Raw = append(count.Raw, str)
}
}
}
return count, nil
}
func decodeSourceURL(encoded string) string {
if encoded == "" {
return ""
}
encoded = strings.TrimPrefix(encoded, "--")
substitutions := map[string]string{
"79": "A", "7a": "B", "7b": "C", "7c": "D", "7d": "E",
"7e": "F", "7f": "G", "70": "H", "71": "I", "72": "J",
"73": "K", "74": "L", "75": "M", "76": "N", "77": "O",
"68": "P", "69": "Q", "6a": "R", "6b": "S", "6c": "T",
"6d": "U", "6e": "V", "6f": "W", "60": "X", "61": "Y",
"62": "Z",
"59": "a", "5a": "b", "5b": "c", "5c": "d", "5d": "e",
"5e": "f", "5f": "g", "50": "h", "51": "i", "52": "j",
"53": "k", "54": "l", "55": "m", "56": "n", "57": "o",
"48": "p", "49": "q", "4a": "r", "4b": "s", "4c": "t",
"4d": "u", "4e": "v", "4f": "w", "40": "x", "41": "y",
"42": "z",
"08": "0", "09": "1", "0a": "2", "0b": "3", "0c": "4",
"0d": "5", "0e": "6", "0f": "7", "00": "8", "01": "9",
"15": "-", "16": ".", "67": "_", "46": "~", "02": ":",
"17": "/", "07": "?", "1b": "#", "63": "[", "65": "]",
"78": "@", "19": "!", "1c": "$", "1e": "&", "10": "(",
"11": ")", "12": "*", "13": "+", "14": ",", "03": ";",
"05": "=", "1d": "%",
}
var result strings.Builder
for idx := 0; idx < len(encoded); {
if idx+2 <= len(encoded) {
pair := encoded[idx : idx+2]
if sub, ok := substitutions[pair]; ok {
result.WriteString(sub)
idx += 2
continue
}
}
result.WriteByte(encoded[idx])
idx++
}
decoded := result.String()
if strings.Contains(decoded, "/clock") && !strings.Contains(decoded, "/clock.json") {
decoded = strings.Replace(decoded, "/clock", "/clock.json", 1)
}
return decoded
}
func detectStreamType(sourceURL string) string {
lower := strings.ToLower(sourceURL)
if strings.Contains(lower, ".m3u8") || strings.Contains(lower, "master.m3u8") {
return "m3u8"
}
if strings.Contains(lower, ".mp4") {
return "mp4"
}
return "unknown"
}
func detectEmbedType(rawURL string) string {
lower := strings.ToLower(rawURL)
embedHosts := []string{"streamwish", "streamsb", "mp4upload", "ok.ru", "gogoplay", "streamlare"}
for _, host := range embedHosts {
if strings.Contains(lower, host) {
return "embed"
}
}
return "unknown"
return resp.StatusCode, body, nil
}

View File

@@ -4,8 +4,12 @@ import (
"bytes"
"context"
"crypto/aes"
"encoding/json"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"mal/internal/domain"
"net/http"
"strings"
"testing"
)
@@ -20,167 +24,209 @@ func isLikelyMP4(data []byte) bool {
return string(data[4:8]) == "ftyp"
}
func TestDecodeSourceURL(t *testing.T) {
t.Parallel()
type stringTransformTestCase struct {
name string
input string
want string
}
tests := []struct {
name string
encoded string
want string
}{
{
name: "empty returns empty",
encoded: "",
want: "",
},
{
name: "with double prefix stripped",
encoded: "--example.com/video.mp4",
want: "example.com/video.mp4",
},
{
name: "hex substitution",
encoded: "7aexample",
want: "Bexample",
},
{
name: "mixed substitution",
encoded: "79url7a01",
want: "AurlB9",
},
{
name: "clock replacement",
encoded: "/clock",
want: "/clock.json",
},
{
name: "no clock replacement if already json",
encoded: "/clock.json",
want: "/clock.json",
},
{
name: "complex url",
encoded: "--79stream7acom",
want: "AstreamBcom",
},
}
type sourceReferencesTestCase struct {
name string
rawURLs []any
wantRefs []sourceReference
}
var _ interface {
GetStreams(context.Context, int, []string, string, string) (*domain.StreamResult, error)
} = (*AllAnimeProvider)(nil)
func runStringTransformTests(t *testing.T, tests []stringTransformTestCase, fn func(string) string) {
t.Helper()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := decodeSourceURL(tt.encoded)
got := fn(tt.input)
if got != tt.want {
t.Errorf("decodeSourceURL(%q) = %q, want %q", tt.encoded, got, tt.want)
t.Errorf("got %q for input %q, want %q", got, tt.input, tt.want)
}
})
}
}
func runSourceReferenceTests(t *testing.T, tests []sourceReferencesTestCase) {
t.Helper()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := sourceRefs(tt.rawURLs)
if len(got) != len(tt.wantRefs) {
t.Errorf("got %d refs, want %d", len(got), len(tt.wantRefs))
return
}
for i, want := range tt.wantRefs {
if got[i].URL != want.URL {
t.Errorf("ref[%d].URL = %q, want %q", i, got[i].URL, want.URL)
}
if got[i].Name != want.Name {
t.Errorf("ref[%d].Name = %q, want %q", i, got[i].Name, want.Name)
}
}
})
}
}
func buildEncryptedTobeparsedPayload(t *testing.T, plaintext []byte) string {
t.Helper()
key := sha256.Sum256([]byte(aesKeys[0]))
block, err := aes.NewCipher(key[:])
if err != nil {
t.Fatalf("create cipher: %v", err)
}
iv := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b}
ctrIV := append([]byte{}, iv...)
ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02)
cipherText := make([]byte, len(plaintext))
cipher.NewCTR(block, ctrIV).XORKeyStream(cipherText, plaintext)
raw := append([]byte{1}, iv...)
raw = append(raw, cipherText...)
raw = append(raw, make([]byte, 16)...)
return base64.StdEncoding.EncodeToString(raw)
}
func TestDecodeSourceURL(t *testing.T) {
t.Parallel()
tests := []stringTransformTestCase{
{
name: "empty returns empty",
input: "",
want: "",
},
{
name: "with double prefix stripped",
input: "--example.com/video.mp4",
want: "example.com/video.mp4",
},
{
name: "hex substitution",
input: "7aexample",
want: "Bexample",
},
{
name: "mixed substitution",
input: "79url7a01",
want: "AurlB9",
},
{
name: "clock replacement",
input: "/clock",
want: "/clock.json",
},
{
name: "no clock replacement if already json",
input: "/clock.json",
want: "/clock.json",
},
{
name: "complex url",
input: "--79stream7acom",
want: "AstreamBcom",
},
}
runStringTransformTests(t, tests, decodeSourceURL)
}
func TestDetectStreamType(t *testing.T) {
t.Parallel()
tests := []struct {
name string
url string
wantType string
}{
tests := []stringTransformTestCase{
{
name: "m3u8 extension",
url: "https://example.com/video.m3u8",
wantType: "m3u8",
name: "m3u8 extension",
input: "https://example.com/video.m3u8",
want: "m3u8",
},
{
name: "master m3u8",
url: "https://example.com/master.m3u8",
wantType: "m3u8",
name: "master m3u8",
input: "https://example.com/master.m3u8",
want: "m3u8",
},
{
name: "mp4 extension",
url: "https://example.com/video.mp4",
wantType: "mp4",
name: "mp4 extension",
input: "https://example.com/video.mp4",
want: "mp4",
},
{
name: "unknown",
url: "https://example.com/video.avi",
wantType: "unknown",
name: "unknown",
input: "https://example.com/video.avi",
want: "unknown",
},
{
name: "empty returns unknown",
url: "",
wantType: "unknown",
name: "empty returns unknown",
input: "",
want: "unknown",
},
{
name: "case insensitive - M3U8",
url: "https://example.com/MASTER.M3U8",
wantType: "m3u8",
name: "case insensitive - M3U8",
input: "https://example.com/MASTER.M3U8",
want: "m3u8",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := detectStreamType(tt.url)
if got != tt.wantType {
t.Errorf("detectStreamType(%q) = %q, want %q", tt.url, got, tt.wantType)
}
})
}
runStringTransformTests(t, tests, detectStreamType)
}
func TestDetectEmbedType(t *testing.T) {
t.Parallel()
tests := []struct {
name string
url string
wantType string
}{
tests := []stringTransformTestCase{
{
name: "streamwish",
url: "https://streamwish.com/e/abc123",
wantType: "embed",
name: "streamwish",
input: "https://streamwish.com/e/abc123",
want: "embed",
},
{
name: "streamsb",
url: "https://streamsb.com/e/abc123",
wantType: "embed",
name: "streamsb",
input: "https://streamsb.com/e/abc123",
want: "embed",
},
{
name: "mp4upload",
url: "https://mp4upload.com/e/abc123",
wantType: "embed",
name: "mp4upload",
input: "https://mp4upload.com/e/abc123",
want: "embed",
},
{
name: "ok.ru",
url: "https://ok.ru/video/123",
wantType: "embed",
name: "ok.ru",
input: "https://ok.ru/video/123",
want: "embed",
},
{
name: "gogoplay",
url: "https://gogoplay.io/embed/123",
wantType: "embed",
name: "gogoplay",
input: "https://gogoplay.io/embed/123",
want: "embed",
},
{
name: "streamlare",
url: "https://streamlare.com/e/abc",
wantType: "embed",
name: "streamlare",
input: "https://streamlare.com/e/abc",
want: "embed",
},
{
name: "unknown host",
url: "https://unknown.com/video",
wantType: "unknown",
name: "unknown host",
input: "https://unknown.com/video",
want: "unknown",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := detectEmbedType(tt.url)
if got != tt.wantType {
t.Errorf("detectEmbedType(%q) = %q, want %q", tt.url, got, tt.wantType)
}
})
}
runStringTransformTests(t, tests, detectEmbedType)
}
func TestBuildStreamSource(t *testing.T) {
@@ -204,14 +250,44 @@ 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) {
t.Parallel()
if _, ok := directSource(sourceReference{
URL: "https://ok.ru/videoembed/123",
Name: "ok",
}); ok {
t.Fatal("expected embed URL to require extraction")
}
}
func TestBuildSourceReferences(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rawURLs []any
wantRefs []sourceReference
}{
tests := []sourceReferencesTestCase{
{
name: "empty returns empty",
rawURLs: nil,
@@ -263,26 +339,7 @@ func TestBuildSourceReferences(t *testing.T) {
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := buildSourceReferences(tt.rawURLs)
if len(got) != len(tt.wantRefs) {
t.Errorf("got %d refs, want %d", len(got), len(tt.wantRefs))
return
}
for i, want := range tt.wantRefs {
if got[i].URL != want.URL {
t.Errorf("ref[%d].URL = %q, want %q", i, got[i].URL, want.URL)
}
if got[i].Name != want.Name {
t.Errorf("ref[%d].Name = %q, want %q", i, got[i].Name, want.Name)
}
}
})
}
runSourceReferenceTests(t, tests)
}
func TestBuildSourceReferencesOrder(t *testing.T) {
@@ -295,7 +352,7 @@ func TestBuildSourceReferencesOrder(t *testing.T) {
map[string]any{"sourceUrl": "https://yt.com/v.mp4", "sourceName": "yt-mp4"},
}
got := buildSourceReferences(rawURLs)
got := sourceRefs(rawURLs)
wantOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
if len(got) != len(wantOrder) {
@@ -391,22 +448,41 @@ func TestIsLikelyMP4(t *testing.T) {
}
}
func TestParseOKRUSources(t *testing.T) {
t.Parallel()
body := `{"flashvars":{"metadata":"{\"hlsManifestUrl\":\"https://vd.example.test/video.m3u8?cmd=videoPlayerCdn\\u0026id=123\"}"}}`
got := parseOKRUSources(body, allAnimeReferer)
if len(got) != 1 {
t.Fatalf("len(got) = %d, want 1", len(got))
}
if got[0].URL != "https://vd.example.test/video.m3u8?cmd=videoPlayerCdn&id=123" {
t.Fatalf("URL = %q", got[0].URL)
}
if got[0].Type != "m3u8" {
t.Fatalf("Type = %q, want m3u8", got[0].Type)
}
if got[0].Provider != "ok" {
t.Fatalf("Provider = %q, want ok", got[0].Provider)
}
}
func TestDecryptTobeparsed(t *testing.T) {
t.Parallel()
t.Run("valid encrypted payload with first key", func(t *testing.T) {
payload := "AQAAAAABc2S7yj94zW6j4A8d9D6C3qFvYjR1hI4L6z1J3qKj5pXhKj"
plaintext := []byte(`{"ok":true,"items":[1,2,3]}`)
payload := buildEncryptedTobeparsedPayload(t, plaintext)
decrypted, err := decryptTobeparsed(payload)
if err == nil {
var result map[string]any
if err := json.Unmarshal(decrypted, &result); err != nil {
t.Logf("decrypted (not valid json): %s", string(decrypted))
} else {
t.Logf("decrypted: %+v", result)
}
} else {
t.Logf("expected decryption to succeed or fail gracefully: %v", err)
if err != nil {
t.Fatalf("decryptTobeparsed: %v", err)
}
if string(decrypted) != string(plaintext) {
t.Fatalf("decrypted = %q, want %q", decrypted, plaintext)
}
})
@@ -441,21 +517,16 @@ func TestTryDecryptCTR(t *testing.T) {
}
iv := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b}
cipherText := []byte("test plaintext ")
plaintext := []byte("test plaintext ")
plainText := tryDecryptCTR(block, iv, cipherText)
_ = plainText
ctrIV := append([]byte{}, iv...)
ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02)
cipherText := make([]byte, len(plaintext))
cipher.NewCTR(block, ctrIV).XORKeyStream(cipherText, plaintext)
got := tryDecryptCTR(block, iv, cipherText)
if !bytes.Equal(got, plaintext) {
t.Fatalf("tryDecryptCTR() = %q, want %q", got, plaintext)
}
})
}
func TestAllAnimeClientImplementsInterfaces(t *testing.T) {
t.Parallel()
var (
_ interface {
GetStreams(context.Context, int, []string, string, string) (*domain.StreamResult, error)
} = &AllAnimeProvider{}
)
t.Log("allAnimeClient implements required interfaces")
}

View File

@@ -0,0 +1,311 @@
package allanime
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
const (
aaEpoch = "4128"
aaBuildID = "9"
aaKeyAHex = "b1a9a4d051988f1b1b12dbb747439d9bd64b09ea17835600a7eaa4de87c1ad87"
aaKeyB64 = "k7DLdv5SGiuEyGUtcncl5wQOR7r4aenLfDV3AOBKlAU="
)
var (
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) {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("base64 decode failed: %w", err)
}
if len(raw) < 29 {
return nil, errors.New("encrypted payload too short")
}
version := raw[0]
iv := raw[1:13]
cipherText := raw[13 : len(raw)-16]
candidates := make([]aesCandidate, 0, len(aesKeys)+1)
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 {
continue
}
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)
if json.Valid(plainText) {
return plainText, nil
}
}
}
return nil, errors.New("decryption failed")
}
func tryDecryptCTR(block cipher.Block, iv []byte, cipherText []byte) []byte {
ctrIV := append([]byte{}, iv...)
ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02)
ctr := cipher.NewCTR(block, ctrIV)
plainText := make([]byte, len(cipherText))
ctr.XORKeyStream(plainText, cipherText)
return plainText
}
func decodeSourceURL(encoded string) string {
if encoded == "" {
return ""
}
encoded = strings.TrimPrefix(encoded, "--")
substitutions := map[string]string{
"79": "A", "7a": "B", "7b": "C", "7c": "D", "7d": "E",
"7e": "F", "7f": "G", "70": "H", "71": "I", "72": "J",
"73": "K", "74": "L", "75": "M", "76": "N", "77": "O",
"68": "P", "69": "Q", "6a": "R", "6b": "S", "6c": "T",
"6d": "U", "6e": "V", "6f": "W", "60": "X", "61": "Y",
"62": "Z",
"59": "a", "5a": "b", "5b": "c", "5c": "d", "5d": "e",
"5e": "f", "5f": "g", "50": "h", "51": "i", "52": "j",
"53": "k", "54": "l", "55": "m", "56": "n", "57": "o",
"48": "p", "49": "q", "4a": "r", "4b": "s", "4c": "t",
"4d": "u", "4e": "v", "4f": "w", "40": "x", "41": "y",
"42": "z",
"08": "0", "09": "1", "0a": "2", "0b": "3", "0c": "4",
"0d": "5", "0e": "6", "0f": "7", "00": "8", "01": "9",
"15": "-", "16": ".", "67": "_", "46": "~", "02": ":",
"17": "/", "07": "?", "1b": "#", "63": "[", "65": "]",
"78": "@", "19": "!", "1c": "$", "1e": "&", "10": "(",
"11": ")", "12": "*", "13": "+", "14": ",", "03": ";",
"05": "=", "1d": "%",
}
var result strings.Builder
for idx := 0; idx < len(encoded); {
if idx+2 <= len(encoded) {
pair := encoded[idx : idx+2]
if sub, ok := substitutions[pair]; ok {
result.WriteString(sub)
idx += 2
continue
}
}
result.WriteByte(encoded[idx])
idx++
}
decoded := result.String()
if strings.Contains(decoded, "/clock") && !strings.Contains(decoded, "/clock.json") {
decoded = strings.Replace(decoded, "/clock", "/clock.json", 1)
}
return decoded
}
func responseFromTobeparsed(data map[string]any) (map[string]any, error) {
toBeParsed := firstString(
nestedString(data, "tobeparsed"),
nestedString(data, "episode", "tobeparsed"),
)
if toBeParsed == "" {
return nil, nil
}
decrypted, err := decryptTobeparsed(toBeParsed)
if err != nil {
return nil, fmt.Errorf("decrypt tobeparsed: %w", err)
}
parsed, err := parseGraphQLResponse(decrypted, "unmarshal decrypted")
if err != nil {
return nil, err
}
sourceURLs := firstSlice(
nestedSlice(parsed, "sourceUrls"),
nestedSlice(parsed, "episode", "sourceUrls"),
)
if len(sourceURLs) == 0 {
return nil, nil
}
return map[string]any{
"episode": map[string]any{
"sourceUrls": sourceURLs,
},
}, nil
}
func parseGraphQLResponse(respBody []byte, decodeErrPrefix string) (map[string]any, error) {
var parsed map[string]any
if err := json.Unmarshal(respBody, &parsed); err != nil {
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 {
return nil, fmt.Errorf("graphql error: %v", errs[0])
}
return parsed, nil
}
// first non-empty
func firstString(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
// first non-empty
func firstSlice(values ...[]any) []any {
for _, value := range values {
if len(value) > 0 {
return value
}
}
return nil
}
func nestedString(data map[string]any, path ...string) string {
value, ok := nestedValue(data, path...)
if !ok {
return ""
}
str, ok := value.(string)
if !ok {
return ""
}
return str
}
func nestedSlice(data map[string]any, path ...string) []any {
value, ok := nestedValue(data, path...)
if !ok {
return nil
}
slice, ok := value.([]any)
if !ok {
return nil
}
return slice
}
func nestedValue(data map[string]any, path ...string) (any, bool) {
var current any = data
for _, key := range path {
currentMap, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = currentMap[key]
if !ok {
return nil, false
}
}
return current, true
}

View File

@@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"html"
"io"
errlog "mal/pkg"
netutil "mal/pkg/net"
"net/http"
"regexp"
@@ -19,10 +21,27 @@ type providerExtractor struct {
referer string
}
type providerLinkItem struct {
link string
resolutionStr string
}
type providerHLSItem struct {
url string
hardsubLang string
}
type providerResponseData struct {
referer string
links []providerLinkItem
hls []providerHLSItem
subtitles []Subtitle
}
func newProviderExtractor() *providerExtractor {
return &providerExtractor{
httpClient: &http.Client{Timeout: 30 * time.Second},
baseURL: allAnimeBaseURL,
baseURL: allAnimeSiteURL,
referer: allAnimeReferer,
}
}
@@ -53,75 +72,60 @@ func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath
}
}
defer func() { _ = resp.Body.Close() }()
defer func() {
errlog.Log("failed to close provider response body", resp.Body.Close())
}()
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2)) // 2MB limit
if err != nil {
return nil, fmt.Errorf("read provider response: %w", err)
}
return e.parseProviderResponse(ctx, string(body)), nil
return e.parseResponse(ctx, string(body)), nil
}
// parseProviderResponse extracts stream sources from provider JSON response.
func (e *providerExtractor) parseProviderResponse(ctx context.Context, response string) []StreamSource {
sources := make([]StreamSource, 0)
providerReferer := e.referer
func (e *providerExtractor) ExtractEmbedVideoLinks(ctx context.Context, rawURL string) ([]StreamSource, error) {
resp, err := doProxiedRequest(ctx, e.httpClient, rawURL, e.referer)
if err != nil {
return nil, fmt.Errorf("fetch embed response: %w", err)
}
defer func() {
errlog.Log("failed to close embed response body", resp.Body.Close())
}()
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
if err != nil {
return nil, fmt.Errorf("read embed response: %w", err)
}
return parseEmbed(rawURL, string(body), e.referer), nil
}
// provider response
func (e *providerExtractor) parseResponse(ctx context.Context, response string) []StreamSource {
var root any
if err := json.Unmarshal([]byte(response), &root); err != nil {
return sources
return []StreamSource{}
}
type linkItem struct {
link string
resolutionStr string
}
type hlsItem struct {
url string
hardsubLang string
}
data := collectData(root, e.referer)
sources := linkSources(data.links, data.referer)
sources = append(sources, e.hlsSources(ctx, data.hls, data.referer)...)
linkItems := make([]linkItem, 0)
hlsItems := make([]hlsItem, 0)
subtitles := make([]Subtitle, 0)
attachSubtitles(sources, data.subtitles)
return sources
}
// provider payload
func collectData(root any, fallbackReferer string) providerResponseData {
data := providerResponseData{referer: fallbackReferer}
var walk func(v any)
walk = func(v any) {
switch x := v.(type) {
case map[string]any:
if ref, ok := x["Referer"].(string); ok && strings.TrimSpace(ref) != "" {
providerReferer = strings.TrimSpace(ref)
}
if link, ok := x["link"].(string); ok {
if res, ok := x["resolutionStr"].(string); ok {
linkItems = append(linkItems, linkItem{link: link, resolutionStr: res})
}
}
if u, ok := x["url"].(string); ok {
if lang, ok := x["hardsub_lang"].(string); ok {
hlsItems = append(hlsItems, hlsItem{url: u, hardsubLang: lang})
}
}
if subs, ok := x["subtitles"].([]any); ok {
for _, sub := range subs {
obj, ok := sub.(map[string]any)
if !ok {
continue
}
lang, _ := obj["lang"].(string)
src, _ := obj["src"].(string)
lang = strings.TrimSpace(lang)
src = strings.TrimSpace(src)
if lang == "" || src == "" {
continue
}
subtitles = append(subtitles, Subtitle{Lang: lang, URL: src})
}
}
collectMapData(x, &data)
for _, child := range x {
walk(child)
}
@@ -133,42 +137,104 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
}
walk(root)
if providerReferer == "" {
providerReferer = e.referer
if data.referer == "" {
data.referer = fallbackReferer
}
for _, item := range linkItems {
return data
}
func collectMapData(node map[string]any, data *providerResponseData) {
if ref, ok := node["Referer"].(string); ok {
if trimmedRef := strings.TrimSpace(ref); trimmedRef != "" {
data.referer = trimmedRef
}
}
if link, ok := node["link"].(string); ok {
if res, ok := node["resolutionStr"].(string); ok {
data.links = append(data.links, providerLinkItem{link: link, resolutionStr: res})
}
}
if url, ok := node["url"].(string); ok {
if lang, ok := node["hardsub_lang"].(string); ok {
data.hls = append(data.hls, providerHLSItem{url: url, hardsubLang: lang})
}
}
if subs, ok := node["subtitles"].([]any); ok {
data.subtitles = append(data.subtitles, parseSubtitles(subs)...)
}
}
func parseSubtitles(items []any) []Subtitle {
subtitles := make([]Subtitle, 0, len(items))
for _, item := range items {
node, ok := item.(map[string]any)
if !ok {
continue
}
lang, ok := node["lang"].(string)
if !ok {
continue
}
src, ok := node["src"].(string)
if !ok {
continue
}
lang = strings.TrimSpace(lang)
src = strings.TrimSpace(src)
if lang == "" || src == "" {
continue
}
subtitles = append(subtitles, Subtitle{Lang: lang, URL: src})
}
return subtitles
}
func linkSources(items []providerLinkItem, referer string) []StreamSource {
sources := make([]StreamSource, 0, len(items))
for _, item := range items {
link := strings.TrimSpace(item.link)
if link == "" {
continue
}
quality := strings.TrimSpace(item.resolutionStr)
sourceType := detectStreamType(link)
if sourceType == "unknown" {
sourceType = detectEmbedType(link)
}
sources = append(sources, StreamSource{
URL: link,
Quality: quality,
Quality: strings.TrimSpace(item.resolutionStr),
Provider: "wixmp",
Type: sourceType,
Referer: providerReferer,
Type: sourceType(link),
Referer: referer,
})
}
for _, item := range hlsItems {
if strings.TrimSpace(item.url) == "" {
continue
}
if item.hardsubLang != "en-US" {
return sources
}
func sourceType(link string) string {
typ := detectStreamType(link)
if typ != "unknown" {
return typ
}
return detectEmbedType(link)
}
func (e *providerExtractor) hlsSources(ctx context.Context, items []providerHLSItem, referer string) []StreamSource {
sources := make([]StreamSource, 0, len(items))
for _, item := range items {
playlistURL, ok := playlistURL(item)
if !ok {
continue
}
playlistURL := strings.TrimSpace(item.url)
if strings.Contains(playlistURL, "master.m3u8") {
parsed, err := e.parseM3U8(ctx, playlistURL, providerReferer)
parsed, err := e.parseM3U8(ctx, playlistURL, referer)
if err == nil {
sources = append(sources, parsed...)
}
@@ -180,17 +246,30 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
Quality: "auto",
Provider: "hls",
Type: "m3u8",
Referer: providerReferer,
Referer: referer,
})
}
if len(subtitles) > 0 && len(sources) > 0 {
for idx := range sources {
sources[idx].Subtitles = append([]Subtitle(nil), subtitles...)
}
return sources
}
func playlistURL(item providerHLSItem) (string, bool) {
playlistURL := strings.TrimSpace(item.url)
if playlistURL == "" || item.hardsubLang != "en-US" {
return "", false
}
return sources
return playlistURL, true
}
func attachSubtitles(sources []StreamSource, subtitles []Subtitle) {
if len(subtitles) == 0 || len(sources) == 0 {
return
}
for idx := range sources {
sources[idx].Subtitles = append([]Subtitle(nil), subtitles...)
}
}
// parseM3U8 fetches a master playlist and extracts individual stream URLs with bandwidth-derived quality.
@@ -199,37 +278,31 @@ func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, ref
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
defer func() {
errlog.Log("failed to close m3u8 response body", resp.Body.Close())
}()
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.KiB512)) // 512KB limit
if err != nil {
return nil, err
}
lines := strings.Split(string(body), "\n")
baseURL := masterURL
if idx := strings.LastIndex(masterURL, "/"); idx >= 0 {
baseURL = masterURL[:idx+1]
}
return parseM3U8Sources(string(body), masterURL, referer), nil
}
currentBandwidth := 0
sources := make([]StreamSource, 0)
func parseM3U8Sources(body string, masterURL string, referer string) []StreamSource {
lines := strings.Split(body, "\n")
baseURL := playlistBaseURL(masterURL)
bwPattern := regexp.MustCompile(`BANDWIDTH=(\d+)`)
bw := 0
sources := make([]StreamSource, 0)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#EXT-X-STREAM-INF") {
match := bwPattern.FindStringSubmatch(trimmed)
if len(match) >= 2 {
value, convErr := strconv.Atoi(match[1])
if convErr == nil {
currentBandwidth = value
}
}
if bandwidth, ok := streamBandwidth(trimmed, bwPattern); ok {
bw = bandwidth
continue
}
// skip empty lines and non-stream lines
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
@@ -239,27 +312,128 @@ func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, ref
streamURL = baseURL + streamURL
}
quality := "auto"
kbps := currentBandwidth / 1000
switch {
case kbps >= 8000:
quality = "1080p"
case kbps >= 5000:
quality = "720p"
case kbps >= 2500:
quality = "480p"
case kbps > 0:
quality = "360p"
}
sources = append(sources, StreamSource{
URL: streamURL,
Quality: quality,
Quality: quality(bw),
Provider: "hls",
Type: "m3u8",
Referer: referer,
})
}
return sources, nil
return sources
}
func playlistBaseURL(masterURL string) string {
if idx := strings.LastIndex(masterURL, "/"); idx >= 0 {
return masterURL[:idx+1]
}
return masterURL
}
func streamBandwidth(line string, bwPattern *regexp.Regexp) (int, bool) {
if !strings.HasPrefix(line, "#EXT-X-STREAM-INF") {
return 0, false
}
match := bwPattern.FindStringSubmatch(line)
if len(match) < 2 {
return 0, true
}
value, err := strconv.Atoi(match[1])
if err != nil {
return 0, true
}
return value, true
}
func quality(bandwidth int) string {
kbps := bandwidth / 1000
switch {
case kbps >= 8000:
return "1080p"
case kbps >= 5000:
return "720p"
case kbps >= 2500:
return "480p"
case kbps > 0:
return "360p"
default:
return "auto"
}
}
// embed page
func parseEmbed(rawURL string, body string, fallbackReferer string) []StreamSource {
switch {
case strings.Contains(strings.ToLower(rawURL), "ok.ru/"):
return parseOKRUSources(body, fallbackReferer)
case strings.Contains(strings.ToLower(rawURL), "mp4upload.com/"):
return parseMP4Upload(body, fallbackReferer)
default:
return nil
}
}
func parseOKRUSources(body string, referer string) []StreamSource {
unescapedBody := html.UnescapeString(body)
manifestPattern := regexp.MustCompile(`\\"hlsManifestUrl\\":\\"([^"]+)\\"|"hlsManifestUrl":"([^"]+)"`)
match := manifestPattern.FindStringSubmatch(unescapedBody)
if len(match) < 3 {
return nil
}
playlistURL := mediaURL(firstString(match[1], match[2]))
if playlistURL == "" {
return nil
}
return []StreamSource{{
URL: playlistURL,
Quality: "auto",
Provider: "ok",
Type: "m3u8",
Referer: referer,
}}
}
func parseMP4Upload(body string, referer string) []StreamSource {
srcPattern := regexp.MustCompile(`(?m)src:\s*"([^"]+)"`)
match := srcPattern.FindStringSubmatch(body)
if len(match) < 2 {
return nil
}
url := mediaURL(match[1])
if url == "" {
return nil
}
return []StreamSource{{
URL: url,
Provider: "mp4upload",
Type: sourceType(url),
Referer: referer,
}}
}
func mediaURL(raw string) string {
if unquoted, err := strconv.Unquote(`"` + raw + `"`); err == nil {
raw = unquoted
}
replacer := strings.NewReplacer(
`\\u002F`, `/`,
`\\u0026`, "&",
`\/`, `/`,
`\u002F`, `/`,
`\u0026`, "&",
`&amp;`, "&",
)
return strings.TrimSpace(replacer.Replace(raw))
}

View File

@@ -0,0 +1,796 @@
package allanime
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
func TestGraphqlRequest_SuccessAndHeaders(t *testing.T) {
t.Parallel()
var method, url, ct, referer, ua string
var bodyBuf bytes.Buffer
provider := &AllAnimeProvider{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
method = req.Method
url = req.URL.String()
ct = req.Header.Get("Content-Type")
referer = req.Header.Get("Referer")
ua = req.Header.Get("User-Agent")
if _, err := io.Copy(&bodyBuf, req.Body); err != nil {
t.Fatalf("copy request body: %v", err)
}
return mockStringResponse(http.StatusOK, `{"data":{"key":"val"}}`), nil
}),
},
}
_, err := provider.graphqlRequest(
context.Background(),
"query($id:String!){show(_id:$id){name}}",
map[string]any{"id": "abc"},
)
if err != nil {
t.Fatalf("graphqlRequest() error = %v", err)
}
verifyGraphqlRequest(t, method, url, ct, referer, ua, bodyBuf.Bytes())
}
func TestGraphqlRequest_Errors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
status int
body string
}{
{
name: "graphql error in response",
status: http.StatusOK,
body: `{"errors":[{"message":"not found"}]}`,
},
{
name: "non-200 status",
status: http.StatusInternalServerError,
body: `{"data":{}}`,
},
{
name: "invalid json body",
status: http.StatusOK,
body: `not json`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
provider := &AllAnimeProvider{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(tt.status, tt.body), nil
}),
},
}
_, err := provider.graphqlRequest(
context.Background(),
"query($id:String!){show(_id:$id){name}}",
map[string]any{"id": "abc"},
)
if err == nil {
t.Error("expected error, got nil")
}
})
}
}
func verifyGraphqlRequest(t *testing.T, method, url, ct, referer, ua string, body []byte) {
t.Helper()
if method != http.MethodPost {
t.Errorf("method = %q, want POST", method)
}
if url != allAnimeBaseURL+"/api" {
t.Errorf("url = %q, want %q", url, allAnimeBaseURL+"/api")
}
if ct != "application/json" {
t.Errorf("Content-Type = %q", ct)
}
if referer != allAnimeReferer {
t.Errorf("Referer = %q", referer)
}
if ua != defaultUserAgent {
t.Errorf("User-Agent = %q", ua)
}
var sent map[string]any
if err := json.Unmarshal(body, &sent); err != nil {
t.Fatalf("unmarshal sent body: %v", err)
}
if sent["query"] != "query($id:String!){show(_id:$id){name}}" {
t.Errorf("unexpected query in body")
}
vars, ok := sent["variables"].(map[string]any)
if !ok || vars["id"] != "abc" {
t.Errorf("unexpected variables in body")
}
}
func TestGraphqlRequest_SetsTranslationTypeLower(t *testing.T) {
t.Parallel()
provider := &AllAnimeProvider{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusOK, `{"data":{}}`), nil
}),
},
}
_, err := provider.graphqlRequest(
context.Background(),
"query($t:VaildTranslationTypeEnumType!){x(translationType:$t){id}}",
map[string]any{"translationType": "SUB"},
)
if err != nil {
t.Fatalf("graphqlRequest: %v", err)
}
}
func TestGraphqlRequestWithHash_Plain(t *testing.T) {
t.Parallel()
provider := &AllAnimeProvider{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodPost {
t.Errorf("method = %q, want POST", req.Method)
}
if req.Header.Get("Referer") != allAnimeReferer {
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
}),
},
}
result, err := provider.graphqlRequestWithHash(
context.Background(),
"show123", "1", "sub",
)
if err != nil {
t.Fatalf("graphqlRequestWithHash: %v", err)
}
data, ok := result["data"].(map[string]any)
if !ok {
t.Fatal("result missing data key")
}
sources := nestedSlice(data, "episode", "sourceUrls")
if len(sources) != 1 {
t.Fatalf("got %d sources, want 1", len(sources))
}
}
func TestGraphqlRequestWithHash_Encrypted(t *testing.T) {
t.Parallel()
encryptedPayload := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://e.test/v.mp4","sourceName":"default"}]}`))
provider := &AllAnimeProvider{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encryptedPayload+`"}}`), nil
}),
},
}
result, err := provider.graphqlRequestWithHash(
context.Background(),
"show456", "2", "dub",
)
if err != nil {
t.Fatalf("graphqlRequestWithHash: %v", err)
}
ep, ok := result["episode"].(map[string]any)
if !ok {
t.Fatal("result missing episode key")
}
sources := nestedSlice(ep, "sourceUrls")
if len(sources) != 1 {
t.Fatalf("got %d sources, want 1", len(sources))
}
}
func TestGraphqlRequestWithHash_Non200(t *testing.T) {
t.Parallel()
provider := &AllAnimeProvider{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusNotFound, `not found`), nil
}),
},
}
_, err := provider.graphqlRequestWithHash(
context.Background(),
"x", "1", "sub",
)
if err == nil {
t.Fatal("expected error for non-200")
}
}
func TestGraphqlRequestWithHash_EmptyData(t *testing.T) {
t.Parallel()
provider := &AllAnimeProvider{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusOK, `{"data":{}}`), nil
}),
},
}
_, err := provider.graphqlRequestWithHash(
context.Background(),
"x", "1", "sub",
)
if err == nil {
t.Fatal("expected error for empty data")
}
}
func TestGetEpisodeSources_EncryptedHash(t *testing.T) {
t.Parallel()
encrypted := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://direct.test/v.mp4","sourceName":"default"}]}`))
provider := &AllAnimeProvider{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil
}),
},
extractor: newProviderExtractor(),
}
sources, err := provider.GetEpisodeSources(context.Background(), "show1", "1", "sub")
if err != nil {
t.Fatalf("GetEpisodeSources: %v", err)
}
if len(sources) == 0 {
t.Fatal("expected at least one source")
}
if sources[0].URL != "https://direct.test/v.mp4" {
t.Errorf("URL = %q", sources[0].URL)
}
}
func TestGetEpisodeSources_FallbackPost(t *testing.T) {
t.Parallel()
sourceResponse := `{"data":{"episode":{"sourceUrls":[{"sourceUrl":"https://direct.test/v.mp4","sourceName":"default"}]}}}`
fallbackCalled := false
provider := &AllAnimeProvider{
httpClient: &http.Client{
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
return mockStringResponse(http.StatusOK, sourceResponse), nil
}),
},
extractor: newProviderExtractor(),
}
sources, err := provider.GetEpisodeSources(context.Background(), "show3", "3", "sub")
if err != nil {
t.Fatalf("GetEpisodeSources: %v", err)
}
if !fallbackCalled {
t.Fatal("fallback POST was not called")
}
if len(sources) == 0 {
t.Fatal("expected at least one source")
}
}
func TestGetEpisodeSources_BothFail(t *testing.T) {
t.Parallel()
provider := &AllAnimeProvider{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusNotFound, `not found`), nil
}),
},
extractor: newProviderExtractor(),
}
_, err := provider.GetEpisodeSources(context.Background(), "show4", "4", "sub")
if err == nil {
t.Fatal("expected error when both requests fail")
}
}
func TestGetAvailableEpisodes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
body string
wantSub int
wantDub int
wantTitle string
wantErr bool
}{
{
name: "sub and dub available",
body: `{"data":{"show":{"availableEpisodesDetail":{"sub":["1","2","3"],"dub":["1"]}},"episodeInfos":[{"episodeIdNum":1,"notes":"The Beginning"}]}}`,
wantSub: 3,
wantDub: 1,
wantTitle: "The Beginning",
},
{
name: "sub only",
body: `{"data":{"show":{"availableEpisodesDetail":{"sub":["1","2"],"dub":null},"lastEpisodeInfo":{}}}}`,
wantSub: 2,
wantDub: 0,
},
{
name: "show not found",
body: `{"data":{"show":null}}`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
provider := &AllAnimeProvider{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusOK, tt.body), nil
}),
},
}
available, err := provider.GetAvailableEpisodes(context.Background(), "showX")
if (err != nil) != tt.wantErr {
t.Fatalf("GetAvailableEpisodes() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if len(available.Sub) != tt.wantSub {
t.Errorf("Sub count = %d, want %d", len(available.Sub), tt.wantSub)
}
if 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) {
t.Parallel()
t.Run("returns results", func(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":"id1","malId":"1","name":"Title One"},{"_id":"id2","malId":"2","name":"Title Two"}]}}}`), nil
}),
},
}
results, err := provider.Search(context.Background(), "test", "sub")
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(results) != 2 {
t.Fatalf("len = %d, want 2", len(results))
}
if results[0].ID != "id1" || results[0].MalID != "1" || results[0].Name != "Title One" {
t.Errorf("result[0] = %+v", results[0])
}
})
t.Run("empty results", func(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":[]}}}`), nil
}),
},
}
results, err := provider.Search(context.Background(), "nonexistent", "sub")
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(results) != 0 {
t.Errorf("len = %d, want 0", len(results))
}
})
}
func TestGetStreams_FullSuccess(t *testing.T) {
t.Parallel()
searchBody := `{"data":{"shows":{"edges":[{"_id":"show123","malId":"1","name":"Test Anime"}]}}}`
encrypted := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://stream.test/video.mp4","sourceName":"default"}]}`))
provider := &AllAnimeProvider{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
req.Body.Close()
if strings.Contains(string(body), `"search":`) {
return mockStringResponse(http.StatusOK, searchBody), nil
}
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil
}),
},
extractor: newProviderExtractor(),
}
result, err := provider.GetStreams(context.Background(), 1, []string{"Test Anime"}, "1", "sub")
if err != nil {
t.Fatalf("GetStreams: %v", err)
}
if result.URL != "https://stream.test/video.mp4" {
t.Errorf("URL = %q", result.URL)
}
if result.Referer != allAnimeReferer {
t.Errorf("Referer = %q", result.Referer)
}
if result.Type != "mp4" {
t.Errorf("Type = %q", result.Type)
}
}
func TestGetStreams_ShowNotFound(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":[]}}}`), nil
}),
},
extractor: newProviderExtractor(),
}
_, err := provider.GetStreams(context.Background(), 999, []string{"Nothing"}, "1", "sub")
if err == nil {
t.Fatal("expected error for show not found")
}
}
func TestGetStreams_NoSources(t *testing.T) {
t.Parallel()
provider := &AllAnimeProvider{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
req.Body.Close()
if strings.Contains(string(body), `"search":`) {
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"showX","malId":"1","name":"Anime"}]}}}`), nil
}
return mockStringResponse(http.StatusNotFound, `not found`), nil
}),
},
extractor: newProviderExtractor(),
}
_, err := provider.GetStreams(context.Background(), 1, []string{"Anime"}, "1", "sub")
if err == nil {
t.Fatal("expected error when no sources")
}
}
func TestParseProviderResponse(t *testing.T) {
t.Parallel()
t.Run("extracts links and subtitles", func(t *testing.T) {
t.Parallel()
body := `{"links":[{"link":"https://cdn.test/video.mp4","resolutionStr":"1080p"}],"subtitles":[{"lang":"en","src":"https://sub.test/en.vtt"}]}`
extractor := &providerExtractor{
baseURL: allAnimeSiteURL,
referer: allAnimeReferer,
}
sources := extractor.parseResponse(context.Background(), body)
if len(sources) == 0 {
t.Fatal("expected at least one source")
}
if sources[0].URL != "https://cdn.test/video.mp4" {
t.Errorf("URL = %q", sources[0].URL)
}
if sources[0].Quality != "1080p" {
t.Errorf("Quality = %q", sources[0].Quality)
}
if len(sources[0].Subtitles) != 1 {
t.Fatalf("subtitles count = %d, want 1", len(sources[0].Subtitles))
}
if sources[0].Subtitles[0].Lang != "en" {
t.Errorf("sub lang = %q", sources[0].Subtitles[0].Lang)
}
})
t.Run("invalid json returns empty", func(t *testing.T) {
t.Parallel()
extractor := &providerExtractor{
baseURL: allAnimeSiteURL,
referer: allAnimeReferer,
}
sources := extractor.parseResponse(context.Background(), "not json")
if len(sources) != 0 {
t.Errorf("expected empty, got %d sources", len(sources))
}
})
t.Run("empty response returns empty", func(t *testing.T) {
t.Parallel()
extractor := &providerExtractor{
baseURL: allAnimeSiteURL,
referer: allAnimeReferer,
}
sources := extractor.parseResponse(context.Background(), "{}")
if len(sources) != 0 {
t.Errorf("expected empty, got %d sources", len(sources))
}
})
}
func TestParseExternalEmbedResponse(t *testing.T) {
t.Parallel()
t.Run("ok.ru extracts hls manifest", func(t *testing.T) {
t.Parallel()
body := `{"flashvars":{"metadata":"{\"hlsManifestUrl\":\"https://ok.example.test/playlist.m3u8\"}"}}`
sources := parseEmbed("https://ok.ru/video/123", body, allAnimeReferer)
if len(sources) != 1 {
t.Fatalf("got %d sources, want 1", len(sources))
}
if sources[0].URL != "https://ok.example.test/playlist.m3u8" {
t.Errorf("URL = %q", sources[0].URL)
}
if sources[0].Provider != "ok" {
t.Errorf("Provider = %q", sources[0].Provider)
}
})
t.Run("mp4upload extracts src", func(t *testing.T) {
t.Parallel()
body := `src: "https://mp4upload.example.test/video.mp4"`
sources := parseEmbed("https://mp4upload.com/e/abc", body, allAnimeReferer)
if len(sources) != 1 {
t.Fatalf("got %d sources, want 1", len(sources))
}
if sources[0].URL != "https://mp4upload.example.test/video.mp4" {
t.Errorf("URL = %q", sources[0].URL)
}
if sources[0].Provider != "mp4upload" {
t.Errorf("Provider = %q", sources[0].Provider)
}
})
t.Run("unknown embed returns empty", func(t *testing.T) {
t.Parallel()
sources := parseEmbed("https://unknown.example.com/video", "<html></html>", allAnimeReferer)
if len(sources) != 0 {
t.Errorf("expected empty, got %d sources", len(sources))
}
})
}
func TestParseM3U8Sources(t *testing.T) {
t.Parallel()
t.Run("parses bandwidth entries", func(t *testing.T) {
t.Parallel()
body := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080\n1080p.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=5000000\n720p.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2500000\n480p.m3u8"
masterURL := "https://cdn.test/master.m3u8"
sources := parseM3U8Sources(body, masterURL, allAnimeReferer)
if len(sources) != 3 {
t.Fatalf("got %d sources, want 3", len(sources))
}
expected := []struct {
url string
quality string
}{
{"https://cdn.test/1080p.m3u8", "1080p"},
{"https://cdn.test/720p.m3u8", "720p"},
{"https://cdn.test/480p.m3u8", "480p"},
}
for i, exp := range expected {
if sources[i].URL != exp.url {
t.Errorf("sources[%d].URL = %q, want %q", i, sources[i].URL, exp.url)
}
if sources[i].Quality != exp.quality {
t.Errorf("sources[%d].Quality = %q, want %q", i, sources[i].Quality, exp.quality)
}
if sources[i].Type != "m3u8" {
t.Errorf("sources[%d].Type = %q", i, sources[i].Type)
}
}
})
t.Run("empty body returns nothing", func(t *testing.T) {
t.Parallel()
sources := parseM3U8Sources("", "https://cdn.test/master.m3u8", allAnimeReferer)
if len(sources) != 0 {
t.Errorf("expected empty, got %d", len(sources))
}
})
t.Run("absolute URLs not rebased", func(t *testing.T) {
t.Parallel()
body := "#EXT-X-STREAM-INF:BANDWIDTH=8000000\nhttps://cdn2.test/video.m3u8"
sources := parseM3U8Sources(body, "https://cdn.test/master.m3u8", allAnimeReferer)
if len(sources) != 1 {
t.Fatalf("got %d sources", len(sources))
}
if sources[0].URL != "https://cdn2.test/video.m3u8" {
t.Errorf("URL = %q", sources[0].URL)
}
})
}
func TestExtractVideoLinks(t *testing.T) {
t.Parallel()
t.Run("fetches and parses provider response", func(t *testing.T) {
t.Parallel()
extractor := &providerExtractor{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodGet {
t.Errorf("method = %q, want GET", req.Method)
}
if req.Header.Get("Referer") != allAnimeReferer {
t.Errorf("Referer = %q", req.Header.Get("Referer"))
}
body := `{"links":[{"link":"https://cdn.test/video.mp4","resolutionStr":"720p"}]}`
return mockStringResponse(http.StatusOK, body), nil
}),
},
baseURL: allAnimeSiteURL,
referer: allAnimeReferer,
}
sources, err := extractor.ExtractVideoLinks(context.Background(), "/some-path")
if err != nil {
t.Fatalf("ExtractVideoLinks: %v", err)
}
if len(sources) != 1 {
t.Fatalf("got %d sources, want 1", len(sources))
}
if sources[0].Provider != "wixmp" {
t.Errorf("Provider = %q", sources[0].Provider)
}
})
t.Run("server error returns empty sources", func(t *testing.T) {
t.Parallel()
extractor := &providerExtractor{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusInternalServerError, ""), nil
}),
},
baseURL: allAnimeSiteURL,
referer: allAnimeReferer,
}
sources, err := extractor.ExtractVideoLinks(context.Background(), "/error-path")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(sources) != 0 {
t.Errorf("expected empty sources, got %d", len(sources))
}
})
}
func TestExtractEmbedVideoLinks(t *testing.T) {
t.Parallel()
t.Run("ok.ru embed extracted", func(t *testing.T) {
t.Parallel()
extractor := &providerExtractor{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body := `{"flashvars":{"metadata":"{\"hlsManifestUrl\":\"https://ok.test/play.m3u8\"}"}}`
return mockStringResponse(http.StatusOK, body), nil
}),
},
referer: allAnimeReferer,
}
sources, err := extractor.ExtractEmbedVideoLinks(context.Background(), "https://ok.ru/video/123")
if err != nil {
t.Fatalf("ExtractEmbedVideoLinks: %v", err)
}
if len(sources) != 1 {
t.Fatalf("got %d sources, want 1", len(sources))
}
if sources[0].URL != "https://ok.test/play.m3u8" {
t.Errorf("URL = %q", sources[0].URL)
}
})
t.Run("unknown embed returns empty", func(t *testing.T) {
t.Parallel()
extractor := &providerExtractor{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusOK, "<html></html>"), nil
}),
},
referer: allAnimeReferer,
}
sources, err := extractor.ExtractEmbedVideoLinks(context.Background(), "https://unknown.com/video")
if err != nil {
t.Fatalf("ExtractEmbedVideoLinks: %v", err)
}
if len(sources) != 0 {
t.Errorf("expected empty, got %d sources", len(sources))
}
})
}

View File

@@ -0,0 +1,23 @@
package allanime
import (
"io"
"net/http"
"strings"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func mockStringResponse(status int, body string) *http.Response {
hdr := make(http.Header)
hdr.Set("Content-Type", "application/json")
return &http.Response{
StatusCode: status,
Header: hdr,
Body: io.NopCloser(strings.NewReader(body)),
}
}

View File

@@ -0,0 +1,126 @@
package allanime
import (
"context"
"fmt"
"mal/pkg"
netutil "mal/pkg/net"
"strconv"
"strings"
)
const searchQuery = `query(
$search: SearchInput
$translationType: VaildTranslationTypeEnumType
$limit: Int = 40
$page: Int = 1
$countryOrigin: VaildCountryOriginEnumType = ALL
) {
shows(
search: $search
limit: $limit
page: $page
translationType: $translationType
countryOrigin: $countryOrigin
) {
edges {
_id
malId
name
}
}
}`
type searchResult struct {
ID string
MalID string
Name string
}
func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
type searchData struct {
Shows struct {
Edges []struct {
ID string `json:"_id"`
MalID string `json:"malId"`
Name string `json:"name"`
} `json:"edges"`
} `json:"shows"`
}
type searchInput struct {
AllowAdult bool `json:"allowAdult"`
AllowUnknown bool `json:"allowUnknown"`
Query string `json:"query"`
}
type searchVariables struct {
Search searchInput `json:"search"`
TranslationType string `json:"translationType"`
}
vars := searchVariables{
Search: searchInput{
AllowAdult: false,
AllowUnknown: false,
Query: query,
},
TranslationType: mode,
}
data, err := graphql.Post[searchData](ctx, c.httpClient, c.apiBaseURL()+"/api", searchQuery, vars, graphql.PostOptions{
Headers: map[string]string{
"Referer": allAnimeReferer,
"User-Agent": defaultUserAgent,
},
BodyMax: netutil.MiB2,
})
if err != nil {
return nil, err
}
out := make([]searchResult, 0, len(data.Shows.Edges))
for _, edge := range data.Shows.Edges {
id := edge.ID
malID := edge.MalID
name := edge.Name
if unquoted, err := strconv.Unquote("\"" + name + "\""); err == nil {
name = unquoted
}
name = strings.TrimSpace(name)
if id == "" {
continue
}
out = append(out, searchResult{ID: id, MalID: malID, Name: name})
}
return out, nil
}
func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error) {
for _, mode := range []string{"sub", "dub"} {
showID, err := c.strictShowID(ctx, animeID, titleCandidates, mode)
if err == nil {
return showID, nil
}
}
return "", fmt.Errorf("allanime: no exact mal id match for %d", animeID)
}
func (c *AllAnimeProvider) strictShowID(ctx context.Context, animeID int, titleCandidates []string, mode string) (string, error) {
targetMalIDStr := strconv.Itoa(animeID)
for _, title := range titleCandidates {
searchResults, err := c.Search(ctx, title, mode)
if err != nil {
continue
}
for _, res := range searchResults {
if res.MalID == targetMalIDStr {
return res.ID, nil
}
}
}
return "", fmt.Errorf("allanime: no exact mal id match for %d in %s search", animeID, 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

@@ -0,0 +1,345 @@
package allanime
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
)
const episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"
type sourceReference struct {
URL string
Name string
}
func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) {
episodeQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) {
sourceUrls
}
}`
result, err := c.graphqlRequestWithHash(ctx, showID, episode, mode)
if err == nil {
sources := c.sourcesFrom(ctx, result)
if len(sources) > 0 {
return sources, nil
}
}
result, err = c.graphqlRequest(ctx, episodeQuery, map[string]any{
"showId": showID,
"translationType": mode,
"episodeString": episode,
})
if err != nil {
return nil, err
}
data, ok := result["data"].(map[string]any)
if !ok {
return nil, errors.New("invalid source response")
}
rawSourceURLs, ok := data["episode"].(map[string]any)
if !ok {
return nil, errors.New("invalid episode response")
}
sourceURLs, ok := rawSourceURLs["sourceUrls"].([]any)
if !ok || len(sourceURLs) == 0 {
return nil, errors.New("no source urls")
}
references := sourceRefs(sourceURLs)
if len(references) == 0 {
return nil, errors.New("no source references")
}
out := c.resolveRefs(ctx, references)
if len(out) == 0 {
return nil, errors.New("no playable sources extracted")
}
return out, nil
}
func (c *AllAnimeProvider) sourcesFrom(ctx context.Context, data map[string]any) []StreamSource {
episodeData, ok := data["episode"].(map[string]any)
if !ok {
return nil
}
sourceURLs, ok := episodeData["sourceUrls"].([]any)
if !ok || len(sourceURLs) == 0 {
return nil
}
references := sourceRefs(sourceURLs)
if len(references) == 0 {
return nil
}
return c.resolveRefs(ctx, references)
}
func (c *AllAnimeProvider) resolveRefs(ctx context.Context, references []sourceReference) []StreamSource {
out := make([]StreamSource, 0, len(references))
for _, ref := range references {
if source, ok := directSource(ref); ok {
out = append(out, source)
return out
}
extracted := c.resolveExtracted(ctx, ref)
if len(extracted) > 0 {
out = append(out, extracted...)
return out
}
}
return out
}
func directSource(ref sourceReference) (StreamSource, bool) {
target := strings.TrimSpace(ref.URL)
if target == "" {
return StreamSource{}, false
}
if isHTTPURL(target) {
if detectEmbedType(target) == "embed" {
return StreamSource{}, false
}
return buildStreamSource(target, detectSourceType(target), ref.Name), true
}
decoded := decodeSourceURL(target)
if !isHTTPURL(decoded) {
return StreamSource{}, false
}
if detectEmbedType(decoded) == "embed" {
return StreamSource{}, false
}
return buildStreamSource(decoded, detectSourceType(decoded), ref.Name), true
}
func (c *AllAnimeProvider) resolveExtracted(ctx context.Context, ref sourceReference) []StreamSource {
rawURL := strings.TrimSpace(ref.URL)
decoded := decodeSourceURL(rawURL)
if decoded == "" {
return nil
}
if isHTTPURL(decoded) {
extracted, err := c.extractor.ExtractEmbedVideoLinks(ctx, decoded)
if err != nil {
return nil
}
return extracted
}
if !strings.HasPrefix(decoded, "/") {
decoded = "/" + decoded
}
extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded)
if err != nil {
return nil
}
return extracted
}
func detectSourceType(sourceURL string) string {
sourceType := detectStreamType(sourceURL)
if sourceType != "unknown" {
return sourceType
}
return detectEmbedType(sourceURL)
}
func isHTTPURL(value string) bool {
return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://")
}
func buildStreamSource(url, sourceType, provider string) StreamSource {
referer := allAnimeReferer
if strings.Contains(strings.ToLower(provider), "yt-mp4") {
referer = allAnimeSiteURL
}
return StreamSource{
URL: url,
Provider: provider,
Type: sourceType,
Referer: referer,
}
}
// source priority
func sourceRefs(rawSourceURLs []any) []sourceReference {
priorityOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
prioritySet := map[string]struct{}{"default": {}, "yt-mp4": {}, "s-mp4": {}, "luf-mp4": {}}
prioritized := make(map[string]sourceReference)
fallback := make([]sourceReference, 0, len(rawSourceURLs))
seen := make(map[string]struct{})
for _, source := range rawSourceURLs {
item, ok := source.(map[string]any)
if !ok {
continue
}
sourceURL, ok := stringMapValue(item, "sourceUrl")
if !ok {
continue
}
sourceName, _ := stringMapValue(item, "sourceName")
sourceURL = strings.TrimSpace(sourceURL)
sourceName = strings.TrimSpace(sourceName)
if sourceURL == "" {
continue
}
if _, exists := seen[sourceURL]; exists {
continue
}
seen[sourceURL] = struct{}{}
ref := sourceReference{URL: sourceURL, Name: sourceName}
normalized := strings.ToLower(sourceName)
if _, priority := prioritySet[normalized]; priority {
if _, exists := prioritized[normalized]; !exists {
prioritized[normalized] = ref
}
continue
}
fallback = append(fallback, ref)
}
ordered := make([]sourceReference, 0, len(prioritized)+len(fallback))
for _, provider := range priorityOrder {
if ref, ok := prioritized[provider]; ok {
ordered = append(ordered, ref)
}
}
ordered = append(ordered, fallback...)
return ordered
}
func stringMapValue(item map[string]any, key string) (string, bool) {
value, ok := item[key].(string)
return value, ok
}
func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) {
req, err := c.newHashRequest(ctx, showID, episode, mode)
if err != nil {
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("Referer", allAnimeReferer)
req.Header.Set("Origin", allAnimeOrigin)
req.Header.Set("x-build-id", "9")
statusCode, respBody, err := executeAndReadResponse(c.httpClient, req, "execute POST request (aaReq)", "read response")
if err != nil {
return nil, err
}
if statusCode != http.StatusOK {
return nil, fmt.Errorf("POST status %d: %s", statusCode, string(respBody))
}
parsed, err := parseGraphQLResponse(respBody, "decode response")
if err != nil {
return nil, err
}
data, ok := parsed["data"].(map[string]any)
if !ok {
return nil, errors.New("no data in response")
}
decrypted, err := responseFromTobeparsed(data)
if err != nil {
return nil, err
}
if decrypted != nil {
return decrypted, nil
}
if len(nestedSlice(data, "episode", "sourceUrls")) > 0 {
return parsed, nil
}
return nil, errors.New("no usable data in response")
}
func (c *AllAnimeProvider) newHashRequest(ctx context.Context, showID, episode, mode string) (*http.Request, error) {
aaReq, err := makeAALease(episodeQueryHash)
if err != nil {
return nil, fmt.Errorf("create aa lease: %w", err)
}
payload := map[string]any{
"variables": map[string]any{
"showId": showID,
"translationType": strings.ToLower(mode),
"episodeString": episode,
},
"extensions": map[string]any{
"persistedQuery": map[string]any{
"version": 1,
"sha256Hash": episodeQueryHash,
},
"aaReq": aaReq,
},
}
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 {
lower := strings.ToLower(sourceURL)
if strings.Contains(lower, ".m3u8") || strings.Contains(lower, "master.m3u8") {
return "m3u8"
}
if strings.Contains(lower, ".mp4") {
return "mp4"
}
return "unknown"
}
func detectEmbedType(rawURL string) string {
lower := strings.ToLower(rawURL)
embedHosts := []string{"streamwish", "streamsb", "mp4upload", "ok.ru", "gogoplay", "streamlare"}
for _, host := range embedHosts {
if strings.Contains(lower, host) {
return "embed"
}
}
return "unknown"
}

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

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
errlog "mal/pkg"
netutil "mal/pkg/net"
"net/http"
"regexp"
@@ -155,23 +156,19 @@ func extractRows(doc *goquery.Document) []watchOrderRow {
}
title := strings.TrimSpace(selection.Find(".wo_title").First().Text())
alternativeTitle := strings.TrimSpace(selection.Find(".uk-text-small").First().Text())
alt := strings.TrimSpace(selection.Find(".uk-text-small").First().Text())
rows = append(rows, watchOrderRow{
id: id,
typeID: typeID,
title: title,
alternativeTitle: alternativeTitle,
alternativeTitle: alt,
})
})
return rows
}
func hasWatchOrderTable(doc *goquery.Document) bool {
return doc.Find("#wo_list").Length() > 0
}
// shouldTryProxy returns true for transient errors where the Jina proxy may help
// (e.g. Cloudflare blocking, rate limits)
func shouldTryProxy(err error) bool {
@@ -205,7 +202,9 @@ func fetchProxyText(ctx context.Context, httpClient *http.Client, url string) (s
if err != nil {
return "", fmt.Errorf("proxy request failed: %w", err)
}
defer func() { _ = response.Body.Close() }()
defer func() {
errlog.Log("failed to close watch order proxy response body", response.Body.Close())
}()
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("proxy status %d", response.StatusCode)
@@ -355,7 +354,7 @@ func FetchWatchOrder(ctx context.Context, httpClient *http.Client, url string) (
}
// empty table indicates JS-rendered content; need proxy
if !hasWatchOrderTable(doc) {
if doc.Find("#wo_list").Length() == 0 {
return fetchViaProxy(ctx, httpClient, url, rootID)
}

View File

@@ -0,0 +1,431 @@
package anime
import (
"context"
"fmt"
"mal/integrations/jikan"
"mal/internal/domain"
"mal/internal/observability"
"mal/internal/server"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type producerItem struct {
ID int `json:"id"`
Name string `json:"name"`
}
type browseQuery struct {
q string
animeType string
status string
orderBy string
sort string
sfw bool
studioID int
genres []int
page int
}
func producerQueryParams(c *gin.Context) (string, int, int, error) {
q := strings.TrimSpace(c.Query("q"))
rawPage := c.DefaultQuery("page", "1")
page, err := strconv.Atoi(rawPage)
if err != nil {
return "", 0, 0, fmt.Errorf("invalid page %q: %w", rawPage, err)
}
if page < 1 {
page = 1
}
rawLimit := c.DefaultQuery("limit", "50")
limit, err := strconv.Atoi(rawLimit)
if err != nil {
return "", 0, 0, fmt.Errorf("invalid limit %q: %w", rawLimit, err)
}
if limit < 1 || limit > 12 {
limit = 12
}
return q, page, limit, nil
}
func producerItems(entries []jikan.ProducerListEntry) []producerItem {
items := make([]producerItem, 0, len(entries))
for _, producer := range entries {
name := jikan.ProducerListEntryName(producer)
if producer.MalID <= 0 || name == "" {
continue
}
items = append(items, producerItem{ID: producer.MalID, Name: name})
}
return items
}
func producerHTMLPayload(items []producerItem, hasNextPage bool, page int, q string, limit int) gin.H {
return gin.H{
"_fragment": "studio_dropdown_items",
"StudioItems": items,
"HasNextPage": hasNextPage,
"Page": page,
"NextPage": page + 1,
"Query": q,
"Limit": limit,
}
}
func requestWantsHTML(c *gin.Context) bool {
return strings.Contains(c.GetHeader("Accept"), "text/html")
}
func (h *AnimeHandler) HandleProducers(c *gin.Context) {
q, page, limit, err := producerQueryParams(c)
if err != nil {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, err.Error())
return
}
res, err := h.svc.GetProducers(c.Request.Context(), q, page, limit)
if err != nil {
observability.WarnContext(c.Request.Context(),
"producers_fetch_failed",
"anime",
"",
map[string]any{
"q": q,
"page": page,
"limit": limit,
},
err,
)
if requestWantsHTML(c) {
c.HTML(http.StatusOK, "browse.gohtml", producerHTMLPayload([]producerItem{}, false, page, q, limit))
return
}
server.RespondError(
c,
http.StatusInternalServerError,
"producers_fetch_failed",
"anime",
"failed to load producers",
map[string]any{"q": q, "page": page, "limit": limit},
err,
)
return
}
items := producerItems(res.Items)
if requestWantsHTML(c) {
c.HTML(http.StatusOK, "browse.gohtml", producerHTMLPayload(items, res.HasNextPage, page, q, limit))
return
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"hasNextPage": res.HasNextPage,
"nextPage": page + 1,
})
}
func parseBrowseQuery(c *gin.Context) (browseQuery, error) {
studioID := 0
if raw := strings.TrimSpace(c.Query("studio")); raw != "" {
id, err := strconv.Atoi(raw)
if err != nil {
return browseQuery{}, fmt.Errorf("invalid studio id %q: %w", raw, err)
}
if id < 0 {
return browseQuery{}, fmt.Errorf("invalid studio id %d", id)
}
studioID = id
}
genres := make([]int, 0, len(c.QueryArray("genres")))
for _, g := range c.QueryArray("genres") {
id, err := strconv.Atoi(g)
if err != nil {
return browseQuery{}, fmt.Errorf("invalid genre id %q: %w", g, err)
}
if id > 0 {
genres = append(genres, id)
}
}
rawPage := c.DefaultQuery("page", "1")
page, err := strconv.Atoi(rawPage)
if err != nil {
return browseQuery{}, fmt.Errorf("invalid page %q: %w", rawPage, err)
}
if page < 1 {
page = 1
}
return browseQuery{
q: c.Query("q"),
animeType: c.Query("type"),
status: c.Query("status"),
orderBy: c.Query("order_by"),
sort: c.Query("sort"),
sfw: c.Query("sfw") != "false",
studioID: studioID,
genres: genres,
page: page,
}, nil
}
func canonicalBrowseURL(rawURL *url.URL) (string, bool) {
if rawURL == nil {
return "", false
}
query := rawURL.Query()
if _, exists := query["sfw"]; exists {
return "", false
}
query.Set("sfw", "true")
encoded := query.Encode()
if encoded == "" {
return rawURL.Path, true
}
return rawURL.Path + "?" + encoded, true
}
func browseStudioName(ctx context.Context, svc Service, studioID int) string {
if studioID <= 0 {
return ""
}
name, err := svc.GetProducerNameByID(ctx, studioID)
if err != nil {
return ""
}
return name
}
func browseTemplateData(
q browseQuery,
studioName string,
genresList []domain.Genre,
animes []domain.Anime,
user any,
watchlistMap map[int64]bool,
hasNextPage bool,
) gin.H {
return gin.H{
"CurrentPath": "/browse",
"Query": q.q,
"Type": q.animeType,
"Status": q.status,
"OrderBy": q.orderBy,
"Sort": q.sort,
"Genres": q.genres,
"Studio": q.studioID,
"StudioName": studioName,
"SFW": q.sfw,
"GenresList": genresList,
"Animes": animes,
"HasNextPage": hasNextPage,
"NextPage": q.page + 1,
"User": user,
"WatchlistMap": watchlistMap,
}
}
func (h *AnimeHandler) searchBrowse(ctx context.Context, query browseQuery) (jikan.SearchResult, error) {
return h.svc.SearchAdvanced(
ctx,
query.q,
query.animeType,
query.status,
query.orderBy,
query.sort,
query.genres,
query.studioID,
query.sfw,
query.page,
24,
)
}
func browseScrollData(
query browseQuery,
studioName string,
animes []domain.Anime,
watchlistMap map[int64]bool,
hasNextPage bool,
) gin.H {
return gin.H{
"_fragment": "anime_card_scroll",
"Animes": animes,
"NextPage": query.page + 1,
"HasNextPage": hasNextPage,
"Query": query.q,
"Type": query.animeType,
"Status": query.status,
"OrderBy": query.orderBy,
"Sort": query.sort,
"Genres": query.genres,
"Studio": query.studioID,
"StudioName": studioName,
"SFW": query.sfw,
"WatchlistMap": watchlistMap,
}
}
func (h *AnimeHandler) respondBrowseSearchError(c *gin.Context, query browseQuery, err error) {
server.RespondError(
c,
http.StatusInternalServerError,
"browse_search_failed",
"anime",
"failed to load browse results",
map[string]any{
"q": query.q,
"type": query.animeType,
"status": query.status,
"order_by": query.orderBy,
"sort": query.sort,
"studio": query.studioID,
"sfw": query.sfw,
"page": query.page,
},
err,
)
}
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
if target, ok := canonicalBrowseURL(c.Request.URL); ok {
c.Redirect(http.StatusSeeOther, target)
return
}
query, err := parseBrowseQuery(c)
if err != nil {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, err.Error())
return
}
res, err := h.searchBrowse(c.Request.Context(), query)
if err != nil {
h.respondBrowseSearchError(c, query, err)
return
}
user := server.CurrentUser(c)
userID := server.CurrentUserID(c)
animes := wrapAnimes(res.Animes)
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
studioName := browseStudioName(c.Request.Context(), h.svc, query.studioID)
if c.GetHeader("HX-Request") == "true" && query.page > 1 {
c.HTML(http.StatusOK, "browse.gohtml", browseScrollData(query, studioName, animes, watchlistMap, res.HasNextPage))
return
}
genresList, err := h.svc.GetGenres(c.Request.Context())
if err != nil {
observability.WarnContext(c.Request.Context(),
"genres_fetch_failed",
"anime",
"",
map[string]any{"q": query.q, "type": query.animeType, "status": query.status},
err,
)
}
browseData := browseTemplateData(query, studioName, genresList, animes, user, watchlistMap, res.HasNextPage)
if c.GetHeader("HX-Request") == "true" {
browseData["_fragment"] = "browse_content"
c.HTML(http.StatusOK, "browse.gohtml", browseData)
return
}
c.HTML(http.StatusOK, "browse.gohtml", browseData)
}
type quickSearchResult struct {
ID int `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Year int `json:"year"`
Image string `json:"image"`
InWatchlist bool `json:"in_watchlist"`
}
func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.JSON(http.StatusOK, []any{})
return
}
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, 1, 5)
if err != nil {
c.JSON(http.StatusOK, []any{})
return
}
userID := server.CurrentUserID(c)
animes := wrapAnimes(res.Animes)
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
output := make([]quickSearchResult, len(animes))
for i, anime := range animes {
output[i] = quickSearchResult{
ID: anime.MalID,
Title: anime.DisplayTitle(),
Type: anime.Type,
Year: anime.Year,
Image: anime.Images.Webp.LargeImageURL,
InWatchlist: watchlistMap[int64(anime.MalID)],
}
}
c.JSON(http.StatusOK, output)
}
func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
anime, err := h.svc.GetRandomAnime(ctx)
if err != nil {
server.RespondError(
c,
http.StatusInternalServerError,
"random_anime_fetch_failed",
"anime",
"failed to fetch random anime",
nil,
err,
)
return
}
if anime.MalID == 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadGateway, "random anime unavailable")
return
}
inWatchlist := false
userID := server.CurrentUserID(c)
if userID != "" {
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, []int64{int64(anime.MalID)})
inWatchlist = watchlistMap[int64(anime.MalID)]
}
c.JSON(http.StatusOK, gin.H{
"data": anime,
"in_watchlist": inWatchlist,
})
}

View File

@@ -0,0 +1,39 @@
package anime
import (
"net/url"
"testing"
)
func TestCanonicalBrowseURLAddsSFWTrueWhenMissing(t *testing.T) {
t.Parallel()
rawURL, err := url.Parse("/browse?status=airing&order_by=popularity&sort=asc")
if err != nil {
t.Fatalf("url.Parse() error = %v", err)
}
got, ok := canonicalBrowseURL(rawURL)
if !ok {
t.Fatal("canonicalBrowseURL() should request redirect when sfw is missing")
}
want := "/browse?order_by=popularity&sfw=true&sort=asc&status=airing"
if got != want {
t.Fatalf("canonicalBrowseURL() = %q, want %q", got, want)
}
}
func TestCanonicalBrowseURLSkipsWhenSFWAlreadyPresent(t *testing.T) {
t.Parallel()
rawURL, err := url.Parse("/browse?status=airing&sfw=false")
if err != nil {
t.Fatalf("url.Parse() error = %v", err)
}
got, ok := canonicalBrowseURL(rawURL)
if ok {
t.Fatalf("canonicalBrowseURL() unexpectedly requested redirect to %q", got)
}
}

View File

@@ -0,0 +1,177 @@
package anime
import (
"mal/internal/observability"
"mal/internal/server"
"net/http"
"net/url"
"strings"
"time"
"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) {
c.HTML(http.StatusOK, "search.gohtml", gin.H{
"User": server.CurrentUser(c),
"CurrentPath": "/search",
})
}
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
user := server.CurrentUser(c)
c.HTML(http.StatusOK, "index.gohtml", gin.H{
"CurrentPath": "/",
"User": user,
"WatchlistMap": map[int64]bool{},
})
}
func (h *AnimeHandler) HandleCatalogAiring(c *gin.Context) {
h.renderCatalogSection(c, "Airing")
}
func (h *AnimeHandler) HandleCatalogPopular(c *gin.Context) {
h.renderCatalogSection(c, "Popular")
}
func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) {
h.renderCatalogSection(c, "Continue")
}
func (h *AnimeHandler) HandleCatalogTopPickForYou(c *gin.Context) {
userID := server.CurrentUserID(c)
data, err := h.svc.GetTopPickForYou(c.Request.Context(), userID)
if err != nil {
observability.WarnContext(c.Request.Context(),
"top_pick_for_you_fetch_failed",
"anime",
"",
map[string]any{
"user_id": userID,
},
err,
)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
data.Section = "TopPickForYou"
data.Fragment = "top_pick_for_you_section"
data.WatchlistMap = watchlistMap
c.HTML(http.StatusOK, "index.gohtml", data)
}
func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
userID := server.CurrentUserID(c)
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
if err != nil {
h.abortSectionFetch(c, "catalog_section_fetch_failed", userID, section, err)
return
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
data.Section = section
data.Fragment = "catalog_section"
data.WatchlistMap = watchlistMap
c.HTML(http.StatusOK, "index.gohtml", data)
}
func (h *AnimeHandler) HandleTopPicks(c *gin.Context) {
user := server.CurrentUser(c)
userID := server.CurrentUserID(c)
data, err := h.svc.GetTopPicksForYou(c.Request.Context(), userID)
if err != nil {
observability.WarnContext(c.Request.Context(),
"top_picks_for_you_fetch_failed",
"anime",
"",
map[string]any{
"user_id": userID,
},
err,
)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
c.HTML(http.StatusOK, "top_picks.gohtml", gin.H{
"CurrentPath": "/top-picks",
"User": user,
"Animes": data.Animes,
"WatchlistMap": watchlistMap,
})
}
func (h *AnimeHandler) abortSectionFetch(c *gin.Context, event string, userID string, section string, err error) {
observability.WarnContext(c.Request.Context(),
event,
"anime",
"",
map[string]any{
"section": section,
"user_id": userID,
},
err,
)
c.AbortWithStatus(http.StatusInternalServerError)
}

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

@@ -1,193 +0,0 @@
package anime
import (
"context"
"fmt"
"mal/internal/db"
"mal/internal/domain"
"mal/internal/server"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type commandPaletteItem struct {
ID string `json:"id"`
Type string `json:"type"`
Label string `json:"label"`
Subtitle string `json:"subtitle"`
Href string `json:"href"`
Image string `json:"image,omitempty"`
Icon string `json:"icon,omitempty"`
}
func (h *AnimeHandler) HandleCommandPalette(c *gin.Context) {
user := server.CurrentUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
query := strings.TrimSpace(c.Query("q"))
items := make([]commandPaletteItem, 0, 12)
if query != "" {
items = append(items, commandPaletteItem{
ID: "search:" + strings.ToLower(query),
Type: "search",
Label: fmt.Sprintf("Search anime for %q", query),
Subtitle: "Browse",
Href: "/browse?q=" + url.QueryEscape(query),
Icon: "search",
})
if len(query) >= 2 {
items = append(items, h.commandPaletteAnimeResults(c, query)...)
}
items = append(items, h.commandPaletteNavigationItems(query)...)
items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...)
items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...)
c.JSON(http.StatusOK, items)
return
}
items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...)
items = append(items, h.commandPaletteNavigationItems(query)...)
items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...)
c.JSON(http.StatusOK, items)
}
func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPaletteItem {
all := []commandPaletteItem{
{ID: "nav:discover", Type: "navigation", Label: "Go to Discover", Subtitle: "Navigation", Href: "/discover", Icon: "compass"},
{ID: "nav:watchlist", Type: "navigation", Label: "Go to Watchlist", Subtitle: "Navigation", Href: "/watchlist", Icon: "bookmark"},
{ID: "nav:popular", Type: "navigation", Label: "Browse popular", Subtitle: "Browse", Href: "/browse?order_by=popularity&sort=desc", Icon: "trending"},
{ID: "nav:airing", Type: "navigation", Label: "Currently airing", Subtitle: "Browse", Href: "/browse?status=airing&order_by=popularity&sort=desc", Icon: "play"},
}
if query == "" {
return all
}
filtered := make([]commandPaletteItem, 0, len(all))
for _, item := range all {
if commandPaletteMatches(query, item.Label, item.Subtitle) {
filtered = append(filtered, item)
}
}
return filtered
}
func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string) []commandPaletteItem {
searchCtx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
defer cancel()
res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, 0, true, 1, 5)
if err != nil {
return nil
}
animes := wrapAnimes(res.Animes)
items := make([]commandPaletteItem, 0, len(animes))
for _, anime := range animes {
items = append(items, commandPaletteItem{
ID: fmt.Sprintf("anime:%d", anime.MalID),
Type: "anime",
Label: anime.DisplayTitle(),
Subtitle: strings.TrimSpace("Anime " + anime.Type),
Href: fmt.Sprintf("/anime/%d", anime.MalID),
Image: anime.ImageURL(),
})
}
return items
}
func (h *AnimeHandler) commandPalettePersonalItems(c *gin.Context, userID string, query string) []commandPaletteItem {
items := make([]commandPaletteItem, 0, 5)
watchlist, err := h.watchlistSvc.GetCommandPaletteWatchlist(c.Request.Context(), userID, query, 5)
if err != nil {
return items
}
for _, entry := range watchlist {
title := watchlistTitle(entry)
items = append(items, commandPaletteItem{
ID: fmt.Sprintf("watchlist:%d", entry.AnimeID),
Type: "watchlist",
Label: title,
Subtitle: watchlistStatusLabel(entry.Status),
Href: fmt.Sprintf("/anime/%d", entry.AnimeID),
Image: entry.ImageUrl,
})
if len(items) >= 5 {
return items
}
}
return items
}
func (h *AnimeHandler) commandPaletteContinueItems(c *gin.Context, userID string, query string) []commandPaletteItem {
items := make([]commandPaletteItem, 0, 5)
rows, err := h.watchlistSvc.GetCommandPaletteContinueWatching(c.Request.Context(), userID, query, 5)
if err != nil {
return items
}
for _, row := range rows {
title := continueWatchingTitle(row)
episode := ""
href := fmt.Sprintf("/anime/%d/watch", row.AnimeID)
if row.CurrentEpisode.Valid {
episode = fmt.Sprintf(" episode %d", row.CurrentEpisode.Int64)
href = fmt.Sprintf("%s?ep=%d", href, row.CurrentEpisode.Int64)
}
items = append(items, commandPaletteItem{
ID: fmt.Sprintf("continue:%d", row.AnimeID),
Type: "continue",
Label: "Continue watching " + title,
Subtitle: "Resume" + episode,
Href: href,
Image: row.ImageUrl,
})
if len(items) >= 5 {
return items
}
}
return items
}
func commandPaletteMatches(query string, values ...string) bool {
needle := strings.ToLower(strings.TrimSpace(query))
for _, value := range values {
if strings.Contains(strings.ToLower(value), needle) {
return true
}
}
return false
}
func continueWatchingTitle(row db.GetContinueWatchingEntriesRow) string {
return row.DisplayTitle()
}
func watchlistTitle(row domain.UserWatchListRow) string {
return row.DisplayTitle()
}
func watchlistStatusLabel(status string) string {
switch status {
case "watching":
return "Watching"
case "plan_to_watch":
return "Plan to Watch"
default:
return "Watchlist"
}
}

View File

@@ -0,0 +1,351 @@
package anime
import (
"context"
"errors"
"fmt"
"mal/integrations/jikan"
"mal/internal/domain"
"mal/internal/observability"
"mal/internal/server"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const (
animeSectionTimeout = 12 * time.Second
watchOrderTimeout = 15 * time.Second
audioLookupTimeout = 8 * time.Second
episodeCountTimeout = 4 * time.Second
)
type animeEpisodeCountDisplay struct {
Count int
Label string
}
type animeReleaseInfoDisplay struct {
Count int
Label string
Status string
}
func releasedEpisodeCount(anime domain.Anime, now time.Time) int {
if !anime.Airing || anime.Aired.From == "" {
return 0
}
firstAired, err := time.Parse(time.RFC3339, anime.Aired.From)
if err != nil || now.Before(firstAired) {
return 0
}
count := int(now.Sub(firstAired)/(7*24*time.Hour)) + 1
if anime.Episodes > 0 && count > anime.Episodes {
return anime.Episodes
}
return count
}
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 {
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout)
defer cancel()
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(episodeCtx, anime, false)
if err == nil {
return releaseInfoFromCanonical(anime, episodeList)
} else {
observability.Warn(
"anime_episode_availability_count_fetch_failed",
"anime",
"",
map[string]any{
"anime_id": anime.MalID,
},
err,
)
}
}
return animeInitialReleaseInfo(anime, now)
}
func releaseInfoFromCanonical(anime domain.Anime, episodeList domain.CanonicalEpisodeList) animeReleaseInfoDisplay {
info := animeReleaseInfoDisplay{Status: trustedAnimeStatus(anime, len(episodeList.Episodes))}
if count := len(episodeList.Episodes); count > 0 {
info.Count = count
info.Label = canonicalEpisodeCountLabel(episodeList.Source)
}
return info
}
func canonicalEpisodeCountLabel(source string) string {
if source == "jikan_fallback" || source == "legacy_disabled" {
return "Estimated aired episodes"
}
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 {
info.Count = anime.Episodes
info.Label = "Total episodes"
return info
}
if count := releasedEpisodeCount(anime, now); count > 0 {
info.Count = count
info.Label = "Estimated aired episodes"
}
return info
}
func animeInitialEpisodeCount(anime domain.Anime, now time.Time) animeEpisodeCountDisplay {
info := animeInitialReleaseInfo(anime, now)
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 status := strings.TrimSpace(anime.Status); status != "" {
return status
}
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 {
hasKnownSub := false
for _, episode := range episodes {
if episode.HasDub {
return "Dub available"
}
if episode.HasSub || episode.SubOnly {
hasKnownSub = true
}
}
if hasKnownSub {
return "Subtitled only"
}
return ""
}
func (h *AnimeHandler) animeAudioAvailability(ctx context.Context, anime domain.Anime) string {
if h.episodeSvc == nil {
return ""
}
audioCtx, cancel := context.WithTimeout(ctx, audioLookupTimeout)
defer cancel()
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(audioCtx, anime, true)
if err != nil {
observability.Warn(
"anime_audio_availability_fetch_failed",
"anime",
"",
map[string]any{
"anime_id": anime.MalID,
},
err,
)
return ""
}
if episodeList.Source != "AllAnime" {
return ""
}
return animeAudioAvailabilityLabel(episodeList.Episodes)
}
func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
return
}
section := c.Query("section")
if section != "" && c.GetHeader("HX-Request") == "true" {
h.handleAnimeDetailsSection(c, id, section)
return
}
anime, err := h.svc.GetAnimeByID(c.Request.Context(), id)
if err != nil {
c.Status(http.StatusNotFound)
return
}
h.svc.WarmDetailSections(id)
user := server.CurrentUser(c)
status := ""
var watchlistIDs []int64
ep := 0
var cwSeconds float64
if user != nil {
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), user.ID, int64(id))
if err == nil {
status = entry.Status
watchlistIDs = []int64{entry.AnimeID}
}
cwEntry, err := h.watchlistSvc.GetContinueWatchingEntry(c.Request.Context(), user.ID, int64(id))
if err == nil && cwEntry.CurrentEpisode.Valid {
ep = int(cwEntry.CurrentEpisode.Int64)
cwSeconds = cwEntry.CurrentTimeSeconds
}
}
releaseInfo := animeInitialReleaseInfo(anime, time.Now())
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"Anime": anime,
"CurrentPath": fmt.Sprintf("/anime/%d", id),
"User": user,
"Status": status,
"WatchlistIDs": watchlistIDs,
"ContinueWatchingEp": ep,
"ContinueWatchingTime": cwSeconds,
"ReleaseInfo": releaseInfo,
})
}
func (h *AnimeHandler) handleAnimeDetailsSection(c *gin.Context, id int, section string) {
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), animeSectionTimeout)
defer cancel()
data, tplName, err := h.loadAnimeDetailsSection(sectionCtx, id, section)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
observability.Warn(
"anime_section_fetch_failed",
"anime",
"",
map[string]any{
"section": section,
"anime_id": id,
},
err,
)
if section == "recommendations" {
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"_fragment": "anime_recommendations_loading",
"AnimeID": id,
})
return
}
c.Status(http.StatusNoContent)
return
}
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"_fragment": tplName,
"Items": data,
})
}
func (h *AnimeHandler) loadAnimeDetailsSection(ctx context.Context, id int, section string) (any, string, error) {
switch section {
case "characters":
data, err := h.svc.GetCharacters(ctx, id)
return data, "anime_characters", err
case "recommendations":
data, err := h.svc.GetRecommendations(ctx, id)
return data, "anime_recommendations", err
case "statistics":
data, err := h.svc.GetStatistics(ctx, id)
return data, "anime_statistics", err
case "episode-count", "release-info":
anime, err := h.svc.GetAnimeByID(ctx, id)
if err != nil {
return nil, "", err
}
return h.animeReleaseInfo(ctx, anime, time.Now()), "anime_release_info", nil
case "audio-availability":
anime, err := h.svc.GetAnimeByID(ctx, id)
if err != nil {
return nil, "", err
}
return h.animeAudioAvailability(ctx, anime), "anime_audio_availability", nil
case "themes":
data, err := h.svc.GetThemes(ctx, id)
return data, "anime_themes", err
default:
return nil, "", nil
}
}
func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
id, err := strconv.Atoi(c.Query("animeId"))
if err != nil || id <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
return
}
userID := server.CurrentUserID(c)
mode := jikan.NormalizeWatchOrderMode(c.Query("mode"))
relationsCtx, cancel := context.WithTimeout(c.Request.Context(), watchOrderTimeout)
defer cancel()
relations, err := h.svc.GetRelations(relationsCtx, id, mode)
if err != nil {
observability.Warn(
"relations_fetch_failed",
"anime",
"",
map[string]any{
"anime_id": id,
},
err,
)
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"_fragment": "watch_order_loading",
"AnimeID": id,
"Mode": string(mode),
})
return
}
ids := make([]int64, 0, len(relations))
for _, relation := range relations {
if relation.Anime.MalID > 0 {
ids = append(ids, int64(relation.Anime.MalID))
}
}
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, ids)
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"_fragment": "watch_order",
"Relations": relations,
"AnimeID": id,
"Mode": string(mode),
"WatchlistMap": watchlistMap,
})
}

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

@@ -2,49 +2,31 @@ package anime
import (
"context"
"fmt"
"mal/integrations/jikan"
"mal/internal/domain"
"mal/internal/observability"
"mal/internal/server"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
const (
animeSectionTimeout = 12 * time.Second
watchOrderTimeout = 15 * time.Second
audioLookupTimeout = 8 * time.Second
)
type AnimeHandler struct {
svc Service
watchlistSvc domain.WatchlistService
episodeSvc domain.EpisodeService
scheduleCacheMu sync.Mutex
scheduleCache map[string]cachedWeekSchedule
discoverySvc *SeasonDiscoveryService
}
type Service interface {
domain.AnimeCatalogService
domain.AnimeDiscoverService
domain.AnimeSearchService
domain.AnimeDetailsService
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{
svc: svc,
watchlistSvc: watchlistSvc,
episodeSvc: episodeSvc,
scheduleCache: map[string]cachedWeekSchedule{},
svc: svc,
watchlistSvc: watchlistSvc,
episodeSvc: episodeSvc,
discoverySvc: discoverySvc,
}
}
@@ -70,766 +52,22 @@ func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, an
return watchlistMap
}
func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string {
hasKnownSub := false
for _, episode := range episodes {
if episode.HasDub {
return "Dub available"
}
if episode.HasSub || episode.SubOnly {
hasKnownSub = true
}
}
if hasKnownSub {
return "Subtitled only"
}
return ""
}
func (h *AnimeHandler) animeAudioAvailability(ctx context.Context, anime domain.Anime) string {
if h.episodeSvc == nil {
return ""
}
audioCtx, cancel := context.WithTimeout(ctx, audioLookupTimeout)
defer cancel()
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(audioCtx, anime, true)
if err != nil {
observability.Warn(
"anime_audio_availability_fetch_failed",
"anime",
"",
map[string]any{
"anime_id": anime.MalID,
},
err,
)
return ""
}
if episodeList.Source != "AllAnime" {
return ""
}
return animeAudioAvailabilityLabel(episodeList.Episodes)
}
func (h *AnimeHandler) Register(r *gin.Engine) {
r.GET("/", h.HandleCatalog)
r.GET("/api/catalog/airing", h.HandleCatalogAiring)
r.GET("/api/catalog/popular", h.HandleCatalogPopular)
r.GET("/api/catalog/continue", h.HandleCatalogContinue)
r.GET("/api/catalog/top-pick", h.HandleCatalogTopPickForYou)
r.GET("/discover", h.HandleDiscover)
r.GET("/discover/top-picks", h.HandleDiscoverTopPicksForYou)
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
r.GET("/api/discover/top", h.HandleDiscoverTop)
r.GET("/schedule", h.HandleSchedule)
r.GET("/api/schedule", h.HandleScheduleSection)
r.GET("/search", h.HandleSearch)
r.GET("/top-picks", h.HandleTopPicks)
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/reviews", h.HandleAnimeReviews)
r.GET("/api/watch-order", h.HandleHTMLWatchOrder)
r.GET("/api/search-quick", h.HandleQuickSearch)
r.GET("/api/command-palette", h.HandleCommandPalette)
r.GET("/api/search", h.HandleSearchAPI)
r.GET("/api/jikan/random/anime", h.HandleRandomAnime)
r.GET("/api/jikan/producers", h.HandleProducers)
}
func (h *AnimeHandler) HandleProducers(c *gin.Context) {
q := strings.TrimSpace(c.Query("q"))
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
return
}
if page < 1 {
page = 1
}
limit, err := strconv.Atoi(c.DefaultQuery("limit", "50"))
if err != nil {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid limit")
return
}
if limit < 1 {
limit = 12
}
if limit > 12 {
limit = 12
}
res, err := h.svc.GetProducers(c.Request.Context(), q, page, limit)
if err != nil {
observability.Warn(
"producers_fetch_failed",
"anime",
"",
map[string]any{
"q": q,
"page": page,
"limit": limit,
},
err,
)
if strings.Contains(c.GetHeader("Accept"), "text/html") {
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
"_fragment": "studio_dropdown_items",
"StudioItems": []any{},
"HasNextPage": false,
"Page": page,
"NextPage": page + 1,
"Query": q,
"Limit": limit,
})
return
}
server.RespondError(
c,
http.StatusInternalServerError,
"producers_fetch_failed",
"anime",
"failed to load producers",
map[string]any{"q": q, "page": page, "limit": limit},
err,
)
return
}
type item struct {
ID int `json:"id"`
Name string `json:"name"`
}
items := make([]item, 0, len(res.Items))
for _, p := range res.Items {
name := jikan.ProducerListEntryName(p)
if p.MalID <= 0 || name == "" {
continue
}
items = append(items, item{ID: p.MalID, Name: name})
}
if strings.Contains(c.GetHeader("Accept"), "text/html") {
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
"_fragment": "studio_dropdown_items",
"StudioItems": items,
"HasNextPage": res.HasNextPage,
"Page": page,
"NextPage": page + 1,
"Query": q,
"Limit": limit,
})
return
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"hasNextPage": res.HasNextPage,
"nextPage": page + 1,
})
}
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
user := server.CurrentUser(c)
c.HTML(http.StatusOK, "index.gohtml", gin.H{
"CurrentPath": "/",
"User": user,
"WatchlistMap": map[int64]bool{},
})
}
func (h *AnimeHandler) HandleCatalogAiring(c *gin.Context) {
h.renderCatalogSection(c, "Airing")
}
func (h *AnimeHandler) HandleCatalogPopular(c *gin.Context) {
h.renderCatalogSection(c, "Popular")
}
func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) {
h.renderCatalogSection(c, "Continue")
}
func (h *AnimeHandler) HandleCatalogTopPickForYou(c *gin.Context) {
userID := server.CurrentUserID(c)
data, err := h.svc.GetTopPickForYou(c.Request.Context(), userID)
if err != nil {
observability.Warn(
"top_pick_for_you_fetch_failed",
"anime",
"",
map[string]any{
"user_id": userID,
},
err,
)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
data.Section = "TopPickForYou"
data.Fragment = "top_pick_for_you_section"
data.WatchlistMap = watchlistMap
c.HTML(http.StatusOK, "index.gohtml", data)
}
func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
userID := server.CurrentUserID(c)
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
if err != nil {
h.abortSectionFetch(c, "catalog_section_fetch_failed", userID, section, err)
return
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
data.Section = section
data.Fragment = "catalog_section"
data.WatchlistMap = watchlistMap
c.HTML(http.StatusOK, "index.gohtml", data)
}
func (h *AnimeHandler) HandleDiscover(c *gin.Context) {
user := server.CurrentUser(c)
c.HTML(http.StatusOK, "discover.gohtml", gin.H{
"CurrentPath": "/discover",
"User": user,
})
}
func (h *AnimeHandler) HandleDiscoverTopPicksForYou(c *gin.Context) {
user := server.CurrentUser(c)
userID := server.CurrentUserID(c)
data, err := h.svc.GetTopPicksForYou(c.Request.Context(), userID)
if err != nil {
observability.Warn(
"top_picks_for_you_fetch_failed",
"anime",
"",
map[string]any{
"user_id": userID,
},
err,
)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
c.HTML(http.StatusOK, "discover.gohtml", gin.H{
"_fragment": "",
"CurrentPath": "/discover",
"User": user,
"Animes": data.Animes,
"WatchlistMap": watchlistMap,
"IsTopPicks": true,
})
}
func (h *AnimeHandler) HandleDiscoverTrending(c *gin.Context) {
h.renderDiscoverSection(c, "Trending")
}
func (h *AnimeHandler) HandleDiscoverUpcoming(c *gin.Context) {
h.renderDiscoverSection(c, "Upcoming")
}
func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) {
h.renderDiscoverSection(c, "Top")
}
func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
userID := server.CurrentUserID(c)
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)
if err != nil {
h.abortSectionFetch(c, "discover_section_fetch_failed", userID, section, err)
return
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
data.Section = section
data.Fragment = "discover_section"
data.WatchlistMap = watchlistMap
c.HTML(http.StatusOK, "discover.gohtml", data)
}
func (h *AnimeHandler) abortSectionFetch(c *gin.Context, event string, userID string, section string, err error) {
observability.Warn(
event,
"anime",
"",
map[string]any{
"section": section,
"user_id": userID,
},
err,
)
c.AbortWithStatus(http.StatusInternalServerError)
}
func (h *AnimeHandler) HandleSchedule(c *gin.Context) {
user := server.CurrentUser(c)
year, week := parseYearWeek(c)
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
"CurrentPath": "/schedule",
"User": user,
"ScheduleYear": year,
"ScheduleWeek": week,
})
}
func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) {
year, week := parseYearWeek(c)
timezone := scheduleTimezone(c)
schedule, err := h.getCachedAnimeScheduleWeek(c.Request.Context(), year, week, timezone)
if err != nil {
prevYear, prevWeek := adjacentISOWeek(year, week, -1)
nextYear, nextWeek := adjacentISOWeek(year, week, 1)
observability.Warn(
"animeschedule_fetch_failed",
"anime",
"",
map[string]any{
"year": year,
"week": week,
"timezone": timezone,
},
err,
)
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
"_fragment": "schedule_section_scraped",
"ScheduleDays": []any{},
"ScheduleYear": year,
"ScheduleWeek": week,
"PrevYear": prevYear,
"PrevWeek": prevWeek,
"NextYear": nextYear,
"NextWeek": nextWeek,
"ScheduleError": true,
})
return
}
days := buildScheduleDays(schedule, schedule.Year, schedule.Week)
prevYear, prevWeek := adjacentISOWeek(schedule.Year, schedule.Week, -1)
nextYear, nextWeek := adjacentISOWeek(schedule.Year, schedule.Week, 1)
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
"_fragment": "schedule_section_scraped",
"ScheduleDays": days,
"ScheduleYear": schedule.Year,
"ScheduleWeek": schedule.Week,
"PrevYear": prevYear,
"PrevWeek": prevWeek,
"NextYear": nextYear,
"NextWeek": nextWeek,
})
}
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
q := c.Query("q")
animeType := c.Query("type")
status := c.Query("status")
orderBy := c.Query("order_by")
sort := c.Query("sort")
sfw := c.Query("sfw") != "false"
studioID := 0
if raw := strings.TrimSpace(c.Query("studio")); raw != "" {
id, err := strconv.Atoi(raw)
if err != nil || id < 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid studio id")
return
}
studioID = id
}
var genres []int
for _, g := range c.QueryArray("genres") {
id, err := strconv.Atoi(g)
if err != nil {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid genre id")
return
}
if id > 0 {
genres = append(genres, id)
}
}
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
return
}
if page < 1 {
page = 1
}
res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, studioID, sfw, page, 24)
if err != nil {
server.RespondError(
c,
http.StatusInternalServerError,
"browse_search_failed",
"anime",
"failed to load browse results",
map[string]any{
"q": q,
"type": animeType,
"status": status,
"order_by": orderBy,
"sort": sort,
"studio": studioID,
"sfw": sfw,
"page": page,
},
err,
)
return
}
user := server.CurrentUser(c)
userID := server.CurrentUserID(c)
animes := wrapAnimes(res.Animes)
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
studioName := ""
if studioID > 0 {
name, err := h.svc.GetProducerNameByID(c.Request.Context(), studioID)
if err == nil {
studioName = name
}
}
if c.GetHeader("HX-Request") == "true" && page > 1 {
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
"_fragment": "anime_card_scroll",
"Animes": animes,
"NextPage": page + 1,
"HasNextPage": res.HasNextPage,
"Query": q,
"Type": animeType,
"Status": status,
"OrderBy": orderBy,
"Sort": sort,
"Genres": genres,
"Studio": studioID,
"StudioName": studioName,
"SFW": sfw,
"WatchlistMap": watchlistMap,
})
return
}
genresList, _ := h.svc.GetGenres(c.Request.Context())
browseData := gin.H{
"CurrentPath": "/browse",
"Query": q,
"Type": animeType,
"Status": status,
"OrderBy": orderBy,
"Sort": sort,
"Genres": genres,
"Studio": studioID,
"StudioName": studioName,
"SFW": sfw,
"GenresList": genresList,
"Animes": animes,
"HasNextPage": res.HasNextPage,
"NextPage": page + 1,
"User": user,
"WatchlistMap": watchlistMap,
}
if c.GetHeader("HX-Request") == "true" {
browseData["_fragment"] = "browse_content"
c.HTML(http.StatusOK, "browse.gohtml", browseData)
return
}
c.HTML(http.StatusOK, "browse.gohtml", browseData)
}
func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
return
}
section := c.Query("section")
if section != "" && c.GetHeader("HX-Request") == "true" {
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), animeSectionTimeout)
defer cancel()
var data any
var tplName string
var err error
switch section {
case "characters":
data, err = h.svc.GetCharacters(sectionCtx, id)
tplName = "anime_characters"
case "recommendations":
data, err = h.svc.GetRecommendations(sectionCtx, id)
tplName = "anime_recommendations"
case "statistics":
data, err = h.svc.GetStatistics(sectionCtx, id)
tplName = "anime_statistics"
case "themes":
data, err = h.svc.GetThemes(sectionCtx, id)
tplName = "anime_themes"
}
if err != nil {
observability.Warn(
"anime_section_fetch_failed",
"anime",
"",
map[string]any{
"section": section,
"anime_id": id,
},
err,
)
if section == "recommendations" {
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"_fragment": "anime_recommendations_loading",
"AnimeID": id,
})
return
}
c.Status(http.StatusNoContent)
return
}
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"_fragment": tplName,
"Items": data,
})
return
}
anime, err := h.svc.GetAnimeByID(c.Request.Context(), id)
if err != nil {
c.Status(http.StatusNotFound)
return
}
h.svc.WarmDetailSections(id)
user := server.CurrentUser(c)
status := ""
var watchlistIDs []int64
ep := 0
var cwSeconds float64
if user != nil {
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), user.ID, int64(id))
if err == nil {
status = entry.Status
watchlistIDs = []int64{entry.AnimeID}
}
cwEntry, err := h.watchlistSvc.GetContinueWatchingEntry(c.Request.Context(), user.ID, int64(id))
if err == nil && cwEntry.CurrentEpisode.Valid {
ep = int(cwEntry.CurrentEpisode.Int64)
cwSeconds = cwEntry.CurrentTimeSeconds
}
}
audioAvailability := h.animeAudioAvailability(c.Request.Context(), anime)
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"Anime": anime,
"AudioAvailability": audioAvailability,
"CurrentPath": fmt.Sprintf("/anime/%d", id),
"User": user,
"Status": status,
"WatchlistIDs": watchlistIDs,
"ContinueWatchingEp": ep,
"ContinueWatchingTime": cwSeconds,
})
}
func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
id, err := strconv.Atoi(c.Query("animeId"))
if err != nil || id <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
return
}
userID := server.CurrentUserID(c)
relationsCtx, cancel := context.WithTimeout(c.Request.Context(), watchOrderTimeout)
defer cancel()
relations, err := h.svc.GetRelations(relationsCtx, id)
if err != nil {
observability.Warn(
"relations_fetch_failed",
"anime",
"",
map[string]any{
"anime_id": id,
},
err,
)
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"_fragment": "watch_order_loading",
"AnimeID": id,
})
return
}
relationAnimeIDs := make([]int64, 0, len(relations))
for _, relation := range relations {
if relation.Anime.MalID > 0 {
relationAnimeIDs = append(relationAnimeIDs, int64(relation.Anime.MalID))
}
}
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, relationAnimeIDs)
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"_fragment": "watch_order",
"Relations": relations,
"AnimeID": id,
"WatchlistMap": watchlistMap,
})
}
func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.JSON(http.StatusOK, []any{})
return
}
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, 1, 5)
if err != nil {
c.JSON(http.StatusOK, []any{})
return
}
userID := server.CurrentUserID(c)
animes := wrapAnimes(res.Animes)
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
type quickSearchResult struct {
ID int `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Year int `json:"year"`
Image string `json:"image"`
InWatchlist bool `json:"in_watchlist"`
}
output := make([]quickSearchResult, len(animes))
for i, anime := range animes {
output[i] = quickSearchResult{
ID: anime.MalID,
Title: anime.DisplayTitle(),
Type: anime.Type,
Year: anime.Year,
Image: anime.ImageURL(),
InWatchlist: watchlistMap[int64(anime.MalID)],
}
}
c.JSON(http.StatusOK, output)
}
func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
anime, err := h.svc.GetRandomAnime(ctx)
if err != nil {
server.RespondError(
c,
http.StatusInternalServerError,
"random_anime_fetch_failed",
"anime",
"failed to fetch random anime",
nil,
err,
)
return
}
if anime.MalID == 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadGateway, "random anime unavailable")
return
}
inWatchlist := false
userID := server.CurrentUserID(c)
if userID != "" {
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, []int64{int64(anime.MalID)})
inWatchlist = watchlistMap[int64(anime.MalID)]
}
c.JSON(http.StatusOK, gin.H{
"data": anime,
"in_watchlist": inWatchlist,
})
}
func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
return
}
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
return
}
if page < 1 {
page = 1
}
reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), id, page)
if err != nil {
server.RespondError(
c,
http.StatusInternalServerError,
"anime_reviews_fetch_failed",
"anime",
"failed to load reviews",
map[string]any{"anime_id": id, "page": page},
err,
)
return
}
user := server.CurrentUser(c)
if c.GetHeader("HX-Request") == "true" && page > 1 {
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
"_fragment": "review_cards",
"Reviews": reviews,
"NextPage": page + 1,
"HasNextPage": hasNextPage,
"AnimeID": id,
})
return
}
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
"CurrentPath": fmt.Sprintf("/anime/%d/reviews", id),
"Reviews": reviews,
"NextPage": page + 1,
"HasNextPage": hasNextPage,
"AnimeID": id,
"User": user,
})
}

View File

@@ -6,16 +6,19 @@ import (
"mal/integrations/jikan"
"mal/internal/domain"
"testing"
"time"
)
type stubEpisodeService struct {
episodes domain.CanonicalEpisodeList
err error
forced bool
episodes domain.CanonicalEpisodeList
err error
called int
forceRefresh bool
}
func (s *stubEpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.Anime, forceRefresh bool) (domain.CanonicalEpisodeList, error) {
s.forced = forceRefresh
s.called++
s.forceRefresh = forceRefresh
if s.err != nil {
return domain.CanonicalEpisodeList{}, s.err
}
@@ -26,6 +29,272 @@ func (s *stubEpisodeService) RefreshTrackedDue(ctx context.Context, limit int) e
return nil
}
type releasedCountTest struct {
name string
anime domain.Anime
now time.Time
want int
}
var releasedCountTests = []releasedCountTest{
{
name: "weekly airing count",
anime: domain.Anime{Anime: jikan.Anime{
Airing: true,
Episodes: 24,
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
}},
now: time.Date(2026, time.June, 13, 15, 0, 0, 0, time.UTC),
want: 11,
},
{
name: "before first release",
anime: domain.Anime{Anime: jikan.Anime{
Airing: true,
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
}},
now: time.Date(2026, time.April, 4, 14, 59, 0, 0, time.UTC),
want: 0,
},
{
name: "first release counts as one",
anime: domain.Anime{Anime: jikan.Anime{
Airing: true,
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
}},
now: time.Date(2026, time.April, 4, 15, 0, 0, 0, time.UTC),
want: 1,
},
{
name: "caps at total episode count",
anime: domain.Anime{Anime: jikan.Anime{
Airing: true,
Episodes: 12,
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
}},
now: time.Date(2026, time.December, 1, 15, 0, 0, 0, time.UTC),
want: 12,
},
{
name: "unknown total still estimates current count",
anime: domain.Anime{Anime: jikan.Anime{
Airing: true,
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
}},
now: time.Date(2026, time.April, 18, 15, 0, 0, 0, time.UTC),
want: 3,
},
{
name: "non airing anime is not estimated",
anime: domain.Anime{Anime: jikan.Anime{
Airing: false,
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
}},
now: time.Date(2026, time.April, 18, 15, 0, 0, 0, time.UTC),
want: 0,
},
{
name: "invalid aired date is ignored",
anime: domain.Anime{Anime: jikan.Anime{
Airing: true,
Aired: jikan.Aired{From: "not-a-date"},
}},
now: time.Date(2026, time.April, 18, 15, 0, 0, 0, time.UTC),
want: 0,
},
}
func TestReleasedEpisodeCount(t *testing.T) {
for _, tt := range releasedCountTests {
t.Run(tt.name, func(t *testing.T) {
got := releasedEpisodeCount(tt.anime, tt.now)
if got != tt.want {
t.Fatalf("releasedEpisodeCount() = %d, want %d", got, tt.want)
}
})
}
}
func TestAnimeEpisodeCountUsesCanonicalEpisodes(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.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
MalID: 59970,
Airing: true,
Episodes: 12,
Aired: jikan.Aired{From: "2026-04-03T00:00:00+00:00"},
}}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC))
if got.Count != 3 || got.Label != "Available episodes" {
t.Fatalf("animeEpisodeCount() = %+v, want count=3 label=%q", got, "Available episodes")
}
if episodeSvc.called != 1 {
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 1", episodeSvc.called)
}
if episodeSvc.forceRefresh {
t.Fatal("animeEpisodeCount() should use fresh cache when available")
}
}
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) {
episodeSvc := &stubEpisodeService{err: errors.New("provider unavailable")}
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
MalID: 59970,
Airing: false,
Episodes: 12,
}}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC))
if got.Count != 12 || got.Label != "Total episodes" {
t.Fatalf("animeEpisodeCount() = %+v, want count=12 label=%q", got, "Total episodes")
}
}
func TestAnimeInitialEpisodeCountDoesNotTrustCurrentlyAiringMetadata(t *testing.T) {
episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{
Episodes: []domain.CanonicalEpisode{{Number: 1}, {Number: 2}, {Number: 3}},
},
}
got := animeInitialEpisodeCount(domain.Anime{Anime: jikan.Anime{
MalID: 59970,
Airing: true,
Status: "Currently Airing",
Episodes: 12,
Aired: jikan.Aired{From: "2026-04-03T00:00:00+00:00"},
}}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC))
if got.Count != 0 || got.Label != "" {
t.Fatalf("animeInitialEpisodeCount() = %+v, want empty display", got)
}
if episodeSvc.called != 0 {
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 0", episodeSvc.called)
}
}
func TestAnimeAudioAvailabilityLabel(t *testing.T) {
tests := []struct {
name string
@@ -108,7 +377,7 @@ func TestAnimeAudioAvailabilityRequiresAllAnimeSource(t *testing.T) {
},
err: tt.err,
}
handler := NewAnimeHandler(nil, nil, episodeSvc)
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeAudioAvailability(context.Background(), domain.Anime{
Anime: jikan.Anime{MalID: 52991},
@@ -116,7 +385,7 @@ func TestAnimeAudioAvailabilityRequiresAllAnimeSource(t *testing.T) {
if got != tt.want {
t.Fatalf("animeAudioAvailability() = %q, want %q", got, tt.want)
}
if !episodeSvc.forced {
if !episodeSvc.forceRefresh {
t.Fatal("animeAudioAvailability() did not force provider refresh")
}
})

View File

@@ -10,14 +10,15 @@ import (
var Module = fx.Options(
fx.Provide(
NewAnimeRepository,
NewSeasonDiscoveryService,
fx.Annotate(
NewAnimeService,
fx.As(new(Service)),
fx.As(new(domain.AnimeCatalogService)),
fx.As(new(domain.AnimeDiscoverService)),
fx.As(new(domain.AnimeSearchService)),
fx.As(new(domain.AnimeDetailsService)),
fx.As(new(domain.AnimePlaybackService)),
fx.As(new(domain.RecommendationInvalidator)),
),
NewAnimeHandler,
),

View File

@@ -1,503 +1,139 @@
package anime
import (
"mal/integrations/jikan"
"mal/internal/db"
"context"
"mal/internal/anime/recommendations"
"mal/internal/domain"
"math"
"slices"
"sort"
"mal/internal/observability"
"maps"
"strings"
"sync"
"time"
)
const (
forYouMaxSeeds = 8
forYouMaxRecommendations = 10
forYouCandidateFetchLimit = 60
forYouResultLimit = 18
forYouFullResultLimit = 60
forYouProfileSearchLimit = 8
forYouProfileGenreSearches = 2
forYouProfileThemeSearches = 2
forYouCollaborativeWeight = 1.4
forYouProfileSearchWeight = 0.8
forYouSeedRecencyWindow = 180 * 24 * time.Hour
forYouFreshReleaseWindow = 540 * 24 * time.Hour
forYouGenreMatchWeight = 1.8
forYouThemeMatchWeight = 1.0
forYouStudioMatchWeight = 0.7
forYouDemographicMatchWeight = 0.9
forYouRecentDiversityWindow = 3
forYouGenreDiversityPenalty = 1.7
forYouThemeDiversityPenalty = 1.2
forYouDemoDiversityPenalty = 1.0
forYouStudioDiversityPenalty = 0.7
)
type recommendationComputeFunc func(context.Context, string, int) (domain.CatalogSectionData, error)
type recommendationSeed struct {
animeID int
weight float64
type topPicksCacheKey struct {
userID string
limit int
}
type weightedEntity struct {
id int
weight float64
type topPicksCacheEntry struct {
data domain.CatalogSectionData
updatedAt time.Time
hasData bool
refreshing bool
}
type profileSearchQuery struct {
genreIDs []int
studioID int
weight float64
type topPicksCache struct {
mu sync.Mutex
entries map[topPicksCacheKey]*topPicksCacheEntry
}
type recommendationCandidate struct {
anime jikan.Anime
score float64
genreMatches int
themeMatches int
studioMatches int
demographicMatches int
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
}
type userTasteProfile struct {
genres map[int]float64
themes map[int]float64
studios map[int]float64
demographics map[int]float64
prefersAiring bool
prefersRecent bool
func (s *animeService) GetTopPicksForYou(_ context.Context, userID string) (domain.CatalogSectionData, error) {
return s.getCachedTopPicksForYou(userID, recommendations.TopPicksLimit), nil
}
func buildRecommendationSeeds(
now time.Time,
watchlist []db.GetUserWatchListRow,
) []recommendationSeed {
seeds := make([]recommendationSeed, 0, min(len(watchlist), forYouMaxSeeds))
func (s *animeService) fetchTopPicksForYou(ctx context.Context, userID string, limit int) (domain.CatalogSectionData, error) {
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, limit)
}
for _, entry := range watchlist {
weight := recommendationEntryWeight(now, entry)
if weight <= 0 || entry.AnimeID <= 0 {
continue
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
}
seeds = append(seeds, recommendationSeed{
animeID: int(entry.AnimeID),
weight: weight,
})
if len(seeds) >= forYouMaxSeeds {
break
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)
}
}
return seeds
}
func recommendationEntryWeight(now time.Time, entry db.GetUserWatchListRow) float64 {
status := strings.TrimSpace(entry.Status)
var statusWeight float64
switch status {
case "completed":
statusWeight = 1.0
case "watching":
statusWeight = 0.9
case "plan_to_watch":
statusWeight = 0.35
default:
return 0
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
}
recencyWeight := 1.0
if !entry.UpdatedAt.IsZero() {
age := now.Sub(entry.UpdatedAt)
if age > 0 {
recencyWeight = math.Max(0.35, 1-(age.Hours()/forYouSeedRecencyWindow.Hours()))
}
}
progressWeight := 0.6
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 {
progressWeight = min(1.0, 0.6+(0.08*float64(entry.CurrentEpisode.Int64)))
}
return statusWeight * recencyWeight * progressWeight
}
func buildTasteProfile(
now time.Time,
seeds []recommendationSeed,
seedAnimes []jikan.Anime,
) userTasteProfile {
profile := userTasteProfile{
genres: make(map[int]float64),
themes: make(map[int]float64),
studios: make(map[int]float64),
demographics: make(map[int]float64),
}
var totalWeight float64
var airingWeight float64
var recentWeight float64
for i, anime := range seedAnimes {
seedWeight := 1.0
if i < len(seeds) && seeds[i].weight > 0 {
seedWeight = seeds[i].weight
}
addEntityWeights(profile.genres, anime.Genres, seedWeight)
addEntityWeights(profile.themes, anime.Themes, seedWeight*0.7)
addEntityWeights(profile.studios, anime.Studios, seedWeight*0.5)
addEntityWeights(profile.demographics, anime.Demographics, seedWeight*0.7)
if anime.Airing {
airingWeight += seedWeight
}
if anime.Year > 0 && now.Year()-anime.Year <= 4 {
recentWeight += seedWeight
}
totalWeight += seedWeight
}
if totalWeight > 0 {
profile.prefersAiring = airingWeight/totalWeight >= 0.5
profile.prefersRecent = recentWeight/totalWeight >= 0.5
}
return profile
}
func addEntityWeights(target map[int]float64, entities []jikan.NamedEntity, weight float64) {
for _, entity := range entities {
if entity.MalID <= 0 {
continue
}
target[entity.MalID] += weight
}
}
func buildProfileSearchQueries(profile userTasteProfile) []profileSearchQuery {
queries := make([]profileSearchQuery, 0, 6)
for _, entity := range strongestWeightedEntities(profile.genres, forYouProfileGenreSearches) {
queries = append(queries, profileSearchQuery{
genreIDs: []int{entity.id},
weight: entity.weight,
})
}
for _, entity := range strongestWeightedEntities(profile.themes, forYouProfileThemeSearches) {
queries = append(queries, profileSearchQuery{
genreIDs: []int{entity.id},
weight: entity.weight * 0.8,
})
}
for _, entity := range strongestWeightedEntities(profile.demographics, 1) {
queries = append(queries, profileSearchQuery{
genreIDs: []int{entity.id},
weight: entity.weight * 0.8,
})
}
for _, entity := range strongestWeightedEntities(profile.studios, 1) {
queries = append(queries, profileSearchQuery{
studioID: entity.id,
weight: entity.weight * 0.7,
})
}
return queries
}
func strongestWeightedEntities(weights map[int]float64, limit int) []weightedEntity {
if limit <= 0 || len(weights) == 0 {
return []weightedEntity{}
}
items := make([]weightedEntity, 0, len(weights))
for id, weight := range weights {
if id <= 0 || weight <= 0 {
continue
}
items = append(items, weightedEntity{id: id, weight: weight})
}
sort.Slice(items, func(i, j int) bool {
if items[i].weight == items[j].weight {
return items[i].id < items[j].id
}
return items[i].weight > items[j].weight
})
if len(items) > limit {
return items[:limit]
}
return items
}
func profileSearchRankWeight(rank int) float64 {
return math.Max(0.35, 1-(float64(rank)*0.08))
}
func rankedCandidateRetrievalScore(collaborativeScore float64, profileSearchScore float64) float64 {
return (math.Log1p(collaborativeScore) * forYouCollaborativeWeight) +
(profileSearchScore * forYouProfileSearchWeight)
}
func hasTasteMetadata(anime jikan.Anime) bool {
return len(anime.Genres) > 0 ||
len(anime.Themes) > 0 ||
len(anime.Studios) > 0 ||
len(anime.Demographics) > 0
}
func scoreRecommendationCandidate(
now time.Time,
profile userTasteProfile,
candidate jikan.Anime,
collaborativeScore float64,
profileSearchScore float64,
) recommendationCandidate {
genreMatches, genreScore := weightedEntityMatch(profile.genres, candidate.Genres)
themeMatches, themeScore := weightedEntityMatch(profile.themes, candidate.Themes)
studioMatches, studioScore := weightedEntityMatch(profile.studios, candidate.Studios)
demographicMatches, demographicScore := weightedEntityMatch(profile.demographics, candidate.Demographics)
score := rankedCandidateRetrievalScore(collaborativeScore, profileSearchScore)
score += genreScore * forYouGenreMatchWeight
score += themeScore * forYouThemeMatchWeight
score += studioScore * forYouStudioMatchWeight
score += demographicScore * forYouDemographicMatchWeight
if candidate.Score > 0 {
score += min(candidate.Score/10.0, 1.0)
}
if candidate.Popularity > 0 {
score += 1.0 / math.Log(float64(candidate.Popularity)+8)
}
if profile.prefersAiring && candidate.Airing {
score += 0.5
}
if profile.prefersRecent && candidate.Year > 0 && now.Year()-candidate.Year <= 4 {
score += 0.45
}
if candidate.Year > 0 && now.Year()-candidate.Year > 15 {
score -= 0.2
}
if candidate.Status == "Not yet aired" {
score -= 0.35
}
if candidate.Aired.From != "" {
if airedAt, err := time.Parse(time.RFC3339, candidate.Aired.From); err == nil {
if now.Sub(airedAt) <= forYouFreshReleaseWindow {
score += 0.3
}
}
}
return recommendationCandidate{
anime: candidate,
score: score,
genreMatches: genreMatches,
themeMatches: themeMatches,
studioMatches: studioMatches,
demographicMatches: demographicMatches,
}
}
func weightedEntityMatch(weights map[int]float64, entities []jikan.NamedEntity) (int, float64) {
var (
matches int
score float64
)
for _, entity := range entities {
weight, ok := weights[entity.MalID]
if !ok {
continue
}
matches++
score += weight
}
return matches, score
}
func rerankRecommendationCandidates(candidates []recommendationCandidate, limit int) []domain.Anime {
selected := make([]domain.Anime, 0, min(limit, len(candidates)))
remaining := slices.Clone(candidates)
seenFeatures := newDiversityFeatureCounts()
recentFeatures := make([]diversityFeatureSet, 0, forYouRecentDiversityWindow)
for len(selected) < limit && len(remaining) > 0 {
bestIndex := bestDiverseCandidateIndex(remaining, seenFeatures, recentFeatures)
candidate := remaining[bestIndex]
remaining = slices.Delete(remaining, bestIndex, bestIndex+1)
if slices.ContainsFunc(selected, func(anime domain.Anime) bool {
return anime.MalID == candidate.anime.MalID
}) {
continue
}
selected = append(selected, domain.Anime{Anime: candidate.anime})
features := diversityFeatures(candidate.anime)
seenFeatures.add(features)
recentFeatures = append(recentFeatures, features)
if len(recentFeatures) > forYouRecentDiversityWindow {
recentFeatures = recentFeatures[1:]
}
}
return selected
}
type diversityFeatureSet struct {
genres map[int]struct{}
themes map[int]struct{}
demographics map[int]struct{}
studios map[int]struct{}
}
type diversityFeatureCounts struct {
genres map[int]int
themes map[int]int
demographics map[int]int
studios map[int]int
}
func newDiversityFeatureCounts() diversityFeatureCounts {
return diversityFeatureCounts{
genres: make(map[int]int),
themes: make(map[int]int),
demographics: make(map[int]int),
studios: make(map[int]int),
}
}
func (counts diversityFeatureCounts) add(features diversityFeatureSet) {
addDiversityCounts(counts.genres, features.genres)
addDiversityCounts(counts.themes, features.themes)
addDiversityCounts(counts.demographics, features.demographics)
addDiversityCounts(counts.studios, features.studios)
}
func addDiversityCounts(target map[int]int, features map[int]struct{}) {
for id := range features {
target[id]++
}
}
func bestDiverseCandidateIndex(
candidates []recommendationCandidate,
seen diversityFeatureCounts,
recent []diversityFeatureSet,
) int {
bestIndex := 0
bestScore := math.Inf(-1)
for i, candidate := range candidates {
score := candidate.score - diversityPenalty(diversityFeatures(candidate.anime), seen, recent)
if score == bestScore {
if candidate.score <= candidates[bestIndex].score {
continue
}
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
return bestIndex
}
func diversityFeatures(anime jikan.Anime) diversityFeatureSet {
return diversityFeatureSet{
genres: entityIDSet(anime.Genres),
themes: entityIDSet(anime.Themes),
demographics: entityIDSet(anime.Demographics),
studios: entityIDSet(anime.Studios),
}
}
func entityIDSet(entities []jikan.NamedEntity) map[int]struct{} {
ids := make(map[int]struct{}, len(entities))
for _, entity := range entities {
if entity.MalID <= 0 {
continue
}
ids[entity.MalID] = struct{}{}
}
return ids
}
func diversityPenalty(
features diversityFeatureSet,
seen diversityFeatureCounts,
recent []diversityFeatureSet,
) float64 {
penalty := 0.0
penalty += repeatedFeaturePenalty(features.genres, seen.genres, recentGenreCounts(recent), forYouGenreDiversityPenalty)
penalty += repeatedFeaturePenalty(features.themes, seen.themes, recentThemeCounts(recent), forYouThemeDiversityPenalty)
penalty += repeatedFeaturePenalty(
features.demographics,
seen.demographics,
recentDemographicCounts(recent),
forYouDemoDiversityPenalty,
)
penalty += repeatedFeaturePenalty(features.studios, seen.studios, recentStudioCounts(recent), forYouStudioDiversityPenalty)
return penalty
}
func repeatedFeaturePenalty(
features map[int]struct{},
seen map[int]int,
recent map[int]int,
weight float64,
) float64 {
total := 0.0
for id := range features {
total += float64(seen[id]) * weight * 0.35
total += float64(recent[id]) * weight
}
return total
}
func recentGenreCounts(recent []diversityFeatureSet) map[int]int {
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
return features.genres
})
}
func recentThemeCounts(recent []diversityFeatureSet) map[int]int {
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
return features.themes
})
}
func recentDemographicCounts(recent []diversityFeatureSet) map[int]int {
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
return features.demographics
})
}
func recentStudioCounts(recent []diversityFeatureSet) map[int]int {
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
return features.studios
})
}
func recentFeatureCounts(
recent []diversityFeatureSet,
selectFeatures func(diversityFeatureSet) map[int]struct{},
) map[int]int {
counts := make(map[int]int)
for _, features := range recent {
addDiversityCounts(counts, selectFeatures(features))
}
return counts
return data
}

View File

@@ -0,0 +1,28 @@
package recommendations
import "time"
const (
maxSeeds = 8
maxRecommendations = 10
candidateFetchLimit = 60
candidateFetchBuffer = 6
TopPickLimit = 18
TopPicksLimit = 60
profileSearchLimit = 8
profileGenreSearches = 2
profileThemeSearches = 2
collaborativeWeight = 1.4
profileSearchWeight = 0.8
seedRecencyWindow = 180 * 24 * time.Hour
freshReleaseWindow = 540 * 24 * time.Hour
genreMatchWeight = 1.8
themeMatchWeight = 1.0
studioMatchWeight = 0.7
demographicMatchWeight = 0.9
recentDiversityWindow = 3
genreDiversityPenalty = 1.7
themeDiversityPenalty = 1.2
demoDiversityPenalty = 1.0
studioDiversityPenalty = 0.7
)

View File

@@ -0,0 +1,262 @@
package recommendations
import (
"context"
"fmt"
"mal/integrations/jikan"
"mal/internal/domain"
"mal/internal/observability"
"sort"
"strings"
"sync"
"time"
"golang.org/x/sync/errgroup"
)
type engine struct {
jikan *jikan.Client
repo domain.AnimeRepository
}
func GetTopPicksForYou(
ctx context.Context,
jikanClient *jikan.Client,
repo domain.AnimeRepository,
userID string,
resultLimit int,
) (domain.CatalogSectionData, error) {
return engine{jikan: jikanClient, repo: repo}.getTopPicksForYou(ctx, userID, resultLimit)
}
func (e engine) getTopPicksForYou(ctx context.Context, userID string, resultLimit int) (domain.CatalogSectionData, error) {
if strings.TrimSpace(userID) == "" {
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
}
watchlist, err := e.repo.GetUserWatchList(ctx, userID)
if err != nil {
return domain.CatalogSectionData{}, fmt.Errorf("get user watchlist for %q: %w", userID, err)
}
now := time.Now()
seedPool := buildRecommendationSeeds(now, watchlist)
if len(seedPool) == 0 {
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
}
seedAnimes, err := e.fetchSeedAnimes(ctx, seedPool)
if err != nil {
return domain.CatalogSectionData{}, fmt.Errorf("fetch seed animes: %w", err)
}
profile := buildTasteProfile(now, seedPool, seedAnimes)
store := newCandidateStore(watchlist)
if err := e.collectCollaborativeCandidates(ctx, seedPool, store); err != nil {
return domain.CatalogSectionData{}, fmt.Errorf("collect collaborative candidates: %w", err)
}
if err := e.collectProfileSearchCandidates(ctx, profile, store); err != nil {
return domain.CatalogSectionData{}, fmt.Errorf("collect profile search candidates: %w", err)
}
ranked := store.ranked()
if len(ranked) == 0 {
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
}
candidates, err := e.scoreRankedCandidates(ctx, now, profile, ranked, resultLimit)
if err != nil {
return domain.CatalogSectionData{}, fmt.Errorf("score ranked candidates: %w", err)
}
return domain.CatalogSectionData{
Animes: rerankRecommendationCandidates(candidates, resultLimit),
}, nil
}
func (e engine) fetchSeedAnimes(ctx context.Context, seedPool []recommendationSeed) ([]jikan.Anime, error) {
seedAnimes := make([]jikan.Anime, len(seedPool))
var g errgroup.Group
g.SetLimit(4)
for i, seed := range seedPool {
g.Go(func() error {
anime, err := e.jikan.GetAnimeByID(ctx, seed.animeID)
if err != nil {
return fmt.Errorf("get seed anime %d: %w", seed.animeID, err)
}
seedAnimes[i] = anime
return nil
})
}
if err := g.Wait(); err != nil {
return nil, fmt.Errorf("wait for seed anime fetches: %w", err)
}
return seedAnimes, nil
}
func (e engine) collectCollaborativeCandidates(ctx context.Context, seedPool []recommendationSeed, store *candidateStore) error {
var g errgroup.Group
g.SetLimit(4)
for _, seed := range seedPool {
g.Go(func() error {
recs, err := e.jikan.GetAnimeRecommendations(ctx, seed.animeID)
if err != nil {
observability.Warn(
"collaborative_recommendations_failed",
"anime",
"",
map[string]any{"seed_id": seed.animeID},
err,
)
return nil
}
for i, rec := range recs {
if i >= maxRecommendations {
break
}
id := rec.Entry.MalID
if id <= 0 || id == seed.animeID {
continue
}
store.upsert(rankedCandidate{
id: id,
collaborativeScore: float64(rec.Votes) * seed.weight,
})
}
return nil
})
}
if err := g.Wait(); err != nil {
return fmt.Errorf("wait for collaborative candidate fetches: %w", err)
}
return nil
}
func (e engine) collectProfileSearchCandidates(ctx context.Context, profile userTasteProfile, store *candidateStore) error {
queries := buildProfileSearchQueries(profile)
var g errgroup.Group
g.SetLimit(3)
for _, query := range queries {
g.Go(func() error {
res, err := e.jikan.SearchAdvanced(
ctx,
"",
"",
"",
"score",
"desc",
query.genreIDs,
query.studioID,
true,
1,
profileSearchLimit,
)
if err != nil {
observability.Warn(
"top_pick_profile_search_failed",
"anime",
"",
map[string]any{
"genres": query.genreIDs,
"studio_id": query.studioID,
},
err,
)
return nil
}
for i, anime := range res.Animes {
if anime.MalID <= 0 {
continue
}
store.upsert(rankedCandidate{
id: anime.MalID,
profileSearchScore: query.weight * profileSearchRankWeight(i),
anime: anime,
hasAnime: true,
})
}
return nil
})
}
if err := g.Wait(); err != nil {
return fmt.Errorf("wait for profile search candidate fetches: %w", err)
}
return nil
}
func (e engine) scoreRankedCandidates(
ctx context.Context,
now time.Time,
profile userTasteProfile,
ranked []rankedCandidate,
resultLimit int,
) ([]recommendationCandidate, error) {
limit := min(len(ranked), candidateScoreLimit(resultLimit))
candidates := make([]recommendationCandidate, 0, limit)
var candidatesMu sync.Mutex
var g errgroup.Group
g.SetLimit(6)
for i := range limit {
item := ranked[i]
g.Go(func() error {
anime := item.anime
if !item.hasAnime || !hasTasteMetadata(anime) {
fetchedAnime, err := e.jikan.GetAnimeByID(ctx, item.id)
if err != nil {
observability.Warn(
"recommendation_anime_fetch_failed",
"anime",
"",
map[string]any{"anime_id": item.id},
err,
)
return nil
}
anime = fetchedAnime
}
candidate := scoreRecommendationCandidate(
now,
profile,
anime,
item.collaborativeScore,
item.profileSearchScore,
)
candidatesMu.Lock()
candidates = append(candidates, candidate)
candidatesMu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil {
return nil, fmt.Errorf("wait for candidate scoring: %w", err)
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].score == candidates[j].score {
return candidates[i].anime.MalID < candidates[j].anime.MalID
}
return candidates[i].score > candidates[j].score
})
return candidates, nil
}
func candidateScoreLimit(resultLimit int) int {
if resultLimit <= 0 {
return 0
}
return min(candidateFetchLimit, resultLimit+candidateFetchBuffer)
}

View File

@@ -0,0 +1,171 @@
package recommendations
import (
"mal/integrations/jikan"
"mal/internal/db"
"math"
"sort"
"strings"
"time"
)
func buildRecommendationSeeds(now time.Time, watchlist []db.GetUserWatchListRow) []recommendationSeed {
seeds := make([]recommendationSeed, 0, min(len(watchlist), maxSeeds))
for _, entry := range watchlist {
weight := recommendationEntryWeight(now, entry)
if weight <= 0 || entry.AnimeID <= 0 {
continue
}
seeds = append(seeds, recommendationSeed{
animeID: int(entry.AnimeID),
weight: weight,
})
if len(seeds) >= maxSeeds {
break
}
}
return seeds
}
func recommendationEntryWeight(now time.Time, entry db.GetUserWatchListRow) float64 {
status := strings.TrimSpace(entry.Status)
var statusWeight float64
switch status {
case "completed":
statusWeight = 1.0
case "watching":
statusWeight = 0.9
case "plan_to_watch":
statusWeight = 0.35
default:
return 0
}
recencyWeight := 1.0
if !entry.UpdatedAt.IsZero() {
age := now.Sub(entry.UpdatedAt)
if age > 0 {
recencyWeight = math.Max(0.35, 1-(age.Hours()/seedRecencyWindow.Hours()))
}
}
progressWeight := 0.6
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 {
progressWeight = min(1.0, 0.6+(0.08*float64(entry.CurrentEpisode.Int64)))
}
return statusWeight * recencyWeight * progressWeight
}
func buildTasteProfile(now time.Time, seeds []recommendationSeed, seedAnimes []jikan.Anime) userTasteProfile {
profile := userTasteProfile{
genres: make(map[int]float64),
themes: make(map[int]float64),
studios: make(map[int]float64),
demographics: make(map[int]float64),
}
var totalWeight float64
var airingWeight float64
var recentWeight float64
for i, anime := range seedAnimes {
seedWeight := 1.0
if i < len(seeds) && seeds[i].weight > 0 {
seedWeight = seeds[i].weight
}
addEntityWeights(profile.genres, anime.Genres, seedWeight)
addEntityWeights(profile.themes, anime.Themes, seedWeight*0.7)
addEntityWeights(profile.studios, anime.Studios, seedWeight*0.5)
addEntityWeights(profile.demographics, anime.Demographics, seedWeight*0.7)
if anime.Airing {
airingWeight += seedWeight
}
if anime.Year > 0 && now.Year()-anime.Year <= 4 {
recentWeight += seedWeight
}
totalWeight += seedWeight
}
if totalWeight > 0 {
profile.prefersAiring = airingWeight/totalWeight >= 0.5
profile.prefersRecent = recentWeight/totalWeight >= 0.5
}
return profile
}
func addEntityWeights(target map[int]float64, entities []jikan.NamedEntity, weight float64) {
for _, entity := range entities {
if entity.MalID <= 0 {
continue
}
target[entity.MalID] += weight
}
}
func buildProfileSearchQueries(profile userTasteProfile) []profileSearchQuery {
queries := make([]profileSearchQuery, 0, 6)
for _, entity := range strongestWeightedEntities(profile.genres, profileGenreSearches) {
queries = append(queries, profileSearchQuery{
genreIDs: []int{entity.id},
weight: entity.weight,
})
}
for _, entity := range strongestWeightedEntities(profile.themes, profileThemeSearches) {
queries = append(queries, profileSearchQuery{
genreIDs: []int{entity.id},
weight: entity.weight * 0.8,
})
}
for _, entity := range strongestWeightedEntities(profile.demographics, 1) {
queries = append(queries, profileSearchQuery{
genreIDs: []int{entity.id},
weight: entity.weight * 0.8,
})
}
for _, entity := range strongestWeightedEntities(profile.studios, 1) {
queries = append(queries, profileSearchQuery{
studioID: entity.id,
weight: entity.weight * 0.7,
})
}
return queries
}
func strongestWeightedEntities(weights map[int]float64, limit int) []weightedEntity {
if limit <= 0 || len(weights) == 0 {
return []weightedEntity{}
}
items := make([]weightedEntity, 0, len(weights))
for id, weight := range weights {
if id <= 0 || weight <= 0 {
continue
}
items = append(items, weightedEntity{id: id, weight: weight})
}
sort.Slice(items, func(i, j int) bool {
if items[i].weight == items[j].weight {
return items[i].id < items[j].id
}
return items[i].weight > items[j].weight
})
if len(items) > limit {
return items[:limit]
}
return items
}

View File

@@ -1,10 +1,11 @@
package anime
package recommendations
import (
"database/sql"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"slices"
"testing"
"time"
)
@@ -73,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) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
@@ -174,6 +216,18 @@ func TestRerankRecommendationCandidatesSpreadsRepeatedGenres(t *testing.T) {
}
}
func TestCandidateScoreLimitTracksRequestedResultSize(t *testing.T) {
if got := candidateScoreLimit(TopPickLimit); got != TopPickLimit+candidateFetchBuffer {
t.Fatalf("expected top-pick scoring to fetch a small oversample, got %d", got)
}
if got := candidateScoreLimit(TopPicksLimit); got != candidateFetchLimit {
t.Fatalf("expected full top-picks scoring to keep existing cap, got %d", got)
}
if got := candidateScoreLimit(0); got != 0 {
t.Fatalf("expected zero result limit to skip scoring, got %d", got)
}
}
func testRecommendationAnime(id int, genreID int) jikan.Anime {
return jikan.Anime{
MalID: id,
@@ -207,10 +261,8 @@ func animeIDs(animes []domain.Anime) []int {
func hasGenreSearchQuery(queries []profileSearchQuery, genreID int) bool {
for _, query := range queries {
for _, id := range query.genreIDs {
if id == genreID {
return true
}
if slices.Contains(query.genreIDs, genreID) {
return true
}
}
return false

View File

@@ -0,0 +1,167 @@
package recommendations
import (
"mal/integrations/jikan"
"mal/internal/domain"
"math"
"slices"
)
func rerankRecommendationCandidates(candidates []recommendationCandidate, limit int) []domain.Anime {
selected := make([]domain.Anime, 0, min(limit, len(candidates)))
remaining := slices.Clone(candidates)
seen := newDiversityFeatureCounts()
recent := make([]diversityFeatureSet, 0, recentDiversityWindow)
for len(selected) < limit && len(remaining) > 0 {
bestIndex := bestDiverseCandidateIndex(remaining, seen, recent)
candidate := remaining[bestIndex]
remaining = slices.Delete(remaining, bestIndex, bestIndex+1)
if slices.ContainsFunc(selected, func(anime domain.Anime) bool {
return anime.MalID == candidate.anime.MalID
}) {
continue
}
selected = append(selected, domain.Anime{Anime: candidate.anime, RecommendationRationale: candidate.rationale})
features := diversityFeatures(candidate.anime)
seen.add(features)
recent = append(recent, features)
if len(recent) > recentDiversityWindow {
recent = recent[1:]
}
}
return selected
}
type diversityFeatureSet struct {
genres map[int]struct{}
themes map[int]struct{}
demographics map[int]struct{}
studios map[int]struct{}
}
type diversityFeatureCounts struct {
genres map[int]int
themes map[int]int
demographics map[int]int
studios map[int]int
}
func newDiversityFeatureCounts() diversityFeatureCounts {
return diversityFeatureCounts{
genres: make(map[int]int),
themes: make(map[int]int),
demographics: make(map[int]int),
studios: make(map[int]int),
}
}
func (counts diversityFeatureCounts) add(features diversityFeatureSet) {
addDiversityCounts(counts.genres, features.genres)
addDiversityCounts(counts.themes, features.themes)
addDiversityCounts(counts.demographics, features.demographics)
addDiversityCounts(counts.studios, features.studios)
}
func addDiversityCounts(target map[int]int, features map[int]struct{}) {
for id := range features {
target[id]++
}
}
func bestDiverseCandidateIndex(candidates []recommendationCandidate, seen diversityFeatureCounts, recent []diversityFeatureSet) int {
bestIndex := 0
bestScore := math.Inf(-1)
for i, candidate := range candidates {
score := candidate.score - diversityPenalty(diversityFeatures(candidate.anime), seen, recent)
if score == bestScore {
if candidate.score <= candidates[bestIndex].score {
continue
}
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
return bestIndex
}
func diversityFeatures(anime jikan.Anime) diversityFeatureSet {
return diversityFeatureSet{
genres: entityIDSet(anime.Genres),
themes: entityIDSet(anime.Themes),
demographics: entityIDSet(anime.Demographics),
studios: entityIDSet(anime.Studios),
}
}
func entityIDSet(entities []jikan.NamedEntity) map[int]struct{} {
ids := make(map[int]struct{}, len(entities))
for _, entity := range entities {
if entity.MalID <= 0 {
continue
}
ids[entity.MalID] = struct{}{}
}
return ids
}
func diversityPenalty(features diversityFeatureSet, seen diversityFeatureCounts, recent []diversityFeatureSet) float64 {
penalty := 0.0
penalty += repeatedFeaturePenalty(features.genres, seen.genres, recentGenreCounts(recent), genreDiversityPenalty)
penalty += repeatedFeaturePenalty(features.themes, seen.themes, recentThemeCounts(recent), themeDiversityPenalty)
penalty += repeatedFeaturePenalty(features.demographics, seen.demographics, recentDemographicCounts(recent), demoDiversityPenalty)
penalty += repeatedFeaturePenalty(features.studios, seen.studios, recentStudioCounts(recent), studioDiversityPenalty)
return penalty
}
func repeatedFeaturePenalty(features map[int]struct{}, seen map[int]int, recent map[int]int, weight float64) float64 {
total := 0.0
for id := range features {
total += float64(seen[id]) * weight * 0.35
total += float64(recent[id]) * weight
}
return total
}
func recentGenreCounts(recent []diversityFeatureSet) map[int]int {
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
return features.genres
})
}
func recentThemeCounts(recent []diversityFeatureSet) map[int]int {
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
return features.themes
})
}
func recentDemographicCounts(recent []diversityFeatureSet) map[int]int {
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
return features.demographics
})
}
func recentStudioCounts(recent []diversityFeatureSet) map[int]int {
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
return features.studios
})
}
func recentFeatureCounts(
recent []diversityFeatureSet,
selectFeatures func(diversityFeatureSet) map[int]struct{},
) map[int]int {
counts := make(map[int]int)
for _, features := range recent {
addDiversityCounts(counts, selectFeatures(features))
}
return counts
}

View File

@@ -0,0 +1,150 @@
package recommendations
import (
"mal/integrations/jikan"
"math"
"time"
)
func profileSearchRankWeight(rank int) float64 {
return math.Max(0.35, 1-(float64(rank)*0.08))
}
func rankedCandidateRetrievalScore(collaborativeScore float64, profileSearchScore float64) float64 {
return (math.Log1p(collaborativeScore) * collaborativeWeight) +
(profileSearchScore * profileSearchWeight)
}
func hasTasteMetadata(anime jikan.Anime) bool {
return len(anime.Genres) > 0 ||
len(anime.Themes) > 0 ||
len(anime.Studios) > 0 ||
len(anime.Demographics) > 0
}
func scoreRecommendationCandidate(
now time.Time,
profile userTasteProfile,
candidate jikan.Anime,
collaborativeScore float64,
profileSearchScore float64,
) recommendationCandidate {
genres, genreScore := weightedEntityMatch(profile.genres, candidate.Genres)
themes, themeScore := weightedEntityMatch(profile.themes, candidate.Themes)
studios, studioScore := weightedEntityMatch(profile.studios, candidate.Studios)
demos, demoScore := weightedEntityMatch(profile.demographics, candidate.Demographics)
score := rankedCandidateRetrievalScore(collaborativeScore, profileSearchScore)
score += genreScore * genreMatchWeight
score += themeScore * themeMatchWeight
score += studioScore * studioMatchWeight
score += demoScore * demographicMatchWeight
score += recommendationCandidateScoreAdjustments(now, profile, candidate)
return recommendationCandidate{
anime: candidate,
score: score,
genreMatches: genres,
themeMatches: themes,
studioMatches: studios,
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 {
var score float64
if candidate.Score > 0 {
score += min(candidate.Score/10.0, 1.0)
}
if candidate.Popularity > 0 {
score += 1.0 / math.Log(float64(candidate.Popularity)+8)
}
if profile.prefersAiring && candidate.Airing {
score += 0.5
}
if profile.prefersRecent && isRecentCandidate(now, candidate.Year) {
score += 0.45
}
if isClassicCandidate(now, candidate.Year) {
score -= 0.2
}
if candidate.Status == "Not yet aired" {
score -= 0.35
}
if isFreshRelease(now, candidate.Aired.From) {
score += 0.3
}
return score
}
func isRecentCandidate(now time.Time, year int) bool {
return year > 0 && now.Year()-year <= 4
}
func isClassicCandidate(now time.Time, year int) bool {
return year > 0 && now.Year()-year > 15
}
func isFreshRelease(now time.Time, airedFrom string) bool {
if airedFrom == "" {
return false
}
airedAt, err := time.Parse(time.RFC3339, airedFrom)
if err != nil {
return false
}
return now.Sub(airedAt) <= freshReleaseWindow
}
func weightedEntityMatch(weights map[int]float64, entities []jikan.NamedEntity) (int, float64) {
var matches int
var score float64
for _, entity := range entities {
weight, ok := weights[entity.MalID]
if !ok {
continue
}
matches++
score += weight
}
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

@@ -0,0 +1,136 @@
package recommendations
import (
"math"
"testing"
"time"
"mal/integrations/jikan"
)
func TestProfileSearchRankWeightHasFloor(t *testing.T) {
if got := profileSearchRankWeight(0); got != 1 {
t.Fatalf("rank 0 weight = %f, want 1", got)
}
if got := profileSearchRankWeight(100); got != 0.35 {
t.Fatalf("rank 100 weight = %f, want floor 0.35", got)
}
}
func TestRankedCandidateRetrievalScoreUsesLogForCollaborativeSignal(t *testing.T) {
low := rankedCandidateRetrievalScore(1, 0)
high := rankedCandidateRetrievalScore(100, 0)
if high <= low {
t.Fatalf("expected higher collaborative score to rank higher, low=%f high=%f", low, high)
}
linearGrowth := 100.0 - 1.0
actualGrowth := high - low
if actualGrowth >= linearGrowth {
t.Fatalf("expected log scaling, growth=%f linear=%f", actualGrowth, linearGrowth)
}
}
func TestHasTasteMetadata(t *testing.T) {
if hasTasteMetadata(jikan.Anime{}) {
t.Fatalf("empty anime should not have taste metadata")
}
if !hasTasteMetadata(jikan.Anime{Studios: []jikan.NamedEntity{{MalID: 1}}}) {
t.Fatalf("studio metadata should count as taste metadata")
}
}
func TestRecommendationCandidateScoreAdjustments(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
profile := userTasteProfile{prefersAiring: true, prefersRecent: true}
preferred := recommendationCandidateScoreAdjustments(now, profile, jikan.Anime{
Score: 9,
Popularity: 10,
Airing: true,
Year: 2026,
Aired: jikan.Aired{From: now.Add(-24 * time.Hour).Format(time.RFC3339)},
})
penalized := recommendationCandidateScoreAdjustments(now, profile, jikan.Anime{
Score: 9,
Year: 2000,
Status: "Not yet aired",
})
if preferred <= penalized {
t.Fatalf("expected preferred candidate to outscore penalized candidate, preferred=%f penalized=%f", preferred, penalized)
}
if !isRecentCandidate(now, 2024) || isRecentCandidate(now, 2010) {
t.Fatalf("recent candidate boundary failed")
}
if !isClassicCandidate(now, 2010) || isClassicCandidate(now, 2020) {
t.Fatalf("classic candidate boundary failed")
}
if !isFreshRelease(now, now.Add(-freshReleaseWindow+time.Hour).Format(time.RFC3339)) {
t.Fatalf("expected fresh release inside window")
}
if isFreshRelease(now, "not a date") {
t.Fatalf("invalid release timestamp should not be fresh")
}
}
func TestWeightedEntityMatchCountsAndScoresMatches(t *testing.T) {
matches, score := weightedEntityMatch(map[int]float64{1: 2.5, 3: 1.0}, []jikan.NamedEntity{
{MalID: 1, Name: "Action"},
{MalID: 2, Name: "Drama"},
{MalID: 3, Name: "Sports"},
})
if matches != 2 || score != 3.5 {
t.Fatalf("weightedEntityMatch = matches:%d score:%f, want 2 and 3.5", matches, score)
}
}
func TestAddEntityWeightsSkipsInvalidIDsAndAccumulates(t *testing.T) {
target := map[int]float64{1: 1.0}
addEntityWeights(target, []jikan.NamedEntity{{MalID: 0}, {MalID: 1}, {MalID: 2}}, 0.5)
if target[1] != 1.5 || target[2] != 0.5 {
t.Fatalf("entity weights = %#v, want accumulated valid ids", target)
}
if _, ok := target[0]; ok {
t.Fatalf("invalid id should not be added: %#v", target)
}
}
func TestStrongestWeightedEntitiesSortsByWeightThenID(t *testing.T) {
got := strongestWeightedEntities(map[int]float64{3: 1, 2: 2, 1: 2, 4: -1}, 3)
want := []weightedEntity{{id: 1, weight: 2}, {id: 2, weight: 2}, {id: 3, weight: 1}}
if len(got) != len(want) {
t.Fatalf("len(got) = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got[%d] = %+v, want %+v", i, got[i], want[i])
}
}
}
func TestScoreRecommendationCandidateIncludesMatchCounts(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
profile := userTasteProfile{
genres: map[int]float64{1: 1, 2: 1},
themes: map[int]float64{10: 1},
studios: map[int]float64{20: 1},
demographics: map[int]float64{30: 1},
}
candidate := scoreRecommendationCandidate(now, profile, jikan.Anime{
Genres: []jikan.NamedEntity{{MalID: 1}, {MalID: 2}},
Themes: []jikan.NamedEntity{{MalID: 10}},
Studios: []jikan.NamedEntity{{MalID: 20}},
Demographics: []jikan.NamedEntity{{MalID: 30}},
}, 0, 0)
if candidate.genreMatches != 2 || candidate.themeMatches != 1 || candidate.studioMatches != 1 || candidate.demographicMatches != 1 {
t.Fatalf("match counts = genres:%d themes:%d studios:%d demos:%d", candidate.genreMatches, candidate.themeMatches, candidate.studioMatches, candidate.demographicMatches)
}
if math.Abs(candidate.score) < 0.001 {
t.Fatalf("expected non-zero score for metadata matches")
}
}

View File

@@ -0,0 +1,72 @@
package recommendations
import (
"mal/internal/db"
"sort"
"sync"
)
type candidateStore struct {
watchlistAnimeIDs map[int]struct{}
byID map[int]rankedCandidate
mu sync.Mutex
}
func newCandidateStore(watchlist []db.GetUserWatchListRow) *candidateStore {
watched := make(map[int]struct{}, len(watchlist))
for _, entry := range watchlist {
if entry.AnimeID <= 0 {
continue
}
watched[int(entry.AnimeID)] = struct{}{}
}
return &candidateStore{
watchlistAnimeIDs: watched,
byID: map[int]rankedCandidate{},
}
}
func (s *candidateStore) upsert(candidate rankedCandidate) {
if candidate.id <= 0 {
return
}
if _, exists := s.watchlistAnimeIDs[candidate.id]; exists {
return
}
s.mu.Lock()
defer s.mu.Unlock()
current, ok := s.byID[candidate.id]
if !ok {
s.byID[candidate.id] = candidate
return
}
current.collaborativeScore += candidate.collaborativeScore
current.profileSearchScore += candidate.profileSearchScore
if candidate.hasAnime {
current.anime = candidate.anime
current.hasAnime = true
}
s.byID[candidate.id] = current
}
func (s *candidateStore) ranked() []rankedCandidate {
ranked := make([]rankedCandidate, 0, len(s.byID))
for _, item := range s.byID {
ranked = append(ranked, item)
}
sort.Slice(ranked, func(i, j int) bool {
left := rankedCandidateRetrievalScore(ranked[i].collaborativeScore, ranked[i].profileSearchScore)
right := rankedCandidateRetrievalScore(ranked[j].collaborativeScore, ranked[j].profileSearchScore)
if left == right {
return ranked[i].id < ranked[j].id
}
return left > right
})
return ranked
}

View File

@@ -0,0 +1,46 @@
package recommendations
import "mal/integrations/jikan"
type recommendationSeed struct {
animeID int
weight float64
}
type weightedEntity struct {
id int
weight float64
}
type profileSearchQuery struct {
genreIDs []int
studioID int
weight float64
}
type recommendationCandidate struct {
anime jikan.Anime
score float64
genreMatches int
themeMatches int
studioMatches int
demographicMatches int
rationale []string
}
type userTasteProfile struct {
genres map[int]float64
themes map[int]float64
studios map[int]float64
demographics map[int]float64
prefersAiring bool
prefersRecent bool
}
type rankedCandidate struct {
id int
collaborativeScore float64
profileSearchScore float64
anime jikan.Anime
hasAnime bool
}

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

@@ -0,0 +1,81 @@
package anime
import (
"fmt"
"mal/internal/server"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type reviewsQuery struct {
animeID int
page int
}
func parseReviewsQuery(c *gin.Context) (reviewsQuery, error) {
rawID := c.Param("id")
id, err := strconv.Atoi(rawID)
if err != nil {
return reviewsQuery{}, fmt.Errorf("invalid anime id %q: %w", rawID, err)
}
if id <= 0 {
return reviewsQuery{}, fmt.Errorf("invalid anime id %d", id)
}
rawPage := c.DefaultQuery("page", "1")
page, err := strconv.Atoi(rawPage)
if err != nil {
return reviewsQuery{}, fmt.Errorf("invalid page %q: %w", rawPage, err)
}
if page < 1 {
page = 1
}
return reviewsQuery{animeID: id, page: page}, nil
}
func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) {
query, err := parseReviewsQuery(c)
if err != nil {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, err.Error())
return
}
reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), query.animeID, query.page)
if err != nil {
server.RespondError(
c,
http.StatusInternalServerError,
"anime_reviews_fetch_failed",
"anime",
"failed to load reviews",
map[string]any{"anime_id": query.animeID, "page": query.page},
err,
)
return
}
user := server.CurrentUser(c)
if c.GetHeader("HX-Request") == "true" && query.page > 1 {
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
"_fragment": "review_cards",
"Reviews": reviews,
"NextPage": query.page + 1,
"HasNextPage": hasNextPage,
"AnimeID": query.animeID,
})
return
}
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
"CurrentPath": fmt.Sprintf("/anime/%d/reviews", query.animeID),
"Reviews": reviews,
"NextPage": query.page + 1,
"HasNextPage": hasNextPage,
"AnimeID": query.animeID,
"User": user,
})
}

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.scheduleCacheMu.Lock()
cached, ok := h.scheduleCache[cacheKey]
h.scheduleCacheMu.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.scheduleCacheMu.Lock()
h.scheduleCache[cacheKey] = cachedWeekSchedule{fetchedAt: time.Now(), value: value}
h.scheduleCacheMu.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

@@ -0,0 +1,80 @@
package anime
import (
"fmt"
"mal/internal/server"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
const searchAnimeLimit = 24
type searchItem struct {
ID string `json:"id"`
Type string `json:"type"`
Label string `json:"label"`
Subtitle string `json:"subtitle"`
Href string `json:"href"`
Image string `json:"image,omitempty"`
Icon string `json:"icon,omitempty"`
InWatchlist bool `json:"inWatchlist,omitempty"`
}
type searchResponse struct {
Items []searchItem `json:"items"`
HasNextPage bool `json:"hasNextPage"`
NextPage int `json:"nextPage,omitempty"`
}
func (h *AnimeHandler) HandleSearchAPI(c *gin.Context) {
user := server.CurrentUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
query := strings.TrimSpace(c.Query("q"))
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil || page < 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid page"})
return
}
if query == "" || len(query) < 2 {
c.JSON(http.StatusOK, searchResponse{})
return
}
items, hasNextPage := h.searchAnimeResults(c, user.ID, query, page)
c.JSON(http.StatusOK, searchResponse{
Items: items,
HasNextPage: hasNextPage,
NextPage: page + 1,
})
}
func (h *AnimeHandler) searchAnimeResults(c *gin.Context, userID string, query string, page int) ([]searchItem, bool) {
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, page, searchAnimeLimit)
if err != nil {
return nil, false
}
animes := wrapAnimes(res.Animes)
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
items := make([]searchItem, 0, len(animes))
for _, anime := range animes {
items = append(items, searchItem{
ID: fmt.Sprintf("anime:%d", anime.MalID),
Type: "anime",
Label: anime.DisplayTitle(),
Subtitle: strings.TrimSpace("Anime " + anime.Type),
Href: fmt.Sprintf("/anime/%d", anime.MalID),
Image: anime.Images.Webp.LargeImageURL,
InWatchlist: watchlistMap[int64(anime.MalID)],
})
}
return items, res.HasNextPage
}

View File

@@ -1,27 +1,29 @@
// Package anime provides anime catalog, discovery, search, and details services.
// Package anime provides anime catalog, search, and details services.
package anime
import (
"context"
"errors"
"fmt"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"mal/internal/observability"
"math/rand"
"sort"
"strings"
"sync"
"time"
"golang.org/x/sync/errgroup"
)
type animeService struct {
jikan *jikan.Client
repo domain.AnimeRepository
jikan *jikan.Client
repo domain.AnimeRepository
topPicksCache *topPicksCache
topPicksCacheTTL time.Duration
computeTopPicks recommendationComputeFunc
}
const continueWatchingCarouselLimit int64 = 24
func wrapAnimes(in []jikan.Anime) []domain.Anime {
out := make([]domain.Anime, 0, len(in))
for _, a := range in {
@@ -31,7 +33,14 @@ func wrapAnimes(in []jikan.Anime) []domain.Anime {
}
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) {
@@ -50,19 +59,25 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
case "Popular":
res, err = s.jikan.GetTopAnime(gCtx, 1)
}
return err
if err != nil {
return fmt.Errorf("get catalog section %q: %w", section, err)
}
return nil
})
if userID != "" && section == "Continue" {
g.Go(func() error {
var err error
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
return err
cw, err = s.repo.GetContinueWatchingCarouselEntries(gCtx, userID, continueWatchingCarouselLimit)
if err != nil {
return fmt.Errorf("get continue watching entries for %q: %w", userID, err)
}
return nil
})
}
if err := g.Wait(); err != nil {
return domain.CatalogSectionData{}, err
return domain.CatalogSectionData{}, fmt.Errorf("wait for catalog section %q: %w", section, err)
}
animes := wrapAnimes(res.Animes)
@@ -76,359 +91,10 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
}, nil
}
func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, section string) (domain.DiscoverSectionData, error) {
var res jikan.TopAnimeResult
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
switch section {
case "Trending":
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
case "Upcoming":
res, err = s.jikan.GetSeasonsUpcoming(gCtx, 1)
case "Top":
res, err = s.jikan.GetTopAnime(gCtx, 1)
}
return err
})
if err := g.Wait(); err != nil {
return domain.DiscoverSectionData{}, err
}
animes := wrapAnimes(res.Animes)
if len(animes) > 8 {
animes = animes[:8]
}
return domain.DiscoverSectionData{
Animes: animes,
}, nil
}
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
return s.getTopPicksForYou(ctx, userID, forYouResultLimit)
}
func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
return s.getTopPicksForYou(ctx, userID, forYouFullResultLimit)
}
func (s *animeService) getTopPicksForYou(
ctx context.Context,
userID string,
resultLimit int,
) (domain.CatalogSectionData, error) {
if strings.TrimSpace(userID) == "" {
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
}
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
if err != nil {
return domain.CatalogSectionData{}, err
}
now := time.Now()
seedPool := buildRecommendationSeeds(now, watchlist)
if len(seedPool) == 0 {
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
}
type rankedCandidate struct {
id int
collaborativeScore float64
profileSearchScore float64
anime jikan.Anime
hasAnime bool
}
watchlistAnimeIDs := make(map[int]struct{}, len(watchlist))
for _, entry := range watchlist {
if entry.AnimeID <= 0 {
continue
}
watchlistAnimeIDs[int(entry.AnimeID)] = struct{}{}
}
candidatesByID := map[int]rankedCandidate{}
var candidatesByIDMu sync.Mutex
upsertCandidate := func(candidate rankedCandidate) {
if candidate.id <= 0 {
return
}
if _, exists := watchlistAnimeIDs[candidate.id]; exists {
return
}
candidatesByIDMu.Lock()
defer candidatesByIDMu.Unlock()
current, ok := candidatesByID[candidate.id]
if !ok {
candidatesByID[candidate.id] = candidate
return
}
current.collaborativeScore += candidate.collaborativeScore
current.profileSearchScore += candidate.profileSearchScore
if candidate.hasAnime {
current.anime = candidate.anime
current.hasAnime = true
}
candidatesByID[candidate.id] = current
}
seedAnimes := make([]jikan.Anime, len(seedPool))
var seedFetchGroup errgroup.Group
seedFetchGroup.SetLimit(4)
for i, seed := range seedPool {
seedFetchGroup.Go(func() error {
anime, fetchErr := s.jikan.GetAnimeByID(ctx, seed.animeID)
if fetchErr != nil {
return fetchErr
}
seedAnimes[i] = anime
return nil
})
}
if err := seedFetchGroup.Wait(); err != nil {
return domain.CatalogSectionData{}, err
}
profile := buildTasteProfile(now, seedPool, seedAnimes)
var recommendationGroup errgroup.Group
recommendationGroup.SetLimit(4)
for _, seed := range seedPool {
recommendationGroup.Go(func() error {
recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
if recErr != nil {
return recErr
}
for i, rec := range recs {
if i >= forYouMaxRecommendations {
break
}
id := rec.Entry.MalID
if id <= 0 {
continue
}
if id == seed.animeID {
continue
}
upsertCandidate(rankedCandidate{
id: id,
collaborativeScore: float64(rec.Votes) * seed.weight,
})
}
return nil
})
}
if err := recommendationGroup.Wait(); err != nil {
return domain.CatalogSectionData{}, err
}
profileQueries := buildProfileSearchQueries(profile)
var profileSearchGroup errgroup.Group
profileSearchGroup.SetLimit(3)
for _, query := range profileQueries {
profileSearchGroup.Go(func() error {
res, searchErr := s.jikan.SearchAdvanced(
ctx,
"",
"",
"",
"score",
"desc",
query.genreIDs,
query.studioID,
true,
1,
forYouProfileSearchLimit,
)
if searchErr != nil {
observability.Warn(
"top_pick_profile_search_failed",
"anime",
"",
map[string]any{
"genres": query.genreIDs,
"studio_id": query.studioID,
},
searchErr,
)
return nil
}
for i, anime := range res.Animes {
if anime.MalID <= 0 {
continue
}
upsertCandidate(rankedCandidate{
id: anime.MalID,
profileSearchScore: query.weight * profileSearchRankWeight(i),
anime: anime,
hasAnime: true,
})
}
return nil
})
}
if err := profileSearchGroup.Wait(); err != nil {
return domain.CatalogSectionData{}, err
}
if len(candidatesByID) == 0 {
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
}
rankedIDs := make([]rankedCandidate, 0, len(candidatesByID))
for _, item := range candidatesByID {
rankedIDs = append(rankedIDs, item)
}
sort.Slice(rankedIDs, func(i, j int) bool {
left := rankedCandidateRetrievalScore(rankedIDs[i].collaborativeScore, rankedIDs[i].profileSearchScore)
right := rankedCandidateRetrievalScore(rankedIDs[j].collaborativeScore, rankedIDs[j].profileSearchScore)
if left == right {
return rankedIDs[i].id < rankedIDs[j].id
}
return left > right
})
limit := min(len(rankedIDs), forYouCandidateFetchLimit)
candidates := make([]recommendationCandidate, 0, limit)
var candidatesMu sync.Mutex
var detailGroup errgroup.Group
detailGroup.SetLimit(6)
for i := 0; i < limit; i++ {
item := rankedIDs[i]
detailGroup.Go(func() error {
anime := item.anime
if !item.hasAnime || !hasTasteMetadata(anime) {
fetchedAnime, fetchErr := s.jikan.GetAnimeByID(ctx, item.id)
if fetchErr != nil {
observability.Warn(
"recommendation_anime_fetch_failed",
"anime",
"",
map[string]any{"anime_id": item.id},
fetchErr,
)
return nil
}
anime = fetchedAnime
}
candidate := scoreRecommendationCandidate(
now,
profile,
anime,
item.collaborativeScore,
item.profileSearchScore,
)
candidatesMu.Lock()
candidates = append(candidates, candidate)
candidatesMu.Unlock()
return nil
})
}
if err := detailGroup.Wait(); err != nil {
return domain.CatalogSectionData{}, err
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].score == candidates[j].score {
return candidates[i].anime.MalID < candidates[j].anime.MalID
}
return candidates[i].score > candidates[j].score
})
return domain.CatalogSectionData{
Animes: rerankRecommendationCandidates(candidates, resultLimit),
}, nil
}
func (s *animeService) GetAiringSchedule(ctx context.Context, userID string) ([]domain.Anime, error) {
if strings.TrimSpace(userID) == "" {
return []domain.Anime{}, nil
}
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
if err != nil {
return nil, err
}
ids := make([]int, 0, 50)
for _, entry := range watchlist {
status := strings.TrimSpace(entry.Status)
if status != "watching" && status != "plan_to_watch" {
continue
}
if !entry.Airing.Valid || !entry.Airing.Bool {
continue
}
if entry.AnimeID <= 0 {
continue
}
ids = append(ids, int(entry.AnimeID))
if len(ids) >= 50 {
break
}
}
if len(ids) == 0 {
return []domain.Anime{}, nil
}
animes := make([]domain.Anime, 0, len(ids))
var g errgroup.Group
g.SetLimit(6)
var mu sync.Mutex
for _, id := range ids {
g.Go(func() error {
anime, fetchErr := s.jikan.GetAnimeByID(ctx, id)
if fetchErr != nil {
return fetchErr
}
mu.Lock()
animes = append(animes, domain.Anime{Anime: anime})
mu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
observability.Warn(
"schedule_partial_fetch_failed",
"anime",
"",
map[string]any{"user_id": userID, "count": len(ids)},
err,
)
return animes, nil
}
return animes, nil
}
func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) {
anime, err := s.jikan.GetAnimeByID(ctx, id)
if err != nil {
return domain.Anime{}, err
return domain.Anime{}, fmt.Errorf("get anime by id: %w", err)
}
return domain.Anime{Anime: anime}, nil
}
@@ -440,7 +106,7 @@ func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status,
func (s *animeService) GetProducerNameByID(ctx context.Context, id int) (string, error) {
res, err := s.jikan.GetProducerByID(ctx, id)
if err != nil {
return "", err
return "", fmt.Errorf("get producer name: %w", err)
}
for _, t := range res.Data.Titles {
if t.Title != "" {
@@ -457,7 +123,7 @@ func (s *animeService) GetProducers(ctx context.Context, query string, page int,
func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
genres, err := s.jikan.GetAnimeGenres(ctx)
if err != nil {
return nil, err
return nil, fmt.Errorf("get genres: %w", err)
}
out := make([]domain.Genre, 0, len(genres))
for _, g := range genres {
@@ -472,7 +138,7 @@ func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.CharacterEntry, error) {
items, err := s.jikan.GetAnimeCharacters(ctx, id)
if err != nil {
return nil, err
return nil, fmt.Errorf("get characters: %w", err)
}
out := make([]domain.CharacterEntry, 0, len(items))
@@ -508,7 +174,7 @@ func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.Char
func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain.RecommendationEntry, error) {
items, err := s.jikan.GetAnimeRecommendations(ctx, id)
if err != nil {
return nil, err
return nil, fmt.Errorf("get recommendations: %w", err)
}
out := make([]domain.RecommendationEntry, 0, len(items))
@@ -525,8 +191,8 @@ func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain
return out, nil
}
func (s *animeService) GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error) {
return s.jikan.GetFullRelations(ctx, id)
func (s *animeService) GetRelations(ctx context.Context, id int, mode jikan.WatchOrderMode) ([]jikan.RelationEntry, error) {
return s.jikan.GetFullRelations(ctx, id, mode)
}
func (s *animeService) WarmDetailSections(id int) {
@@ -541,7 +207,7 @@ func (s *animeService) GetEpisodes(ctx context.Context, id int, page int) (jikan
func (s *animeService) GetStaff(ctx context.Context, id int) ([]domain.StaffEntry, error) {
items, err := s.jikan.GetAnimeStaff(ctx, id)
if err != nil {
return nil, err
return nil, fmt.Errorf("get staff: %w", err)
}
out := make([]domain.StaffEntry, 0, len(items))
@@ -560,7 +226,7 @@ func (s *animeService) GetStaff(ctx context.Context, id int) ([]domain.StaffEntr
func (s *animeService) GetStatistics(ctx context.Context, id int) (domain.Statistics, error) {
stats, err := s.jikan.GetAnimeStatistics(ctx, id)
if err != nil {
return domain.Statistics{}, err
return domain.Statistics{}, fmt.Errorf("get statistics: %w", err)
}
out := domain.Statistics{
@@ -583,7 +249,7 @@ func (s *animeService) GetStatistics(ctx context.Context, id int) (domain.Statis
func (s *animeService) GetThemes(ctx context.Context, id int) (domain.ThemesData, error) {
themes, err := s.jikan.GetAnimeThemes(ctx, id)
if err != nil {
return domain.ThemesData{}, err
return domain.ThemesData{}, fmt.Errorf("get themes: %w", err)
}
return domain.ThemesData{
Openings: append([]string(nil), themes.Openings...),
@@ -594,7 +260,7 @@ func (s *animeService) GetThemes(ctx context.Context, id int) (domain.ThemesData
func (s *animeService) GetReviews(ctx context.Context, id int, page int) ([]domain.ReviewEntry, bool, error) {
data, pag, err := s.jikan.GetAnimeReviews(ctx, id, page)
if err != nil {
return nil, false, err
return nil, false, fmt.Errorf("get reviews: %w", err)
}
out := make([]domain.ReviewEntry, 0, len(data))
for _, it := range data {
@@ -652,13 +318,13 @@ func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error)
return domain.Anime{Anime: res.Animes[r.Intn(len(res.Animes))]}, nil
}
return domain.Anime{}, err
return domain.Anime{}, fmt.Errorf("get random anime: %w", err)
}
func (s *animeService) GetAllEpisodes(ctx context.Context, id int) ([]domain.EpisodeData, error) {
episodes, err := s.jikan.GetAllEpisodes(ctx, id)
if err != nil {
return nil, err
return nil, fmt.Errorf("get all episodes: %w", err)
}
result := make([]domain.EpisodeData, len(episodes))
for i, ep := range episodes {

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

View File

@@ -1,5 +1,4 @@
// Package app bootstraps and wires the application dependencies.
package app
package internal
import (
"mal/integrations/jikan"
@@ -10,6 +9,7 @@ import (
"mal/internal/config"
"mal/internal/database"
"mal/internal/episodes"
"mal/internal/observability"
"mal/internal/playback"
"mal/internal/server"
"mal/internal/watchlist"
@@ -22,6 +22,7 @@ import (
func NewApp() *fx.App {
return fx.New(
fx.WithLogger(observability.NewFxLogger),
config.Module,
database.Module,
audit.Module,
@@ -34,6 +35,7 @@ func NewApp() *fx.App {
playback.Module,
templates.Module,
server.Module,
fx.Invoke(RunMigrationsAndFixes),
fx.Provide(func(r *templates.Renderer) render.HTMLRender {
return r
}),

View File

@@ -2,6 +2,7 @@ package audit_test
import (
"context"
"database/sql"
"encoding/json"
"os"
"testing"
@@ -13,29 +14,9 @@ import (
)
func TestRecordInsertsAuditLog(t *testing.T) {
tmp, err := os.CreateTemp("", "mal-audit-*.db")
if err != nil {
t.Fatalf("CreateTemp: %v", err)
}
_ = tmp.Close()
t.Cleanup(func() { _ = os.Remove(tmp.Name()) })
sqlDB, err := db.Open(tmp.Name())
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err := database.RunMigrations(sqlDB); err != nil {
t.Fatalf("RunMigrations: %v", err)
}
queries := db.New(sqlDB)
svc := audit.NewAuditService(queries)
if _, err := sqlDB.Exec("INSERT INTO user (id, username, password_hash) VALUES (?, ?, ?)", "user-1", "test", "hash"); err != nil {
t.Fatalf("insert user: %v", err)
}
sqlDB := openTestDB(t)
svc := audit.NewAuditService(db.New(sqlDB))
insertTestUser(t, sqlDB, "user-1")
ctx := audit.WithRequestInfo(context.Background(), "127.0.0.1", "unit-test")
metadata, err := json.Marshal(struct {
@@ -55,28 +36,95 @@ func TestRecordInsertsAuditLog(t *testing.T) {
t.Fatalf("Record: %v", err)
}
rows, err := sqlDB.Query("SELECT action, resource_type, resource_id, ip, user_agent, metadata_json FROM audit_log WHERE user_id = ?", "user-1")
auditRow := queryAuditRow(t, sqlDB, "user-1")
assertAuditRow(t, auditRow)
}
type auditRow struct {
action string
resourceType string
resourceID string
ip string
userAgent string
metadataJSON string
}
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
tmp, err := os.CreateTemp("", "mal-audit-*.db")
if err != nil {
t.Fatalf("CreateTemp: %v", err)
}
if err := tmp.Close(); err != nil {
t.Fatalf("close temp db: %v", err)
}
t.Cleanup(func() {
if err := os.Remove(tmp.Name()); err != nil {
t.Errorf("remove temp db: %v", err)
}
})
sqlDB, err := db.Open(tmp.Name())
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
})
if err := database.RunMigrations(sqlDB); err != nil {
t.Fatalf("RunMigrations: %v", err)
}
return sqlDB
}
func insertTestUser(t *testing.T, sqlDB *sql.DB, userID string) {
t.Helper()
if _, err := sqlDB.ExecContext(context.Background(), "INSERT INTO user (id, username, password_hash) VALUES (?, ?, ?)", userID, "test", "hash"); err != nil {
t.Fatalf("insert user: %v", err)
}
}
func queryAuditRow(t *testing.T, sqlDB *sql.DB, userID string) auditRow {
t.Helper()
rows, err := sqlDB.QueryContext(context.Background(), "SELECT action, resource_type, resource_id, ip, user_agent, metadata_json FROM audit_log WHERE user_id = ?", userID)
if err != nil {
t.Fatalf("Query: %v", err)
}
defer func() { _ = rows.Close() }()
defer func() {
if err := rows.Close(); err != nil {
t.Errorf("close audit rows: %v", err)
}
}()
if !rows.Next() {
t.Fatalf("expected audit row")
}
var action, resourceType, resourceID, ip, userAgent, metadataJSON string
if err := rows.Scan(&action, &resourceType, &resourceID, &ip, &userAgent, &metadataJSON); err != nil {
var row auditRow
if err := rows.Scan(&row.action, &row.resourceType, &row.resourceID, &row.ip, &row.userAgent, &row.metadataJSON); err != nil {
t.Fatalf("Scan: %v", err)
}
if action != "test_action" || resourceType != "thing" || resourceID != "123" {
t.Fatalf("unexpected row action=%q resourceType=%q resourceID=%q", action, resourceType, resourceID)
return row
}
func assertAuditRow(t *testing.T, row auditRow) {
t.Helper()
if row.action != "test_action" || row.resourceType != "thing" || row.resourceID != "123" {
t.Fatalf("unexpected row action=%q resourceType=%q resourceID=%q", row.action, row.resourceType, row.resourceID)
}
if ip != "127.0.0.1" || userAgent != "unit-test" {
t.Fatalf("unexpected request info ip=%q userAgent=%q", ip, userAgent)
if row.ip != "127.0.0.1" || row.userAgent != "unit-test" {
t.Fatalf("unexpected request info ip=%q userAgent=%q", row.ip, row.userAgent)
}
if metadataJSON == "" || metadataJSON == "null" {
t.Fatalf("expected metadata_json, got %q", metadataJSON)
if row.metadataJSON == "" || row.metadataJSON == "null" {
t.Fatalf("expected metadata_json, got %q", row.metadataJSON)
}
}

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
}

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