Compare commits

...

350 Commits

Author SHA1 Message Date
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
221 changed files with 10438 additions and 5710 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

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

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

6
.mise.toml Normal file
View File

@@ -0,0 +1,6 @@
[tools]
go = "1.25.7"
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

@@ -29,11 +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
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
@@ -49,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"]

178
README.md
View File

@@ -4,52 +4,186 @@
<img src="/static/assets/logo.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 small self-hosted anime tracker and playback app. It keeps the catalog, watchlist, progress tracking, and player in one place, backed by a single SQLite database and a single Go server.
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.
Most of the UI is rendered on the server. HTMX handles lightweight updates like search, pagination, and watchlist changes, while TypeScript is kept for the parts that need real browser state: the video player, command palette, theme handling, and skip segment editor. The app also includes local users, API tokens, subtitle support, playlist rewriting, provider integrations, migrations, and startup data fixes.
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.
## Running
> [!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.
Requires Go `1.25+`, Bun, [`just`](https://github.com/casey/just), and a C compiler for SQLite.
### Contents
- [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)
### What This Project Is
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.
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.
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 build
go run ./cmd/user <username> <password>
just dev
```
The app starts on `http://localhost:3000` by default. Configuration comes from environment variables, and a local `.env` file is loaded automatically. The most useful options are `PORT`, `DATABASE_FILE`, `PLAYBACK_PROXY_SECRET`, `EPISODE_AVAILABILITY_MODE`, and `ANIMESCHEDULE_API_TOKEN`.
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.
## Development
The codebase is split between Go feature packages, external integrations, server-rendered templates, and a small frontend asset pipeline. `cmd/server` starts the web app, `cmd/user` contains local admin tools, `internal` holds the application modules, `integrations` holds provider clients, and `templates`, `static`, and `dist` contain the UI.
The common development commands are in the `justfile`.
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
just fmt
just test
just lint-go
just lint-ts
just typecheck
just build
echo "PLAYBACK_PROXY_SECRET=$(openssl rand -base64 32)" >> .env
```
Run the full local check with:
Create a local user with:
```bash
just check
go run ./cmd/user <username> <password>
```
## License
#### Commands
MIT. See [`LICENSE`](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>
### 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).

67
SECURITY.md Normal file
View File

@@ -0,0 +1,67 @@
# 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.
## 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.

View File

@@ -3,15 +3,15 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "myanimelist-ui",
"name": "mal",
"dependencies": {
"hls.js": "^1.6.16",
"htmx.org": "1.9.12",
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@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",
@@ -148,6 +148,8 @@
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
"@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="],
"@tailwindcss/cli": ["@tailwindcss/cli@4.3.0", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "enhanced-resolve": "^5.21.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.3.0" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-X9kdlqyMopO9fewbgHsEeuy31YzMHbdZ9VsKt004tB+mxSg1CNbyhZYCzvhciN0AM4R4b5lvIprPjtNq7iQxpQ=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.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=="],
@@ -184,6 +186,8 @@
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"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=="],
@@ -258,6 +262,10 @@
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
"playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],

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,4 +1,4 @@
// Package main provides small CLI utilities for local admin tasks.
// Package main provides local user administration commands.
package main
import (
@@ -7,248 +7,189 @@ import (
"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 {
ctx := context.Background()
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(ctx, dbConn)
return 0
case commandRunFixes:
runFixes(ctx, dbConn)
return 0
case commandCreateOrUpdateUser:
if err := createOrUpdateUser(ctx, 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(ctx context.Context, dbConn *sql.DB, username string, password string) error {
existingID, err := lookupUserID(ctx, 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(ctx, 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(ctx, 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(ctx context.Context, dbConn *sql.DB, username string) (string, error) {
var id string
err := dbConn.QueryRowContext(ctx, "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(ctx context.Context, 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.ExecContext(ctx, "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(ctx context.Context, 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.ExecContext(
_, err = sqlDB.ExecContext(
ctx,
"INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)",
id,
username,
string(hash),
avatarURL,
`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(ctx context.Context, dbConn *sql.DB) {
rows, err := dbConn.QueryContext(ctx, "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.ExecContext(ctx, "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(ctx context.Context, 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.QueryContext(ctx, "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

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

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

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

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,115 +37,39 @@ 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 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
return jtransport.IsRetryableError(err)
}
func isRetryableStatus(statusCode int) bool {
if statusCode == http.StatusTooManyRequests {
return true
}
return statusCode >= 500 && statusCode <= 504
func (c *Client) jikanTraceEnabled() bool {
return c.traceEnabled
}
// 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 fmt.Errorf("request canceled while retrying jikan request: %w", ctx.Err())
}
}
func jikanTraceEnabled() bool {
return traceEnabled
}
func shouldSkipJikanCacheLog(source string, duration time.Duration, err error) bool {
if jikanTraceEnabled() || err != nil {
func (c *Client) shouldSkipJikanCacheLog(source string, duration time.Duration, err error) bool {
if c.jikanTraceEnabled() || err != nil {
return false
}
@@ -178,9 +97,13 @@ func jikanCacheLogLevel(source string, err error) observability.LogLevel {
return observability.LogLevelInfo
}
func logJikanCache(cacheKey string, source string, startedAt time.Time, err error) {
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 shouldSkipJikanCacheLog(source, duration, err) {
if c.shouldSkipJikanCacheLog(source, duration, err) {
return
}
@@ -198,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 {
@@ -257,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
}
@@ -390,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)
@@ -401,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)
}
@@ -412,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) {
@@ -429,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,
)
}
})
}
@@ -455,183 +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
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 := 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 fmt.Errorf("request canceled while retrying jikan request: %w", ctx.Err())
default:
}
return c.waitRateLimit(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 errors.Is(err, context.Canceled) {
return false, fmt.Errorf("request canceled while retrying jikan request: %w", 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, out, attempt, maxRetries)
}
err := json.NewDecoder(resp.Body).Decode(out)
_ = resp.Body.Close()
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, out any, 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 {
_ = resp.Body.Close()
if retryErr := waitForRetry(ctx, max(retryAfter, retryDelay(attempt))); retryErr != nil {
return statusCode, false, retryErr
}
return statusCode, true, nil
}
// Best-effort decode (often useful for debugging), but still treat non-200 as error.
_ = json.NewDecoder(resp.Body).Decode(out)
_ = resp.Body.Close()
return statusCode, false, apiErr
}
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"
@@ -24,14 +23,18 @@ func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
sqlDB := newTestCacheDB(t)
defer sqlDB.Close()
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"}}}
insertCachedResponse(t, sqlDB, "top:1", stale, time.Now().Add(-time.Hour))
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{
@@ -52,6 +55,62 @@ func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
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 newTestCacheDB(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
@@ -71,7 +130,9 @@ func newTestCacheDB(t *testing.T) *sql.DB {
);
`)
if err != nil {
sqlDB.Close()
if closeErr := sqlDB.Close(); closeErr != nil {
t.Fatalf("create cache table: %v; close sqlite: %v", err, closeErr)
}
t.Fatalf("create cache table: %v", err)
}
@@ -99,6 +160,27 @@ func insertCachedResponse(t *testing.T, sqlDB *sql.DB, key string, value TopAnim
}
}
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()
@@ -113,6 +195,8 @@ func waitForFreshCache(t *testing.T, sqlDB *sql.DB, client *Client, key string)
var rawData string
var rawExpires string
_ = sqlDB.QueryRowContext(context.Background(), `SELECT data, expires_at FROM jikan_cache WHERE key = ?`, key).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

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

View File

@@ -24,7 +24,7 @@ func setQueryValue(values url.Values, key, value string) {
values.Set(key, value)
}
func setPositiveIntQueryValue(values url.Values, key string, value int) {
func setPositiveInt(values url.Values, key string, value int) {
if value <= 0 {
values.Del(key)
return

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

@@ -54,37 +54,13 @@ func watchOrderTypeLabel(value string) string {
}
}
func isTVWatchOrderType(value string) bool {
return strings.EqualFold(strings.TrimSpace(value), "tv")
}
// isAllowedWatchOrderType returns true for the default uncluttered watch order types.
func isAllowedWatchOrderType(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
return normalized == "tv" || normalized == "movie"
}
func hasTVWatchOrderEntry(entries []watchorder.WatchOrderEntry) bool {
for _, entry := range entries {
if isTVWatchOrderType(entry.Type) {
return true
}
}
return false
}
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 == http.StatusNotFound {
@@ -139,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) {
@@ -201,10 +185,18 @@ func (c *Client) handleWatchOrderError(ctx context.Context, id int, err error) (
return c.currentOnlyRelation(ctx, id)
}
func buildAllowedWatchOrderEntries(result watchorder.WatchOrderResult, mode WatchOrderMode) ([]watchorder.WatchOrderEntry, map[int]bool) {
// 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)
shouldIncludeAllTypes := mode == WatchOrderModeComplete || !hasTVWatchOrderEntry(result.WatchOrder)
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 {
@@ -213,7 +205,8 @@ func buildAllowedWatchOrderEntries(result watchorder.WatchOrderResult, mode Watc
if seen[entry.ID] {
continue
}
if !shouldIncludeAllTypes && !isAllowedWatchOrderType(entry.Type) {
typ := strings.ToLower(strings.TrimSpace(entry.Type))
if !allTypes && typ != "tv" && typ != "movie" {
continue
}
@@ -224,7 +217,7 @@ func buildAllowedWatchOrderEntries(result watchorder.WatchOrderResult, mode Watc
return allowedEntries, seen
}
func (c *Client) fetchRelationResults(ctx context.Context, entries []watchorder.WatchOrderEntry) []fetchResult {
func (c *Client) fetchEntries(ctx context.Context, entries []watchorder.WatchOrderEntry) chan fetchResult {
g, gCtx := errgroup.WithContext(ctx)
g.SetLimit(3)
@@ -237,6 +230,16 @@ func (c *Client) fetchRelationResults(ctx context.Context, entries []watchorder.
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
}
@@ -251,15 +254,37 @@ func (c *Client) fetchRelationResults(ctx context.Context, entries []watchorder.
}
go func() {
_ = g.Wait()
if err := g.Wait(); err != nil {
observability.Warn("relations_fetch_group_failed", "jikan", "", nil, err)
}
close(results)
}()
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)
}
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
})
@@ -267,7 +292,7 @@ func (c *Client) fetchRelationResults(ctx context.Context, entries []watchorder.
return fetched
}
func buildRelationsFromResults(results []fetchResult, id int) []RelationEntry {
func buildRelations(results []fetchResult, id int) []RelationEntry {
relations := make([]RelationEntry, 0, len(results)+1)
for _, res := range results {
relations = append(relations, RelationEntry{
@@ -281,7 +306,7 @@ func buildRelationsFromResults(results []fetchResult, id int) []RelationEntry {
return relations
}
func (c *Client) ensureCurrentRelation(ctx context.Context, id int, seen map[int]bool, relations []RelationEntry) ([]RelationEntry, error) {
func (c *Client) ensureCurrent(ctx context.Context, id int, seen map[int]bool, relations []RelationEntry) ([]RelationEntry, error) {
if seen[id] {
return relations, nil
}
@@ -312,10 +337,10 @@ func (c *Client) GetFullRelations(ctx context.Context, id int, mode WatchOrderMo
return c.handleWatchOrderError(ctx, id, err)
}
allowedEntries, seen := buildAllowedWatchOrderEntries(result, mode)
fetched := c.fetchRelationResults(ctx, allowedEntries)
relations := buildRelationsFromResults(fetched, id)
relations, err = c.ensureCurrentRelation(ctx, id, seen, relations)
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
}
@@ -329,6 +354,14 @@ func (c *Client) GetFullRelations(ctx context.Context, id int, mode WatchOrderMo
func (c *Client) WarmFullRelations(id int) {
c.runAsyncRefresh(func(ctx context.Context) {
_, _ = c.GetFullRelations(ctx, id, WatchOrderModeMain)
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

@@ -5,40 +5,6 @@ import (
"testing"
)
func runBoolCases(t *testing.T, tests []struct {
name string
input string
want bool
}, fn func(string) bool) {
t.Helper()
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
got := fn(testCase.input)
if got != testCase.want {
t.Fatalf("expected %v, got %v", 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},
}
runBoolCases(t, tests, isAllowedWatchOrderType)
}
func TestNormalizeWatchOrderMode(t *testing.T) {
tests := []struct {
name string
@@ -62,51 +28,17 @@ func TestNormalizeWatchOrderMode(t *testing.T) {
}
}
func TestHasTVWatchOrderEntry(t *testing.T) {
tests := []struct {
name string
entries []watchorder.WatchOrderEntry
want bool
}{
{
name: "contains tv",
entries: []watchorder.WatchOrderEntry{
{ID: 1, Type: "Movie"},
{ID: 2, Type: " TV "},
},
want: true,
},
{
name: "ona only",
entries: []watchorder.WatchOrderEntry{
{ID: 1, Type: "ONA"},
{ID: 2, Type: "Special"},
},
want: false,
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
got := hasTVWatchOrderEntry(testCase.entries)
if got != testCase.want {
t.Fatalf("expected %v, got %v", testCase.want, got)
}
})
}
}
func TestBuildAllowedWatchOrderEntriesKeepsDefaultTypesWhenTVExists(t *testing.T) {
result := watchorder.WatchOrderResult{
WatchOrder: []watchorder.WatchOrderEntry{
{ID: 1, Type: "TV"},
{ID: 2, Type: "Special"},
{ID: 3, Type: "Movie"},
{ID: 3, Type: " Movie "},
{ID: 4, Type: "ONA"},
},
}
entries, seen := buildAllowedWatchOrderEntries(result, WatchOrderModeMain)
entries, seen := allowedWatchOrder(result, WatchOrderModeMain)
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
@@ -130,7 +62,7 @@ func TestBuildAllowedWatchOrderEntriesIncludesAllTypesWhenNoTVExists(t *testing.
},
}
entries, seen := buildAllowedWatchOrderEntries(result, WatchOrderModeMain)
entries, seen := allowedWatchOrder(result, WatchOrderModeMain)
if len(entries) != 3 {
t.Fatalf("expected 3 entries, got %d", len(entries))
}
@@ -154,7 +86,7 @@ func TestBuildAllowedWatchOrderEntriesIncludesAllTypesInCompleteMode(t *testing.
},
}
entries, seen := buildAllowedWatchOrderEntries(result, WatchOrderModeComplete)
entries, seen := allowedWatchOrder(result, WatchOrderModeComplete)
if len(entries) != 4 {
t.Fatalf("expected 4 entries, got %d", len(entries))
}
@@ -193,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,7 +8,7 @@ import (
"strings"
)
func normalizeSearchPagination(page, limit int) (int, int) {
func normalizePage(page, limit int) (int, int) {
if page < 1 {
page = 1
}
@@ -32,31 +32,31 @@ func joinGenreIDs(genres []int) string {
return strings.Join(ids, ",")
}
func buildAdvancedSearchURL(baseURL, query, animeType, status, orderBy, sort, genres string, studioID int, sfw bool, page, limit int) string {
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)
setPositiveIntQueryValue(params, "producers", studioID)
setPositiveInt(params, "producers", studioID)
setQueryValue(params, "order_by", orderBy)
setQueryValue(params, "sort", sort)
setQueryValue(params, "genres", genres)
setPositiveIntQueryValue(params, "limit", limit)
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 = normalizeSearchPagination(page, limit)
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 := buildAdvancedSearchURL(c.baseURL, query, animeType, status, orderBy, sort, genresParam, studioID, sfw, page, 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,39 +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
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
}

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"`
@@ -162,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 == "" {
@@ -239,7 +228,7 @@ func (a Anime) DurationSeconds() float64 {
var currentValue int
hasValue := false
for _, token := range strings.Fields(strings.ToLower(a.Duration)) {
for token := range strings.FieldsSeq(strings.ToLower(a.Duration)) {
value, err := strconv.Atoi(token)
if err == nil {
currentValue = value

View File

@@ -2,7 +2,7 @@ package allanime
import (
"context"
"fmt"
"errors"
"mal/internal/domain"
"strconv"
"strings"
@@ -28,8 +28,8 @@ func (c *AllAnimeProvider) GetEpisodeAvailabilityByProviderID(ctx context.Contex
return domain.EpisodeAvailability{}, err
}
sub := parseEpisodeNumbers(append(available.Sub, available.Raw...))
dub := parseEpisodeNumbers(available.Dub)
sub := episodeNums(append(available.Sub, available.Raw...))
dub := episodeNums(available.Dub)
return domain.EpisodeAvailability{Sub: sub, Dub: dub}, nil
}
@@ -48,27 +48,28 @@ func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID stri
data, ok := result["data"].(map[string]any)
if !ok {
return AvailableEpisodes{}, fmt.Errorf("invalid response")
return AvailableEpisodes{}, errors.New("invalid response")
}
show, ok := data["show"].(map[string]any)
if !ok || show == nil {
return AvailableEpisodes{}, fmt.Errorf("show not found")
return AvailableEpisodes{}, errors.New("show not found")
}
detail, ok := show["availableEpisodesDetail"].(map[string]any)
if !ok {
return AvailableEpisodes{}, fmt.Errorf("invalid detail")
return AvailableEpisodes{}, errors.New("invalid detail")
}
return AvailableEpisodes{
Sub: stringSliceFromAny(detail["sub"]),
Dub: stringSliceFromAny(detail["dub"]),
Raw: stringSliceFromAny(detail["raw"]),
Sub: stringsFrom(detail["sub"]),
Dub: stringsFrom(detail["dub"]),
Raw: stringsFrom(detail["raw"]),
}, nil
}
func parseEpisodeNumbers(raw []string) []int {
// episode ids
func episodeNums(raw []string) []int {
seen := make(map[int]bool, len(raw))
out := make([]int, 0, len(raw))
for _, value := range raw {
@@ -82,7 +83,8 @@ func parseEpisodeNumbers(raw []string) []int {
return out
}
func stringSliceFromAny(value any) []string {
// graphql list
func stringsFrom(value any) []string {
items, ok := value.([]any)
if !ok {
return nil

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

@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"mal/internal/domain"
errlog "mal/pkg"
netutil "mal/pkg/net"
"net/http"
"strings"
@@ -45,7 +46,7 @@ func (c *AllAnimeProvider) Name() string {
}
func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string) (*domain.StreamResult, error) {
showID := c.resolveShowIDWithFallback(ctx, animeID, titleCandidates, mode)
showID := c.showID(ctx, animeID, titleCandidates, mode)
if showID == "" {
return nil, fmt.Errorf("allanime: show not found for malID %d", animeID)
}
@@ -123,7 +124,9 @@ func executeAndReadResponse(client *http.Client, req *http.Request, executeErrPr
if err != nil {
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 {

View File

@@ -4,7 +4,9 @@ import (
"bytes"
"context"
"crypto/aes"
"encoding/json"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"mal/internal/domain"
"testing"
)
@@ -32,6 +34,10 @@ type sourceReferencesTestCase struct {
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()
@@ -53,7 +59,7 @@ func runSourceReferenceTests(t *testing.T, tests []sourceReferencesTestCase) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := buildSourceReferences(tt.rawURLs)
got := sourceRefs(tt.rawURLs)
if len(got) != len(tt.wantRefs) {
t.Errorf("got %d refs, want %d", len(got), len(tt.wantRefs))
return
@@ -71,6 +77,29 @@ func runSourceReferenceTests(t *testing.T, tests []sourceReferencesTestCase) {
}
}
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()
@@ -222,7 +251,7 @@ func TestBuildStreamSource(t *testing.T) {
func TestResolveDirectSourceSkipsEmbeds(t *testing.T) {
t.Parallel()
if _, ok := resolveDirectSource(sourceReference{
if _, ok := directSource(sourceReference{
URL: "https://ok.ru/videoembed/123",
Name: "ok",
}); ok {
@@ -298,7 +327,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) {
@@ -419,18 +448,16 @@ 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)
}
})
@@ -465,21 +492,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

@@ -6,6 +6,7 @@ import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
)
@@ -21,7 +22,7 @@ func decryptTobeparsed(encoded string) ([]byte, error) {
}
if len(raw) < 29 {
return nil, fmt.Errorf("encrypted payload too short")
return nil, errors.New("encrypted payload too short")
}
version := raw[0]
@@ -54,7 +55,7 @@ func decryptTobeparsed(encoded string) ([]byte, error) {
}
}
return nil, fmt.Errorf("decryption failed")
return nil, errors.New("decryption failed")
}
func tryDecryptCTR(block cipher.Block, iv []byte, cipherText []byte) []byte {
@@ -119,7 +120,7 @@ func decodeSourceURL(encoded string) string {
}
func responseFromTobeparsed(data map[string]any) (map[string]any, error) {
toBeParsed := firstNonEmptyString(
toBeParsed := firstString(
nestedString(data, "tobeparsed"),
nestedString(data, "episode", "tobeparsed"),
)
@@ -137,7 +138,7 @@ func responseFromTobeparsed(data map[string]any) (map[string]any, error) {
return nil, err
}
sourceURLs := firstNonEmptySlice(
sourceURLs := firstSlice(
nestedSlice(parsed, "sourceUrls"),
nestedSlice(parsed, "episode", "sourceUrls"),
)
@@ -152,10 +153,6 @@ func responseFromTobeparsed(data map[string]any) (map[string]any, error) {
}, nil
}
func hasEpisodeSourceURLs(data map[string]any) bool {
return len(nestedSlice(data, "episode", "sourceUrls")) > 0
}
func parseGraphQLResponse(respBody []byte, decodeErrPrefix string) (map[string]any, error) {
var parsed map[string]any
if err := json.Unmarshal(respBody, &parsed); err != nil {
@@ -169,7 +166,8 @@ func parseGraphQLResponse(respBody []byte, decodeErrPrefix string) (map[string]a
return parsed, nil
}
func firstNonEmptyString(values ...string) string {
// first non-empty
func firstString(values ...string) string {
for _, value := range values {
if value != "" {
return value
@@ -179,7 +177,8 @@ func firstNonEmptyString(values ...string) string {
return ""
}
func firstNonEmptySlice(values ...[]any) []any {
// first non-empty
func firstSlice(values ...[]any) []any {
for _, value := range values {
if len(value) > 0 {
return value

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"html"
"io"
errlog "mal/pkg"
netutil "mal/pkg/net"
"net/http"
"regexp"
@@ -71,14 +72,16 @@ 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
}
func (e *providerExtractor) ExtractEmbedVideoLinks(ctx context.Context, rawURL string) ([]StreamSource, error) {
@@ -86,40 +89,43 @@ func (e *providerExtractor) ExtractEmbedVideoLinks(ctx context.Context, rawURL s
if err != nil {
return nil, fmt.Errorf("fetch embed response: %w", err)
}
defer func() { _ = resp.Body.Close() }()
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 parseExternalEmbedResponse(rawURL, string(body), e.referer), nil
return parseEmbed(rawURL, string(body), e.referer), nil
}
// parseProviderResponse extracts stream sources from provider JSON response.
func (e *providerExtractor) parseProviderResponse(ctx context.Context, response string) []StreamSource {
// 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 []StreamSource{}
}
data := collectProviderResponseData(root, e.referer)
sources := buildProviderLinkSources(data.links, data.referer)
sources = append(sources, e.buildProviderHLSSources(ctx, data.hls, data.referer)...)
data := collectData(root, e.referer)
sources := linkSources(data.links, data.referer)
sources = append(sources, e.hlsSources(ctx, data.hls, data.referer)...)
attachSubtitles(sources, data.subtitles)
return sources
}
func collectProviderResponseData(root any, fallbackReferer string) providerResponseData {
// 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:
collectProviderMapData(x, &data)
collectMapData(x, &data)
for _, child := range x {
walk(child)
}
@@ -138,7 +144,7 @@ func collectProviderResponseData(root any, fallbackReferer string) providerRespo
return data
}
func collectProviderMapData(node map[string]any, data *providerResponseData) {
func collectMapData(node map[string]any, data *providerResponseData) {
if ref, ok := node["Referer"].(string); ok {
if trimmedRef := strings.TrimSpace(ref); trimmedRef != "" {
data.referer = trimmedRef
@@ -158,11 +164,11 @@ func collectProviderMapData(node map[string]any, data *providerResponseData) {
}
if subs, ok := node["subtitles"].([]any); ok {
data.subtitles = append(data.subtitles, parseProviderSubtitles(subs)...)
data.subtitles = append(data.subtitles, parseSubtitles(subs)...)
}
}
func parseProviderSubtitles(items []any) []Subtitle {
func parseSubtitles(items []any) []Subtitle {
subtitles := make([]Subtitle, 0, len(items))
for _, item := range items {
node, ok := item.(map[string]any)
@@ -170,8 +176,14 @@ func parseProviderSubtitles(items []any) []Subtitle {
continue
}
lang, _ := node["lang"].(string)
src, _ := node["src"].(string)
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 == "" {
@@ -184,7 +196,7 @@ func parseProviderSubtitles(items []any) []Subtitle {
return subtitles
}
func buildProviderLinkSources(items []providerLinkItem, referer string) []StreamSource {
func linkSources(items []providerLinkItem, referer string) []StreamSource {
sources := make([]StreamSource, 0, len(items))
for _, item := range items {
link := strings.TrimSpace(item.link)
@@ -196,7 +208,7 @@ func buildProviderLinkSources(items []providerLinkItem, referer string) []Stream
URL: link,
Quality: strings.TrimSpace(item.resolutionStr),
Provider: "wixmp",
Type: detectProviderSourceType(link),
Type: sourceType(link),
Referer: referer,
})
}
@@ -204,19 +216,19 @@ func buildProviderLinkSources(items []providerLinkItem, referer string) []Stream
return sources
}
func detectProviderSourceType(link string) string {
sourceType := detectStreamType(link)
if sourceType != "unknown" {
return sourceType
func sourceType(link string) string {
typ := detectStreamType(link)
if typ != "unknown" {
return typ
}
return detectEmbedType(link)
}
func (e *providerExtractor) buildProviderHLSSources(ctx context.Context, items []providerHLSItem, referer string) []StreamSource {
func (e *providerExtractor) hlsSources(ctx context.Context, items []providerHLSItem, referer string) []StreamSource {
sources := make([]StreamSource, 0, len(items))
for _, item := range items {
playlistURL, ok := providerPlaylistURL(item)
playlistURL, ok := playlistURL(item)
if !ok {
continue
}
@@ -241,7 +253,7 @@ func (e *providerExtractor) buildProviderHLSSources(ctx context.Context, items [
return sources
}
func providerPlaylistURL(item providerHLSItem) (string, bool) {
func playlistURL(item providerHLSItem) (string, bool) {
playlistURL := strings.TrimSpace(item.url)
if playlistURL == "" || item.hardsubLang != "en-US" {
return "", false
@@ -266,7 +278,9 @@ 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 {
@@ -280,22 +294,27 @@ func parseM3U8Sources(body string, masterURL string, referer string) []StreamSou
lines := strings.Split(body, "\n")
baseURL := playlistBaseURL(masterURL)
bwPattern := regexp.MustCompile(`BANDWIDTH=(\d+)`)
currentBandwidth := 0
bw := 0
sources := make([]StreamSource, 0)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if bandwidth, ok := parseStreamBandwidth(trimmed, bwPattern); ok {
currentBandwidth = bandwidth
if bandwidth, ok := streamBandwidth(trimmed, bwPattern); ok {
bw = bandwidth
continue
}
if shouldSkipM3U8Line(trimmed) {
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
streamURL := trimmed
if !strings.HasPrefix(streamURL, "http://") && !strings.HasPrefix(streamURL, "https://") {
streamURL = baseURL + streamURL
}
sources = append(sources, StreamSource{
URL: resolvePlaylistURL(trimmed, baseURL),
Quality: qualityFromBandwidth(currentBandwidth),
URL: streamURL,
Quality: quality(bw),
Provider: "hls",
Type: "m3u8",
Referer: referer,
@@ -313,7 +332,7 @@ func playlistBaseURL(masterURL string) string {
return masterURL
}
func parseStreamBandwidth(line string, bwPattern *regexp.Regexp) (int, bool) {
func streamBandwidth(line string, bwPattern *regexp.Regexp) (int, bool) {
if !strings.HasPrefix(line, "#EXT-X-STREAM-INF") {
return 0, false
}
@@ -331,19 +350,7 @@ func parseStreamBandwidth(line string, bwPattern *regexp.Regexp) (int, bool) {
return value, true
}
func shouldSkipM3U8Line(line string) bool {
return line == "" || strings.HasPrefix(line, "#")
}
func resolvePlaylistURL(streamURL string, baseURL string) string {
if strings.HasPrefix(streamURL, "http://") || strings.HasPrefix(streamURL, "https://") {
return streamURL
}
return baseURL + streamURL
}
func qualityFromBandwidth(bandwidth int) string {
func quality(bandwidth int) string {
kbps := bandwidth / 1000
switch {
@@ -360,12 +367,13 @@ func qualityFromBandwidth(bandwidth int) string {
}
}
func parseExternalEmbedResponse(rawURL string, body string, fallbackReferer string) []StreamSource {
// 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 parseMP4UploadSources(body, fallbackReferer)
return parseMP4Upload(body, fallbackReferer)
default:
return nil
}
@@ -379,7 +387,7 @@ func parseOKRUSources(body string, referer string) []StreamSource {
return nil
}
playlistURL := decodeEscapedMediaURL(firstNonEmptyString(match[1], match[2]))
playlistURL := mediaURL(firstString(match[1], match[2]))
if playlistURL == "" {
return nil
}
@@ -393,27 +401,27 @@ func parseOKRUSources(body string, referer string) []StreamSource {
}}
}
func parseMP4UploadSources(body string, referer string) []StreamSource {
func parseMP4Upload(body string, referer string) []StreamSource {
srcPattern := regexp.MustCompile(`(?m)src:\s*"([^"]+)"`)
match := srcPattern.FindStringSubmatch(body)
if len(match) < 2 {
return nil
}
mediaURL := decodeEscapedMediaURL(match[1])
if mediaURL == "" {
url := mediaURL(match[1])
if url == "" {
return nil
}
return []StreamSource{{
URL: mediaURL,
URL: url,
Provider: "mp4upload",
Type: detectProviderSourceType(mediaURL),
Type: sourceType(url),
Referer: referer,
}}
}
func decodeEscapedMediaURL(raw string) string {
func mediaURL(raw string) string {
if unquoted, err := strconv.Unquote(`"` + raw + `"`); err == nil {
raw = unquoted
}

View File

@@ -0,0 +1,799 @@
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{
utlsClient: &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 !strings.Contains(req.URL.String(), episodeQueryHash) {
t.Errorf("url should contain hash, got %q", req.URL.String())
}
if req.Header.Get("Referer") != allAnimeReferer {
t.Errorf("Referer = %q", req.Header.Get("Referer"))
}
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{
utlsClient: &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)
}
sources := nestedSlice(result, "episode", "sourceUrls")
if len(sources) != 1 {
t.Fatalf("got %d sources, want 1", len(sources))
}
}
func TestGraphqlRequestWithHash_Non200(t *testing.T) {
t.Parallel()
provider := &AllAnimeProvider{
utlsClient: &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{
utlsClient: &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) {
t.Error("fallback POST should not be called")
return nil, nil
}),
},
utlsClient: &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) {
fallbackCalled = true
return mockStringResponse(http.StatusOK, sourceResponse), nil
}),
},
utlsClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusNotFound, `not found`), nil
}),
},
extractor: newProviderExtractor(),
}
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
}),
},
utlsClient: &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
wantErr bool
}{
{
name: "sub and dub available",
body: `{"data":{"show":{"availableEpisodesDetail":{"sub":["1","2","3"],"dub":["1"]},"lastEpisodeInfo":{}}}}`,
wantSub: 3,
wantDub: 1,
},
{
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)
}
})
}
}
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) {
return mockStringResponse(http.StatusOK, searchBody), nil
}),
},
utlsClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
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
}),
},
utlsClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Error("should not call episode sources when show not found")
return nil, nil
}),
},
extractor: newProviderExtractor(),
}
_, 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) {
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"showX","malId":"1","name":"Anime"}]}}}`), nil
}),
},
utlsClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
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

@@ -99,9 +99,9 @@ func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string
return out, nil
}
func (c *AllAnimeProvider) resolveShowIDWithFallback(ctx context.Context, animeID int, titleCandidates []string, mode string) string {
func (c *AllAnimeProvider) showID(ctx context.Context, animeID int, titleCandidates []string, mode string) string {
targetMalIDStr := strconv.Itoa(animeID)
firstAvailableShowID := ""
fallbackID := ""
for _, title := range titleCandidates {
searchResults, err := c.Search(ctx, title, mode)
@@ -111,12 +111,12 @@ func (c *AllAnimeProvider) resolveShowIDWithFallback(ctx context.Context, animeI
if showID := exactMatchShowID(searchResults, targetMalIDStr); showID != "" {
return showID
}
if firstAvailableShowID == "" {
firstAvailableShowID = searchResults[0].ID
if fallbackID == "" {
fallbackID = searchResults[0].ID
}
}
return firstAvailableShowID
return fallbackID
}
func exactMatchShowID(searchResults []searchResult, targetMalID string) string {
@@ -131,7 +131,7 @@ func exactMatchShowID(searchResults []searchResult, targetMalID string) string {
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)
showID, err := c.strictShowID(ctx, animeID, titleCandidates, mode)
if err == nil {
return showID, nil
}
@@ -139,7 +139,7 @@ func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID
return "", fmt.Errorf("allanime: no exact mal id match for %d", animeID)
}
func (c *AllAnimeProvider) resolveShowIDStrict(ctx context.Context, animeID int, titleCandidates []string, mode string) (string, error) {
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)

View File

@@ -2,6 +2,7 @@ package allanime
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
@@ -24,7 +25,7 @@ func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string,
result, err := c.graphqlRequestWithHash(ctx, showID, episode, mode)
if err == nil {
sources := c.extractSourceURLsFromData(ctx, result)
sources := c.sourcesFrom(ctx, result)
if len(sources) > 0 {
return sources, nil
}
@@ -41,34 +42,34 @@ func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string,
data, ok := result["data"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid source response")
return nil, errors.New("invalid source response")
}
rawSourceURLs, ok := data["episode"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid episode response")
return nil, errors.New("invalid episode response")
}
sourceURLs, ok := rawSourceURLs["sourceUrls"].([]any)
if !ok || len(sourceURLs) == 0 {
return nil, fmt.Errorf("no source urls")
return nil, errors.New("no source urls")
}
references := buildSourceReferences(sourceURLs)
references := sourceRefs(sourceURLs)
if len(references) == 0 {
return nil, fmt.Errorf("no source references")
return nil, errors.New("no source references")
}
out := c.resolveSourceReferences(ctx, references)
out := c.resolveRefs(ctx, references)
if len(out) == 0 {
return nil, fmt.Errorf("no playable sources extracted")
return nil, errors.New("no playable sources extracted")
}
return out, nil
}
func (c *AllAnimeProvider) extractSourceURLsFromData(ctx context.Context, data map[string]any) []StreamSource {
func (c *AllAnimeProvider) sourcesFrom(ctx context.Context, data map[string]any) []StreamSource {
episodeData, ok := data["episode"].(map[string]any)
if !ok {
return nil
@@ -79,23 +80,23 @@ func (c *AllAnimeProvider) extractSourceURLsFromData(ctx context.Context, data m
return nil
}
references := buildSourceReferences(sourceURLs)
references := sourceRefs(sourceURLs)
if len(references) == 0 {
return nil
}
return c.resolveSourceReferences(ctx, references)
return c.resolveRefs(ctx, references)
}
func (c *AllAnimeProvider) resolveSourceReferences(ctx context.Context, references []sourceReference) []StreamSource {
func (c *AllAnimeProvider) resolveRefs(ctx context.Context, references []sourceReference) []StreamSource {
out := make([]StreamSource, 0, len(references))
for _, ref := range references {
if source, ok := resolveDirectSource(ref); ok {
if source, ok := directSource(ref); ok {
out = append(out, source)
return out
}
extracted := c.resolveExtractedSources(ctx, ref)
extracted := c.resolveExtracted(ctx, ref)
if len(extracted) > 0 {
out = append(out, extracted...)
return out
@@ -105,7 +106,7 @@ func (c *AllAnimeProvider) resolveSourceReferences(ctx context.Context, referenc
return out
}
func resolveDirectSource(ref sourceReference) (StreamSource, bool) {
func directSource(ref sourceReference) (StreamSource, bool) {
target := strings.TrimSpace(ref.URL)
if target == "" {
return StreamSource{}, false
@@ -130,7 +131,7 @@ func resolveDirectSource(ref sourceReference) (StreamSource, bool) {
return buildStreamSource(decoded, detectSourceType(decoded), ref.Name), true
}
func (c *AllAnimeProvider) resolveExtractedSources(ctx context.Context, ref sourceReference) []StreamSource {
func (c *AllAnimeProvider) resolveExtracted(ctx context.Context, ref sourceReference) []StreamSource {
rawURL := strings.TrimSpace(ref.URL)
decoded := decodeSourceURL(rawURL)
if decoded == "" {
@@ -179,7 +180,8 @@ func buildStreamSource(url, sourceType, provider string) StreamSource {
}
}
func buildSourceReferences(rawSourceURLs []any) []sourceReference {
// 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": {}}
@@ -193,8 +195,11 @@ func buildSourceReferences(rawSourceURLs []any) []sourceReference {
continue
}
sourceURL, _ := item["sourceUrl"].(string)
sourceName, _ := item["sourceName"].(string)
sourceURL, ok := stringMapValue(item, "sourceUrl")
if !ok {
continue
}
sourceName, _ := stringMapValue(item, "sourceName")
sourceURL = strings.TrimSpace(sourceURL)
sourceName = strings.TrimSpace(sourceName)
if sourceURL == "" {
@@ -208,7 +213,7 @@ func buildSourceReferences(rawSourceURLs []any) []sourceReference {
ref := sourceReference{URL: sourceURL, Name: sourceName}
normalized := strings.ToLower(sourceName)
if _, prioritizedProvider := prioritySet[normalized]; prioritizedProvider {
if _, priority := prioritySet[normalized]; priority {
if _, exists := prioritized[normalized]; !exists {
prioritized[normalized] = ref
}
@@ -229,8 +234,13 @@ func buildSourceReferences(rawSourceURLs []any) []sourceReference {
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 := newEpisodeHashRequest(ctx, showID, episode, mode)
req, err := newHashRequest(ctx, showID, episode, mode)
if err != nil {
return nil, fmt.Errorf("create GET request: %w", err)
}
@@ -261,7 +271,7 @@ func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, e
data, ok := parsed["data"].(map[string]any)
if !ok {
return nil, fmt.Errorf("no data in response")
return nil, errors.New("no data in response")
}
decrypted, err := responseFromTobeparsed(data)
@@ -272,14 +282,14 @@ func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, e
return decrypted, nil
}
if hasEpisodeSourceURLs(data) {
if len(nestedSlice(data, "episode", "sourceUrls")) > 0 {
return parsed, nil
}
return nil, fmt.Errorf("no usable data in response")
return nil, errors.New("no usable data in response")
}
func newEpisodeHashRequest(ctx context.Context, showID, episode, mode string) (*http.Request, error) {
func newHashRequest(ctx context.Context, showID, episode, mode string) (*http.Request, error) {
varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, strings.ToLower(mode), episode)
extJSON := fmt.Sprintf(`{"persistedQuery":{"version":1,"sha256Hash":"%s"}}`, episodeQueryHash)

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

@@ -8,6 +8,7 @@ import (
"mal/internal/observability"
"mal/internal/server"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -35,17 +36,19 @@ type browseQuery struct {
func producerQueryParams(c *gin.Context) (string, int, int, error) {
q := strings.TrimSpace(c.Query("q"))
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
rawPage := c.DefaultQuery("page", "1")
page, err := strconv.Atoi(rawPage)
if err != nil {
return "", 0, 0, fmt.Errorf("invalid page")
return "", 0, 0, fmt.Errorf("invalid page %q: %w", rawPage, err)
}
if page < 1 {
page = 1
}
limit, err := strconv.Atoi(c.DefaultQuery("limit", "50"))
rawLimit := c.DefaultQuery("limit", "50")
limit, err := strconv.Atoi(rawLimit)
if err != nil {
return "", 0, 0, fmt.Errorf("invalid limit")
return "", 0, 0, fmt.Errorf("invalid limit %q: %w", rawLimit, err)
}
if limit < 1 || limit > 12 {
limit = 12
@@ -137,8 +140,11 @@ 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 || id < 0 {
return browseQuery{}, fmt.Errorf("invalid studio id")
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
}
@@ -147,16 +153,17 @@ func parseBrowseQuery(c *gin.Context) (browseQuery, error) {
for _, g := range c.QueryArray("genres") {
id, err := strconv.Atoi(g)
if err != nil {
return browseQuery{}, fmt.Errorf("invalid genre id")
return browseQuery{}, fmt.Errorf("invalid genre id %q: %w", g, err)
}
if id > 0 {
genres = append(genres, id)
}
}
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
rawPage := c.DefaultQuery("page", "1")
page, err := strconv.Atoi(rawPage)
if err != nil {
return browseQuery{}, fmt.Errorf("invalid page")
return browseQuery{}, fmt.Errorf("invalid page %q: %w", rawPage, err)
}
if page < 1 {
page = 1
@@ -175,6 +182,25 @@ func parseBrowseQuery(c *gin.Context) (browseQuery, error) {
}, 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 ""
@@ -280,6 +306,11 @@ func (h *AnimeHandler) respondBrowseSearchError(c *gin.Context, query browseQuer
}
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())
@@ -303,7 +334,16 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
return
}
genresList, _ := h.svc.GetGenres(c.Request.Context())
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" {
@@ -348,7 +388,7 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
Title: anime.DisplayTitle(),
Type: anime.Type,
Year: anime.Year,
Image: anime.ImageURL(),
Image: anime.Images.Webp.LargeImageURL,
InWatchlist: watchlistMap[int64(anime.MalID)],
}
}

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

@@ -1,204 +0,0 @@
package anime
import (
"fmt"
"mal/internal/db"
"mal/internal/domain"
"mal/internal/server"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
const commandPaletteAnimeLimit = 24
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"`
}
type commandPaletteResponse struct {
Items []commandPaletteItem `json:"items"`
HasNextPage bool `json:"hasNextPage"`
NextPage int `json:"nextPage,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"))
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil || page < 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid page"})
return
}
items := make([]commandPaletteItem, 0, commandPaletteAnimeLimit)
if query != "" {
hasNextPage := false
if len(query) >= 2 {
var animeItems []commandPaletteItem
animeItems, hasNextPage = h.commandPaletteAnimeResults(c, query, page)
items = append(items, animeItems...)
}
if page == 1 {
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, commandPaletteResponse{
Items: items,
HasNextPage: hasNextPage,
NextPage: page + 1,
})
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, commandPaletteResponse{Items: items})
}
func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPaletteItem {
all := []commandPaletteItem{
{ID: "nav:home", Type: "navigation", Label: "Go to Home", Subtitle: "Navigation", Href: "/", Icon: "home"},
{ID: "nav:watchlist", Type: "navigation", Label: "Go to Watchlist", Subtitle: "Navigation", Href: "/watchlist", Icon: "bookmark"},
{ID: "nav:top-picks", Type: "navigation", Label: "Open Top Picks", Subtitle: "Navigation", Href: "/top-picks", Icon: "sparkles"},
{ID: "nav:popular", Type: "navigation", Label: "Browse popular", Subtitle: "Browse", Href: "/browse?order_by=popularity&sort=asc", Icon: "trending"},
{ID: "nav:airing", Type: "navigation", Label: "Currently airing", Subtitle: "Browse", Href: "/browse?status=airing&order_by=popularity&sort=asc", 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, page int) ([]commandPaletteItem, bool) {
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, page, commandPaletteAnimeLimit)
if err != nil {
return nil, false
}
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, res.HasNextPage
}
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

@@ -2,6 +2,7 @@ package anime
import (
"context"
"errors"
"fmt"
"mal/integrations/jikan"
"mal/internal/domain"
@@ -18,8 +19,106 @@ const (
animeSectionTimeout = 12 * time.Second
watchOrderTimeout = 15 * time.Second
audioLookupTimeout = 8 * time.Second
episodeCountTimeout = 4 * time.Second
)
type animeEpisodeCountDisplay struct {
Count int
Label string
}
func listedEpisodeCount(episodes []domain.EpisodeData) int {
count := 0
for _, episode := range episodes {
if episode.MalID <= 0 || episode.IsRecap {
continue
}
count++
}
return count
}
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 {
if h.episodeSvc != nil {
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout)
defer cancel()
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(episodeCtx, anime, false)
if err == nil {
if count := len(episodeList.Episodes); count > 0 {
return animeEpisodeCountDisplay{Count: count, Label: "Available episodes"}
}
} else {
observability.Warn(
"anime_episode_availability_count_fetch_failed",
"anime",
"",
map[string]any{
"anime_id": anime.MalID,
},
err,
)
}
}
if h.svc != nil && anime.Airing {
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout)
defer cancel()
episodes, err := h.svc.GetAllEpisodes(episodeCtx, anime.MalID)
if err == nil {
if count := listedEpisodeCount(episodes); count > 0 {
return animeEpisodeCountDisplay{Count: count, Label: "Listed episodes"}
}
} else {
observability.Warn(
"anime_episode_count_fetch_failed",
"anime",
"",
map[string]any{
"anime_id": anime.MalID,
},
err,
)
}
}
if anime.Episodes > 0 {
return animeEpisodeCountDisplay{Count: anime.Episodes, Label: "Total episodes"}
}
if count := releasedEpisodeCount(anime, now); count > 0 {
return animeEpisodeCountDisplay{Count: count, Label: "Estimated aired episodes"}
}
return animeEpisodeCountDisplay{}
}
func animeInitialEpisodeCount(anime domain.Anime, now time.Time) animeEpisodeCountDisplay {
if anime.Episodes > 0 {
return animeEpisodeCountDisplay{Count: anime.Episodes, Label: "Total episodes"}
}
if count := releasedEpisodeCount(anime, now); count > 0 {
return animeEpisodeCountDisplay{Count: count, Label: "Estimated aired episodes"}
}
return animeEpisodeCountDisplay{}
}
func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string {
hasKnownSub := false
for _, episode := range episodes {
@@ -104,17 +203,18 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
}
}
audioAvailability := h.animeAudioAvailability(c.Request.Context(), anime)
episodesCount := animeInitialEpisodeCount(anime, time.Now())
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,
"EpisodesCount": episodesCount.Count,
"EpisodesCountLabel": episodesCount.Label,
})
}
@@ -124,6 +224,9 @@ func (h *AnimeHandler) handleAnimeDetailsSection(c *gin.Context, id int, section
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",
@@ -162,6 +265,18 @@ func (h *AnimeHandler) loadAnimeDetailsSection(ctx context.Context, id int, sect
case "statistics":
data, err := h.svc.GetStatistics(ctx, id)
return data, "anime_statistics", err
case "episode-count":
anime, err := h.svc.GetAnimeByID(ctx, id)
if err != nil {
return nil, "", err
}
return h.animeEpisodeCount(ctx, anime, time.Now()), "anime_episode_count", 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
@@ -202,13 +317,13 @@ func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
return
}
relationAnimeIDs := make([]int64, 0, len(relations))
ids := make([]int64, 0, len(relations))
for _, relation := range relations {
if relation.Anime.MalID > 0 {
relationAnimeIDs = append(relationAnimeIDs, int64(relation.Anime.MalID))
ids = append(ids, int64(relation.Anime.MalID))
}
}
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, relationAnimeIDs)
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, ids)
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"_fragment": "watch_order",

View File

@@ -67,7 +67,7 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
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)
}

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,174 @@ 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 TestListedEpisodeCount(t *testing.T) {
episodes := []domain.EpisodeData{
{MalID: 1, Title: "Episode 1"},
{MalID: 2, Title: "Episode 2"},
{MalID: 3, Title: "Recap", IsRecap: true},
{Title: "missing id"},
}
got := listedEpisodeCount(episodes)
if got != 2 {
t.Fatalf("listedEpisodeCount() = %d, want 2", got)
}
}
func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) {
episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{
Source: "AllAnime",
Episodes: []domain.CanonicalEpisode{
{Number: 1},
{Number: 2},
{Number: 3},
},
},
}
handler := NewAnimeHandler(nil, nil, episodeSvc)
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 TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) {
episodeSvc := &stubEpisodeService{err: errors.New("provider unavailable")}
handler := NewAnimeHandler(nil, nil, episodeSvc)
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 TestAnimeInitialEpisodeCountDoesNotCallEpisodeService(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,
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 != 12 || got.Label != "Total episodes" {
t.Fatalf("animeInitialEpisodeCount() = %+v, want count=12 label=%q", got, "Total episodes")
}
if episodeSvc.called != 0 {
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 0", episodeSvc.called)
}
}
func TestAnimeAudioAvailabilityLabel(t *testing.T) {
tests := []struct {
name string
@@ -116,7 +287,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

@@ -2,830 +2,14 @@ package anime
import (
"context"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/anime/recommendations"
"mal/internal/domain"
"mal/internal/observability"
"math"
"slices"
"sort"
"strings"
"sync"
"time"
"golang.org/x/sync/errgroup"
)
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 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
}
type userTasteProfile struct {
genres map[int]float64
themes map[int]float64
studios map[int]float64
demographics map[int]float64
prefersAiring bool
prefersRecent bool
}
func buildRecommendationSeeds(
now time.Time,
watchlist []db.GetUserWatchListRow,
) []recommendationSeed {
seeds := make([]recommendationSeed, 0, min(len(watchlist), forYouMaxSeeds))
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) >= forYouMaxSeeds {
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()/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
score += recommendationCandidateScoreAdjustments(now, profile, candidate)
return recommendationCandidate{
anime: candidate,
score: score,
genreMatches: genreMatches,
themeMatches: themeMatches,
studioMatches: studioMatches,
demographicMatches: demographicMatches,
}
}
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) <= forYouFreshReleaseWindow
}
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
}
type rankedCandidate struct {
id int
collaborativeScore float64
profileSearchScore float64
anime jikan.Anime
hasAnime bool
}
type candidateStore struct {
watchlistAnimeIDs map[int]struct{}
byID map[int]rankedCandidate
mu sync.Mutex
}
func newCandidateStore(watchlist []db.GetUserWatchListRow) *candidateStore {
watchlistAnimeIDs := make(map[int]struct{}, len(watchlist))
for _, entry := range watchlist {
if entry.AnimeID <= 0 {
continue
}
watchlistAnimeIDs[int(entry.AnimeID)] = struct{}{}
}
return &candidateStore{
watchlistAnimeIDs: watchlistAnimeIDs,
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
}
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
return s.getTopPicksForYou(ctx, userID, forYouResultLimit)
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, recommendations.TopPickLimit)
}
func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
return s.getTopPicksForYou(ctx, userID, forYouFullResultLimit)
}
func (s *animeService) 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 := s.jikan.GetAnimeByID(ctx, seed.animeID)
if err != nil {
return err
}
seedAnimes[i] = anime
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return seedAnimes, nil
}
func (s *animeService) 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 := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
if err != nil {
return err
}
for i, rec := range recs {
if i >= forYouMaxRecommendations {
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
})
}
return g.Wait()
}
func (s *animeService) 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 := s.jikan.SearchAdvanced(
ctx,
"",
"",
"",
"score",
"desc",
query.genreIDs,
query.studioID,
true,
1,
forYouProfileSearchLimit,
)
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
})
}
return g.Wait()
}
func (s *animeService) scoreRankedCandidates(
ctx context.Context,
now time.Time,
profile userTasteProfile,
ranked []rankedCandidate,
) ([]recommendationCandidate, error) {
limit := min(len(ranked), forYouCandidateFetchLimit)
candidates := make([]recommendationCandidate, 0, limit)
var candidatesMu sync.Mutex
var g errgroup.Group
g.SetLimit(6)
for i := 0; i < limit; i++ {
item := ranked[i]
g.Go(func() error {
anime := item.anime
if !item.hasAnime || !hasTasteMetadata(anime) {
fetchedAnime, err := s.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, 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 (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
}
seedAnimes, err := s.fetchSeedAnimes(ctx, seedPool)
if err != nil {
return domain.CatalogSectionData{}, err
}
profile := buildTasteProfile(now, seedPool, seedAnimes)
store := newCandidateStore(watchlist)
if err := s.collectCollaborativeCandidates(ctx, seedPool, store); err != nil {
return domain.CatalogSectionData{}, err
}
if err := s.collectProfileSearchCandidates(ctx, profile, store); err != nil {
return domain.CatalogSectionData{}, err
}
ranked := store.ranked()
if len(ranked) == 0 {
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
}
candidates, err := s.scoreRankedCandidates(ctx, now, profile, ranked)
if err != nil {
return domain.CatalogSectionData{}, err
}
return domain.CatalogSectionData{
Animes: rerankRecommendationCandidates(candidates, resultLimit),
}, nil
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, recommendations.TopPicksLimit)
}

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"
)
@@ -174,6 +175,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 +220,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})
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,117 @@
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,
}
}
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
}

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,45 @@
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
}
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

@@ -15,14 +15,19 @@ type reviewsQuery struct {
}
func parseReviewsQuery(c *gin.Context) (reviewsQuery, error) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
return reviewsQuery{}, fmt.Errorf("invalid anime id")
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)
}
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
rawPage := c.DefaultQuery("page", "1")
page, err := strconv.Atoi(rawPage)
if err != nil {
return reviewsQuery{}, fmt.Errorf("invalid page")
return reviewsQuery{}, fmt.Errorf("invalid page %q: %w", rawPage, err)
}
if page < 1 {
page = 1

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

@@ -3,6 +3,7 @@ package anime
import (
"context"
"fmt"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
@@ -46,19 +47,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
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)
@@ -75,7 +82,7 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
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
}
@@ -87,7 +94,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 != "" {
@@ -104,7 +111,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 {
@@ -119,7 +126,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))
@@ -155,7 +162,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))
@@ -188,7 +195,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))
@@ -207,7 +214,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{
@@ -230,7 +237,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...),
@@ -241,7 +248,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 {
@@ -299,13 +306,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

@@ -1,5 +1,4 @@
// Package app bootstraps and wires the application dependencies.
package app
package internal
import (
"mal/integrations/jikan"
@@ -36,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

@@ -56,14 +56,24 @@ func openTestDB(t *testing.T) *sql.DB {
if err != nil {
t.Fatalf("CreateTemp: %v", err)
}
_ = tmp.Close()
t.Cleanup(func() { _ = os.Remove(tmp.Name()) })
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() { _ = sqlDB.Close() })
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)
@@ -87,7 +97,11 @@ func queryAuditRow(t *testing.T, sqlDB *sql.DB, userID string) auditRow {
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")

View File

@@ -3,6 +3,7 @@ package auth
import (
"mal/internal/domain"
"mal/internal/observability"
"net/http"
"github.com/gin-gonic/gin"
@@ -54,7 +55,9 @@ func (h *AuthHandler) HandleLogin(c *gin.Context) {
func (h *AuthHandler) HandleLogout(c *gin.Context) {
sessionID, err := c.Cookie("session_id")
if err == nil {
_ = h.svc.Logout(c.Request.Context(), sessionID)
if err := h.svc.Logout(c.Request.Context(), sessionID); err != nil {
observability.WarnContext(c.Request.Context(), "logout_failed", "auth", "", nil, err)
}
}
c.SetCookie("session_id", "", -1, "/", "", false, true)

View File

@@ -0,0 +1,255 @@
package auth
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"mal/internal/db"
"mal/internal/domain"
"github.com/gin-gonic/gin"
)
func TestHandleAPILogin(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &fakeAuthService{
apiToken: "token-1",
apiUser: &domain.User{User: db.User{ID: "user-1", Username: "alice", AvatarUrl: "avatar.png"}},
}
router := gin.New()
NewAuthHandler(svc).Register(router)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"alice","password":"correct","name":"phone"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"token":"token-1"`) {
t.Fatalf("response missing token: %s", rec.Body.String())
}
if svc.apiLoginName != "phone" {
t.Fatalf("api token name = %q, want phone", svc.apiLoginName)
}
}
func TestHandleAPILoginRejectsInvalidRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
body string
loginErr error
wantStatus int
}{
{name: "bad json", body: `{`, wantStatus: http.StatusBadRequest},
{name: "missing password", body: `{"username":"alice"}`, wantStatus: http.StatusBadRequest},
{name: "bad credentials", body: `{"username":"alice","password":"wrong"}`, loginErr: ErrWrongPassword, wantStatus: http.StatusUnauthorized},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := &fakeAuthService{apiLoginErr: tt.loginErr}
router := gin.New()
NewAuthHandler(svc).Register(router)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/auth/login", strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, tt.wantStatus, rec.Body.String())
}
})
}
}
func TestAuthMiddlewareAllowsPublicRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &fakeAuthService{}
router := gin.New()
router.Use(AuthMiddleware(svc))
router.GET("/static/app.js", func(c *gin.Context) { c.String(http.StatusOK, "asset") })
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/static/app.js", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if svc.validateSessionCalled || svc.validateAPITokenCalled {
t.Fatalf("public route should not authenticate")
}
}
func TestAuthMiddlewareAuthenticatesAPIBearerToken(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &fakeAuthService{user: &domain.User{User: db.User{ID: "user-1", Username: "alice"}}}
router := gin.New()
router.Use(AuthMiddleware(svc))
router.GET("/api/me", func(c *gin.Context) {
user, _ := c.Get("User")
if user.(*domain.User).ID != "user-1" {
c.Status(http.StatusTeapot)
return
}
c.Status(http.StatusOK)
})
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer api-token")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if svc.validatedAPIToken != "api-token" {
t.Fatalf("validated api token = %q, want api-token", svc.validatedAPIToken)
}
if svc.refreshSessionCalled {
t.Fatalf("bearer token auth should not refresh cookie session")
}
}
func TestAuthMiddlewareAuthenticatesCookieSessionAndRefreshes(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &fakeAuthService{user: &domain.User{User: db.User{ID: "user-1", Username: "alice"}}}
router := gin.New()
router.Use(AuthMiddleware(svc))
router.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) })
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: "session_id", Value: "session-1"})
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if svc.validatedSessionID != "session-1" {
t.Fatalf("validated session id = %q, want session-1", svc.validatedSessionID)
}
if svc.refreshedSessionID != "session-1" {
t.Fatalf("refreshed session id = %q, want session-1", svc.refreshedSessionID)
}
if got := rec.Header().Values("Set-Cookie"); len(got) == 0 || !strings.Contains(got[0], "session_id=session-1") {
t.Fatalf("Set-Cookie = %v, want refreshed session cookie", got)
}
}
func TestAuthMiddlewareRejectsUnauthenticatedRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
method string
path string
wantStatus int
wantHeader string
}{
{name: "api", method: http.MethodGet, path: "/api/me", wantStatus: http.StatusUnauthorized},
{name: "page", method: http.MethodGet, path: "/", wantStatus: http.StatusSeeOther, wantHeader: "/login"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := gin.New()
router.Use(AuthMiddleware(&fakeAuthService{validateErr: errors.New("no auth")}))
router.Handle(tt.method, tt.path, func(c *gin.Context) { c.Status(http.StatusOK) })
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), tt.method, tt.path, nil)
router.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d", rec.Code, tt.wantStatus)
}
if tt.wantHeader != "" && rec.Header().Get("Location") != tt.wantHeader {
t.Fatalf("Location = %q, want %q", rec.Header().Get("Location"), tt.wantHeader)
}
})
}
}
type fakeAuthService struct {
user *domain.User
apiToken string
apiUser *domain.User
loginErr error
apiLoginErr error
validateErr error
apiLoginName string
validatedSessionID string
validatedAPIToken string
refreshedSessionID string
loggedOutSessionID string
validateSessionCalled bool
validateAPITokenCalled bool
refreshSessionCalled bool
revokedAPITokensForUser string
}
func (s *fakeAuthService) Login(_ context.Context, _, _ string) (*domain.Session, error) {
if s.loginErr != nil {
return nil, s.loginErr
}
return &domain.Session{Session: db.Session{ID: "session-1", UserID: "user-1"}}, nil
}
func (s *fakeAuthService) LoginForAPIToken(_ context.Context, _, _, name string) (string, *domain.User, error) {
s.apiLoginName = name
if s.apiLoginErr != nil {
return "", nil, s.apiLoginErr
}
return s.apiToken, s.apiUser, nil
}
func (s *fakeAuthService) ValidateSession(_ context.Context, sessionID string) (*domain.User, error) {
s.validateSessionCalled = true
s.validatedSessionID = sessionID
if s.validateErr != nil {
return nil, s.validateErr
}
return s.user, nil
}
func (s *fakeAuthService) RefreshSession(_ context.Context, sessionID string) error {
s.refreshSessionCalled = true
s.refreshedSessionID = sessionID
return nil
}
func (s *fakeAuthService) ValidateAPIToken(_ context.Context, token string) (*domain.User, error) {
s.validateAPITokenCalled = true
s.validatedAPIToken = token
if s.validateErr != nil {
return nil, s.validateErr
}
return s.user, nil
}
func (s *fakeAuthService) Logout(_ context.Context, sessionID string) error {
s.loggedOutSessionID = sessionID
return nil
}
func (s *fakeAuthService) RevokeAllAPITokensForUser(_ context.Context, userID string) error {
s.revokedAPITokensForUser = userID
return nil
}

View File

@@ -24,9 +24,6 @@ var publicRoutes = []publicRoute{
{path: "/static", prefix: true},
{path: "/dist", prefix: true},
// Observability endpoints.
{method: http.MethodGet, path: "/metrics"},
// Auth API.
{method: http.MethodPost, path: "/api/auth/login"},
}

View File

@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"mal/internal/domain"
"mal/internal/observability"
"strings"
"time"
@@ -17,6 +18,11 @@ import (
"golang.org/x/crypto/bcrypt"
)
var (
ErrUserNotFound = fmt.Errorf("user not found")
ErrWrongPassword = fmt.Errorf("wrong password")
)
type authService struct {
repo domain.AuthRepository
auditSvc domain.AuditService
@@ -32,11 +38,11 @@ func (s *authService) Login(ctx context.Context, username, password string) (*do
return nil, err
}
if user == nil {
return nil, errors.New("invalid credentials")
return nil, ErrUserNotFound
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("invalid credentials")
return nil, ErrWrongPassword
}
sessionID := uuid.New().String()
@@ -49,11 +55,11 @@ func (s *authService) LoginForAPIToken(ctx context.Context, username, password,
return "", nil, err
}
if user == nil {
return "", nil, errors.New("invalid credentials")
return "", nil, ErrUserNotFound
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return "", nil, errors.New("invalid credentials")
return "", nil, ErrWrongPassword
}
trimmedName := strings.TrimSpace(name)
@@ -69,22 +75,25 @@ func (s *authService) LoginForAPIToken(ctx context.Context, username, password,
return "", nil, err
}
metadataBytes, err := json.Marshal(struct {
event := domain.AuditEvent{
UserID: user.ID,
Action: "api_token_created",
ResourceType: "api_token",
}
metadataBytes, marshalErr := json.Marshal(struct {
Name string `json:"name"`
}{Name: trimmedName})
if err == nil {
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
UserID: user.ID,
Action: "api_token_created",
ResourceType: "api_token",
MetadataJSON: metadataBytes,
})
} else {
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
UserID: user.ID,
Action: "api_token_created",
ResourceType: "api_token",
})
if marshalErr == nil {
event.MetadataJSON = metadataBytes
}
if err := s.auditSvc.Record(ctx, event); err != nil {
observability.Warn(
"audit_record_failed",
"auth",
"",
map[string]any{"user_id": user.ID, "action": "api_token_created"},
err,
)
}
return rawToken, user, nil
@@ -100,7 +109,15 @@ func (s *authService) ValidateSession(ctx context.Context, sessionID string) (*d
}
if session.ExpiresAt.Before(time.Now()) {
_ = s.repo.DeleteSession(ctx, sessionID)
if err := s.repo.DeleteSession(ctx, sessionID); err != nil {
observability.Warn(
"delete_expired_session_failed",
"auth",
"",
map[string]any{"session_id": sessionID},
err,
)
}
return nil, errors.New("session expired")
}
@@ -132,7 +149,15 @@ func (s *authService) ValidateAPIToken(ctx context.Context, token string) (*doma
return nil, errors.New("token not found")
}
_ = s.repo.TouchAPITokenLastUsedAt(ctx, t.ID)
if err := s.repo.TouchAPITokenLastUsedAt(ctx, t.ID); err != nil {
observability.Warn(
"touch_api_token_last_used_at_failed",
"auth",
"",
map[string]any{"token_id": t.ID},
err,
)
}
return s.repo.GetUserByID(ctx, t.UserID)
}
@@ -147,11 +172,19 @@ func (s *authService) RevokeAllAPITokensForUser(ctx context.Context, userID stri
if err := s.repo.RevokeAllAPITokensForUser(ctx, userID); err != nil {
return err
}
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
if err := s.auditSvc.Record(ctx, domain.AuditEvent{
UserID: userID,
Action: "api_token_revoked_all",
ResourceType: "api_token",
})
}); err != nil {
observability.Warn(
"audit_record_failed",
"auth",
"",
map[string]any{"user_id": userID, "action": "api_token_revoked_all"},
err,
)
}
return nil
}

View File

@@ -0,0 +1,243 @@
package auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"testing"
"time"
"mal/internal/db"
"mal/internal/domain"
"golang.org/x/crypto/bcrypt"
)
func TestAuthServiceLogin(t *testing.T) {
passwordHash := hashPassword(t, "correct")
repo := &fakeAuthRepository{
usersByUsername: map[string]*domain.User{
"alice": {User: db.User{ID: "user-1", Username: "alice", PasswordHash: passwordHash}},
},
}
svc := NewAuthService(repo, &fakeAuditService{})
session, err := svc.Login(context.Background(), "alice", "correct")
if err != nil {
t.Fatalf("Login: %v", err)
}
if session.UserID != "user-1" {
t.Fatalf("session user id = %q, want %q", session.UserID, "user-1")
}
if session.ID == "" {
t.Fatalf("expected generated session id")
}
if repo.createdSessionUserID != "user-1" {
t.Fatalf("created session user id = %q, want user-1", repo.createdSessionUserID)
}
}
func TestAuthServiceLoginRejectsMissingUserAndWrongPassword(t *testing.T) {
passwordHash := hashPassword(t, "correct")
repo := &fakeAuthRepository{
usersByUsername: map[string]*domain.User{
"alice": {User: db.User{ID: "user-1", Username: "alice", PasswordHash: passwordHash}},
},
}
svc := NewAuthService(repo, &fakeAuditService{})
if _, err := svc.Login(context.Background(), "missing", "correct"); !errors.Is(err, ErrUserNotFound) {
t.Fatalf("missing user error = %v, want %v", err, ErrUserNotFound)
}
if _, err := svc.Login(context.Background(), "alice", "wrong"); !errors.Is(err, ErrWrongPassword) {
t.Fatalf("wrong password error = %v, want %v", err, ErrWrongPassword)
}
}
func TestAuthServiceValidateSession(t *testing.T) {
repo := &fakeAuthRepository{
usersByID: map[string]*domain.User{
"user-1": {User: db.User{ID: "user-1", Username: "alice"}},
},
sessions: map[string]*domain.Session{
"fresh": {Session: db.Session{ID: "fresh", UserID: "user-1", ExpiresAt: time.Now().Add(time.Hour)}},
"expired": {Session: db.Session{ID: "expired", UserID: "user-1", ExpiresAt: time.Now().Add(-time.Hour)}},
},
}
svc := NewAuthService(repo, &fakeAuditService{})
user, err := svc.ValidateSession(context.Background(), "fresh")
if err != nil {
t.Fatalf("ValidateSession fresh: %v", err)
}
if user == nil || user.ID != "user-1" {
t.Fatalf("validated user = %#v, want user-1", user)
}
if _, err := svc.ValidateSession(context.Background(), "expired"); err == nil || err.Error() != "session expired" {
t.Fatalf("expired session error = %v, want session expired", err)
}
if !repo.deletedSessions["expired"] {
t.Fatalf("expected expired session to be deleted")
}
}
func TestAuthServiceLoginForAPITokenCreatesTokenAndAuditEvent(t *testing.T) {
passwordHash := hashPassword(t, "correct")
repo := &fakeAuthRepository{
usersByUsername: map[string]*domain.User{
"alice": {User: db.User{ID: "user-1", Username: "alice", PasswordHash: passwordHash}},
},
}
auditSvc := &fakeAuditService{}
svc := NewAuthService(repo, auditSvc)
token, user, err := svc.LoginForAPIToken(context.Background(), "alice", "correct", " phone ")
if err != nil {
t.Fatalf("LoginForAPIToken: %v", err)
}
if token == "" {
t.Fatalf("expected raw token")
}
if user == nil || user.ID != "user-1" {
t.Fatalf("user = %#v, want user-1", user)
}
if repo.createdAPITokenName != "phone" {
t.Fatalf("api token name = %q, want phone", repo.createdAPITokenName)
}
if repo.createdAPITokenHash == "" || repo.createdAPITokenHash == token {
t.Fatalf("expected stored token hash, got %q", repo.createdAPITokenHash)
}
if len(auditSvc.events) != 1 || auditSvc.events[0].Action != "api_token_created" {
t.Fatalf("audit events = %#v, want api_token_created", auditSvc.events)
}
}
func TestAuthServiceValidateAPIToken(t *testing.T) {
rawToken := "secret-token"
sum := sha256.Sum256([]byte(rawToken))
tokenHash := hex.EncodeToString(sum[:])
repo := &fakeAuthRepository{
usersByID: map[string]*domain.User{
"user-1": {User: db.User{ID: "user-1", Username: "alice"}},
},
apiTokensByHash: map[string]*domain.APIToken{
tokenHash: {ApiToken: db.ApiToken{ID: "token-1", UserID: "user-1", TokenHash: tokenHash}},
},
}
svc := NewAuthService(repo, &fakeAuditService{})
user, err := svc.ValidateAPIToken(context.Background(), " "+rawToken+" ")
if err != nil {
t.Fatalf("ValidateAPIToken: %v", err)
}
if user == nil || user.ID != "user-1" {
t.Fatalf("user = %#v, want user-1", user)
}
if repo.touchedTokenID != "token-1" {
t.Fatalf("touched token id = %q, want token-1", repo.touchedTokenID)
}
}
func TestAuthServiceRevokeAllAPITokensForUser(t *testing.T) {
repo := &fakeAuthRepository{}
auditSvc := &fakeAuditService{}
svc := NewAuthService(repo, auditSvc)
if err := svc.RevokeAllAPITokensForUser(context.Background(), "user-1"); err != nil {
t.Fatalf("RevokeAllAPITokensForUser: %v", err)
}
if repo.revokedUserID != "user-1" {
t.Fatalf("revoked user id = %q, want user-1", repo.revokedUserID)
}
if len(auditSvc.events) != 1 || auditSvc.events[0].Action != "api_token_revoked_all" {
t.Fatalf("audit events = %#v, want api_token_revoked_all", auditSvc.events)
}
if err := svc.RevokeAllAPITokensForUser(context.Background(), " "); err == nil || err.Error() != "user id missing" {
t.Fatalf("blank user id error = %v, want user id missing", err)
}
}
func hashPassword(t *testing.T, password string) string {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatalf("GenerateFromPassword: %v", err)
}
return string(hash)
}
type fakeAuthRepository struct {
usersByUsername map[string]*domain.User
usersByID map[string]*domain.User
sessions map[string]*domain.Session
apiTokensByHash map[string]*domain.APIToken
createdSessionUserID string
createdAPITokenHash string
createdAPITokenName string
touchedTokenID string
revokedUserID string
deletedSessions map[string]bool
}
func (r *fakeAuthRepository) GetUserByUsername(_ context.Context, username string) (*domain.User, error) {
return r.usersByUsername[username], nil
}
func (r *fakeAuthRepository) GetUserByID(_ context.Context, id string) (*domain.User, error) {
return r.usersByID[id], nil
}
func (r *fakeAuthRepository) CreateSession(_ context.Context, userID string, sessionID string) (*domain.Session, error) {
r.createdSessionUserID = userID
return &domain.Session{Session: db.Session{ID: sessionID, UserID: userID, ExpiresAt: time.Now().Add(domain.SessionLifetime)}}, nil
}
func (r *fakeAuthRepository) GetSession(_ context.Context, sessionID string) (*domain.Session, error) {
return r.sessions[sessionID], nil
}
func (r *fakeAuthRepository) RefreshSession(_ context.Context, _ string, _ time.Time) error {
return nil
}
func (r *fakeAuthRepository) DeleteSession(_ context.Context, sessionID string) error {
if r.deletedSessions == nil {
r.deletedSessions = make(map[string]bool)
}
r.deletedSessions[sessionID] = true
return nil
}
func (r *fakeAuthRepository) CreateAPIToken(_ context.Context, userID, tokenHash, name string) (*domain.APIToken, error) {
r.createdAPITokenHash = tokenHash
r.createdAPITokenName = name
return &domain.APIToken{ApiToken: db.ApiToken{ID: "token-1", UserID: userID, TokenHash: tokenHash, Name: name}}, nil
}
func (r *fakeAuthRepository) GetAPITokenByHash(_ context.Context, tokenHash string) (*domain.APIToken, error) {
return r.apiTokensByHash[tokenHash], nil
}
func (r *fakeAuthRepository) TouchAPITokenLastUsedAt(_ context.Context, tokenID string) error {
r.touchedTokenID = tokenID
return nil
}
func (r *fakeAuthRepository) RevokeAllAPITokensForUser(_ context.Context, userID string) error {
r.revokedUserID = userID
return nil
}
type fakeAuditService struct {
events []domain.AuditEvent
}
func (s *fakeAuditService) Record(_ context.Context, event domain.AuditEvent) error {
s.events = append(s.events, event)
return nil
}

View File

@@ -1,8 +1,12 @@
package internal
import (
"database/sql"
"net/url"
"strings"
"mal/internal/database"
dbfixes "mal/internal/database/fixes"
)
func DefaultAvatarURL(username string) string {
@@ -10,3 +14,9 @@ func DefaultAvatarURL(username string) string {
params.Set("seed", strings.TrimSpace(username))
return "https://api.dicebear.com/9.x/dylan/svg?" + params.Encode()
}
func RunMigrationsAndFixes(sqlDB *sql.DB) error {
return database.RunMigrationsAndFixes(sqlDB, dbfixes.Dependencies{
DefaultAvatarURL: DefaultAvatarURL,
})
}

View File

@@ -6,6 +6,7 @@ import (
"embed"
"fmt"
"mal/internal/config"
dbfixes "mal/internal/database/fixes"
"mal/internal/db"
"mal/internal/observability"
@@ -21,7 +22,7 @@ var Module = fx.Options(
ProvideSQLDB,
ProvideQueries,
),
fx.Invoke(RunMigrationsAndFixes),
fx.Invoke(RegisterJikanCacheCleanupWorker),
)
func ProvideSQLDB(cfg config.Config) (*sql.DB, error) {
@@ -58,9 +59,12 @@ func RunMigrations(sqlDB *sql.DB) error {
return nil
}
func RunMigrationsAndFixes(sqlDB *sql.DB) error {
func RunMigrationsAndFixes(sqlDB *sql.DB, deps dbfixes.Dependencies) error {
if err := RunMigrations(sqlDB); err != nil {
return err
return fmt.Errorf("run migrations: %w", err)
}
return RunDataFixes(sqlDB)
if err := RunDataFixes(sqlDB, deps); err != nil {
return fmt.Errorf("run data fixes: %w", err)
}
return nil
}

View File

@@ -3,6 +3,7 @@ package database
import (
"context"
"database/sql"
"mal/internal/db"
"testing"
_ "github.com/mattn/go-sqlite3"
@@ -13,7 +14,11 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer func() { _ = sqlDB.Close() }()
defer func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
}()
sqlDB.SetMaxOpenConns(1)
if err := RunMigrations(sqlDB); err != nil {
@@ -39,3 +44,81 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
})
}
}
func TestCleanupExpiredJikanCache(t *testing.T) {
sqlDB := newMigratedTestDB(t)
defer closeTestDB(t, sqlDB)
ctx := context.Background()
for _, row := range []struct {
key string
expiresAt string
}{
{key: "expired", expiresAt: "2000-01-01T00:00:00Z"},
{key: "fresh", expiresAt: "2999-01-01T00:00:00Z"},
} {
_, err := sqlDB.ExecContext(ctx, `INSERT INTO jikan_cache (key, data, expires_at) VALUES (?, ?, ?)`, row.key, "{}", row.expiresAt)
if err != nil {
t.Fatalf("insert %s cache row: %v", row.key, err)
}
}
cleanupExpiredJikanCache(ctx, db.New(sqlDB))
keys := jikanCacheKeys(ctx, t, sqlDB)
if len(keys) != 1 || keys[0] != "fresh" {
t.Fatalf("remaining cache keys = %v, want [fresh]", keys)
}
}
func newMigratedTestDB(t *testing.T) *sql.DB {
t.Helper()
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := RunMigrations(sqlDB); err != nil {
closeTestDB(t, sqlDB)
t.Fatalf("RunMigrations: %v", err)
}
return sqlDB
}
func closeTestDB(t *testing.T, sqlDB *sql.DB) {
t.Helper()
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
}
func jikanCacheKeys(ctx context.Context, t *testing.T, sqlDB *sql.DB) []string {
t.Helper()
var keys []string
rows, err := sqlDB.QueryContext(ctx, `SELECT key FROM jikan_cache ORDER BY key`)
if err != nil {
t.Fatalf("query cache keys: %v", err)
}
defer func() {
if err := rows.Close(); err != nil {
t.Errorf("close rows: %v", err)
}
}()
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
t.Fatalf("scan key: %v", err)
}
keys = append(keys, key)
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate keys: %v", err)
}
return keys
}

View File

@@ -8,9 +8,10 @@ import (
dbfixes "mal/internal/database/fixes"
"mal/internal/observability"
errlog "mal/pkg"
)
func RunDataFixes(sqlDB *sql.DB) error {
func RunDataFixes(sqlDB *sql.DB, deps dbfixes.Dependencies) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
@@ -21,12 +22,12 @@ func RunDataFixes(sqlDB *sql.DB) error {
}
if err := ensureDataFixTable(ctx, sqlDB); err != nil {
return err
return fmt.Errorf("ensure data fix table: %w", err)
}
applied, err := loadAppliedFixes(ctx, sqlDB)
if err != nil {
return err
return fmt.Errorf("load applied data fixes: %w", err)
}
for _, fix := range fixes {
@@ -42,11 +43,11 @@ func RunDataFixes(sqlDB *sql.DB) error {
"id": fix.ID,
},
)
if err := fix.Apply(ctx, sqlDB); err != nil {
if err := fix.Apply(ctx, sqlDB, deps); err != nil {
return fmt.Errorf("data fix %s failed: %w", fix.ID, err)
}
if err := markFixApplied(ctx, sqlDB, fix.ID); err != nil {
return err
return fmt.Errorf("mark data fix %s applied: %w", fix.ID, err)
}
}
@@ -72,7 +73,7 @@ func loadAppliedFixes(ctx context.Context, sqlDB *sql.DB) (map[string]bool, erro
if err != nil {
return nil, fmt.Errorf("load applied data fixes: %w", err)
}
defer rows.Close()
defer errlog.Close(rows, "failed to close applied data fixes rows")
applied := make(map[string]bool)
for rows.Next() {

View File

@@ -9,7 +9,7 @@ import (
func init() {
Register(Fix{
ID: "20260526_episode_availability_backfill_next_refresh_at",
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
Apply: func(ctx context.Context, sqlDB *sql.DB, _ Dependencies) error {
// Old caches could have next_refresh_at NULL (especially for airing shows with missing broadcast metadata),
// which can result in "never refresh again" behavior on the server.
_, err := sqlDB.ExecContext(ctx, `

View File

@@ -4,18 +4,22 @@ import (
"context"
"database/sql"
"fmt"
"mal/internal"
errlog "mal/pkg"
)
func init() {
Register(Fix{
ID: "20260528_backfill_avatar_url",
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
Apply: func(ctx context.Context, sqlDB *sql.DB, deps Dependencies) error {
if deps.DefaultAvatarURL == nil {
return fmt.Errorf("default avatar URL dependency is required")
}
rows, err := sqlDB.QueryContext(ctx, `SELECT id, username FROM user WHERE avatar_url = ''`)
if err != nil {
return err
return fmt.Errorf("query users missing avatar_url: %w", err)
}
defer func() { _ = rows.Close() }()
defer errlog.Close(rows, "failed to close avatar backfill rows")
type userRow struct {
id string
@@ -25,16 +29,16 @@ func init() {
for rows.Next() {
var r userRow
if err := rows.Scan(&r.id, &r.username); err != nil {
return err
return fmt.Errorf("scan user missing avatar_url: %w", err)
}
toUpdate = append(toUpdate, r)
}
if err := rows.Err(); err != nil {
return err
return fmt.Errorf("iterate users missing avatar_url: %w", err)
}
for _, u := range toUpdate {
avatarURL := internal.DefaultAvatarURL(u.username)
avatarURL := deps.DefaultAvatarURL(u.username)
if _, err := sqlDB.ExecContext(ctx, `UPDATE user SET avatar_url = ? WHERE id = ?`, avatarURL, u.id); err != nil {
return fmt.Errorf("update avatar_url for user %s: %w", u.id, err)
}

View File

@@ -7,7 +7,7 @@ import (
"mal/integrations/jikan"
"mal/internal/config"
"mal/internal/db"
"mal/internal/observability"
errlog "mal/pkg"
)
type animeDurationRow struct {
@@ -17,18 +17,20 @@ type animeDurationRow struct {
func init() {
Register(Fix{
ID: "20260608_backfill_anime_duration_seconds",
Apply: applyAnimeDurationSecondsBackfill,
ID: "20260608_backfill_anime_duration_seconds",
Apply: func(ctx context.Context, sqlDB *sql.DB, _ Dependencies) error {
return applyAnimeDurationSecondsBackfill(ctx, sqlDB)
},
})
}
func applyAnimeDurationSecondsBackfill(ctx context.Context, sqlDB *sql.DB) error {
toUpdate, err := listAnimeMissingDurationSeconds(ctx, sqlDB)
if err != nil {
return err
return fmt.Errorf("list anime missing duration_seconds: %w", err)
}
client := jikan.NewClient(config.Config{}, db.New(sqlDB), observability.NewMetrics())
client := jikan.NewClient(config.Config{}, db.New(sqlDB))
for _, row := range toUpdate {
anime, err := client.GetAnimeByID(ctx, int(row.id))
if err != nil {
@@ -62,7 +64,7 @@ WHERE duration_seconds IS NULL;
if err != nil {
return nil, fmt.Errorf("query anime rows missing duration_seconds: %w", err)
}
defer func() { _ = rows.Close() }()
defer errlog.Close(rows, "failed to close anime duration backfill rows")
var toUpdate []animeDurationRow
for rows.Next() {

View File

@@ -9,7 +9,11 @@ import (
type Fix struct {
ID string
Apply func(ctx context.Context, sqlDB *sql.DB) error
Apply func(ctx context.Context, sqlDB *sql.DB, deps Dependencies) error
}
type Dependencies struct {
DefaultAvatarURL func(username string) string
}
var registered []Fix

View File

@@ -0,0 +1,71 @@
package database
import (
"context"
"mal/internal/db"
"mal/internal/observability"
"time"
"go.uber.org/fx"
)
const (
jikanCacheCleanupInterval = time.Hour
jikanCacheCleanupTimeout = 30 * time.Second
jikanCacheCleanupWorker = "jikan_cache_cleanup"
)
func RegisterJikanCacheCleanupWorker(lc fx.Lifecycle, queries *db.Queries) {
ctx, cancel := context.WithCancel(context.Background())
lc.Append(fx.Hook{
OnStart: func(startCtx context.Context) error {
go func() {
<-startCtx.Done()
cancel()
}()
go runJikanCacheCleanupWorker(ctx, queries)
return nil
},
OnStop: func(context.Context) error {
cancel()
return nil
},
})
}
func runJikanCacheCleanupWorker(ctx context.Context, queries *db.Queries) {
observability.Info("jikan_cache_cleanup_worker_start", "database", "", nil)
ticker := time.NewTicker(jikanCacheCleanupInterval)
defer ticker.Stop()
for {
cleanupExpiredJikanCache(ctx, queries)
select {
case <-ticker.C:
case <-ctx.Done():
observability.Info("jikan_cache_cleanup_worker_stop", "database", "", nil)
return
}
}
}
func cleanupExpiredJikanCache(ctx context.Context, queries *db.Queries) {
cleanupCtx, cancel := context.WithTimeout(ctx, jikanCacheCleanupTimeout)
defer cancel()
err := queries.DeleteExpiredJikanCache(cleanupCtx)
if err != nil {
observability.Warn(
"jikan_cache_cleanup_failed",
"database",
"",
map[string]any{
"worker": jikanCacheCleanupWorker,
},
err,
)
}
}

View File

@@ -0,0 +1,6 @@
-- +goose Up
CREATE INDEX IF NOT EXISTS idx_anime_relation_related_anime_id
ON anime_relation(related_anime_id);
-- +goose Down
DROP INDEX IF EXISTS idx_anime_relation_related_anime_id;

View File

@@ -1,180 +0,0 @@
package db
import (
"context"
"strings"
)
func (q *Queries) GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]GetContinueWatchingEntriesRow, error) {
if userID == "" {
return nil, nil
}
limit = commandPaletteLimit(limit)
needle, pattern := commandPalettePattern(query)
rows, err := q.db.QueryContext(ctx, `
SELECT
c.id,
c.user_id,
c.anime_id,
c.current_episode,
c.current_time_seconds,
c.duration_seconds,
c.created_at,
c.updated_at,
a.title_original,
a.title_english,
a.title_japanese,
a.image_url,
a.duration_seconds as anime_duration_seconds
FROM continue_watching_entry c
JOIN anime a ON c.anime_id = a.id
WHERE c.user_id = ?
AND (
? = ''
OR lower(a.title_original) LIKE ?
OR lower(coalesce(a.title_english, '')) LIKE ?
OR lower(coalesce(a.title_japanese, '')) LIKE ?
OR lower('Continue watching') LIKE ?
)
ORDER BY c.updated_at DESC
LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
items := make([]GetContinueWatchingEntriesRow, 0, int(limit))
for rows.Next() {
item, err := scanContinueWatchingEntry(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func scanContinueWatchingEntry(rows scanner) (GetContinueWatchingEntriesRow, error) {
var item GetContinueWatchingEntriesRow
err := rows.Scan(
&item.ID,
&item.UserID,
&item.AnimeID,
&item.CurrentEpisode,
&item.CurrentTimeSeconds,
&item.DurationSeconds,
&item.CreatedAt,
&item.UpdatedAt,
&item.TitleOriginal,
&item.TitleEnglish,
&item.TitleJapanese,
&item.ImageUrl,
&item.AnimeDurationSeconds,
)
return item, err
}
func (q *Queries) GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]GetUserWatchListRow, error) {
if userID == "" {
return nil, nil
}
limit = commandPaletteLimit(limit)
needle, pattern := commandPalettePattern(query)
rows, err := q.db.QueryContext(ctx, `
SELECT
e.id,
e.user_id,
e.anime_id,
e.status,
e.created_at,
e.updated_at,
e.current_episode,
e.last_episode_at,
e.current_time_seconds,
a.title_original,
a.title_english,
a.title_japanese,
a.image_url,
a.airing
FROM watch_list_entry e
JOIN anime a ON e.anime_id = a.id
WHERE e.user_id = ?
AND e.status IN ('watching', 'plan_to_watch')
AND (
? = ''
OR lower(a.title_original) LIKE ?
OR lower(coalesce(a.title_english, '')) LIKE ?
OR lower(coalesce(a.title_japanese, '')) LIKE ?
OR lower(e.status) LIKE ?
)
ORDER BY
CASE e.status
WHEN 'watching' THEN 0
WHEN 'plan_to_watch' THEN 1
ELSE 2
END,
e.updated_at DESC
LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
items := make([]GetUserWatchListRow, 0, int(limit))
for rows.Next() {
item, err := scanWatchListEntry(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func scanWatchListEntry(rows scanner) (GetUserWatchListRow, error) {
var item GetUserWatchListRow
err := rows.Scan(
&item.ID,
&item.UserID,
&item.AnimeID,
&item.Status,
&item.CreatedAt,
&item.UpdatedAt,
&item.CurrentEpisode,
&item.LastEpisodeAt,
&item.CurrentTimeSeconds,
&item.TitleOriginal,
&item.TitleEnglish,
&item.TitleJapanese,
&item.ImageUrl,
&item.Airing,
)
return item, err
}
func commandPalettePattern(query string) (string, string) {
needle := strings.ToLower(strings.TrimSpace(query))
return needle, "%" + needle + "%"
}
func commandPaletteLimit(limit int64) int64 {
if limit <= 0 {
return 5
}
return limit
}
type scanner interface {
Scan(dest ...interface{}) error
}

View File

@@ -1,114 +0,0 @@
package db
import (
"context"
"database/sql"
"testing"
_ "github.com/mattn/go-sqlite3"
)
func TestGetCommandPaletteContinueWatchingFiltersAndLimits(t *testing.T) {
sqlDB := openCommandPaletteTestDB(t)
got, err := New(sqlDB).GetCommandPaletteContinueWatching(context.Background(), "user-a", "continue", 1)
if err != nil {
t.Fatalf("GetCommandPaletteContinueWatching: %v", err)
}
if len(got) != 1 || got[0].AnimeID != 20 {
t.Fatalf("continue rows = %+v, want latest anime 20 only", got)
}
got, err = New(sqlDB).GetCommandPaletteContinueWatching(context.Background(), "user-a", "nar", 5)
if err != nil {
t.Fatalf("GetCommandPaletteContinueWatching filtered: %v", err)
}
if len(got) != 1 || got[0].AnimeID != 10 {
t.Fatalf("filtered continue rows = %+v, want anime 10", got)
}
}
func TestGetCommandPaletteWatchlistFiltersAndOrders(t *testing.T) {
sqlDB := openCommandPaletteTestDB(t)
got, err := New(sqlDB).GetCommandPaletteWatchlist(context.Background(), "user-a", "", 5)
if err != nil {
t.Fatalf("GetCommandPaletteWatchlist: %v", err)
}
if len(got) != 2 {
t.Fatalf("watchlist rows len = %d, want 2", len(got))
}
if got[0].AnimeID != 10 || got[1].AnimeID != 20 {
t.Fatalf("watchlist order = [%d %d], want watching anime 10 before plan anime 20", got[0].AnimeID, got[1].AnimeID)
}
got, err = New(sqlDB).GetCommandPaletteWatchlist(context.Background(), "user-a", "plan", 5)
if err != nil {
t.Fatalf("GetCommandPaletteWatchlist filtered: %v", err)
}
if len(got) != 1 || got[0].AnimeID != 20 {
t.Fatalf("filtered watchlist rows = %+v, want anime 20", got)
}
}
func openCommandPaletteTestDB(t *testing.T) *sql.DB {
t.Helper()
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
_, err = sqlDB.ExecContext(context.Background(), `
CREATE TABLE anime (
id INTEGER PRIMARY KEY,
title_original TEXT NOT NULL,
title_english TEXT,
title_japanese TEXT,
image_url TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
airing BOOLEAN DEFAULT 0,
duration_seconds REAL
);
CREATE TABLE watch_list_entry (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
anime_id INTEGER NOT NULL,
status TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
current_episode INTEGER,
last_episode_at DATETIME,
current_time_seconds REAL NOT NULL DEFAULT 0
);
CREATE TABLE continue_watching_entry (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
anime_id INTEGER NOT NULL,
current_episode INTEGER,
current_time_seconds REAL NOT NULL DEFAULT 0,
duration_seconds REAL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing, duration_seconds) VALUES
(10, 'Naruto', NULL, NULL, 'naruto.jpg', 0, 1440),
(20, 'Frieren', 'Frieren: Beyond Journey''s End', NULL, 'frieren.jpg', 0, 1440),
(30, 'Dropped Show', NULL, NULL, 'dropped.jpg', 0, 1440);
INSERT INTO watch_list_entry (id, user_id, anime_id, status, created_at, updated_at, current_episode, current_time_seconds) VALUES
('w1', 'user-a', 10, 'watching', '2026-01-01 00:00:00', '2026-01-01 00:00:00', 3, 0),
('w2', 'user-a', 20, 'plan_to_watch', '2026-01-02 00:00:00', '2026-01-03 00:00:00', 0, 0),
('w3', 'user-a', 30, 'dropped', '2026-01-04 00:00:00', '2026-01-04 00:00:00', 0, 0),
('w4', 'user-b', 10, 'watching', '2026-01-05 00:00:00', '2026-01-05 00:00:00', 1, 0);
INSERT INTO continue_watching_entry (id, user_id, anime_id, current_episode, current_time_seconds, duration_seconds, created_at, updated_at) VALUES
('c1', 'user-a', 10, 4, 120, 1440, '2026-01-01 00:00:00', '2026-01-01 00:00:00'),
('c2', 'user-a', 20, 1, 60, 1440, '2026-01-02 00:00:00', '2026-01-03 00:00:00'),
('c3', 'user-b', 10, 1, 30, 1440, '2026-01-04 00:00:00', '2026-01-04 00:00:00');
`)
if err != nil {
t.Fatalf("seed command palette db: %v", err)
}
return sqlDB
}

View File

@@ -15,6 +15,7 @@ type Querier interface {
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
DeleteAnimeFetchRetry(ctx context.Context, animeID int64) error
DeleteContinueWatchingEntry(ctx context.Context, arg DeleteContinueWatchingEntryParams) error
DeleteExpiredFailedEpisodeProviderMappings(ctx context.Context) error
DeleteExpiredJikanCache(ctx context.Context) error
DeleteSession(ctx context.Context, id string) error
DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListEntryParams) error
@@ -26,12 +27,11 @@ type Querier interface {
GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error)
GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error)
GetContinueWatchingEntry(ctx context.Context, arg GetContinueWatchingEntryParams) (ContinueWatchingEntry, error)
GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]GetContinueWatchingEntriesRow, error)
GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]GetUserWatchListRow, error)
GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]AnimeFetchRetry, error)
GetEpisodeAvailabilityCache(ctx context.Context, animeID int64) (EpisodeAvailabilityCache, error)
GetEpisodeProviderMapping(ctx context.Context, arg GetEpisodeProviderMappingParams) (EpisodeProviderMapping, error)
GetJikanCache(ctx context.Context, key string) (string, error)
GetJikanCacheStats(ctx context.Context) (GetJikanCacheStatsRow, error)
GetJikanCacheStale(ctx context.Context, key string) (string, error)
GetSession(ctx context.Context, id string) (Session, error)
GetTrackedAiringAnimeIDsDueForEpisodeRefresh(ctx context.Context, limit int64) ([]int64, error)

View File

@@ -233,7 +233,14 @@ WHERE key = ? AND datetime(expires_at) > CURRENT_TIMESTAMP LIMIT 1;
-- name: GetJikanCacheStale :one
SELECT data FROM jikan_cache
WHERE key = ? LIMIT 1;
WHERE key = ? AND datetime(expires_at) > datetime(CURRENT_TIMESTAMP, '-14 days') LIMIT 1;
-- name: GetJikanCacheStats :one
SELECT
COUNT(*) AS total_rows,
COUNT(*) FILTER (WHERE datetime(expires_at) <= CURRENT_TIMESTAMP) AS expired_rows,
COALESCE(unixepoch(MIN(expires_at)), 0) AS oldest_expires_at_seconds
FROM jikan_cache;
-- name: SetJikanCache :exec
INSERT INTO jikan_cache (key, data, expires_at)
@@ -333,6 +340,11 @@ SELECT anime_id, provider, provider_show_id, failed_until, last_error, updated_a
FROM episode_provider_mapping
WHERE anime_id = ? AND provider = ? LIMIT 1;
-- name: DeleteExpiredFailedEpisodeProviderMappings :exec
DELETE FROM episode_provider_mapping
WHERE provider_show_id = ''
AND failed_until <= CURRENT_TIMESTAMP;
-- name: GetTrackedAiringAnimeIDsDueForEpisodeRefresh :many
WITH tracked AS (
SELECT DISTINCT w.anime_id
@@ -357,4 +369,4 @@ LIMIT ?;
-- name: GetAllCachedAnime :many
SELECT data FROM jikan_cache
WHERE key LIKE 'anime:%' LIMIT 1000;
WHERE key LIKE 'anime:%' AND datetime(expires_at) > CURRENT_TIMESTAMP LIMIT 1000;

View File

@@ -149,6 +149,17 @@ func (q *Queries) DeleteContinueWatchingEntry(ctx context.Context, arg DeleteCon
return err
}
const deleteExpiredFailedEpisodeProviderMappings = `-- name: DeleteExpiredFailedEpisodeProviderMappings :exec
DELETE FROM episode_provider_mapping
WHERE provider_show_id = ''
AND failed_until <= CURRENT_TIMESTAMP
`
func (q *Queries) DeleteExpiredFailedEpisodeProviderMappings(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, deleteExpiredFailedEpisodeProviderMappings)
return err
}
const deleteExpiredJikanCache = `-- name: DeleteExpiredJikanCache :exec
DELETE FROM jikan_cache WHERE datetime(expires_at) <= CURRENT_TIMESTAMP
`
@@ -227,7 +238,7 @@ func (q *Queries) GetAPITokenByHash(ctx context.Context, tokenHash string) (ApiT
const getAllCachedAnime = `-- name: GetAllCachedAnime :many
SELECT data FROM jikan_cache
WHERE key LIKE 'anime:%' LIMIT 1000
WHERE key LIKE 'anime:%' AND datetime(expires_at) > CURRENT_TIMESTAMP LIMIT 1000
`
func (q *Queries) GetAllCachedAnime(ctx context.Context) ([]string, error) {
@@ -235,7 +246,6 @@ func (q *Queries) GetAllCachedAnime(ctx context.Context) ([]string, error) {
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var data string
@@ -308,7 +318,6 @@ func (q *Queries) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNe
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAnimeNeedingRelationSyncRow
for rows.Next() {
var i GetAnimeNeedingRelationSyncRow
@@ -344,7 +353,6 @@ func (q *Queries) GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUs
if err != nil {
return nil, err
}
defer rows.Close()
var items []AuditLog
for rows.Next() {
var i AuditLog
@@ -414,7 +422,6 @@ func (q *Queries) GetContinueWatchingEntries(ctx context.Context, userID string)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetContinueWatchingEntriesRow
for rows.Next() {
var i GetContinueWatchingEntriesRow
@@ -485,7 +492,6 @@ func (q *Queries) GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]A
if err != nil {
return nil, err
}
defer rows.Close()
var items []AnimeFetchRetry
for rows.Next() {
var i AnimeFetchRetry
@@ -570,9 +576,30 @@ func (q *Queries) GetJikanCache(ctx context.Context, key string) (string, error)
return data, err
}
const getJikanCacheStats = `-- name: GetJikanCacheStats :one
SELECT
COUNT(*) AS total_rows,
COUNT(*) FILTER (WHERE datetime(expires_at) <= CURRENT_TIMESTAMP) AS expired_rows,
COALESCE(unixepoch(MIN(expires_at)), 0) AS oldest_expires_at_seconds
FROM jikan_cache
`
type GetJikanCacheStatsRow struct {
TotalRows int64 `json:"total_rows"`
ExpiredRows int64 `json:"expired_rows"`
OldestExpiresAtSeconds int64 `json:"oldest_expires_at_seconds"`
}
func (q *Queries) GetJikanCacheStats(ctx context.Context) (GetJikanCacheStatsRow, error) {
row := q.db.QueryRowContext(ctx, getJikanCacheStats)
var i GetJikanCacheStatsRow
err := row.Scan(&i.TotalRows, &i.ExpiredRows, &i.OldestExpiresAtSeconds)
return i, err
}
const getJikanCacheStale = `-- name: GetJikanCacheStale :one
SELECT data FROM jikan_cache
WHERE key = ? LIMIT 1
WHERE key = ? AND datetime(expires_at) > datetime(CURRENT_TIMESTAMP, '-14 days') LIMIT 1
`
func (q *Queries) GetJikanCacheStale(ctx context.Context, key string) (string, error) {
@@ -626,7 +653,6 @@ func (q *Queries) GetTrackedAiringAnimeIDsDueForEpisodeRefresh(ctx context.Conte
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var anime_id int64
@@ -703,7 +729,6 @@ func (q *Queries) GetUpcomingSeasons(ctx context.Context, userID string) ([]GetU
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetUpcomingSeasonsRow
for rows.Next() {
var i GetUpcomingSeasonsRow
@@ -803,7 +828,6 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetUserWatchListRow
for rows.Next() {
var i GetUserWatchListRow
@@ -899,7 +923,6 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetWatchingAnimeRow
for rows.Next() {
var i GetWatchingAnimeRow

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
errlog "mal/pkg"
)
type SkipSegmentOverrideRow struct {
@@ -28,7 +29,7 @@ ORDER BY skip_type ASC;
if err != nil {
return nil, fmt.Errorf("list skip segment overrides: %w", err)
}
defer func() { _ = rows.Close() }()
defer errlog.Close(rows, "failed to close skip segment override rows")
var out []SkipSegmentOverrideRow
for rows.Next() {

View File

@@ -13,7 +13,11 @@ func TestHasSkipSegmentOverrideTableReturnsFalseWhenMissing(t *testing.T) {
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer func() { _ = sqlDB.Close() }()
defer func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
}()
ok, err := New(sqlDB).HasSkipSegmentOverrideTable(context.Background())
if err != nil {
@@ -23,3 +27,114 @@ func TestHasSkipSegmentOverrideTableReturnsFalseWhenMissing(t *testing.T) {
t.Fatalf("HasSkipSegmentOverrideTable returned true for missing table")
}
}
func TestHasSkipSegmentOverrideTableReturnsTrueWhenPresent(t *testing.T) {
sqlDB, err := openSkipSegmentOverrideTestDB(t)
if err != nil {
t.Fatalf("open test db: %v", err)
}
defer func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
}()
queries := New(sqlDB)
ok, err := queries.HasSkipSegmentOverrideTable(context.Background())
if err != nil {
t.Fatalf("HasSkipSegmentOverrideTable: %v", err)
}
if !ok {
t.Fatal("HasSkipSegmentOverrideTable returned false for existing table")
}
}
func TestSkipSegmentOverrideUpsertAndList(t *testing.T) {
sqlDB, err := openSkipSegmentOverrideTestDB(t)
if err != nil {
t.Fatalf("open test db: %v", err)
}
defer func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
}()
queries := New(sqlDB)
row := SkipSegmentOverrideRow{
ID: "override-1",
UserID: "user-a",
AnimeID: 123,
Episode: 4,
SkipType: "op",
StartTime: 12.5,
EndTime: 28.25,
}
if err := queries.UpsertSkipSegmentOverride(context.Background(), row); err != nil {
t.Fatalf("UpsertSkipSegmentOverride insert: %v", err)
}
got, err := queries.ListSkipSegmentOverrides(context.Background(), row.UserID, row.AnimeID, row.Episode)
if err != nil {
t.Fatalf("ListSkipSegmentOverrides insert: %v", err)
}
assertSingleSkipSegmentOverrideRow(t, got, row)
updated := row
updated.StartTime = 13.75
updated.EndTime = 29.5
if err := queries.UpsertSkipSegmentOverride(context.Background(), updated); err != nil {
t.Fatalf("UpsertSkipSegmentOverride update: %v", err)
}
got, err = queries.ListSkipSegmentOverrides(context.Background(), row.UserID, row.AnimeID, row.Episode)
if err != nil {
t.Fatalf("ListSkipSegmentOverrides update: %v", err)
}
assertSingleSkipSegmentOverrideRow(t, got, updated)
}
func assertSingleSkipSegmentOverrideRow(t *testing.T, got []SkipSegmentOverrideRow, want SkipSegmentOverrideRow) {
t.Helper()
if len(got) != 1 {
t.Fatalf("len(got) = %d, want 1", len(got))
}
if got[0] != want {
t.Fatalf("row = %+v, want %+v", got[0], want)
}
}
func openSkipSegmentOverrideTestDB(t *testing.T) (*sql.DB, error) {
t.Helper()
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
return nil, err
}
_, err = sqlDB.ExecContext(context.Background(), `
CREATE TABLE skip_segment_override (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
anime_id INTEGER NOT NULL,
episode INTEGER NOT NULL,
skip_type TEXT NOT NULL,
start_time REAL NOT NULL,
end_time REAL NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, anime_id, episode, skip_type)
);
`)
if err != nil {
if closeErr := sqlDB.Close(); closeErr != nil {
return nil, closeErr
}
return nil, err
}
return sqlDB, nil
}

View File

@@ -13,12 +13,21 @@ import (
func Open(dbFile string) (*sql.DB, error) {
// busy_timeout avoids immediate SQLITE_BUSY errors under concurrent access.
// foreign_keys ensures FK constraints are enforced for this connection.
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on&_busy_timeout=5000", dbFile))
// txlock=immediate acquires SQLite's write lock when a transaction starts,
// which avoids deferred read->write lock upgrades failing mid-transaction.
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on&_busy_timeout=5000&_txlock=immediate", dbFile))
if err != nil {
return nil, fmt.Errorf("failed to open db: %w", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
// WAL improves concurrency between readers and writers.
_, _ = db.ExecContext(context.Background(), "PRAGMA journal_mode=WAL;")
_, _ = db.ExecContext(context.Background(), "PRAGMA busy_timeout=5000;")
if _, err := db.ExecContext(context.Background(), "PRAGMA journal_mode=WAL;"); err != nil {
return nil, fmt.Errorf("failed to enable WAL mode: %w", err)
}
if _, err := db.ExecContext(context.Background(), "PRAGMA busy_timeout=5000;"); err != nil {
return nil, fmt.Errorf("failed to set busy timeout: %w", err)
}
return db, nil
}

View File

@@ -2,6 +2,7 @@ package db
import (
"context"
errlog "mal/pkg"
"strings"
)
@@ -24,7 +25,7 @@ func (q *Queries) GetUserWatchlistAnimeIDs(ctx context.Context, userID string, a
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
defer errlog.Close(rows, "failed to close watchlist id rows")
matches := make([]int64, 0, len(animeIDs))
for rows.Next() {

View File

@@ -14,7 +14,11 @@ func TestGetUserWatchlistAnimeIDsFiltersRequestedIDs(t *testing.T) {
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer func() { _ = sqlDB.Close() }()
defer func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
}()
_, err = sqlDB.ExecContext(context.Background(), `
CREATE TABLE watch_list_entry (

View File

@@ -13,8 +13,6 @@ type WatchlistService interface {
RemoveEntry(ctx context.Context, userID string, animeID int64) error
GetWatchlist(ctx context.Context, userID string) ([]UserWatchListRow, error)
GetWatchlistMap(ctx context.Context, userID string, animeIDs []int64) (map[int64]bool, error)
GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]UserWatchListRow, error)
GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]db.GetContinueWatchingEntriesRow, error)
GetWatchListEntry(ctx context.Context, userID string, animeID int64) (WatchlistEntry, error)
GetContinueWatchingEntry(ctx context.Context, userID string, animeID int64) (db.ContinueWatchingEntry, error)
DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error
@@ -28,8 +26,6 @@ type WatchlistRepository interface {
DeleteWatchListEntry(ctx context.Context, arg db.DeleteWatchListEntryParams) error
GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error)
GetUserWatchlistAnimeIDs(ctx context.Context, userID string, animeIDs []int64) ([]int64, error)
GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]db.GetUserWatchListRow, error)
GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]db.GetContinueWatchingEntriesRow, error)
GetWatchListEntry(ctx context.Context, arg db.GetWatchListEntryParams) (db.WatchListEntry, error)
GetContinueWatchingEntry(ctx context.Context, arg db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error)
DeleteContinueWatchingEntry(ctx context.Context, arg db.DeleteContinueWatchingEntryParams) error

View File

@@ -131,34 +131,9 @@ func (s *EpisodeService) markFailure(ctx context.Context, anime domain.Anime, ca
)
}
func (s *EpisodeService) getCached(ctx context.Context, animeID int) (domain.CanonicalEpisodeList, bool) {
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID))
if err != nil {
s.metrics.ObserveCache("episode_availability", "miss")
return domain.CanonicalEpisodeList{}, false
}
var payload domain.CanonicalEpisodeList
if err := json.Unmarshal([]byte(row.Data), &payload); err != nil {
s.metrics.ObserveCache("episode_availability", "miss")
observability.Warn(
"episodes_cached_payload_invalid",
"episodes",
"",
map[string]any{
"anime_id": animeID,
},
err,
)
return domain.CanonicalEpisodeList{}, false
}
s.metrics.ObserveCache("episode_availability", "hit")
return payload, true
}
func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, bool) {
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(anime.MalID))
if err != nil {
s.metrics.ObserveCache("episode_availability_fresh", "miss")
return domain.CanonicalEpisodeList{}, false
}
@@ -167,25 +142,10 @@ func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime)
return domain.CanonicalEpisodeList{}, false
}
payload, ok := s.decodeFreshCachedPayload(anime, row.Data)
payload, ok := s.decodeCachedPayload(anime, row.Data)
if !ok {
return domain.CanonicalEpisodeList{}, false
}
if !isCanonicalEpisodePayloadValid(payload, anime.Episodes) {
s.metrics.ObserveCache("episode_availability_fresh", "miss")
observability.Info(
"episodes_cached_payload_rejected",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"expected_count": anime.Episodes,
"cached_episodes": len(payload.Episodes),
},
)
return domain.CanonicalEpisodeList{}, false
}
s.metrics.ObserveCache("episode_availability_fresh", "hit")
observability.Info(
"episodes_cache_served",
"episodes",
@@ -199,9 +159,20 @@ func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime)
return payload, true
}
func (s *EpisodeService) getDecodedCached(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, bool) {
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(anime.MalID))
if err != nil {
return domain.CanonicalEpisodeList{}, false
}
payload, ok := s.decodeCachedPayload(anime, row.Data)
if !ok {
return domain.CanonicalEpisodeList{}, false
}
return payload, true
}
func (s *EpisodeService) isFreshEpisodeCache(anime domain.Anime, row db.EpisodeAvailabilityCache, now time.Time) bool {
if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(now) {
s.metrics.ObserveCache("episode_availability_fresh", "miss")
observability.Info(
"episodes_cache_due_for_refresh",
"episodes",
@@ -214,7 +185,6 @@ func (s *EpisodeService) isFreshEpisodeCache(anime domain.Anime, row db.EpisodeA
return false
}
if anime.Airing && row.UpdatedAt.Before(now.Add(-airingFallbackRefreshInterval)) {
s.metrics.ObserveCache("episode_availability_fresh", "miss")
observability.Info(
"episodes_cache_too_old_for_airing",
"episodes",
@@ -229,22 +199,34 @@ func (s *EpisodeService) isFreshEpisodeCache(anime domain.Anime, row db.EpisodeA
return true
}
func (s *EpisodeService) decodeFreshCachedPayload(anime domain.Anime, raw string) (domain.CanonicalEpisodeList, bool) {
func (s *EpisodeService) decodeCachedPayload(anime domain.Anime, raw string) (domain.CanonicalEpisodeList, bool) {
var payload domain.CanonicalEpisodeList
err := json.Unmarshal([]byte(raw), &payload)
if err == nil {
return payload, true
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
observability.Warn(
"episodes_cached_payload_invalid",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
},
err,
)
return domain.CanonicalEpisodeList{}, false
}
s.metrics.ObserveCache("episode_availability_fresh", "miss")
observability.Warn(
"episodes_cached_payload_invalid",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
},
err,
)
return domain.CanonicalEpisodeList{}, false
if !isCanonicalEpisodePayloadValid(payload, anime.Episodes) {
observability.Info(
"episodes_cached_payload_rejected",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"expected_count": anime.Episodes,
"cached_episodes": len(payload.Episodes),
},
)
return domain.CanonicalEpisodeList{}, false
}
return payload, true
}

View File

@@ -36,7 +36,7 @@ func titleCandidates(anime domain.Anime) []string {
func isCanonicalEpisodePayloadValid(payload domain.CanonicalEpisodeList, expectedCount int) bool {
if expectedCount <= 0 {
return true
return providerBackedPayloadHasAvailability(payload)
}
if len(payload.Episodes) > expectedCount {
return false
@@ -46,26 +46,31 @@ func isCanonicalEpisodePayloadValid(payload domain.CanonicalEpisodeList, expecte
return false
}
}
return providerBackedPayloadHasAvailability(payload)
}
func providerBackedPayloadHasAvailability(payload domain.CanonicalEpisodeList) bool {
if payload.Source == "" || payload.Source == "jikan_fallback" || payload.Source == "legacy_disabled" {
return true
}
for _, episode := range payload.Episodes {
if !episode.HasSub && !episode.HasDub {
return false
}
}
return true
}
func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, expectedCount int) []domain.CanonicalEpisode {
byNumber := map[int]episodePartial{}
providerNumbers := availableEpisodeNumbers(availability, expectedCount)
providerBacked := len(providerNumbers) > 0
for i, ep := range jikanEpisodes {
if exceedsExpectedCount(i+1, expectedCount) {
break
}
number, ok := jikanEpisodeNumber(ep, i)
if !ok || exceedsExpectedCount(number, expectedCount) {
continue
}
mergeEpisode(&byNumber, number, func(item *episodePartial) {
item.title = strings.TrimSpace(ep.Title)
item.filler = ep.Filler
item.recap = ep.Recap
})
for number := range providerNumbers {
mergeEpisode(&byNumber, number, func(item *episodePartial) {})
}
mergeJikanEpisodes(&byNumber, jikanEpisodes, providerNumbers, providerBacked, expectedCount)
mergeAvailability(&byNumber, availability.Sub, expectedCount, func(item *episodePartial) { item.sub = true })
mergeAvailability(&byNumber, availability.Dub, expectedCount, func(item *episodePartial) { item.dub = true })
@@ -95,6 +100,38 @@ func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAva
return episodes
}
func mergeJikanEpisodes(byNumber *map[int]episodePartial, episodes []jikan.Episode, providerNumbers map[int]bool, providerBacked bool, expectedCount int) {
for i, ep := range episodes {
if exceedsExpectedCount(i+1, expectedCount) {
break
}
number, ok := jikanEpisodeNumber(ep, i)
if !ok || exceedsExpectedCount(number, expectedCount) || (providerBacked && !providerNumbers[number]) {
continue
}
mergeEpisode(byNumber, number, func(item *episodePartial) {
item.title = strings.TrimSpace(ep.Title)
item.filler = ep.Filler
item.recap = ep.Recap
})
}
}
func availableEpisodeNumbers(availability domain.EpisodeAvailability, expectedCount int) map[int]bool {
numbers := map[int]bool{}
for _, number := range availability.Sub {
if number > 0 && !exceedsExpectedCount(number, expectedCount) {
numbers[number] = true
}
}
for _, number := range availability.Dub {
if number > 0 && !exceedsExpectedCount(number, expectedCount) {
numbers[number] = true
}
}
return numbers
}
func mergeEpisode(byNumber *map[int]episodePartial, number int, update func(*episodePartial)) {
item := (*byNumber)[number]
update(&item)

View File

@@ -0,0 +1,116 @@
package service
import (
"testing"
"time"
"mal/integrations/jikan"
"mal/internal/domain"
)
func TestMergeEpisodesFiltersProviderBackedJikanToAvailableNumbers(t *testing.T) {
episodes := mergeEpisodes([]jikan.Episode{
{Episode: "1", Title: "Available"},
{Episode: "2", Title: "Unavailable"},
{Episode: "3", Title: "Dubbed"},
}, domain.EpisodeAvailability{
Sub: []int{1},
Dub: []int{3},
}, 0)
if len(episodes) != 2 {
t.Fatalf("len(episodes) = %d, want 2", len(episodes))
}
assertEpisode(t, episodes[0], 1, "Available", true, false, true, false)
assertEpisode(t, episodes[1], 3, "Dubbed", false, true, false, false)
}
func TestMergeEpisodesHonorsExpectedCountForAvailability(t *testing.T) {
episodes := mergeEpisodes(nil, domain.EpisodeAvailability{
Sub: []int{0, 1, 2, 4},
Dub: []int{-1, 2, 3, 5},
}, 3)
if len(episodes) != 3 {
t.Fatalf("len(episodes) = %d, want 3", len(episodes))
}
assertEpisode(t, episodes[0], 1, "Episode 1", true, false, true, false)
assertEpisode(t, episodes[1], 2, "Episode 2", true, true, false, false)
assertEpisode(t, episodes[2], 3, "Episode 3", false, true, false, false)
}
func TestIsCanonicalEpisodePayloadValidAllowsProviderPayloadWhenNoExpectedCount(t *testing.T) {
payload := domain.CanonicalEpisodeList{
Source: "AllAnime",
Episodes: []domain.CanonicalEpisode{
{Number: 1, HasSub: true},
{Number: 2, HasDub: true},
},
}
if !isCanonicalEpisodePayloadValid(payload, 0) {
t.Fatalf("expected provider-backed payload with availability to be valid")
}
}
func TestIsCanonicalEpisodePayloadValidRejectsOutOfRangeEpisodeNumber(t *testing.T) {
payload := domain.CanonicalEpisodeList{
Episodes: []domain.CanonicalEpisode{
{Number: 0, Title: "Invalid"},
},
}
if isCanonicalEpisodePayloadValid(payload, 12) {
t.Fatalf("expected zero episode number to be invalid")
}
}
func TestNextRefreshAtForFinishedAnimeIsEmpty(t *testing.T) {
now := time.Date(2026, 5, 16, 13, 0, 0, 0, time.UTC)
got := nextRefreshAt(domain.Anime{Anime: jikan.Anime{Airing: false}}, now)
if got.Valid {
t.Fatalf("nextRefreshAt finished anime = %s, want invalid", got.Time)
}
}
func TestNextRefreshAtRetriesSoonAfterRecentBroadcast(t *testing.T) {
anime := domain.Anime{Anime: jikan.Anime{Airing: true}}
anime.Broadcast.Day = "Saturdays"
anime.Broadcast.Time = "12:00"
anime.Broadcast.Timezone = "UTC"
now := time.Date(2026, 5, 16, 13, 0, 0, 0, time.UTC)
got := nextRefreshAt(anime, now)
want := now.Add(retryInterval).UTC()
if !got.Valid || !got.Time.Equal(want) {
t.Fatalf("nextRefreshAt = %#v, want %s", got, want)
}
}
func TestNextRefreshAtFallsBackWhenBroadcastMetadataMissing(t *testing.T) {
anime := domain.Anime{Anime: jikan.Anime{Airing: true}}
now := time.Date(2026, 5, 16, 13, 0, 0, 0, time.UTC)
got := nextRefreshAt(anime, now)
want := now.Add(airingFallbackRefreshInterval).UTC()
if !got.Valid || !got.Time.Equal(want) {
t.Fatalf("nextRefreshAt = %#v, want %s", got, want)
}
}
func TestBroadcastHelpersRejectInvalidValues(t *testing.T) {
if day := weekdayFromJikan("someday"); day != -1 {
t.Fatalf("weekdayFromJikan invalid = %d, want -1", day)
}
if _, _, ok := parseBroadcastTime("25:99"); ok {
t.Fatalf("parseBroadcastTime should reject invalid time")
}
anime := domain.Anime{Anime: jikan.Anime{MalID: 1}}
anime.Broadcast.Day = "Saturdays"
anime.Broadcast.Time = "bad"
if got := nextBroadcastAfter(anime, time.Date(2026, 5, 16, 13, 0, 0, 0, time.UTC)); !got.IsZero() {
t.Fatalf("nextBroadcastAfter invalid time = %s, want zero", got)
}
}

View File

@@ -45,7 +45,6 @@ func (s *EpisodeService) cachedProviderID(ctx context.Context, anime domain.Anim
Provider: provider.Name(),
})
if err != nil {
s.metrics.ObserveCache("episode_provider_mapping", "miss")
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
@@ -63,15 +62,12 @@ func (s *EpisodeService) cachedProviderID(ctx context.Context, anime domain.Anim
}
if row.FailedUntil.Valid && row.FailedUntil.Time.After(s.clock.Now()) {
s.metrics.ObserveCache("episode_provider_mapping", "hit")
return "", true, fmt.Errorf("cached provider mapping failure active until %s: %s", row.FailedUntil.Time.Format(time.RFC3339), row.LastError)
}
if strings.TrimSpace(row.ProviderShowID) == "" {
s.metrics.ObserveCache("episode_provider_mapping", "miss")
return "", false, nil
}
s.metrics.ObserveCache("episode_provider_mapping", "hit")
observability.Info(
"episodes_provider_id_cache_hit",
"episodes",
@@ -86,13 +82,27 @@ func (s *EpisodeService) cachedProviderID(ctx context.Context, anime domain.Anim
}
func (s *EpisodeService) cacheProviderIDFailure(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, resolveErr error) {
_ = s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{
err := s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{
AnimeID: int64(anime.MalID),
Provider: provider.Name(),
ProviderShowID: "",
FailedUntil: sql.NullTime{Time: s.clock.Now().Add(time.Hour), Valid: true},
LastError: truncate(resolveErr.Error(), 400),
})
if err == nil {
return
}
observability.Warn(
"episodes_provider_id_cache_write_failed",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"provider": provider.Name(),
},
err,
)
}
func (s *EpisodeService) cacheProviderIDSuccess(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, providerID string) {

View File

@@ -26,21 +26,19 @@ type EpisodeService struct {
providers []domain.EpisodeAvailabilityProvider
clock Clock
enabled bool
metrics *observability.Metrics
}
func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, metrics *observability.Metrics) domain.EpisodeService {
return NewEpisodeServiceWithClock(queries, jikanClient, providers, enabled, realClock{}, metrics)
func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool) domain.EpisodeService {
return NewEpisodeServiceWithClock(queries, jikanClient, providers, enabled, realClock{})
}
func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock, metrics *observability.Metrics) *EpisodeService {
func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock) *EpisodeService {
return &EpisodeService{
queries: queries,
jikan: jikanClient,
providers: providers,
clock: clock,
enabled: enabled,
metrics: metrics,
}
}
@@ -140,10 +138,10 @@ func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domai
)
}
providerAvailability, source, providerErr := s.fetchProviderAvailability(ctx, anime)
availability, source, providerErr := s.fetchProviderAvailability(ctx, anime)
if providerErr != nil {
s.markFailure(ctx, anime, providerErr)
if cached, ok := s.getCached(ctx, anime.MalID); ok {
if cached, ok := s.getDecodedCached(ctx, anime); ok {
observability.Warn(
"episodes_provider_failed_serving_stale_cache",
"episodes",
@@ -167,7 +165,7 @@ func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domai
return domain.CanonicalEpisodeList{}, providerErr
}
return s.store(ctx, anime, jikanEpisodes, providerAvailability, source, now, true)
return s.store(ctx, anime, jikanEpisodes, availability, source, now, true)
}
func (s *EpisodeService) fetchProviderAvailability(ctx context.Context, anime domain.Anime) (domain.EpisodeAvailability, string, error) {

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