Compare commits

...

368 Commits

Author SHA1 Message Date
2e26a82aa7 feat: populate duration_seconds on anime upsert and add backfill fix
Some checks failed
Build and Push Container Image / build-and-push (push) Has been cancelled
2026-06-08 08:32:10 +02:00
b319b2d93d test: add HLS playlist rewrite and detection tests 2026-06-08 08:32:10 +02:00
e13330367d feat: add HLS playlist rewriting to proxy stream 2026-06-08 08:32:10 +02:00
600c8dec2e refactor: replace HMAC proxy tokens with in-memory store 2026-06-08 08:32:10 +02:00
162265a3f3 refactor: update playback domain types and interfaces 2026-06-08 08:32:10 +02:00
9e3185c04e style: migrate watch page to v4 syntax 2026-06-08 08:32:10 +02:00
b8a89b7d2d style: migrate schedule page to v4 syntax 2026-06-08 08:32:10 +02:00
295afa6b59 style: migrate index page to v4 syntax 2026-06-08 08:32:10 +02:00
633ed066d4 style: migrate filter_bar and video_player to v4 syntax 2026-06-08 08:32:10 +02:00
15ac8e4065 style: migrate anime page, watchlist_actions, and watchlist to v4 syntax 2026-06-08 08:32:10 +02:00
f12df9b515 style: migrate z-index/scrollbar in continue_watching, size shorthands in login 2026-06-08 08:32:09 +02:00
b81bc63042 style: migrate shadow variable syntax in dropdown component 2026-06-08 08:32:09 +02:00
4e375adcee style: migrate z-index syntax in toast 2026-06-08 08:32:09 +02:00
b87a8feb1b style: migrate important modifier syntax in browse and discover 2026-06-08 08:32:09 +02:00
7142e7745e chore(deps): bump tailwindcss from 4.2.4 to 4.3.0 2026-06-08 08:32:09 +02:00
5311640056 fix: update anime page layout 2026-06-08 08:32:09 +02:00
24d77cfe98 fix: handle edge cases in continue watching carousel 2026-06-08 08:32:09 +02:00
5c10bd1a5a feat: add continue watching carousel 2026-06-08 08:32:09 +02:00
550d594f00 test: add tests for mergeEpisodes capping and cache validation 2026-06-08 08:32:09 +02:00
a328d72665 feat: cap episode numbers to expected count and validate cached payload 2026-06-08 08:32:09 +02:00
97477807d4 feat: add visual filler/recap indicator in episode list 2026-06-08 08:32:09 +02:00
731b13a2aa refactor: move video source construction from inline script to initPlayer 2026-06-08 08:32:09 +02:00
b01eec3925 refactor: update anime page scripts 2026-06-08 08:32:09 +02:00
ac02fb9b71 refactor: simplify dedupe module 2026-06-08 08:32:09 +02:00
44786455b4 refactor: streamline mobile menu with event delegation 2026-06-08 08:32:09 +02:00
037a8abd1b feat: improve command palette focus management and aria 2026-06-08 08:32:09 +02:00
33b0d4b3c6 feat: add htmx error toast on error class swap 2026-06-08 08:32:09 +02:00
9b2846af33 refactor: read watchlist IDs from JSON script tag instead of global var 2026-06-08 08:32:09 +02:00
81966520a1 refactor: switch watchlist IDs from global to JSON script tag 2026-06-08 08:32:09 +02:00
072f565c1b refactor: replace inline theme dialog script with data attributes 2026-06-08 08:32:09 +02:00
c9b3df573e refactor: replace inline scripts with module scripts block 2026-06-08 08:32:09 +02:00
d6390acf3c refactor: use browseURL helper and simplify filter bar templates 2026-06-08 08:32:09 +02:00
103b6acb9a test: add tests for browseURL helper 2026-06-08 08:32:09 +02:00
cd38bbad16 refactor: add browseURL template helper for filter URLs 2026-06-08 08:32:09 +02:00
407bda720e feat: improve dropdown accessibility with aria and focus management 2026-06-08 08:32:09 +02:00
26509e6741 refactor: consolidate scripts into single app.js entry point 2026-06-08 08:32:09 +02:00
6c5bfd95c1 feat: add app entry point, password toggle, and schedule modules 2026-06-08 08:32:09 +02:00
2b7aef0072 refactor: migrate from htmx:afterSwap to onHtmxLoad 2026-06-08 08:32:09 +02:00
0482a43ac7 refactor: replace DOMContentLoaded with onReady utility 2026-06-08 08:32:09 +02:00
61218c2676 feat: add onHtmxLoad and closestFocusable utilities 2026-06-08 08:32:09 +02:00
64d62e79ce refactor: remove docs folder 2026-06-08 08:32:09 +02:00
77971d611c feat: add top picks for you page 2026-06-08 08:32:09 +02:00
7d3aea8625 test: verify diversity reranker spreads repeated genres 2026-06-08 08:32:09 +02:00
0cd8f8563d feat: add multi-feature diversity reranker for recommendations 2026-06-08 08:32:09 +02:00
31a59b60b8 feat: dedupe after htmx swap on swap target 2026-06-08 08:32:09 +02:00
cd55def040 refactor: scope dedupe to parent container 2026-06-08 08:32:09 +02:00
388a1623aa refactor: remove theme toggle from navigation 2026-06-08 08:32:09 +02:00
ce9b6efe46 refactor: remove theme toggle from footer 2026-06-08 08:32:09 +02:00
b2f6db8ae1 feat: add inline theme script to prevent FOUC 2026-06-08 08:32:09 +02:00
2df8b7863d refactor: follow system color scheme via matchMedia listener 2026-06-08 08:32:09 +02:00
351640e604 refactor: remove unused htmx global type declaration 2026-06-08 08:32:09 +02:00
41be0fc923 style: add color-scheme for light and dark themes 2026-06-08 08:32:09 +02:00
e235f36a45 feat: add top pick for you section to homepage 2026-06-08 08:32:09 +02:00
8e66581f6c test: add weighted taste profile and search query tests 2026-06-08 08:32:09 +02:00
9b92f37cb1 feat: integrate profile search into top pick service 2026-06-08 08:32:09 +02:00
ed48aa340c feat: add profile search query builders and weighted scoring 2026-06-08 08:32:09 +02:00
c13895b7cd feat: timezone-aware schedule with browser tz and JST client conversion 2026-06-08 08:32:09 +02:00
7ebfe4807b feat: show audio availability on anime detail page 2026-06-08 08:32:09 +02:00
1327cb3b86 refactor: try sub and dub modes in allanime resolution, drop fallback 2026-06-08 08:32:09 +02:00
16ee2ed0ee fix: polish watch page layout and button consistency 2026-06-08 08:32:09 +02:00
91e0280ec7 refactor: use recommendation engine in discover for-you 2026-06-08 08:32:09 +02:00
f880205f5c feat: add recommendation scoring and reranking engine 2026-06-08 08:32:09 +02:00
fcdfd0a623 docs: add recommendation architecture document 2026-06-08 08:32:09 +02:00
32d7301788 feat: add loading fragment templates and optimize section triggers 2026-06-08 08:32:09 +02:00
136afa05a5 feat: wire background warming for detail sections 2026-06-08 08:32:09 +02:00
63802bfc5a feat: warm anime recommendations in background 2026-06-08 08:32:09 +02:00
3f482b69be feat: stale-while-revalidate cache for watch order 2026-06-08 08:32:09 +02:00
b0429ead6e feat: add htmx type declarations and process on ready 2026-06-08 08:32:09 +02:00
d15e1a33b6 feat: bundle htmx.org locally instead of loading from unpkg 2026-06-08 08:32:09 +02:00
d82eeecfc0 refactor: replace discover for-you swap with targeted htmx fragment 2026-06-08 08:32:09 +02:00
41be636c4d redesign: schedule page layout with scrollable calendar grid 2026-06-08 08:32:09 +02:00
95b1e2b93e refactor: consolidate skeleton styles into global css 2026-06-08 08:32:09 +02:00
1e6e619a3f test: add skip segment overrides table check 2026-06-08 08:32:09 +02:00
8c0f345bde refactor: share jst helpers 2026-06-08 08:32:09 +02:00
322cdac21d refactor: dedupe scrub seek 2026-06-08 08:32:09 +02:00
b7f10e71da refactor: dedupe next nav 2026-06-08 08:32:09 +02:00
2863c3e7ef refactor: share stream url 2026-06-08 08:32:09 +02:00
e269d15199 refactor: share dom ready 2026-06-08 08:32:09 +02:00
4a1467467c refactor: dedupe html fetch 2026-06-08 08:32:09 +02:00
34f52428a2 refactor: dedupe html headers 2026-06-08 08:32:09 +02:00
b9ad50b67a refactor: dedupe repo tx 2026-06-08 08:32:09 +02:00
be27625a3c refactor: dedupe jikan refresh 2026-06-08 08:32:09 +02:00
085fe3e83d test: dedupe jikan bool cases 2026-06-08 08:32:09 +02:00
0e92c2ce25 refactor: dedupe season fetch 2026-06-08 08:32:09 +02:00
9e4e3214f7 refactor: dedupe anime warnings 2026-06-08 08:32:09 +02:00
fa078c7de6 refactor: dedupe allanime requests 2026-06-08 08:32:09 +02:00
0cc9207755 refactor: dedupe watchlist ids 2026-06-08 08:32:09 +02:00
b9e1cc9aeb refactor: dedupe proxy handlers 2026-06-08 08:32:09 +02:00
04b7a1e3ee refactor: dedupe browse render 2026-06-08 08:32:09 +02:00
433ed28514 refactor: dedupe allanime sources 2026-06-08 08:32:09 +02:00
b35acfcce3 fix: hide scrollbar on studio and genre dropdowns 2026-06-08 08:32:09 +02:00
0f85c1b405 fix: hide episode list scrollbar on desktop 2026-06-08 08:32:09 +02:00
7c548c4d31 fix: give toggle inactive state a solid background 2026-06-08 08:32:09 +02:00
6253bc5b63 fix: open More dropdown upward on watch page 2026-06-08 08:32:09 +02:00
28c453847a docs: add package comments to public and template packages 2026-06-08 08:32:09 +02:00
399f68a7f2 docs: add package comments to server and watchlist packages 2026-06-08 08:32:09 +02:00
f818bd4395 docs: add package comments to playback packages 2026-06-08 08:32:09 +02:00
d77952522a docs: add package comments to anime and episodes packages 2026-06-08 08:32:09 +02:00
ab519a5361 docs: add package comments to data layer packages 2026-06-08 08:32:09 +02:00
6303d3c83c docs: add package comments to auth and audit packages 2026-06-08 08:32:09 +02:00
cc2b885c76 docs: add package comments to core infrastructure packages 2026-06-08 08:32:09 +02:00
e3051d8860 docs: add package comments to integrations 2026-06-08 08:32:09 +02:00
5cf7fe7e8e feat: refacotr cmd/user/main.go 2026-06-08 08:32:09 +02:00
555c4f2f9b refactor: extract generic graphql client 2026-06-08 08:32:09 +02:00
65405402a8 chore: cleanup 2026-06-08 08:32:09 +02:00
2f7af1f739 feat: extract video module and add mode-switch fallback 2026-06-08 08:32:09 +02:00
be7994b806 fix: sort scraped schedule entries by time within each day 2026-06-08 08:32:09 +02:00
e200fa5fa6 style: format cmd/readme table alignment 2026-06-08 08:32:09 +02:00
fbc9eeeb86 docs: improve readmes for cmd and template components 2026-06-08 08:32:09 +02:00
704b03655b fix: episode refresh resilience and allanime fallback 2026-06-08 08:32:09 +02:00
9383e132e7 docs: clarify animeschedule api key is optional 2026-06-08 08:32:09 +02:00
420e748bab fix: remove forgejo ci/cd 2026-06-08 08:32:09 +02:00
0bb4da858b feat: add create-user cli to image 2026-06-08 08:32:09 +02:00
8c3ff3df94 feat: add end-state detection and prevent airing auto-complete 2026-06-08 08:32:09 +02:00
c044c30bd8 feat: add airing status and end-state helpers to player 2026-06-08 08:32:09 +02:00
faf0a4db9f fix: preserve watchlist progress on complete and status update 2026-06-08 08:32:09 +02:00
9e8d479ce0 refactor: redesign schedule with responsive grid and expanded spacing 2026-06-08 08:32:09 +02:00
0d25099b91 feat: prefer english titles from animeschedule api 2026-06-08 08:32:09 +02:00
532e03d354 refactor: decompose anime handler and parallelize for-you fetches 2026-06-08 08:32:09 +02:00
0a0b4895de refactor: remove CONFLICTS.md and inline avatar URL from migration 2026-06-08 08:32:09 +02:00
bf28c307c9 refactor: extract CurrentUser and CurrentUserID helpers 2026-06-08 08:32:09 +02:00
8454d01b09 refactor: remove unused watchlist partial template 2026-06-08 08:32:09 +02:00
324dcc29b5 refactor: replace regex parser with JSON walker in allanime extractor 2026-06-08 08:32:09 +02:00
0fd478cadb refactor: update template embed to remove anime subdirectory 2026-06-08 08:32:09 +02:00
23e7a417b2 refactor: update backfill migration to use internal.DefaultAvatarURL 2026-06-08 08:32:09 +02:00
089d79bc5f refactor: update user CLI to use internal.DefaultAvatarURL 2026-06-08 08:32:09 +02:00
0ec987f39f refactor: update audit middleware to use flattened audit package 2026-06-08 08:32:09 +02:00
e0126c964e refactor: update watchlist module imports for flattened package structure 2026-06-08 08:32:09 +02:00
7ff407bafa refactor: update playback module imports for flattened package structure 2026-06-08 08:32:09 +02:00
b6604629fc refactor: update auth module imports for flattened package structure 2026-06-08 08:32:09 +02:00
8a207d383c refactor: update audit module imports for flattened package structure 2026-06-08 08:32:09 +02:00
59b1e0513b refactor: update anime module imports for flattened package structure 2026-06-08 08:32:09 +02:00
10c2d50d23 refactor: move reviews template from subdirectory 2026-06-08 08:32:09 +02:00
cd26b24252 refactor: move watchlist service from subdirectory 2026-06-08 08:32:09 +02:00
9c8075eedd refactor: move watchlist repository from subdirectory 2026-06-08 08:32:09 +02:00
6bb9b06ebf refactor: move watchlist handler from subdirectory 2026-06-08 08:32:09 +02:00
198786d743 refactor: move playback service from subdirectory 2026-06-08 08:32:09 +02:00
d6b96068fb refactor: move playback repository from subdirectory 2026-06-08 08:32:09 +02:00
4aac57d40d refactor: move anime service from subdirectory 2026-06-08 08:32:09 +02:00
219dbe0f4b refactor: move anime repository from subdirectory 2026-06-08 08:32:09 +02:00
a71fab0c35 refactor: move anime handler from subdirectory 2026-06-08 08:32:09 +02:00
f80a52b171 refactor: move auth service from subdirectory 2026-06-08 08:32:09 +02:00
e6ab45da74 refactor: move auth repository from subdirectory 2026-06-08 08:32:09 +02:00
bc90145fca refactor: move auth middleware from subdirectory 2026-06-08 08:32:09 +02:00
7a6765c1bd refactor: move auth handler from subdirectory 2026-06-08 08:32:09 +02:00
0695fb7472 refactor: move audit service test from internal/audit/service to internal/audit 2026-06-08 08:32:09 +02:00
3853e4a327 refactor: move audit service from internal/audit/service to internal/audit 2026-06-08 08:32:09 +02:00
5909a46803 refactor: move audit context from internal/auditctx to internal/audit 2026-06-08 08:32:09 +02:00
2068e6b0b7 refactor: move avatar from internal/users to internal 2026-06-08 08:32:09 +02:00
2091f0f365 refactor: update playback handler imports for flattened pkg/net 2026-06-08 08:32:09 +02:00
7e6153afa1 refactor: update watchorder imports for flattened pkg/net 2026-06-08 08:32:09 +02:00
4b690ebd99 refactor: update allanime client imports for flattened pkg/net 2026-06-08 08:32:09 +02:00
5b8988ff14 refactor: update jikan imports for flattened pkg/net 2026-06-08 08:32:09 +02:00
e3fe31fff7 refactor: update animeschedule imports for flattened pkg/net 2026-06-08 08:32:09 +02:00
cef7d1055a refactor: flatten pkg/net/utls into pkg/net 2026-06-08 08:32:09 +02:00
c2831f8aca refactor: flatten pkg/net/useragent into pkg/net 2026-06-08 08:32:09 +02:00
47a7aa8e81 refactor: flatten pkg/net/proxytransport into pkg/net 2026-06-08 08:32:09 +02:00
3b6d1b6439 refactor: flatten pkg/net/limits into pkg/net 2026-06-08 08:32:09 +02:00
34b8b96a62 refactor: move utls client from package var to provider field 2026-06-08 08:32:09 +02:00
2df19af6ad refactor: centralize avatar URL generation and backfill existing users 2026-06-08 08:32:09 +02:00
d528f6b372 feat: add transactional InTx to playback and watchlist repos 2026-06-08 08:32:09 +02:00
86586ed344 refactor: decouple domain types from jikan 2026-06-08 08:32:09 +02:00
4a4ed6ef02 refactor: switch playback to AnimePlaybackService interface 2026-06-08 08:32:08 +02:00
3accf85f99 refactor: wire anime handler to use new service interfaces via fx 2026-06-08 08:32:08 +02:00
931ee7f493 refactor: split AnimeService into segregated interfaces 2026-06-08 08:32:08 +02:00
a57b0b79de chore: format player main 2026-06-08 08:32:08 +02:00
5a11343a19 chore: format player controls 2026-06-08 08:32:08 +02:00
ea63544998 chore: format player skip editor 2026-06-08 08:32:08 +02:00
95a434cd04 chore: format player skip index and segments 2026-06-08 08:32:08 +02:00
c2650aae07 chore: format player subtitles 2026-06-08 08:32:08 +02:00
e500af6102 chore: format player episode nav and ui 2026-06-08 08:32:08 +02:00
df1e65f5c2 chore: format player episode complete and thumbnails 2026-06-08 08:32:08 +02:00
1c4ade5e6c chore: format player mode and state 2026-06-08 08:32:08 +02:00
4c2c54229b chore: format player progress quality keyboard 2026-06-08 08:32:08 +02:00
2172d32dc6 chore: format player storage and timeline 2026-06-08 08:32:08 +02:00
d66eb79295 chore: format watchlist 2026-06-08 08:32:08 +02:00
3c121cb1ac chore: format search 2026-06-08 08:32:08 +02:00
bd979cdb68 chore: format schedule_board 2026-06-08 08:32:08 +02:00
fbf94970fa chore: format toast and sort_filter 2026-06-08 08:32:08 +02:00
ecd11f70c3 chore: format theme and timezone 2026-06-08 08:32:08 +02:00
6a5cf4f375 chore: format htmx and shell 2026-06-08 08:32:08 +02:00
7aff463580 chore: format discover and dropdown 2026-06-08 08:32:08 +02:00
1536590fa2 chore: format anime and dedupe 2026-06-08 08:32:08 +02:00
c2afb6eafc chore: format style.css 2026-06-08 08:32:08 +02:00
a92d2b46c8 chore: format small utility files 2026-06-08 08:32:08 +02:00
fdfe082e45 chore: format scripts/new-data-fix.ts 2026-06-08 08:32:08 +02:00
44563959ca chore: update bun.lock for oxlint and oxfmt 2026-06-08 08:32:08 +02:00
a3a9b01794 ci: replace prettier and eslint with oxfmt and oxlint 2026-06-08 08:32:08 +02:00
003c94f62f chore: replace eslint and prettier with oxlint and oxfmt 2026-06-08 08:32:08 +02:00
dba96e6713 chore: update lefthook hooks for oxlint and oxfmt 2026-06-08 08:32:08 +02:00
b4c31b04dd chore: remove eslint config 2026-06-08 08:32:08 +02:00
78378f79fa feat: add oxfmt configuration 2026-06-08 08:32:08 +02:00
193c8d78a1 feat: add oxlint configuration 2026-06-08 08:32:08 +02:00
a4f46c67a2 ci: gracefully skip docker build if unavailable 2026-06-08 08:32:08 +02:00
429974dc33 docs: remove ci section from readme 2026-06-08 08:32:08 +02:00
1df47ccc02 ci: use golangci-lint v2 install path 2026-06-08 08:32:08 +02:00
e25aba4d70 ci: add forgejo actions workflows 2026-06-08 08:32:08 +02:00
580b17e5b9 chore: formatting 2026-06-08 08:32:08 +02:00
2b167a8df8 fix: pre push is no more 2026-06-08 08:32:08 +02:00
f44d6def6b chore: formatting 2026-06-08 08:32:08 +02:00
23eb2f9a1b fix: remove redundant type declaration 2026-06-08 08:32:08 +02:00
a92bb0287c docs: document ANIMESCHEDULE_API_TOKEN in readme 2026-06-08 08:32:08 +02:00
73cad8f7d5 refine: adjust schedule board spacing and grid layout 2026-06-08 08:32:08 +02:00
c23b298f26 chore: remove debug logging from animeschedule integration 2026-06-08 08:32:08 +02:00
318de9cb74 feat: wire scraped schedule into handler with caching and week nav 2026-06-08 08:32:08 +02:00
228003b013 feat: add schedule board client logic 2026-06-08 08:32:08 +02:00
feeeb89cfc feat: add animeschedule integration 2026-06-08 08:32:08 +02:00
Gitea Action
f04b148b43 chore(deploy): update image to latest 2026-05-27 08:00:54 +00:00
Gitea Action
6f3ca3e21b chore(deploy): update image to latest
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 7m39s
2026-05-27 09:48:29 +02:00
331d6fbbb9 Merge branch 'main' of github.com:/mkelvers/mal into dev 2026-05-27 09:47:21 +02:00
6450233fea feat: persist volume to localStorage 2026-05-26 23:18:06 +02:00
25bd91934c fix: add root and entry-naming flags to ts build 2026-05-26 23:14:39 +02:00
95116de349 feat: add input placeholders to login form 2026-05-26 23:13:07 +02:00
91db8a5fe0 refactor: remove cookie-based theme persistence 2026-05-26 23:11:33 +02:00
f70e2e4bcd fix: add POST /login to public routes 2026-05-26 23:08:03 +02:00
eb9e682b75 chore: formatting 2026-05-26 22:51:50 +02:00
509ce93904 chore: remove fix checklist 2026-05-26 22:50:16 +02:00
447f540b44 chore: trim conflicts 2026-05-26 22:49:12 +02:00
a5fdd8b999 chore: format 2026-05-26 22:49:00 +02:00
95ca4dd892 docs: add conflicts 2026-05-26 22:48:53 +02:00
e9576d7584 refactor: domain anime type 2026-05-26 22:45:16 +02:00
5a054d250e refactor: domain auth types 2026-05-26 22:41:29 +02:00
65a7b0f50d refactor: typed proxy key 2026-05-26 22:40:09 +02:00
b8521d2219 fix: validate player json 2026-05-26 22:39:03 +02:00
edbd83f8e8 refactor: share time formatter 2026-05-26 22:38:19 +02:00
c9059be57b fix: color skip segments 2026-05-26 22:37:51 +02:00
afbe74d975 perf: subtitles binary search 2026-05-26 22:37:30 +02:00
9938bf6c57 fix: stop swallowing errors 2026-05-26 22:36:41 +02:00
91bf399ebc fix: remove inline onclick 2026-05-26 22:35:02 +02:00
b63a5c48a2 fix: remove inline watchlist js 2026-05-26 22:33:27 +02:00
2a266c6b1e fix: wire nav collapse 2026-05-26 22:30:14 +02:00
28df1fc5f7 chore: drop empty fxtags 2026-05-26 22:28:57 +02:00
1165458cfa fix: complete db querier 2026-05-26 22:28:19 +02:00
8bed032a44 chore: update checklist 2026-05-26 22:27:46 +02:00
f2a319af4d fix: goose tx for user rebuild 2026-05-26 22:26:15 +02:00
627421255d fix: wrap user rebuild migration 2026-05-26 22:25:49 +02:00
cce840e7f5 fix: harden subtitle cache 2026-05-26 22:25:22 +02:00
7279eac949 fix: avoid metrics panic 2026-05-26 22:24:59 +02:00
4ffa6af298 fix: add jikan user-agent 2026-05-26 22:24:45 +02:00
7bff60f08a fix: browse genres params 2026-05-26 22:24:29 +02:00
4e8ba7205b fix: unify handler errors 2026-05-26 22:23:59 +02:00
c6090604ef fix: sqlite concurrency defaults 2026-05-26 22:21:09 +02:00
30441c3e1f fix: reinit player safely 2026-05-26 22:20:26 +02:00
6da80df655 build: fix dist static output 2026-05-26 22:12:18 +02:00
083c0ee0c9 chore: small fixes 2026-05-26 21:40:54 +02:00
8785c19b66 chore: go fixes 2026-05-26 21:38:05 +02:00
3e79f62805 style: wrap long query selector in getRenderedWatchlistIds 2026-05-26 20:29:39 +02:00
50159286b4 fix: sync server-rendered watchlist state to client 2026-05-26 20:29:19 +02:00
749a275dc0 feat: add schedule page 2026-05-26 20:16:14 +02:00
71dd130744 feat: add For You recommendations to discover 2026-05-26 20:16:09 +02:00
f2b4a7994a fix: remove redundant anime_id conversion 2026-05-26 16:20:43 +02:00
518370842c fix: satisfy staticcheck in json logger 2026-05-26 16:20:31 +02:00
68225cbb52 fix: pass config to jikan client in test 2026-05-26 16:18:06 +02:00
e24ae1d113 style: fix import ordering in app and audit test 2026-05-26 16:18:00 +02:00
9c3636f31a style: align struct fields in config, domain, and auth 2026-05-26 16:17:54 +02:00
ff8f760750 chore: remove trailing newlines across packages 2026-05-26 16:17:48 +02:00
5f4010901a chore: remove unused strings import from renderer 2026-05-26 16:14:43 +02:00
57be9a5d70 feat: record audit events for watch progress and completion 2026-05-26 16:14:37 +02:00
6dd84976de feat: record audit events for api token creation and revocation 2026-05-26 16:14:31 +02:00
a303c131f1 feat: wire audit module and middleware into app 2026-05-26 16:14:26 +02:00
dfe3c6b7d8 feat: add audit service and request context middleware 2026-05-26 16:14:20 +02:00
51bfc9d2af feat: add audit log sqlc queries and generated code 2026-05-26 16:14:14 +02:00
90e7a9323a feat: add audit_log table migration 2026-05-26 16:14:08 +02:00
1feee731cf feat: add audit request info context helpers 2026-05-26 16:14:02 +02:00
fa91c2a22d feat: add audit event domain type and service interface 2026-05-26 16:13:56 +02:00
f196862aeb refactor: extract template funcs into separate file 2026-05-26 15:59:21 +02:00
118c028873 feat: add structured error response helpers 2026-05-26 15:57:29 +02:00
28251876e1 fix: handle mac.Write errors in proxy token signing 2026-05-26 15:56:55 +02:00
3331c96c06 fix: propagate rand.Read error in token generation 2026-05-26 15:56:49 +02:00
4fc79bc692 refactor: migrate user CLI logs to observability 2026-05-26 15:56:43 +02:00
96307d2979 refactor: migrate database logs to observability 2026-05-26 15:56:38 +02:00
e08a0e1f71 refactor: migrate episodes logs to observability 2026-05-26 15:56:33 +02:00
d64dbaf7df refactor: migrate handler logs to observability 2026-05-26 15:56:27 +02:00
d787625435 refactor: migrate jikan relations logs to observability 2026-05-26 15:56:22 +02:00
3f496ac65c refactor: migrate server logs to observability 2026-05-26 15:56:16 +02:00
8daad49061 feat: add observability Info/Warn/Error helpers 2026-05-26 15:56:10 +02:00
e99070c6d4 fix: use config.Config for database path 2026-05-26 15:41:49 +02:00
513bfe07f2 refactor: migrate template renderer to embedded fs 2026-05-26 15:41:22 +02:00
1e9874a482 refactor: migrate env-var reads to config package 2026-05-26 15:38:14 +02:00
26ff84d70f feat: add central config package 2026-05-26 15:38:08 +02:00
82072b256d refactor: extract public route check into declarative table 2026-05-26 15:32:28 +02:00
f8ba6db3d6 fix: use constant-time comparison for proxy token signature 2026-05-26 15:31:37 +02:00
a190ca417d chore: remove trailing newlines in data fixes 2026-05-26 15:30:33 +02:00
4bf31fb511 fix: log and abort on missing catalog/discover sections 2026-05-26 15:30:28 +02:00
46cff45d0e refactor: extract data fixes into dedicated package 2026-05-26 15:19:40 +02:00
ab5476d3d2 chore: chmod entrypoint executable 2026-05-26 14:04:50 +02:00
f4061c0213 chore: add run-fixes cli 2026-05-26 13:56:57 +02:00
1eb28dad64 fix: formatting and typecheck 2026-05-26 13:49:44 +02:00
76a32e1dc4 feat: add new-data-fix scaffolding script 2026-05-26 13:48:38 +02:00
4af68021f6 feat: backfill null next_refresh_at in episode cache 2026-05-26 13:48:33 +02:00
36213edd60 feat: add data fix framework 2026-05-26 13:48:31 +02:00
f5dfb91ffe chore: formatting 2026-05-26 13:40:27 +02:00
f5fd50d472 fix: episode refresh lag for airing shows 2026-05-26 13:17:59 +02:00
698fcc9b5b docs: tighten README to opener and essentials only 2026-05-25 20:28:04 +02:00
b95427998c chore: delete screenshot 2026-05-25 20:20:56 +02:00
b6e06870aa docs: rewrite README with prose focus and screenshot 2026-05-25 20:19:28 +02:00
246fa7439d chore: delete docker/entrypoint.sh 2026-05-25 19:56:37 +02:00
53abdace1c chore: restructure Dockerfile and move entrypoint to root 2026-05-25 19:55:21 +02:00
76a92894e8 chore: formatting 2026-05-25 18:24:09 +02:00
3a0e04dda9 feat: add studio filter UI and studio links on anime page 2026-05-25 17:59:22 +02:00
29c0c0bb18 feat: add studio filter to search pipeline 2026-05-25 17:59:17 +02:00
e54d6b8142 feat: add producer data types and caching 2026-05-25 17:59:11 +02:00
f4a9453514 fix: standardize watchlist partial styles 2026-05-25 01:57:21 +02:00
a9dfb77bc4 fix: standardize command palette styles
Add ring, border separator, font-normal, and focus-visible styles to search and command palette.
2026-05-25 01:55:28 +02:00
48b5523d95 style: format segment editor 2026-05-25 01:55:23 +02:00
345c3b05f7 fix: standardize watch page and player dropdown styles 2026-05-25 01:54:30 +02:00
585b02b37a fix: improve segment editor accessibility and modal behavior 2026-05-25 01:54:25 +02:00
c480a9be1f fix: standardize anime detail page and review styles 2026-05-25 01:46:53 +02:00
fe39e094d8 fix: standardize watchlist filter tabs and empty state 2026-05-25 01:43:56 +02:00
f9c1fc9391 fix: standardize empty state and grid styles 2026-05-25 01:41:31 +02:00
900e56d7ca fix: standardize headings and button styles 2026-05-25 01:37:30 +02:00
019a519b81 fix: improve accessibility and visual consistency 2026-05-25 01:34:54 +02:00
28bfbe5257 fix: improve accessibility and keyboard navigation 2026-05-25 01:31:05 +02:00
6932d4b8d0 refactor: extract inline JS to modules 2026-05-25 01:16:02 +02:00
83f64a1dfe fix: add aria attributes and cleanup to toast system 2026-05-25 01:15:56 +02:00
44a36e3fb7 feat: improve theme system with cookie and prefers-color-scheme 2026-05-25 01:15:50 +02:00
931398fa67 refactor: use maps.Copy from stdlib 2026-05-25 01:15:45 +02:00
f13f7b7fc6 style: fix gofmt indentation 2026-05-25 01:15:39 +02:00
e0749066ec chore: add node types for typecheck 2026-05-24 22:47:52 +02:00
233beb609c fix: satisfy typecheck in player 2026-05-24 22:47:44 +02:00
e87b79bbe1 fix: add package comments to cmd 2026-05-24 22:46:21 +02:00
624a02c49d fix: satisfy staticcheck in integrations 2026-05-24 22:46:14 +02:00
5d7518afd9 fix: ignore close errors in tests and queries 2026-05-24 22:46:08 +02:00
4606c790f1 fix: handle backend errors and driver import 2026-05-24 22:46:02 +02:00
05e963151c chore: configure strict golangci-lint 2026-05-24 22:45:51 +02:00
6012ba824f fix: use type-only imports in player 2026-05-24 22:45:36 +02:00
2324d2a8e6 fix: use array shorthand in thumbnails 2026-05-24 22:45:24 +02:00
36f1961c9e fix: remove noop arrow functions in player 2026-05-24 22:45:04 +02:00
aa650068b1 fix: avoid unused expressions in overlay 2026-05-24 22:44:53 +02:00
0edc8feb8d fix: prefer interfaces in static types 2026-05-24 22:44:47 +02:00
258c676e89 fix: simplify dropdown boolean fields 2026-05-24 22:44:39 +02:00
fc1883a6c3 feat: setup stricter linting 2026-05-24 22:36:41 +02:00
e022b60920 chore: remove @toolwind/anchors 2026-05-24 22:32:29 +02:00
ea831b3e2d refactor: restyle progress bar, scrubber and preview popover 2026-05-24 21:14:23 +02:00
6e41bb2789 fix: manage preview popover hidden class properly 2026-05-24 21:14:13 +02:00
650b2e614a refactor: use explicit hex color for skip segments 2026-05-24 21:13:42 +02:00
7c1045df93 refactor: update accent color to #00b3c4 2026-05-24 21:13:34 +02:00
31b763b714 refactor: remove redundant current relation override 2026-05-24 20:50:52 +02:00
679c26e43f feat: show only episodes in current range, update label 2026-05-24 20:31:06 +02:00
bdf09ccdb7 refactor: close episode dropdown on range selection 2026-05-24 20:30:44 +02:00
ae0ac66c2a feat: add atoi and idiv template functions 2026-05-24 20:30:04 +02:00
2cf5bc2017 refactor: restructure episode controls for high episode counts 2026-05-24 20:20:50 +02:00
e25b0acf7d refactor: remove rounded from watch order dropdowns 2026-05-24 20:15:58 +02:00
54aca51e2b refactor: remove watch page borders and update filler/recap indicator 2026-05-24 20:07:36 +02:00
3cd7302c9c refactor: remove remaining border and ring classes 2026-05-24 20:07:28 +02:00
df0c00a2f9 feat: add theme toggle to sidebar 2026-05-24 20:07:09 +02:00
125b2e2510 feat: add login page background image 2026-05-24 20:07:01 +02:00
7e3e138fee feat: redesign login page with password toggle 2026-05-24 20:06:53 +02:00
79a518d941 refactor: inline scrollbar styles as tailwind arbitrary 2026-05-24 20:06:44 +02:00
cfaf6e6640 refactor: replace custom css utilities with tailwind arbitrary 2026-05-24 20:06:32 +02:00
da9bb56d80 fix: continue watching label 2026-05-24 02:48:07 +02:00
4403301f72 fix: allow progress requests 2026-05-24 02:34:05 +02:00
c0606ef938 fix: use session cookie for progress 2026-05-24 02:31:27 +02:00
2ac8660435 fix: save progress on player actions 2026-05-24 02:29:54 +02:00
9da9edae7f fix: restore command palette overlay 2026-05-24 02:27:35 +02:00
323c503581 fix: unstyle watch list menu 2026-05-24 02:13:22 +02:00
0e1bf7a36f fix: unstyle watchlist options 2026-05-24 02:12:28 +02:00
f6f95bc164 fix: unstyle settings mode buttons 2026-05-24 02:10:53 +02:00
391a4f750c fix: normalize button styling 2026-05-24 02:09:25 +02:00
905e00ef6a fix: restore mobile drawer 2026-05-24 02:09:10 +02:00
07a6b6e4aa fix: keep sidebar collapsed 2026-05-24 02:04:28 +02:00
ad3817dfee fix: reserve continue watching space 2026-05-24 01:50:24 +02:00
065e3fd7d6 fix: improve form accessibility 2026-05-24 01:48:14 +02:00
bfb8cc0274 fix: player dropdown light-mode visibility 2026-05-24 01:45:39 +02:00
7a18461ca6 fix: add warn levels to observability logs 2026-05-23 18:16:03 +02:00
f33c2e18af refactor: emit structured json logs 2026-05-23 18:08:43 +02:00
c2e4cae253 feat: add observability metrics 2026-05-23 17:13:18 +02:00
767e056aad feat: remove firefox extension 2026-05-23 16:32:08 +02:00
199 changed files with 12403 additions and 5017 deletions

54
.golangci.yml Normal file
View File

@@ -0,0 +1,54 @@
version: "2"
linters:
default: none
enable:
- copyloopvar
- errcheck
- govet
- ineffassign
- revive
- staticcheck
- unconvert
- unused
settings:
revive:
enable-all-rules: false
rules:
- name: blank-imports
- name: context-as-argument
- name: context-keys-type
- name: early-return
- name: error-naming
- name: error-return
- name: if-return
- name: increment-decrement
- name: range
- name: receiver-naming
- name: time-naming
- name: unnecessary-stmt
- name: var-declaration
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
- node_modules$
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

4
.oxfmtrc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": []
}

4
.oxlintignore Normal file
View File

@@ -0,0 +1,4 @@
dist/**
node_modules/**
server
*.js

15
.oxlintrc.json Normal file
View File

@@ -0,0 +1,15 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "unicorn", "oxc"],
"categories": {
"correctness": "error"
},
"rules": {
"typescript/unbound-method": "off",
"typescript/no-base-to-string": "off",
"typescript/no-floating-promises": "off"
},
"env": {
"builtin": true
}
}

View File

@@ -5,14 +5,22 @@ WORKDIR /app
# Enable CGO for sqlite3
ENV CGO_ENABLED=1
# Install sqlc for code generation
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
unzip \
gcc \
libc6-dev \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
# Install build dependencies for bun + assets
RUN apt-get update && apt-get install -y ca-certificates sqlite3 curl unzip && rm -rf /var/lib/apt/lists/*
# Install bun (for building frontend assets)
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
# Install sqlc for code generation
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0
ENV GOPROXY=direct
COPY go.mod go.sum ./
RUN go mod download
@@ -31,6 +39,7 @@ RUN sqlc generate
# 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
FROM debian:bookworm-slim
@@ -46,11 +55,12 @@ 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/templates ./templates
COPY --from=builder /app/static ./static
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/internal/database/migrations ./migrations
COPY docker/entrypoint.sh ./entrypoint.sh
COPY entrypoint.sh ./entrypoint.sh
EXPOSE 3000

127
README.md
View File

@@ -1,136 +1,71 @@
# MyAnimeList
<table align="center">
<tr>
<td>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/static/assets/readme-logo-dark.svg" />
<img src="/static/assets/readme-logo-light.svg" alt="MyAnimeList logo" width="140" />
</picture>
</td>
<td>
<strong>MyAnimeList</strong><br />
My personal anime tracker, built because nothing else felt right.
</td>
</tr>
</table>
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/static/assets/readme-logo-dark.svg" />
<img src="/static/assets/readme-logo-light.svg" alt="MyAnimeList logo" width="120" />
</picture>
</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="Tailwind" src="https://img.shields.io/badge/tailwind-4-06B6D4?style=flat-square&logo=tailwindcss" />
<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" />
</p>
---
## Why this project exists
I built this because nothing else felt right. Every tracker I tried had decent pieces but the whole never clicked — awkward UI, missing features, or it just got in the way of actually watching anime. So I built one that fits how I work.
I built this for myself.
It is a self-hosted Go server that streams anime through a proxy layer, catalogs metadata, and tracks your progress.
I was frustrated with the UI and UX of every tracker I tried. Even when something looked decent, it still felt awkward to use day-to-day, or it was missing pieces I considered essential. I wanted one place that matched how I actually watch anime: search fast, get context fast, update status fast, and move on.
So this project is personal first and public second. I put it on GitHub because I like shipping in the open, not because it was originally designed as a general-purpose product for everyone.
Technically, I also wanted to prove that a small, server-rendered Go app could stay reliable even when upstream anime APIs are inconsistent. A lot of this code exists because real APIs rate-limit, timeout, and occasionally fail at the worst possible moment.
## What the application offers
For my own workflow, MyAnimeList combines catalog browsing, seasonal discovery, quick search, detail pages with recommendations and relations, watchlist management, continue-watching, and in-app playback in one server-rendered interface.
The interface is minimal and functional, featuring a dark theme and quick access to tracking tools.
## Technical approach
The application is written in Go and rendered on the server with `html/template`, with SQLite as the primary datastore and `sqlc` for typed query generation. Styling uses Tailwind CSS v4. HTMX and small TypeScript modules handle incremental interactions, which keeps the interface responsive without moving the entire product into a heavy client-side architecture.
The external anime data source is Jikan (`https://api.jikan.moe/v4`). Because reliability is a first-class concern, the client layer includes request pacing, bounded retries, backoff behavior, stale-cache fallback, and a persisted retry queue for failed fetches. Playback proxying uses uTLS to bypass Cloudflare protections.
Upstream APIs can fail transiently with `429` and `5xx` responses, so the app favors graceful degradation over hard failure. Cached values are used when fresh requests fail, retryable failures are persisted and replayed in a background worker, and relation synchronization is incremental so one bad fetch does not block the rest of the graph.
The frontend is Tailwind CSS v4 with HTMX handling pagination, infinite scroll, search, and watchlist interactions. TypeScript only steps in where HTMX cannot — the video player, command palette bound to Cmd+K, skip segment editor, theme toggling with system preference detection, and custom UI components. Everything lives in one process, one SQLite database, one deployment.
## Repository structure
The codebase follows standard Go project layout conventions.
| Path | Purpose |
| ----------------- | ------------------------------------------------ |
| `api/*` | Feature routes: anime, auth, playback, watchlist |
| `cmd/server` | Application entrypoint and CLI commands |
| `cmd/user` | User management CLI (create, update, delete) |
| `integrations/*` | External API clients and scraping |
| `internal/*` | Core services: db, middleware, server, worker |
| `pkg/middleware` | Generic HTTP middleware |
| `templates/*` | Server-rendered HTML templates |
| `migrations` | Schema evolution |
| `migrations` | Schema evolution (20 migrations) |
| `static` / `dist` | Frontend assets |
## Getting started
## Running locally
Requires Go `1.25+`, Bun, and [just](https://github.com/casey/just) (`brew install just`).
Requires Go `1.25+`, Bun, and [just](https://github.com/casey/just). Migrations run on startup. Configuration lives in environment variables — see `cmd/server/main.go` for the full list.
An optional API key from [animeschedule.net](https://animeschedule.net) can be used for the schedule board to enable English titles and improve performance. Create an account, generate a token under your profile, and set it as `ANIMESCHEDULE_API_TOKEN`.
```bash
git clone https://github.com/mkelvers/mal.git && cd mal
openssl rand -base32 32
PLAYBACK_PROXY_SECRET="your-32-char-secret" go run ./cmd/server
go run ./cmd/user <username> <password>
just dev
```
The app runs at `http://localhost:3000`.
### Tasks
The justfile automates common tasks:
```bash
just fmt # format go code
just lint # go fmt && go vet
just test # run go tests
just build # build go binary + frontend
just check # lint, test, typecheck, build
just dev # build and run
just install-hooks # install pre-push hooks
```
### Docker
```bash
docker build -t mal .
docker run --rm -p 3000:3000 -e PLAYBACK_PROXY_SECRET="$(openssl rand -base32 32)" mal
# persistent data
docker run --rm -p 3000:3000 \
-e DATABASE_FILE=/app/data/mal.db \
-e PLAYBACK_PROXY_SECRET="your-secret" \
-v "$(pwd)/data:/app/data" \
mal
docker exec mal ./cmd/user <username> <password>
```
## Configuration
| Variable | Default | Description |
| ----------------------- | ------------------- | ----------------------------------------------------------- |
| `PORT` | `3000` | HTTP listen port |
| `DATABASE_FILE` | `mal.db` | SQLite database file path |
| `ENV` | _(empty)_ | Set to `production` to enable secure session cookies |
| `MIGRATIONS_DIR` | _(auto-discovered)_ | Optional explicit path to migration files |
| `PLAYBACK_PROXY_SECRET` | _(required)_ | HMAC secret for signed playback proxy tokens (min 32 chars) |
| `MAL_JIKAN_TRACE` | `false` | Log all Jikan cache/upstream timings when enabled |
## Testing
Run locally with `just check` or manually:
## Quality checks
```bash
gofmt -l .
go test ./...
go build -o server ./cmd/server
golangci-lint run ./...
go mod tidy
go test -race ./...
bunx oxfmt --check
bun run lint:ts
bun run typecheck
bun run build:assets
docker build -t mal:ci .
```
Migrations run automatically on startup.
## Contributing
## Security
Keep secrets out of version control, do not publish real credentials in documentation or screenshots, and report security issues privately before public disclosure.
Bug reports and pull requests are welcome. This is a personal project, so there is no strict roadmap or issue triage cycle. If something is broken or missing, open an issue or send a PR.
## License
This project is released under the MIT License. See `LICENSE` for details.
MIT. See `LICENSE`.

333
bun.lock
View File

@@ -5,49 +5,22 @@
"": {
"name": "myanimelist-ui",
"dependencies": {
"dompurify": "^3.4.1",
"htmx.org": "1.9.12",
},
"devDependencies": {
"@tailwindcss/cli": "^4.2.4",
"@toolwind/anchors": "^1.0.10",
"@typescript-eslint/eslint-plugin": "^8.59.2",
"@typescript-eslint/parser": "^8.59.2",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"@tailwindcss/cli": "^4.3.0",
"@types/node": "^24.0.0",
"jiti": "^2.7.0",
"lefthook": "^2.1.6",
"prettier": "^3.8.3",
"tailwindcss": "^4.2.4",
"oxfmt": "^0.52.0",
"oxlint": "^1.67.0",
"oxlint-tsgolint": "^0.23.0",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
},
},
},
"packages": {
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="],
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -58,6 +31,94 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.52.0", "", { "os": "android", "cpu": "arm" }, "sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ=="],
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.52.0", "", { "os": "android", "cpu": "arm64" }, "sha512-A2G1IdwGEW2lLJkIxcvuirRH1CzSl/e0NX11zTlW1gvxJThfwbI/BEoaKrTNpm7M2FchvIf6guvIQU7d5iz+OQ=="],
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.52.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-f9+bLvOYxy7NttCLFTvQ7afmqDOWY4wIP9xdvfj5trQ1qj6f2UFAGwZESlfsMjvJNTyRpXfIlOanCI9FOvoeQA=="],
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.52.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-YSTB9sJ5nnQd/Q0ddHkgof0ZCHPAnWZT1IW2SJ8omz7CP7KluJhO1fNHrpqdxCtpztJwSs4hY1uAee35wKxxaw=="],
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.52.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-NIrRNTTPCs4UbmVs0bxLSCDlLCtIRMJIXklNKaXa5Oj2/K1UIMBvgE8+uPVo01Io3N9HF0+GAX+aAHjUgZS7vA=="],
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JXUCde8mn3GpgQouz2PXUokgy/uT1QrRJBL2s983VWcSQp62wTFYiNXgTKdeo1Jgbr0IgUnKKvzIk/YBlj/nVQ=="],
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-psbUXaRZ+V8DaXz10Qf7LSHtdtdKAmC8fxXgeU608jjzrmWK4quamZMOpl6sf+dikoFHA85uE93Q0BqxrCdQrQ=="],
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Jw7MgWUU9lcLCcy82updISP3EthTlfvAwR6gWNxPzqly7+fLvOi2gHQE9xXQjpqaVLm/8P+gOzlv9ODuoVlaaw=="],
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg=="],
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.52.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA=="],
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA=="],
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw=="],
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.52.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg=="],
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA=="],
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw=="],
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.52.0", "", { "os": "none", "cpu": "arm64" }, "sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ=="],
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.52.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-k2sz6gWQdMfh5HPpIS+Bw/0UEV/kaK2xuqJRrWL233sEHx9WLlsmvlPFM4HUNThkYbSN0U0vPW7LVKZWDS8hPQ=="],
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.52.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-rhke69GTcArodLHpjMTfNnvjTEBryDeZcUCKK/VjXDMtfTULl6QRh0ymX5/hbCUv2WjYm9h/QbW++q2vE15gWQ=="],
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.52.0", "", { "os": "win32", "cpu": "x64" }, "sha512-q5xL7oeXkZdEtNZWBdvehJcmt+GRu9l2bK40yJs1jJXlqq+r0Hygb1rTjq+FM2o/2xyt4cufH6KRplHp3Jjsvw=="],
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.23.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw=="],
"@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.23.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA=="],
"@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.23.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw=="],
"@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.23.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA=="],
"@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.23.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw=="],
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.23.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.67.0", "", { "os": "android", "cpu": "arm" }, "sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.67.0", "", { "os": "android", "cpu": "arm64" }, "sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.67.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.67.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.67.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.67.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.67.0", "", { "os": "linux", "cpu": "none" }, "sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.67.0", "", { "os": "linux", "cpu": "none" }, "sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.67.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.67.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.67.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.67.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.67.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ=="],
"@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
@@ -86,154 +147,52 @@
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
"@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
"@tailwindcss/cli": ["@tailwindcss/cli@4.3.0", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "enhanced-resolve": "^5.21.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.3.0" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-X9kdlqyMopO9fewbgHsEeuy31YzMHbdZ9VsKt004tB+mxSg1CNbyhZYCzvhciN0AM4R4b5lvIprPjtNq7iQxpQ=="],
"@tailwindcss/cli": ["@tailwindcss/cli@4.2.4", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "enhanced-resolve": "^5.19.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.2.4" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-e87GGhuXxnyQPyA0TS8an/3wNpj+OUmx8u0F4BicYr48TF72032AIu5917rRYaWm7HorXi3GSZ/uG+ohqP6AKA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="],
"@toolwind/anchors": ["@toolwind/anchors@1.0.10", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0" } }, "sha512-F3J/lxGGPUy+GIpT49NmYMF1X7l0d7UzdDASni29il2ro5sT4cYfPBFHBAfOM0lpgKOr/HnqINlomngt8BcvnA=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dompurify": ["dompurify@3.4.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw=="],
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.3.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw=="],
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
"eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.5", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.12" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw=="],
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"htmx.org": ["htmx.org@1.9.12", "", {}, "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"lefthook": ["lefthook@2.1.6", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.6", "lefthook-darwin-x64": "2.1.6", "lefthook-freebsd-arm64": "2.1.6", "lefthook-freebsd-x64": "2.1.6", "lefthook-linux-arm64": "2.1.6", "lefthook-linux-x64": "2.1.6", "lefthook-openbsd-arm64": "2.1.6", "lefthook-openbsd-x64": "2.1.6", "lefthook-windows-arm64": "2.1.6", "lefthook-windows-x64": "2.1.6" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-w9sBoR0mdN+kJc3SB85VzpiAAl451/rxdCRcZlwW71QLjkeH3EBQFgc4VMj5apePychYDHAlqEWTB8J8JK/j1Q=="],
"lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ=="],
@@ -256,8 +215,6 @@
"lefthook-windows-x64": ["lefthook-windows-x64@2.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-q4z2n3xucLscoWiyMwFViEj3N8MDSkPulMwcJYuCYFHoPhP1h+icqNu7QRLGYj6AnVrCQweiUJY3Tb2X+GbD/A=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -282,88 +239,44 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"oxfmt": ["oxfmt@0.52.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.52.0", "@oxfmt/binding-android-arm64": "0.52.0", "@oxfmt/binding-darwin-arm64": "0.52.0", "@oxfmt/binding-darwin-x64": "0.52.0", "@oxfmt/binding-freebsd-x64": "0.52.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.52.0", "@oxfmt/binding-linux-arm-musleabihf": "0.52.0", "@oxfmt/binding-linux-arm64-gnu": "0.52.0", "@oxfmt/binding-linux-arm64-musl": "0.52.0", "@oxfmt/binding-linux-ppc64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-musl": "0.52.0", "@oxfmt/binding-linux-s390x-gnu": "0.52.0", "@oxfmt/binding-linux-x64-gnu": "0.52.0", "@oxfmt/binding-linux-x64-musl": "0.52.0", "@oxfmt/binding-openharmony-arm64": "0.52.0", "@oxfmt/binding-win32-arm64-msvc": "0.52.0", "@oxfmt/binding-win32-ia32-msvc": "0.52.0", "@oxfmt/binding-win32-x64-msvc": "0.52.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"oxlint": ["oxlint@1.67.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.67.0", "@oxlint/binding-android-arm64": "1.67.0", "@oxlint/binding-darwin-arm64": "1.67.0", "@oxlint/binding-darwin-x64": "1.67.0", "@oxlint/binding-freebsd-x64": "1.67.0", "@oxlint/binding-linux-arm-gnueabihf": "1.67.0", "@oxlint/binding-linux-arm-musleabihf": "1.67.0", "@oxlint/binding-linux-arm64-gnu": "1.67.0", "@oxlint/binding-linux-arm64-musl": "1.67.0", "@oxlint/binding-linux-ppc64-gnu": "1.67.0", "@oxlint/binding-linux-riscv64-gnu": "1.67.0", "@oxlint/binding-linux-riscv64-musl": "1.67.0", "@oxlint/binding-linux-s390x-gnu": "1.67.0", "@oxlint/binding-linux-x64-gnu": "1.67.0", "@oxlint/binding-linux-x64-musl": "1.67.0", "@oxlint/binding-openharmony-arm64": "1.67.0", "@oxlint/binding-win32-arm64-msvc": "1.67.0", "@oxlint/binding-win32-ia32-msvc": "1.67.0", "@oxlint/binding-win32-x64-msvc": "1.67.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"oxlint-tsgolint": ["oxlint-tsgolint@0.23.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.23.0", "@oxlint-tsgolint/darwin-x64": "0.23.0", "@oxlint-tsgolint/linux-arm64": "0.23.0", "@oxlint-tsgolint/linux-x64": "0.23.0", "@oxlint-tsgolint/win32-arm64": "0.23.0", "@oxlint-tsgolint/win32-x64": "0.23.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
"prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
"tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
}
}

View File

@@ -1,8 +1,14 @@
# cmd
Executables live here.
Application entrypoints.
| binary | purpose |
| ------------ | ----------------- |
| `cmd/server` | web server |
| `cmd/user` | user creation CLI |
| 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

@@ -1,3 +1,4 @@
// Package main runs the MAL web server.
package main
import (

View File

@@ -1,87 +1,194 @@
// Package main provides small CLI utilities for local admin tasks.
package main
import (
"bufio"
"database/sql"
"errors"
"fmt"
"log"
"os"
"strings"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"mal/internal"
"mal/internal/config"
"mal/internal/database"
"mal/internal/db"
"mal/internal/observability"
)
func main() {
dbConn, err := db.Open(db.GetDBFile())
cfg, err := config.Load()
if err != nil {
log.Fatalf("failed to open db: %v", err)
observability.Error("cli_config_load_failed", "cmd/user", "", nil, 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() }()
if len(os.Args) == 2 && os.Args[1] == "update-avatar" {
os.Exit(run(dbConn, os.Args))
}
func run(dbConn *sql.DB, args []string) int {
cmd, err := parseArgs(args)
if err != nil {
observability.Warn("cli_usage", "cmd/user", "invalid arguments", map[string]any{"argc": len(args)}, err)
_, _ = fmt.Fprintln(os.Stderr, usage())
return 2
}
switch cmd.kind {
case commandUpdateAvatar:
updateAvatars(dbConn)
return
return 0
case commandRunFixes:
runFixes(dbConn)
return 0
case commandCreateOrUpdateUser:
if err := createOrUpdateUser(dbConn, cmd.username, cmd.password); err != nil {
return 1
}
return 0
default:
observability.Error("cli_command_unreachable", "cmd/user", "", map[string]any{"kind": cmd.kind}, errors.New("unhandled command"))
return 1
}
}
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) {
if len(args) == 2 {
switch args[1] {
case string(commandUpdateAvatar):
return command{kind: commandUpdateAvatar}, nil
case string(commandRunFixes):
return command{kind: commandRunFixes}, nil
}
}
if len(os.Args) != 3 {
log.Fatalf("Usage: go run cmd/user/main.go <username> <password>\n go run cmd/user/main.go update-avatar")
if len(args) == 3 {
return command{
kind: commandCreateOrUpdateUser,
username: args[1],
password: args[2],
}, nil
}
username := os.Args[1]
password := os.Args[2]
return command{}, errors.New("invalid arguments")
}
var existingID string
err = dbConn.QueryRow("SELECT id FROM user WHERE username = ?", username).Scan(&existingID)
if err != nil && err != sql.ErrNoRows {
log.Fatalf("database error: %v", err)
func usage() string {
return "Usage: go run cmd/user/main.go <username> <password>\n go run cmd/user/main.go update-avatar\n go run cmd/user/main.go run-fixes"
}
func createOrUpdateUser(dbConn *sql.DB, username string, password string) error {
existingID, err := lookupUserID(dbConn, username)
if err != nil {
observability.Error("cli_user_lookup_failed", "cmd/user", "", map[string]any{"username": username}, err)
return err
}
if err == nil {
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))
if response != "y" && response != "yes" {
if existingID != "" {
if !promptConfirmOverwrite(username) {
fmt.Println("Operation cancelled.")
return
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
log.Fatalf("failed to hash password: %v", err)
if err := updateUserPassword(dbConn, existingID, username, password); err != nil {
return err
}
_, err = dbConn.Exec("UPDATE user SET password_hash = ? WHERE id = ?", string(hash), existingID)
if err != nil {
log.Fatalf("failed to update user: %v", err)
}
fmt.Printf("Password for '%s' updated successfully!\n", username)
return
return nil
}
if err := createUser(dbConn, username, password); err != nil {
return err
}
fmt.Printf("User '%s' was created successfully!\n", username)
return nil
}
func lookupUserID(dbConn *sql.DB, username string) (string, error) {
var id string
err := dbConn.QueryRow("SELECT id FROM user WHERE username = ?", username).Scan(&id)
if err == nil {
return id, nil
}
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return "", err
}
func promptConfirmOverwrite(username string) bool {
fmt.Printf("User '%s' already exists. Do you want to overwrite their password? [y/N]: ", username)
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
return response == "y" || response == "yes"
}
func updateUserPassword(dbConn *sql.DB, userID string, username string, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
log.Fatalf("failed to hash password: %v", err)
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
return err
}
_, err = dbConn.Exec("UPDATE user SET password_hash = ? WHERE id = ?", string(hash), userID)
if err != nil {
observability.Error("cli_user_password_update_failed", "cmd/user", "", map[string]any{"username": username}, err)
return err
}
return nil
}
func createUser(dbConn *sql.DB, username string, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
return err
}
id := uuid.New().String()
avatarURL := fmt.Sprintf("https://api.dicebear.com/9.x/dylan/svg?seed=%s", username)
_, err = dbConn.Exec("INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)", id, username, string(hash), avatarURL)
avatarURL := internal.DefaultAvatarURL(username)
_, err = dbConn.Exec(
"INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)",
id,
username,
string(hash),
avatarURL,
)
if err != nil {
log.Fatalf("failed to create user: %v", err)
observability.Error("cli_user_create_failed", "cmd/user", "", map[string]any{"username": username}, err)
return err
}
fmt.Printf("User '%s' was created successfully!\n", username)
return nil
}
func updateAvatars(dbConn *sql.DB) {
rows, err := dbConn.Query("SELECT id, username FROM user")
if err != nil {
log.Fatalf("failed to fetch users: %v", err)
observability.Error("cli_users_list_failed", "cmd/user", "", nil, err)
os.Exit(1)
}
defer func() { _ = rows.Close() }()
@@ -89,20 +196,55 @@ func updateAvatars(dbConn *sql.DB) {
for rows.Next() {
var id, username string
if err := rows.Scan(&id, &username); err != nil {
log.Fatalf("failed to scan user: %v", err)
observability.Error("cli_user_scan_failed", "cmd/user", "", nil, err)
os.Exit(1)
}
avatarURL := fmt.Sprintf("https://api.dicebear.com/9.x/dylan/svg?seed=%s", username)
avatarURL := internal.DefaultAvatarURL(username)
_, err := dbConn.Exec("UPDATE user SET avatar_url = ? WHERE id = ?", avatarURL, id)
if err != nil {
log.Fatalf("failed to update avatar for %s: %v", username, err)
observability.Error("cli_user_avatar_update_failed", "cmd/user", "", map[string]any{"username": username}, err)
os.Exit(1)
}
count++
}
if err := rows.Err(); err != nil {
log.Fatalf("iteration error: %v", err)
observability.Error("cli_users_iter_failed", "cmd/user", "", nil, err)
os.Exit(1)
}
fmt.Printf("Updated avatars for %d user(s)\n", count)
}
func runFixes(dbConn *sql.DB) {
if err := database.RunMigrationsAndFixes(dbConn); err != nil {
observability.Error("cli_run_migrations_and_fixes_failed", "cmd/user", "", nil, err)
os.Exit(1)
}
rows, err := dbConn.Query("SELECT id, applied_at FROM data_fixes ORDER BY id ASC")
if err != nil {
observability.Error("cli_data_fixes_list_failed", "cmd/user", "", nil, err)
os.Exit(1)
}
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 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)
}

View File

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

View File

@@ -9,3 +9,4 @@ if [ ! -x /app/main_server ]; then
fi
exec /app/main_server

View File

@@ -1,29 +0,0 @@
import tseslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import prettier from 'eslint-plugin-prettier';
import eslintConfigPrettier from 'eslint-config-prettier';
export default [
{
ignores: ['dist/**', 'node_modules/**', 'server', '*.js'],
},
{
files: ['**/*.ts'],
plugins: {
'@typescript-eslint': tseslint,
prettier,
},
languageOptions: {
parser: tsParser,
},
rules: {
...eslintConfigPrettier.rules,
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
'prettier/prettier': 'error',
},
},
];

View File

@@ -1,12 +0,0 @@
# MAL Firefox Extension (dev)
## Load in Firefox
1. Open `about:debugging#/runtime/this-firefox`
2. Click **Load Temporary Add-on…**
3. Select `extensions/mal-firefox/manifest.json`
## Usage
- Click the toolbar icon to open the popup and log in.
- After login, select text on any page → right click → **MyAnimeList****Add to Watchlist** → pick a status.

View File

@@ -1,103 +0,0 @@
const MENU_ROOT_ID = 'mal-root';
const MENU_WATCHLIST_ID = 'mal-watchlist';
const MENU_STATUS_PREFIX = 'mal-status:';
const STATUSES = [
{ value: 'watching', label: 'Watching' },
{ value: 'completed', label: 'Completed' },
{ value: 'on_hold', label: 'On Hold' },
{ value: 'dropped', label: 'Dropped' },
{ value: 'plan_to_watch', label: 'Plan to Watch' },
];
async function getSettings() {
const { authToken, apiBaseUrl } = await browser.storage.local.get(['authToken', 'apiBaseUrl']);
return {
authToken: authToken || '',
apiBaseUrl: apiBaseUrl || 'https://mal.mkelvers.tech',
};
}
async function apiFetch(path, init = {}) {
const { authToken, apiBaseUrl } = await getSettings();
const url = apiBaseUrl.replace(/\/+$/, '') + path;
const headers = new Headers(init.headers || {});
if (authToken) headers.set('Authorization', `Bearer ${authToken}`);
const res = await fetch(url, { ...init, headers });
if (!res.ok) {
const msg = await res.text().catch(() => '');
throw new Error(msg || `HTTP ${res.status}`);
}
return res;
}
async function ensureContextMenu() {
const { authToken } = await getSettings();
await browser.contextMenus.removeAll();
if (!authToken) return;
browser.contextMenus.create({
id: MENU_ROOT_ID,
title: 'MyAnimeList',
contexts: ['selection'],
});
browser.contextMenus.create({
id: MENU_WATCHLIST_ID,
parentId: MENU_ROOT_ID,
title: 'Add to Watchlist',
contexts: ['selection'],
});
for (const s of STATUSES) {
browser.contextMenus.create({
id: MENU_STATUS_PREFIX + s.value,
parentId: MENU_WATCHLIST_ID,
title: s.label,
contexts: ['selection'],
});
}
}
browser.runtime.onInstalled.addListener(() => {
ensureContextMenu();
});
browser.runtime.onStartup.addListener(() => {
ensureContextMenu();
});
browser.storage.onChanged.addListener((changes, area) => {
if (area !== 'local') return;
if (changes.authToken) ensureContextMenu();
});
browser.contextMenus.onClicked.addListener(async info => {
if (typeof info.menuItemId !== 'string') return;
if (!info.menuItemId.startsWith(MENU_STATUS_PREFIX)) return;
const status = info.menuItemId.slice(MENU_STATUS_PREFIX.length);
const text = (info.selectionText || '').trim().replace(/\s+/g, ' ').slice(0, 120);
if (!text) return;
try {
const searchRes = await apiFetch(`/api/search-quick?q=${encodeURIComponent(text)}`);
const items = await searchRes.json();
const top = items && items[0];
if (!top || !top.id) {
await browser.notifications?.create?.({
type: 'basic',
title: 'MyAnimeList',
message: `No matches for: ${text}`,
});
return;
}
await apiFetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ animeId: top.id, status }),
});
} catch {
// Silent failure by default; can be extended with notifications later.
}
});

View File

@@ -1,23 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<defs>
<radialGradient id="bg" cx="35%" cy="35%" r="75%">
<stop offset="0%" style="stop-color: var(--accent, #0466c8)" />
<stop offset="100%" style="stop-color: var(--accent-dark, #1d4ed8)" />
</radialGradient>
<clipPath id="clip">
<circle cx="50" cy="50" r="45" />
</clipPath>
</defs>
<!-- Base -->
<circle cx="50" cy="50" r="45" fill="url(#bg)" />
<!-- Crescent moon cutout -->
<g clip-path="url(#clip)">
<path
d="M70 50a25 25 0 1 1 -25 -25 20 20 0 1 0 25 25z"
fill="#FFF7ED"
transform="translate(-2 -2)"
/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 685 B

View File

@@ -1,18 +0,0 @@
{
"manifest_version": 3,
"name": "MyAnimeList",
"version": "0.1.0",
"description": "Right-click selected anime titles and add them to your watchlist.",
"permissions": ["contextMenus", "storage"],
"host_permissions": ["<all_urls>"],
"background": {
"scripts": ["background.js"]
},
"action": {
"default_title": "MAL Watchlist",
"default_popup": "popup.html"
},
"icons": {
"48": "icon.svg"
}
}

View File

@@ -1,229 +0,0 @@
:root {
color-scheme: light dark;
--bg: #0b0f1a;
--card: rgba(255, 255, 255, 0.06);
--border: rgba(255, 255, 255, 0.12);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.65);
--accent: #6ea8fe;
--danger: #ff6b6b;
--ok: #4ade80;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f6f7fb;
--card: rgba(0, 0, 0, 0.03);
--border: rgba(0, 0, 0, 0.1);
--text: rgba(0, 0, 0, 0.88);
--muted: rgba(0, 0, 0, 0.6);
--accent: #1f6feb;
--danger: #b42318;
}
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font:
14px/1.4 system-ui,
-apple-system,
Segoe UI,
Roboto,
sans-serif;
}
body {
width: 380px;
min-width: 380px;
}
#app {
padding: 10px;
}
.panel {
background: transparent;
border-radius: 0;
padding: 12px;
display: grid;
gap: 10px;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
}
.brandIcon {
width: 28px;
height: 28px;
border-radius: 8px;
}
.title {
font-weight: 650;
letter-spacing: 0.2px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.link {
background: transparent;
color: var(--accent);
border: 0;
padding: 6px 0;
cursor: pointer;
}
.divider {
height: 1px;
background: transparent;
opacity: 0.9;
}
.subtitle {
font-weight: 600;
color: var(--muted);
}
.label {
display: grid;
gap: 4px;
color: var(--muted);
}
.input {
width: 100%;
box-sizing: border-box;
padding: 9px 10px;
border-radius: 0;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.15);
color: var(--text);
outline: none;
}
.input:focus {
border: 1px solid var(--border);
outline: none;
}
.btn {
width: 100%;
padding: 10px 12px;
border-radius: 0;
border: 0;
background: rgba(110, 168, 254, 0.18);
color: var(--text);
cursor: pointer;
}
.btn.danger {
background: rgba(255, 107, 107, 0.18);
}
.error {
color: var(--danger);
}
.body {
color: var(--muted);
}
.login {
display: grid;
gap: 8px;
}
.statusRow {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
}
.statusDot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--ok);
}
.statusText {
font-size: 12px;
}
[hidden] {
display: none !important;
}
.list {
display: grid;
gap: 8px;
}
.item {
display: grid;
grid-template-columns: 44px 1fr;
gap: 10px;
align-items: center;
padding: 8px;
border-radius: 10px;
border: 0;
}
.thumb {
width: 44px;
height: 62px;
border-radius: 8px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
}
.meta {
display: grid;
gap: 4px;
}
.metaTitle {
font-weight: 650;
}
.metaSub {
color: var(--muted);
font-size: 12px;
}
.row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.select {
padding: 8px 10px;
border-radius: 10px;
border: 0;
background: rgba(0, 0, 0, 0.15);
color: var(--text);
flex: 1;
}
.mini {
padding: 8px 10px;
border-radius: 10px;
border: 0;
background: rgba(110, 168, 254, 0.18);
color: var(--text);
cursor: pointer;
}

View File

@@ -1,51 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MAL Watchlist</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<div id="app">
<section class="panel">
<header class="header">
<div class="brand">
<img class="brandIcon" src="icon.svg" alt="" />
<div class="title">MyAnimeList</div>
</div>
<button id="logoutBtn" class="link" hidden>Log out</button>
</header>
<div class="divider"></div>
<div class="body">
Select an anime title on any page, then right click to open the context menu. Under
“MyAnimeList”, choose “Add to Watchlist” and pick a status to save it to your watchlist.
</div>
<div class="divider"></div>
<div id="loggedIn" class="statusRow" hidden>
<div class="statusDot"></div>
<div class="statusText">Signed in — context menu enabled</div>
</div>
<div id="login" class="login" hidden>
<label class="label">
Username
<input id="username" class="input" autocomplete="username" />
</label>
<label class="label">
Password
<input id="password" class="input" type="password" autocomplete="current-password" />
</label>
<button id="loginBtn" class="btn">Log in</button>
<div id="loginErr" class="error" hidden></div>
</div>
</section>
</div>
<script src="popup.js"></script>
</body>
</html>

View File

@@ -1,74 +0,0 @@
function qs(id) {
return document.getElementById(id);
}
async function getSettings() {
const { authToken, apiBaseUrl } = await browser.storage.local.get(['authToken', 'apiBaseUrl']);
return {
authToken: authToken || '',
apiBaseUrl: apiBaseUrl || 'https://mal.mkelvers.tech',
};
}
async function setSettings(patch) {
await browser.storage.local.set(patch);
}
function show(el, on) {
el.hidden = !on;
}
async function render() {
const settings = await getSettings();
document.body.dataset.state = settings.authToken ? 'in' : 'out';
const logoutBtn = qs('logoutBtn');
logoutBtn.addEventListener('click', async () => {
await setSettings({ authToken: '' });
await render();
});
const hasToken = !!settings.authToken;
show(logoutBtn, hasToken);
show(qs('login'), !hasToken);
show(qs('loggedIn'), hasToken);
if (!hasToken) {
setupLogin();
return;
}
}
function setupLogin() {
const loginErr = qs('loginErr');
show(loginErr, false);
qs('loginBtn').onclick = async () => {
show(loginErr, false);
const username = qs('username').value.trim();
const password = qs('password').value;
if (!username || !password) {
loginErr.textContent = 'Missing username or password';
show(loginErr, true);
return;
}
try {
const { apiBaseUrl } = await getSettings();
const res = await fetch(apiBaseUrl.replace(/\/+$/, '') + '/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, name: 'Firefox extension' }),
});
if (!res.ok) throw new Error('Invalid username or password');
const data = await res.json();
await setSettings({ authToken: data.token });
await render();
} catch (e) {
loginErr.textContent = e.message || 'Login failed';
show(loginErr, true);
}
};
}
render();

View File

@@ -0,0 +1,489 @@
// Package animeschedule provides an integration with the animeschedule.net API.
package animeschedule
import (
"context"
"encoding/json"
"fmt"
"io"
netutil "mal/pkg/net"
"net/http"
"net/url"
"os"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
)
type AirType string
const (
AirTypeJPN AirType = "JPN"
AirTypeSUB AirType = "SUB"
AirTypeDUB AirType = "DUB"
)
type Entry struct {
Title string
AnimeURL string
ImageURL string
EpisodeText string
AirType AirType
AirsAt time.Time
LocalTime string
DateLabel string
Weekday time.Weekday
}
type WeekSchedule struct {
Year int
Week int
Days map[time.Weekday][]Entry
}
type HTTPStatusError struct {
StatusCode int
URL string
ContentType string
BodyPreview string
}
func (e *HTTPStatusError) Error() string {
return fmt.Sprintf("unexpected status %d for %s", e.StatusCode, e.URL)
}
var reWeek = regexp.MustCompile(`(?i)[?&]week=(\d+)`)
var reYear = regexp.MustCompile(`(?i)[?&]year=(\d+)`)
func scheduleLocation(timezone string) (*time.Location, error) {
timezone = strings.TrimSpace(timezone)
if timezone == "" {
timezone = "UTC"
}
location, err := time.LoadLocation(timezone)
if err != nil {
return nil, fmt.Errorf("load schedule timezone %q: %w", timezone, err)
}
return location, nil
}
func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int, timezone string) (WeekSchedule, error) {
apiToken := strings.TrimSpace(os.Getenv("ANIMESCHEDULE_API_TOKEN"))
if apiToken != "" {
return fetchWeekAPI(ctx, httpClient, apiToken, year, week, timezone)
}
location, err := scheduleLocation(timezone)
if err != nil {
return WeekSchedule{}, err
}
u, _ := url.Parse("https://animeschedule.net/")
q := u.Query()
if year > 0 {
q.Set("year", strconv.Itoa(year))
}
if week > 0 {
q.Set("week", strconv.Itoa(week))
}
u.RawQuery = q.Encode()
doc, finalURL, err := fetchDocument(ctx, httpClient, u.String())
if err != nil {
return WeekSchedule{}, err
}
resolvedYear := year
resolvedWeek := week
if resolvedWeek == 0 {
if match := reWeek.FindStringSubmatch(finalURL); len(match) == 2 {
if v, err := strconv.Atoi(match[1]); err == nil {
resolvedWeek = v
}
}
}
if resolvedYear == 0 {
if match := reYear.FindStringSubmatch(finalURL); len(match) == 2 {
if v, err := strconv.Atoi(match[1]); err == nil {
resolvedYear = v
}
}
}
out := WeekSchedule{
Year: resolvedYear,
Week: resolvedWeek,
Days: map[time.Weekday][]Entry{},
}
doc.Find(".timetable-column").Each(func(_ int, column *goquery.Selection) {
h1 := column.Find("h1.timetable-column-date").First()
rawHeader := strings.Join(strings.Fields(strings.TrimSpace(h1.Text())), " ")
weekday := parseWeekdayFromHeader(rawHeader)
if weekday == nil {
return
}
dayEntries := make([]Entry, 0, 16)
column.Find(".timetable-column-show").Each(func(_ int, show *goquery.Selection) {
if selectionHasClass(show, "filtered-out") {
return
}
a := show.Find("a.show-link").First()
title := strings.TrimSpace(a.Find("h2").First().Text())
if title == "" {
title = strings.TrimSpace(a.Text())
}
href, _ := a.Attr("href")
animeURL := absolutizeURL("https://animeschedule.net", href)
imageURL := ""
if img := a.Find("img").First(); img != nil && img.Length() == 1 {
if src, ok := img.Attr("data-src"); ok {
imageURL = strings.TrimSpace(src)
}
if imageURL == "" {
if src, ok := img.Attr("src"); ok && !strings.HasPrefix(src, "data:") {
imageURL = strings.TrimSpace(src)
}
}
}
meta := show.Find("h3.time-bar").First()
metaText := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ")
epText, _, airType := parseMeta(metaText)
localTime, airsAt, _, _ := parseLocalTime(meta, location)
if title == "" || animeURL == "" || localTime == "" || airType != AirTypeSUB {
return
}
dayEntries = append(dayEntries, Entry{
Title: title,
AnimeURL: animeURL,
ImageURL: imageURL,
EpisodeText: epText,
AirType: airType,
AirsAt: airsAt,
LocalTime: localTime,
DateLabel: rawHeader,
Weekday: *weekday,
})
})
if len(dayEntries) == 0 {
return
}
out.Days[*weekday] = append(out.Days[*weekday], preferredReleaseEntries(dayEntries)...)
})
return out, nil
}
func selectionHasClass(selection *goquery.Selection, className string) bool {
raw, ok := selection.Attr("class")
if !ok {
return false
}
return slices.Contains(strings.Fields(raw), className)
}
func parseWeekdayFromHeader(header string) *time.Weekday {
lower := strings.ToLower(header)
candidates := []struct {
key string
val time.Weekday
}{
{"monday", time.Monday},
{"tuesday", time.Tuesday},
{"wednesday", time.Wednesday},
{"thursday", time.Thursday},
{"friday", time.Friday},
{"saturday", time.Saturday},
{"sunday", time.Sunday},
}
for _, c := range candidates {
if strings.Contains(lower, c.key) {
v := c.val
return &v
}
}
return nil
}
func parseMeta(meta string) (episodeText string, localTime string, airType AirType) {
// Example: "Ep 8 04:00 PM SUB"
parts := strings.Fields(meta)
if len(parts) < 4 {
return "", "", ""
}
// Find the time token(s)
var timeIdx = -1
for i := range parts {
if strings.Contains(parts[i], ":") && len(parts[i]) >= 4 {
timeIdx = i
break
}
}
if timeIdx == -1 || timeIdx+2 >= len(parts) {
return "", "", ""
}
localTime = strings.TrimSpace(parts[timeIdx] + " " + parts[timeIdx+1])
typeRaw := strings.TrimSpace(parts[timeIdx+2])
switch strings.ToUpper(typeRaw) {
case "JPN":
airType = AirTypeJPN
case "SUB":
airType = AirTypeSUB
case "DUB":
airType = AirTypeDUB
default:
return "", "", ""
}
episodeText = strings.TrimSpace(strings.Join(parts[:timeIdx], " "))
return episodeText, localTime, airType
}
func preferredReleaseEntries(entries []Entry) []Entry {
type keyedEntry struct {
index int
entry Entry
}
selected := map[string]keyedEntry{}
for i, entry := range entries {
key := entry.AnimeURL + "\x00" + entry.EpisodeText
current, ok := selected[key]
if !ok || airTypePriority(entry.AirType) > airTypePriority(current.entry.AirType) {
selected[key] = keyedEntry{index: i, entry: entry}
}
}
out := make([]keyedEntry, 0, len(selected))
for _, entry := range selected {
out = append(out, entry)
}
slices.SortFunc(out, func(a keyedEntry, b keyedEntry) int {
return a.index - b.index
})
preferred := make([]Entry, 0, len(out))
for _, entry := range out {
preferred = append(preferred, entry.entry)
}
return preferred
}
func airTypePriority(airType AirType) int {
switch airType {
case AirTypeSUB:
return 3
case AirTypeDUB:
return 2
case AirTypeJPN:
return 1
default:
return 0
}
}
func parseLocalTime(meta *goquery.Selection, location *time.Location) (localTime string, airsAt time.Time, rawDatetime string, rawRenderedTime string) {
// AnimeSchedule updates rendered time client-side based on the viewer's timezone.
// The server-rendered HTML can show a different time string, so we prefer the `datetime`
// attribute when available.
t := meta.Find("time").First()
if t.Length() == 1 {
rawRenderedTime = strings.Join(strings.Fields(strings.TrimSpace(t.Text())), " ")
if raw, ok := t.Attr("datetime"); ok {
rawDatetime = raw
if parsed, err := parseScheduleDatetime(rawDatetime); err == nil {
airsAt = parsed.In(location)
localTime = airsAt.Format("15:04")
return localTime, airsAt, rawDatetime, rawRenderedTime
}
}
}
fallback := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ")
_, parsedTime, _ := parseMeta(fallback)
return parsedTime, time.Time{}, "", ""
}
func parseScheduleDatetime(value string) (time.Time, error) {
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04Z07:00"} {
parsed, err := time.Parse(layout, strings.TrimSpace(value))
if err == nil {
return parsed, nil
}
}
return time.Time{}, fmt.Errorf("parse schedule datetime %q", value)
}
func absolutizeURL(base string, href string) string {
href = strings.TrimSpace(href)
if href == "" {
return ""
}
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
return href
}
return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(href, "/")
}
func addCommonHeaders(request *http.Request) {
netutil.SetBrowserHTMLHeaders(request, "https://animeschedule.net/")
}
func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, string, error) {
document, response, err := netutil.FetchHTMLDocument(ctx, httpClient, url, addCommonHeaders, func(response *http.Response, body []byte) error {
return &HTTPStatusError{
StatusCode: response.StatusCode,
URL: url,
ContentType: strings.TrimSpace(response.Header.Get("Content-Type")),
BodyPreview: strings.Join(strings.Fields(strings.TrimSpace(string(body))), " "),
}
})
if err != nil {
return nil, url, err
}
return document, response.Request.URL.String(), nil
}
type timetableAnimeAPI struct {
Title string `json:"title"`
English string `json:"english"`
Route string `json:"route"`
EpisodeDate time.Time `json:"episodeDate"`
EpisodeNumber int `json:"episodeNumber"`
SubtractedEpisodeNumber int `json:"subtractedEpisodeNumber"`
AirType string `json:"airType"`
ImageVersionRoute string `json:"imageVersionRoute"`
}
func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, year int, week int, timezone string) (WeekSchedule, error) {
client := httpClient
if client == nil {
client = http.DefaultClient
}
location, err := scheduleLocation(timezone)
if err != nil {
return WeekSchedule{}, err
}
u, _ := url.Parse("https://animeschedule.net/api/v3/timetables/sub")
q := u.Query()
if year > 0 && week > 0 {
q.Set("year", strconv.Itoa(year))
q.Set("week", strconv.Itoa(week))
}
q.Set("tz", location.String())
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return WeekSchedule{}, fmt.Errorf("create api request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", netutil.Chrome135)
res, err := client.Do(req)
if err != nil {
return WeekSchedule{}, fmt.Errorf("api request failed: %w", err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(res.Body, netutil.Bytes512))
return WeekSchedule{}, &HTTPStatusError{
StatusCode: res.StatusCode,
URL: u.String(),
ContentType: strings.TrimSpace(res.Header.Get("Content-Type")),
BodyPreview: strings.Join(strings.Fields(strings.TrimSpace(string(body))), " "),
}
}
var payload []timetableAnimeAPI
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return WeekSchedule{}, fmt.Errorf("decode timetables api: %w", err)
}
resolvedYear := year
resolvedWeek := week
if resolvedYear == 0 || resolvedWeek == 0 {
resolvedYear, resolvedWeek = time.Now().In(time.Local).ISOWeek()
}
out := WeekSchedule{
Year: resolvedYear,
Week: resolvedWeek,
Days: map[time.Weekday][]Entry{},
}
for _, item := range payload {
title := strings.TrimSpace(item.English)
if title == "" {
title = strings.TrimSpace(item.Title)
}
if title == "" {
continue
}
episodeNumber := item.EpisodeNumber
subtracted := item.SubtractedEpisodeNumber
episodeText := ""
switch {
case subtracted > 0 && subtracted < episodeNumber:
episodeText = fmt.Sprintf("Ep %d-%d", subtracted, episodeNumber)
case episodeNumber > 0:
episodeText = fmt.Sprintf("Ep %d", episodeNumber)
default:
episodeText = "Ep ?"
}
airType := AirType(strings.ToUpper(strings.TrimSpace(item.AirType)))
if airType != AirTypeSUB {
continue
}
episodeTime := item.EpisodeDate.In(location)
weekday := episodeTime.Weekday()
localTime := episodeTime.Format("15:04")
imageURL := ""
if strings.TrimSpace(item.ImageVersionRoute) != "" {
imageURL = "https://img.animeschedule.net/production/assets/public/img/" + strings.TrimLeft(strings.TrimSpace(item.ImageVersionRoute), "/")
}
animeURL := ""
if strings.TrimSpace(item.Route) != "" {
animeURL = "https://animeschedule.net/anime/" + strings.TrimLeft(strings.TrimSpace(item.Route), "/")
}
out.Days[weekday] = append(out.Days[weekday], Entry{
Title: title,
AnimeURL: animeURL,
ImageURL: imageURL,
EpisodeText: episodeText,
AirType: airType,
AirsAt: episodeTime,
LocalTime: localTime,
Weekday: weekday,
})
}
return out, nil
}

View File

@@ -0,0 +1,101 @@
package animeschedule
import (
"strings"
"testing"
"time"
"github.com/PuerkitoBio/goquery"
)
func TestParseLocalTimeUsesRequestedTimezone(t *testing.T) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(`
<h3 class="time-bar">
<span class="show-episode">Ep 9</span>
<time datetime="2026-06-05T16:00+01:00" class="show-air-time">04:00 PM</time>
<span>SUB</span>
</h3>
`))
if err != nil {
t.Fatalf("parse document: %v", err)
}
location, err := time.LoadLocation("Europe/Copenhagen")
if err != nil {
t.Fatalf("load location: %v", err)
}
localTime, airsAt, _, rendered := parseLocalTime(doc.Find(".time-bar").First(), location)
if localTime != "17:00" {
t.Fatalf("localTime = %q, want %q", localTime, "17:00")
}
if rendered != "04:00 PM" {
t.Fatalf("rendered = %q, want %q", rendered, "04:00 PM")
}
if airsAt.Location().String() != "Europe/Copenhagen" {
t.Fatalf("airsAt location = %q, want Europe/Copenhagen", airsAt.Location().String())
}
}
func TestParseLocalTimeUsesExactAngelNextDoorSubRelease(t *testing.T) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(`
<h3 class="time-bar">
<span class="show-episode">Ep 10</span>
<time datetime="2026-06-05T15:30+01:00" class="show-air-time">03:30 PM</time>
<span>SUB</span>
</h3>
`))
if err != nil {
t.Fatalf("parse document: %v", err)
}
location, err := time.LoadLocation("Europe/Copenhagen")
if err != nil {
t.Fatalf("load location: %v", err)
}
localTime, _, _, _ := parseLocalTime(doc.Find(".time-bar").First(), location)
if localTime != "16:30" {
t.Fatalf("localTime = %q, want %q", localTime, "16:30")
}
}
func TestPreferredReleaseEntriesPrefersSubForSameEpisode(t *testing.T) {
entries := []Entry{
{
Title: "Tensei shitara Slime Datta Ken 4th Season",
AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season",
EpisodeText: "Ep 9",
AirType: AirTypeJPN,
LocalTime: "16:00",
},
{
Title: "Tensei shitara Slime Datta Ken 4th Season",
AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season",
EpisodeText: "Ep 9",
AirType: AirTypeSUB,
LocalTime: "17:00",
},
{
Title: "Tensei shitara Slime Datta Ken 4th Season",
AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season",
EpisodeText: "Ep 6",
AirType: AirTypeDUB,
LocalTime: "17:00",
},
}
got := preferredReleaseEntries(entries)
if len(got) != 2 {
t.Fatalf("len(got) = %d, want 2", len(got))
}
if got[0].AirType != AirTypeSUB {
t.Fatalf("first air type = %q, want %q", got[0].AirType, AirTypeSUB)
}
if got[1].AirType != AirTypeDUB {
t.Fatalf("second air type = %q, want %q", got[1].AirType, AirTypeDUB)
}
}

View File

@@ -32,6 +32,16 @@ func (c *Client) GetAnimeRecommendations(ctx context.Context, id int) ([]Recomme
return resp.Data, nil
}
func (c *Client) WarmAnimeRecommendations(id int) {
url := fmt.Sprintf("%s/anime/%d/recommendations", c.baseURL, id)
cacheKey := fmt.Sprintf("anime:recommendations:%d", id)
c.runAsyncRefresh(func(ctx context.Context) {
var resp RecommendationsResponse
_ = c.getWithCache(ctx, cacheKey, 24*time.Hour, url, &resp)
})
}
// 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)
@@ -94,18 +104,7 @@ func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
}
func (c *Client) refreshAnimeByIDAsync(id int) {
select {
case c.refreshSem <- struct{}{}:
default:
return
}
go func() {
defer func() { <-c.refreshSem }()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
c.runAsyncRefresh(func(ctx context.Context) {
_, _ = c.refreshAnimeByID(ctx, id)
}()
})
}

View File

@@ -5,21 +5,24 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"reflect"
"strconv"
"strings"
"sync"
"time"
"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
@@ -29,6 +32,7 @@ type Client struct {
lastReqTime time.Time // rate limiting: last request timestamp
sf singleflight.Group
refreshSem chan struct{}
metrics *observability.Metrics
// Random anime pool for DDoS-proof truly random "Surprise Me"
randomPool []Anime
@@ -38,7 +42,8 @@ type Client struct {
const jikanSlowLogThreshold = 750 * time.Millisecond
func NewClient(queries *db.Queries) *Client {
func NewClient(cfg config.Config, queries *db.Queries, metrics *observability.Metrics) *Client {
traceEnabled = cfg.JikanTrace
return &Client{
httpClient: &http.Client{
Timeout: 10 * time.Second,
@@ -51,6 +56,7 @@ func NewClient(queries *db.Queries) *Client {
},
baseURL: "https://api.jikan.moe/v4",
db: queries,
metrics: metrics,
retrySignal: make(chan struct{}, 1),
refreshSem: make(chan struct{}, 4),
randomPool: make([]Anime, 0),
@@ -140,8 +146,7 @@ func waitForRetry(ctx context.Context, delay time.Duration) error {
}
func jikanTraceEnabled() bool {
value := strings.ToLower(strings.TrimSpace(os.Getenv("MAL_JIKAN_TRACE")))
return value == "1" || value == "true" || value == "yes"
return traceEnabled
}
func logJikanCache(cacheKey string, source string, startedAt time.Time, err error) {
@@ -153,17 +158,25 @@ func logJikanCache(cacheKey string, source string, startedAt time.Time, err erro
return
}
errorValue := ""
level := observability.LogLevelInfo
if err != nil {
errorValue = err.Error()
level = observability.LogLevelError
} else if source != "fresh" && source != "refresh" {
// Stale reads are expected sometimes, but worth tracking in logs.
level = observability.LogLevelWarn
}
log.Printf(
"jikan_cache key=%s source=%s duration_ms=%.2f error=%s",
strconv.Quote(cacheKey),
source,
float64(duration.Microseconds())/1000,
strconv.Quote(errorValue),
observability.LogJSON(
level,
"jikan_cache",
"jikan",
"",
map[string]any{
"cache_key": cacheKey,
"source": source,
"duration_ms": float64(duration.Microseconds()) / 1000,
},
err,
)
}
@@ -173,18 +186,26 @@ func logJikanUpstream(urlStr string, statusCode int, attempts int, startedAt tim
return
}
errorValue := ""
if err != nil {
errorValue = err.Error()
level := observability.LogLevelInfo
if err != nil || statusCode >= http.StatusInternalServerError {
level = observability.LogLevelError
} else if statusCode == http.StatusTooManyRequests || statusCode >= http.StatusBadRequest {
level = observability.LogLevelWarn
}
log.Printf(
"jikan_upstream url=%s status=%d attempts=%d duration_ms=%.2f error=%s",
strconv.Quote(urlStr),
statusCode,
attempts,
float64(duration.Microseconds())/1000,
strconv.Quote(errorValue),
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,
)
}
@@ -262,11 +283,18 @@ func (c *Client) getCache(parentCtx context.Context, key string, out any) bool {
data, err := c.db.GetJikanCache(ctx, key)
if err != nil {
c.metrics.ObserveCache("jikan", "miss")
return false
}
err = json.Unmarshal([]byte(data), out)
return err == nil
if err != nil {
c.metrics.ObserveCache("jikan", "miss")
return false
}
c.metrics.ObserveCache("jikan", "hit")
return true
}
// getStaleCache retrieves expired-but-available cache by key.
@@ -276,11 +304,18 @@ func (c *Client) getStaleCache(parentCtx context.Context, key string, out any) b
data, err := c.db.GetJikanCacheStale(ctx, key)
if err != nil {
c.metrics.ObserveCache("jikan_stale", "miss")
return false
}
err = json.Unmarshal([]byte(data), out)
return err == nil
if err != nil {
c.metrics.ObserveCache("jikan_stale", "miss")
return false
}
c.metrics.ObserveCache("jikan_stale", "hit")
return true
}
// setCache stores data in cache with specified TTL.
@@ -375,6 +410,12 @@ func (c *Client) refreshWithCacheAsync(cacheKey string, ttl time.Duration, url s
return
}
c.runAsyncRefresh(func(ctx context.Context) {
_ = c.refreshWithCache(ctx, cacheKey, ttl, url, target)
})
}
func (c *Client) runAsyncRefresh(refresh func(context.Context)) {
select {
case c.refreshSem <- struct{}{}:
default:
@@ -387,7 +428,7 @@ func (c *Client) refreshWithCacheAsync(cacheKey string, ttl time.Duration, url s
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
_ = c.refreshWithCache(ctx, cacheKey, ttl, url, target)
refresh(ctx)
}()
}
@@ -425,7 +466,9 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err
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
}
@@ -446,6 +489,7 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err
if err != nil {
return logAndReturn(0, fmt.Errorf("failed to create jikan request: %w", err))
}
req.Header.Set("User-Agent", netutil.Generic)
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -506,3 +550,36 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err
return logAndReturn(0, fmt.Errorf("max retries exceeded for %s", urlStr))
}
func metricsEndpoint(urlStr string) string {
trimmed := strings.TrimSpace(urlStr)
if trimmed == "" {
return "unknown"
}
prefix := "https://api.jikan.moe/v4"
trimmed = strings.TrimPrefix(trimmed, prefix)
if idx := strings.Index(trimmed, "?"); idx >= 0 {
trimmed = trimmed[:idx]
}
parts := strings.Split(trimmed, "/")
out := make([]string, 0, len(parts))
for _, part := range parts {
if part == "" {
continue
}
if _, err := strconv.Atoi(part); err == nil {
out = append(out, "{id}")
continue
}
out = append(out, part)
}
if len(out) == 0 {
return "/"
}
return "/" + strings.Join(out, "/")
}

View File

@@ -5,7 +5,9 @@ import (
"database/sql"
"encoding/json"
"io"
"mal/internal/config"
"mal/internal/db"
"mal/internal/observability"
"net/http"
"strings"
"testing"
@@ -41,7 +43,7 @@ func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
}
queries := db.New(sqlDB)
client := NewClient(queries)
client := NewClient(config.Config{}, queries, observability.NewMetrics())
stale := TopAnimeResponse{Data: []Anime{{MalID: 1, Title: "stale"}}}
staleBytes, err := json.Marshal(stale)
if err != nil {

View File

@@ -1,6 +1,8 @@
// Package jikan provides a client for the Jikan v4 API.
package jikan
import "time"
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

@@ -1,8 +1,6 @@
package jikan
import (
"go.uber.org/fx"
)
import "go.uber.org/fx"
var Module = fx.Options(
fx.Provide(NewClient),

View File

@@ -0,0 +1,138 @@
package jikan
import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
)
type ProducerListEntry struct {
MalID int `json:"mal_id"`
Titles []struct {
Type string `json:"type"`
Title string `json:"title"`
} `json:"titles"`
}
type ProducersResponse struct {
Data []ProducerListEntry `json:"data"`
Pagination Pagination `json:"pagination"`
}
type ProducerListResult struct {
Items []ProducerListEntry
HasNextPage bool
}
func (c *Client) GetProducers(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) {
if page < 1 {
page = 1
}
if limit < 1 {
limit = 1
}
q := strings.TrimSpace(query)
if q == "" {
return c.fetchProducersPage(ctx, "", page, limit)
}
result, err := c.fetchProducersPage(ctx, q, page, limit)
if err == nil {
return result, nil
}
var apiErr *APIError
if !errors.As(err, &apiErr) {
return ProducerListResult{}, err
}
return c.searchProducersFromPages(ctx, q, page, limit)
}
func (c *Client) fetchProducersPage(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) {
q := strings.TrimSpace(query)
cacheKey := fmt.Sprintf("producers:%s:%d:%d", q, page, limit)
reqURL := fmt.Sprintf("%s/producers?page=%d&limit=%d", c.baseURL, page, limit)
if q != "" {
reqURL += "&q=" + url.QueryEscape(q)
}
var result ProducersResponse
if err := c.getWithCache(ctx, cacheKey, producerCacheTTL, reqURL, &result); err != nil {
return ProducerListResult{}, err
}
return ProducerListResult{
Items: result.Data,
HasNextPage: result.Pagination.HasNextPage,
}, nil
}
func (c *Client) searchProducersFromPages(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) {
const maxPagesToScan = 25
needle := strings.ToLower(strings.TrimSpace(query))
startIndex := (page - 1) * limit
endIndex := startIndex + limit
matches := make([]ProducerListEntry, 0, endIndex)
scannedAll := false
for currentPage := 1; currentPage <= maxPagesToScan; currentPage++ {
result, err := c.fetchProducersPage(ctx, "", currentPage, limit)
if err != nil {
return ProducerListResult{}, err
}
for _, item := range result.Items {
name := strings.ToLower(ProducerListEntryName(item))
if strings.Contains(name, needle) {
matches = append(matches, item)
}
}
if len(matches) >= endIndex {
return ProducerListResult{
Items: matches[startIndex:endIndex],
HasNextPage: len(matches) > endIndex || result.HasNextPage,
}, nil
}
if !result.HasNextPage {
scannedAll = true
break
}
}
if startIndex >= len(matches) {
return ProducerListResult{
Items: []ProducerListEntry{},
HasNextPage: !scannedAll,
}, nil
}
if endIndex > len(matches) {
endIndex = len(matches)
}
return ProducerListResult{
Items: matches[startIndex:endIndex],
HasNextPage: !scannedAll,
}, nil
}
func ProducerListEntryName(entry ProducerListEntry) string {
for _, t := range entry.Titles {
if t.Title != "" {
return t.Title
}
}
if entry.MalID > 0 {
return strconv.Itoa(entry.MalID)
}
return ""
}

View File

@@ -4,11 +4,12 @@ import (
"context"
"errors"
"fmt"
"log"
"sort"
"strings"
"time"
"mal/internal/observability"
"mal/integrations/watchorder"
"golang.org/x/sync/errgroup"
@@ -42,15 +43,8 @@ func relationCacheKey(id int) string {
return fmt.Sprintf("relations:watch-order:%d", id)
}
// getWatchOrder fetches watch order from chiaki, caches result for 24h.
func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
func (c *Client) refreshWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
cacheKey := relationCacheKey(id)
var cached watchorder.WatchOrderResult
if c.getCache(ctx, cacheKey, &cached) {
return cached, nil
}
watchOrderURL := fmt.Sprintf(chiakiWatchOrderURL, id)
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
@@ -62,21 +56,44 @@ func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrd
return watchorder.WatchOrderResult{}, watchorder.ErrWatchOrderNotFound
}
if errors.Is(err, watchorder.ErrWatchOrderMarkupNotFound) {
log.Printf("relations: watch-order markup missing for %d (%s): %v", id, watchOrderURL, err)
observability.Warn(
"relations_watch_order_markup_missing",
"jikan",
"",
map[string]any{
"anime_id": id,
"url": watchOrderURL,
},
err,
)
} else if errors.As(err, &statusError) {
log.Printf(
"relations: watch-order http error for %d (%s): status=%d server=%q cf_ray=%q location=%q content_type=%q body=%q",
id,
watchOrderURL,
statusError.StatusCode,
statusError.Server,
statusError.CFRay,
statusError.Location,
statusError.ContentType,
statusError.BodyPreview,
observability.Warn(
"relations_watch_order_http_error",
"jikan",
"",
map[string]any{
"anime_id": id,
"url": watchOrderURL,
"status": statusError.StatusCode,
"server": statusError.Server,
"cf_ray": statusError.CFRay,
"location": statusError.Location,
"content_type": statusError.ContentType,
"body_preview": statusError.BodyPreview,
},
err,
)
} else {
log.Printf("relations: watch-order fetch failed for %d (%s): %v", id, watchOrderURL, err)
observability.Warn(
"relations_watch_order_fetch_failed",
"jikan",
"",
map[string]any{
"anime_id": id,
"url": watchOrderURL,
},
err,
)
}
return watchorder.WatchOrderResult{}, err
}
@@ -85,6 +102,37 @@ func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrd
return result, nil
}
func (c *Client) refreshWatchOrderAsync(id int) {
c.runAsyncRefresh(func(ctx context.Context) {
_, _ = c.refreshWatchOrder(ctx, id)
})
}
// 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)
var cached watchorder.WatchOrderResult
if c.getCache(ctx, cacheKey, &cached) {
return cached, nil
}
if c.getStaleCache(ctx, cacheKey, &cached) {
c.refreshWatchOrderAsync(id)
return cached, nil
}
result, err := c.refreshWatchOrder(ctx, id)
if err != nil {
if c.getStaleCache(ctx, cacheKey, &cached) {
return cached, nil
}
return watchorder.WatchOrderResult{}, err
}
return result, nil
}
// currentOnlyRelation returns just the current anime when watch order lookup fails.
func (c *Client) currentOnlyRelation(ctx context.Context, id int) ([]RelationEntry, error) {
currentAnime, err := c.GetAnimeByID(ctx, id)
@@ -107,7 +155,15 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
if errors.Is(err, watchorder.ErrWatchOrderNotFound) {
return c.currentOnlyRelation(ctx, id)
}
log.Printf("relations: using current-only fallback for %d: %v", id, err)
observability.Warn(
"relations_watch_order_fallback_current_only",
"jikan",
"",
map[string]any{
"anime_id": id,
},
err,
)
return c.currentOnlyRelation(ctx, id)
}
@@ -176,9 +232,6 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
IsCurrent: res.entry.ID == id,
IsExtra: false,
})
if res.entry.ID == id {
relations[len(relations)-1].Relation = "Current"
}
}
if !seen[id] {
@@ -201,3 +254,9 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
return relations, nil
}
func (c *Client) WarmFullRelations(id int) {
c.runAsyncRefresh(func(ctx context.Context) {
_, _ = c.GetFullRelations(ctx, id)
})
}

View File

@@ -2,6 +2,23 @@ package jikan
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
@@ -16,14 +33,7 @@ func TestIsAllowedWatchOrderType(t *testing.T) {
{name: "empty", input: "", want: false},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
got := isAllowedWatchOrderType(testCase.input)
if got != testCase.want {
t.Fatalf("expected %v, got %v", testCase.want, got)
}
})
}
runBoolCases(t, tests, isAllowedWatchOrderType)
}
func TestWatchOrderTypeLabel(t *testing.T) {
@@ -58,12 +68,5 @@ func TestAllowedWatchOrderTypeFromDataset(t *testing.T) {
{name: "label special", input: "Special", want: false},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
got := isAllowedWatchOrderType(testCase.input)
if got != testCase.want {
t.Fatalf("expected %v, got %v", testCase.want, got)
}
})
}
runBoolCases(t, tests, isAllowedWatchOrderType)
}

View File

@@ -8,8 +8,8 @@ import (
"strings"
)
// SearchAdvanced performs a filtered anime search with type, status, ordering, and genre filters.
func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (SearchResult, error) {
// 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) {
if page < 1 {
page = 1
}
@@ -26,7 +26,7 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o
genresParam = strings.Join(ids, ",")
}
cacheKey := fmt.Sprintf("search:%s:%s:%s:%s:%s:%s:%v:%d:%d", query, animeType, status, orderBy, sort, genresParam, sfw, page, limit)
cacheKey := fmt.Sprintf("search:%s:%s:%s:%s:%s:%s:%d:%v:%d:%d", query, animeType, status, orderBy, sort, genresParam, studioID, sfw, page, limit)
var result SearchResponse
reqURL := fmt.Sprintf("%s/anime?page=%d", c.baseURL, page)
@@ -42,6 +42,9 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o
if status != "" {
reqURL += "&status=" + url.QueryEscape(status)
}
if studioID > 0 {
reqURL += "&producers=" + strconv.Itoa(studioID)
}
if orderBy != "" {
reqURL += "&order_by=" + url.QueryEscape(orderBy)
}

View File

@@ -15,34 +15,22 @@ type ScheduleResult struct {
// GetSeasonsNow returns currently airing anime for the current season.
func (c *Client) GetSeasonsNow(ctx context.Context, page int) (TopAnimeResult, error) {
if page < 1 {
page = 1
}
cacheKey := fmt.Sprintf("seasons_now:%d", page)
var result TopAnimeResponse
reqURL := fmt.Sprintf("%s/seasons/now?page=%d", c.baseURL, page)
err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result)
if err != nil {
return TopAnimeResult{}, err
}
return TopAnimeResult{
Animes: result.Data,
HasNextPage: result.Pagination.HasNextPage,
}, nil
return c.getSeasonList(ctx, page, "now")
}
// GetSeasonsUpcoming returns anime scheduled to air in upcoming seasons.
func (c *Client) GetSeasonsUpcoming(ctx context.Context, page int) (TopAnimeResult, error) {
return c.getSeasonList(ctx, page, "upcoming")
}
func (c *Client) getSeasonList(ctx context.Context, page int, season string) (TopAnimeResult, error) {
if page < 1 {
page = 1
}
cacheKey := fmt.Sprintf("seasons_upcoming:%d", page)
cacheKey := fmt.Sprintf("seasons_%s:%d", season, page)
var result TopAnimeResponse
reqURL := fmt.Sprintf("%s/seasons/upcoming?page=%d", c.baseURL, page)
reqURL := fmt.Sprintf("%s/seasons/%s?page=%d", c.baseURL, season, page)
err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result)
if err != nil {

View File

@@ -1,6 +1,9 @@
package jikan
import ()
import (
"context"
"fmt"
)
type ProducerResponse struct {
Data struct {
@@ -24,3 +27,18 @@ type ProducerResponse struct {
} `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

@@ -1,3 +1,4 @@
// Package allanime provides an integration with the AllAnime API for episode playback.
package allanime
import (
@@ -11,9 +12,8 @@ import (
"fmt"
"io"
"mal/internal/domain"
"mal/pkg/net/limits"
"mal/pkg/net/useragent"
"mal/pkg/net/utls"
"mal/pkg"
netutil "mal/pkg/net"
"net/http"
"net/url"
"strconv"
@@ -25,18 +25,13 @@ const (
allAnimeBaseURL = "https://api.allanime.day"
allAnimeReferer = "https://allmanga.to/"
allAnimeOrigin = "https://youtu-chan.com"
defaultUserAgent = useragent.Firefox121
defaultUserAgent = netutil.Firefox121
)
var (
aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"}
)
var allAnimeUTLSClient = &http.Client{
Transport: &utls.UtlsRoundTripper{},
Timeout: 30 * time.Second,
}
type searchResult struct {
ID string
MalID string
@@ -51,6 +46,7 @@ type AvailableEpisodes struct {
type AllAnimeProvider struct {
httpClient *http.Client
utlsClient *http.Client
extractor *providerExtractor
}
@@ -59,6 +55,10 @@ func NewAllAnimeProvider() *AllAnimeProvider {
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
utlsClient: &http.Client{
Transport: &netutil.UtlsRoundTripper{},
Timeout: 30 * time.Second,
},
extractor: newProviderExtractor(),
}
}
@@ -67,60 +67,75 @@ func (c *AllAnimeProvider) Name() string {
return "AllAnime"
}
func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
// 1. Search for the show to get its AllAnime ID
graphqlQuery := `query($search: SearchInput, $limit: Int, $page: Int, $translationType: VaildTranslationTypeEnumType, $countryOrigin: VaildCountryOriginEnumType) {
shows(search: $search, limit: $limit, page: $page, translationType: $translationType, countryOrigin: $countryOrigin) {
edges {
_id
malId
name
}
}
}`
const searchQuery = `query(
$search: SearchInput
$translationType: VaildTranslationTypeEnumType
$limit: Int = 40
$page: Int = 1
$countryOrigin: VaildCountryOriginEnumType = ALL
) {
shows(
search: $search
limit: $limit
page: $page
translationType: $translationType
countryOrigin: $countryOrigin
) {
edges {
_id
malId
name
}
}
}`
variables := map[string]any{
"search": map[string]any{
"allowAdult": false,
"allowUnknown": false,
"query": query,
},
"limit": 40,
"page": 1,
"translationType": mode,
"countryOrigin": "ALL",
func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
type searchData struct {
Shows struct {
Edges []struct {
ID string `json:"_id"`
MalID string `json:"malId"`
Name string `json:"name"`
} `json:"edges"`
} `json:"shows"`
}
result, err := c.graphqlRequest(ctx, graphqlQuery, variables)
type searchInput struct {
AllowAdult bool `json:"allowAdult"`
AllowUnknown bool `json:"allowUnknown"`
Query string `json:"query"`
}
type searchVariables struct {
Search searchInput `json:"search"`
TranslationType string `json:"translationType"`
}
vars := searchVariables{
Search: searchInput{
AllowAdult: false,
AllowUnknown: false,
Query: query,
},
TranslationType: mode,
}
data, err := graphql.Post[searchData](ctx, c.httpClient, allAnimeBaseURL+"/api", searchQuery, vars, graphql.PostOptions{
Headers: map[string]string{
"Referer": allAnimeReferer,
"User-Agent": defaultUserAgent,
},
BodyMax: netutil.MiB2,
})
if err != nil {
return nil, err
}
data, ok := result["data"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid search response")
}
shows, ok := data["shows"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid shows payload")
}
edges, ok := shows["edges"].([]any)
if !ok {
return nil, fmt.Errorf("invalid search edges")
}
out := make([]searchResult, 0, len(edges))
for _, edge := range edges {
item, ok := edge.(map[string]any)
if !ok {
continue
}
id, _ := item["_id"].(string)
malID, _ := item["malId"].(string)
name, _ := item["name"].(string)
out := make([]searchResult, 0, len(data.Shows.Edges))
for _, edge := range data.Shows.Edges {
id := edge.ID
malID := edge.MalID
name := edge.Name
if unquoted, err := strconv.Unquote("\"" + name + "\""); err == nil {
name = unquoted
}
@@ -206,7 +221,13 @@ func (c *AllAnimeProvider) GetEpisodeAvailability(ctx context.Context, animeID i
}
func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error) {
return c.resolveShowIDStrict(ctx, animeID, titleCandidates, "sub")
for _, mode := range []string{"sub", "dub"} {
showID, err := c.resolveShowIDStrict(ctx, animeID, titleCandidates, mode)
if err == nil {
return showID, nil
}
}
return "", fmt.Errorf("allanime: no exact mal id match for %d", animeID)
}
func (c *AllAnimeProvider) GetEpisodeAvailabilityByProviderID(ctx context.Context, showID string) (domain.EpisodeAvailability, error) {
@@ -233,7 +254,7 @@ func (c *AllAnimeProvider) resolveShowIDStrict(ctx context.Context, animeID int,
}
}
}
return "", fmt.Errorf("allanime: no strict mal id match for %d", animeID)
return "", fmt.Errorf("allanime: no exact mal id match for %d in %s search", animeID, mode)
}
func parseEpisodeNumbers(raw []string) []int {
@@ -274,15 +295,9 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
req.Header.Set("Referer", allAnimeReferer)
req.Header.Set("User-Agent", defaultUserAgent)
resp, err := c.httpClient.Do(req)
resp, respBody, err := executeAndReadResponse(c.httpClient, req, "execute graphql request", "read graphql response")
if err != nil {
return nil, fmt.Errorf("execute graphql request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, limits.MiB2))
if err != nil {
return nil, fmt.Errorf("read graphql response: %w", err)
return nil, err
}
if resp.StatusCode != http.StatusOK {
@@ -329,15 +344,9 @@ func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, e
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Site", "cross-site")
resp, err := allAnimeUTLSClient.Do(req)
resp, respBody, err := executeAndReadResponse(c.utlsClient, req, "execute GET request", "read response")
if err != nil {
return nil, fmt.Errorf("execute GET request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, limits.MiB2))
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
return nil, err
}
if resp.StatusCode != http.StatusOK {
@@ -450,49 +459,7 @@ func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string,
return nil, fmt.Errorf("no source references")
}
out := make([]StreamSource, 0, len(references))
for _, ref := range references {
target := strings.TrimSpace(ref.URL)
if target == "" {
continue
}
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
sourceType := detectStreamType(target)
if sourceType == "unknown" {
sourceType = detectEmbedType(target)
}
out = append(out, buildStreamSource(target, sourceType, ref.Name))
continue
}
decoded := decodeSourceURL(target)
if decoded == "" {
continue
}
if strings.HasPrefix(decoded, "http://") || strings.HasPrefix(decoded, "https://") {
sourceType := detectStreamType(decoded)
if sourceType == "unknown" {
sourceType = detectEmbedType(decoded)
}
out = append(out, buildStreamSource(decoded, sourceType, ref.Name))
continue
}
if !strings.HasPrefix(decoded, "/") {
decoded = "/" + decoded
}
extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded)
if err != nil {
continue
}
out = append(out, extracted...)
}
out := c.resolveSourceReferences(ctx, references)
if len(out) == 0 {
return nil, fmt.Errorf("no playable sources extracted")
@@ -517,6 +484,10 @@ func (c *AllAnimeProvider) extractSourceURLsFromData(ctx context.Context, data m
return nil
}
return c.resolveSourceReferences(ctx, references)
}
func (c *AllAnimeProvider) resolveSourceReferences(ctx context.Context, references []sourceReference) []StreamSource {
out := make([]StreamSource, 0, len(references))
for _, ref := range references {
target := strings.TrimSpace(ref.URL)
@@ -564,6 +535,21 @@ func (c *AllAnimeProvider) extractSourceURLsFromData(ctx context.Context, data m
return out
}
func executeAndReadResponse(client *http.Client, req *http.Request, executeErrPrefix string, readErrPrefix string) (*http.Response, []byte, error) {
resp, err := client.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", executeErrPrefix, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", readErrPrefix, err)
}
return resp, body, nil
}
func buildStreamSource(url, sourceType, provider string) StreamSource {
return StreamSource{
URL: url,

View File

@@ -2,9 +2,10 @@ package allanime
import (
"context"
"encoding/json"
"fmt"
"io"
"mal/pkg/net/limits"
netutil "mal/pkg/net"
"net/http"
"regexp"
"strconv"
@@ -54,7 +55,7 @@ func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, limits.MiB2)) // 2MB limit
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2)) // 2MB limit
if err != nil {
return nil, fmt.Errorf("read provider response: %w", err)
}
@@ -66,25 +67,83 @@ func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath
func (e *providerExtractor) parseProviderResponse(ctx context.Context, response string) []StreamSource {
sources := make([]StreamSource, 0)
providerReferer := e.referer
// extract per-source referer if present
refererPattern := regexp.MustCompile(`"Referer":"([^"]+)"`)
if match := refererPattern.FindStringSubmatch(response); len(match) >= 2 {
providerReferer = strings.ReplaceAll(match[1], `\/`, "/")
var root any
if err := json.Unmarshal([]byte(response), &root); err != nil {
return sources
}
type linkItem struct {
link string
resolutionStr string
}
type hlsItem struct {
url string
hardsubLang string
}
linkItems := make([]linkItem, 0)
hlsItems := make([]hlsItem, 0)
subtitles := make([]Subtitle, 0)
var walk func(v any)
walk = func(v any) {
switch x := v.(type) {
case map[string]any:
if ref, ok := x["Referer"].(string); ok && strings.TrimSpace(ref) != "" {
providerReferer = strings.TrimSpace(ref)
}
if link, ok := x["link"].(string); ok {
if res, ok := x["resolutionStr"].(string); ok {
linkItems = append(linkItems, linkItem{link: link, resolutionStr: res})
}
}
if u, ok := x["url"].(string); ok {
if lang, ok := x["hardsub_lang"].(string); ok {
hlsItems = append(hlsItems, hlsItem{url: u, hardsubLang: lang})
}
}
if subs, ok := x["subtitles"].([]any); ok {
for _, sub := range subs {
obj, ok := sub.(map[string]any)
if !ok {
continue
}
lang, _ := obj["lang"].(string)
src, _ := obj["src"].(string)
lang = strings.TrimSpace(lang)
src = strings.TrimSpace(src)
if lang == "" || src == "" {
continue
}
subtitles = append(subtitles, Subtitle{Lang: lang, URL: src})
}
}
for _, child := range x {
walk(child)
}
case []any:
for _, child := range x {
walk(child)
}
}
}
walk(root)
if providerReferer == "" {
providerReferer = e.referer
}
// extract direct link sources (mp4/embed)
linkPattern := regexp.MustCompile(`"link":"([^"]+)","resolutionStr":"([^"]+)"`)
for _, match := range linkPattern.FindAllStringSubmatch(response, -1) {
if len(match) < 3 {
for _, item := range linkItems {
link := strings.TrimSpace(item.link)
if link == "" {
continue
}
link := strings.ReplaceAll(match[1], `\/`, "/")
quality := strings.TrimSpace(match[2])
quality := strings.TrimSpace(item.resolutionStr)
sourceType := detectStreamType(link)
if sourceType == "unknown" {
sourceType = detectEmbedType(link)
@@ -99,14 +158,15 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
})
}
// extract HLS playlist sources
hlsPattern := regexp.MustCompile(`"url":"([^"]+)","hardsub_lang":"en-US"`)
for _, match := range hlsPattern.FindAllStringSubmatch(response, -1) {
if len(match) < 2 {
for _, item := range hlsItems {
if strings.TrimSpace(item.url) == "" {
continue
}
if item.hardsubLang != "en-US" {
continue
}
playlistURL := strings.ReplaceAll(match[1], `\/`, "/")
playlistURL := strings.TrimSpace(item.url)
if strings.Contains(playlistURL, "master.m3u8") {
parsed, err := e.parseM3U8(ctx, playlistURL, providerReferer)
if err == nil {
@@ -124,26 +184,9 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
})
}
// extract subtitles and attach to all sources
subtitlePattern := regexp.MustCompile(`"subtitles":\[(.*?)\]`)
if subtitleMatch := subtitlePattern.FindStringSubmatch(response); len(subtitleMatch) >= 2 {
subtitles := make([]Subtitle, 0)
subtitleEntryPattern := regexp.MustCompile(`"lang":"([^"]+)".*?"src":"([^"]+)"`)
for _, entry := range subtitleEntryPattern.FindAllStringSubmatch(subtitleMatch[1], -1) {
if len(entry) < 3 {
continue
}
subtitles = append(subtitles, Subtitle{
Lang: strings.TrimSpace(entry[1]),
URL: strings.ReplaceAll(entry[2], `\/`, "/"),
})
}
if len(subtitles) > 0 {
for idx := range sources {
sources[idx].Subtitles = subtitles
}
if len(subtitles) > 0 && len(sources) > 0 {
for idx := range sources {
sources[idx].Subtitles = append([]Subtitle(nil), subtitles...)
}
}
@@ -158,7 +201,7 @@ func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, ref
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, limits.KiB512)) // 512KB limit
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.KiB512)) // 512KB limit
if err != nil {
return nil, err
}

View File

@@ -1,3 +1,4 @@
// Package watchorder provides anime watch order data from various sources.
package watchorder
import (
@@ -5,8 +6,7 @@ import (
"errors"
"fmt"
"io"
"mal/pkg/net/limits"
"mal/pkg/net/useragent"
netutil "mal/pkg/net"
"net/http"
"regexp"
"strconv"
@@ -82,36 +82,12 @@ func parseRootID(url string) (int, error) {
}
func addCommonHeaders(request *http.Request) {
request.Header.Set("User-Agent", useragent.Chrome135)
request.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
request.Header.Set("Accept-Language", "en-US,en;q=0.9")
request.Header.Set("Referer", "https://chiaki.site/")
request.Header.Set("Cache-Control", "no-cache")
netutil.SetBrowserHTMLHeaders(request, "https://chiaki.site/")
}
func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, error) {
client := httpClient
if client == nil {
client = http.DefaultClient
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
addCommonHeaders(request)
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode != http.StatusOK {
// limit body read for error context; avoid reading large error pages
body, _ := io.ReadAll(io.LimitReader(response.Body, limits.Bytes512))
return nil, &HTTPStatusError{
document, _, err := netutil.FetchHTMLDocument(ctx, httpClient, url, addCommonHeaders, func(response *http.Response, body []byte) error {
return &HTTPStatusError{
StatusCode: response.StatusCode,
URL: url,
Server: strings.TrimSpace(response.Header.Get("Server")),
@@ -120,14 +96,8 @@ func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*g
ContentType: strings.TrimSpace(response.Header.Get("Content-Type")),
BodyPreview: strings.Join(strings.Fields(strings.TrimSpace(string(body))), " "),
}
}
document, err := goquery.NewDocumentFromReader(response.Body)
if err != nil {
return nil, fmt.Errorf("failed to parse html: %w", err)
}
return document, nil
})
return document, err
}
func extractTypeLabelsByID(doc *goquery.Document) map[int]string {
@@ -241,7 +211,7 @@ func fetchProxyText(ctx context.Context, httpClient *http.Client, url string) (s
return "", fmt.Errorf("proxy status %d", response.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(response.Body, limits.MiB2))
body, err := io.ReadAll(io.LimitReader(response.Body, netutil.MiB2))
if err != nil {
return "", fmt.Errorf("failed to read proxy response: %w", err)
}

View File

@@ -141,10 +141,10 @@ Jujutsu Kaisen 0
testClient := &http.Client{
Timeout: time.Second,
Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
switch {
case request.URL.Host == "chiaki.site":
switch request.URL.Host {
case "chiaki.site":
return mockResponse(http.StatusForbidden, map[string]string{"Content-Type": "text/html; charset=utf-8"}, "blocked"), nil
case request.URL.Host == "r.jina.ai":
case "r.jina.ai":
// Proxy response is plain text/markdown.
return mockResponse(http.StatusOK, map[string]string{"Content-Type": "text/plain; charset=utf-8"}, proxyPayload), nil
default:

View File

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

835
internal/anime/handler.go Normal file
View File

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

View File

@@ -1,654 +0,0 @@
package handler
import (
"context"
"fmt"
"log"
"mal/internal/db"
"mal/internal/domain"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type AnimeHandler struct {
svc domain.AnimeService
watchlistSvc domain.WatchlistService
}
func NewAnimeHandler(svc domain.AnimeService, watchlistSvc domain.WatchlistService) *AnimeHandler {
return &AnimeHandler{
svc: svc,
watchlistSvc: watchlistSvc,
}
}
func (h *AnimeHandler) watchlistMapForAnimes(ctx context.Context, userID string, animes []domain.Anime) map[int64]bool {
animeIDs := make([]int64, 0, len(animes))
for _, anime := range animes {
if anime.MalID > 0 {
animeIDs = append(animeIDs, int64(anime.MalID))
}
}
return h.watchlistMapForIDs(ctx, userID, animeIDs)
}
func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, animeIDs []int64) map[int64]bool {
if userID == "" || len(animeIDs) == 0 {
return map[int64]bool{}
}
watchlistMap, err := h.watchlistSvc.GetWatchlistMap(ctx, userID, animeIDs)
if err != nil {
return map[int64]bool{}
}
return watchlistMap
}
func (h *AnimeHandler) Register(r *gin.Engine) {
r.GET("/", h.HandleCatalog)
r.GET("/api/catalog/airing", h.HandleCatalogAiring)
r.GET("/api/catalog/popular", h.HandleCatalogPopular)
r.GET("/api/catalog/continue", h.HandleCatalogContinue)
r.GET("/discover", h.HandleDiscover)
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
r.GET("/api/discover/top", h.HandleDiscoverTop)
r.GET("/browse", h.HandleBrowse)
r.GET("/anime/:id", h.HandleAnimeDetails)
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
r.GET("/api/watch-order", h.HandleHTMLWatchOrder)
r.GET("/api/search-quick", h.HandleQuickSearch)
r.GET("/api/command-palette", h.HandleCommandPalette)
r.GET("/api/jikan/random/anime", h.HandleRandomAnime)
}
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
user, _ := c.Get("User")
c.HTML(http.StatusOK, "index.gohtml", gin.H{
"CurrentPath": "/",
"User": user,
"WatchlistMap": map[int64]bool{},
})
}
func (h *AnimeHandler) HandleCatalogAiring(c *gin.Context) {
h.renderCatalogSection(c, "Airing")
}
func (h *AnimeHandler) HandleCatalogPopular(c *gin.Context) {
h.renderCatalogSection(c, "Popular")
}
func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) {
h.renderCatalogSection(c, "Continue")
}
func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
if err != nil {
return
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
data.Section = section
data.Fragment = "catalog_section"
data.WatchlistMap = watchlistMap
c.HTML(http.StatusOK, "index.gohtml", data)
}
func (h *AnimeHandler) HandleDiscover(c *gin.Context) {
user, _ := c.Get("User")
c.HTML(http.StatusOK, "discover.gohtml", gin.H{
"CurrentPath": "/discover",
"User": user,
})
}
func (h *AnimeHandler) HandleDiscoverTrending(c *gin.Context) {
h.renderDiscoverSection(c, "Trending")
}
func (h *AnimeHandler) HandleDiscoverUpcoming(c *gin.Context) {
h.renderDiscoverSection(c, "Upcoming")
}
func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) {
h.renderDiscoverSection(c, "Top")
}
func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)
if err != nil {
return
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
data.Section = section
data.Fragment = "discover_section"
data.WatchlistMap = watchlistMap
c.HTML(http.StatusOK, "discover.gohtml", data)
}
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
q := c.Query("q")
animeType := c.Query("type")
status := c.Query("status")
orderBy := c.Query("order_by")
sort := c.Query("sort")
sfw := c.Query("sfw") != "false"
var genres []int
for _, g := range c.QueryArray("genres") {
id, _ := strconv.Atoi(g)
if id > 0 {
genres = append(genres, id)
}
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, sfw, page, 24)
if err != nil {
}
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, res.Animes)
if c.GetHeader("HX-Request") == "true" && page > 1 {
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
"_fragment": "anime_card_scroll",
"Animes": res.Animes,
"NextPage": page + 1,
"HasNextPage": res.HasNextPage,
"Query": q,
"Type": animeType,
"Status": status,
"OrderBy": orderBy,
"Sort": sort,
"Genres": genres,
"SFW": sfw,
"WatchlistMap": watchlistMap,
})
return
}
genresList, _ := h.svc.GetGenres(c.Request.Context())
if c.GetHeader("HX-Request") == "true" {
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
"_fragment": "browse_content",
"CurrentPath": "/browse",
"Query": q,
"Type": animeType,
"Status": status,
"OrderBy": orderBy,
"Sort": sort,
"Genres": genres,
"SFW": sfw,
"GenresList": genresList,
"Animes": res.Animes,
"HasNextPage": res.HasNextPage,
"NextPage": page + 1,
"User": user,
"WatchlistMap": watchlistMap,
})
return
}
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
"CurrentPath": "/browse",
"Query": q,
"Type": animeType,
"Status": status,
"OrderBy": orderBy,
"Sort": sort,
"Genres": genres,
"SFW": sfw,
"GenresList": genresList,
"Animes": res.Animes,
"HasNextPage": res.HasNextPage,
"NextPage": page + 1,
"User": user,
"WatchlistMap": watchlistMap,
})
}
func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
c.Status(http.StatusNotFound)
return
}
section := c.Query("section")
if section != "" && c.GetHeader("HX-Request") == "true" {
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), 4*time.Second)
defer cancel()
var data any
var tplName string
var err error
switch section {
case "characters":
data, err = h.svc.GetCharacters(sectionCtx, id)
tplName = "anime_characters"
case "recommendations":
data, err = h.svc.GetRecommendations(sectionCtx, id)
tplName = "anime_recommendations"
case "statistics":
data, err = h.svc.GetStatistics(sectionCtx, id)
tplName = "anime_statistics"
case "themes":
data, err = h.svc.GetThemes(sectionCtx, id)
tplName = "anime_themes"
}
if err != nil {
log.Printf("failed to fetch section %s: %v", section, err)
c.Status(http.StatusNoContent)
return
}
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"_fragment": tplName,
"Items": data,
})
return
}
anime, err := h.svc.GetAnimeByID(c.Request.Context(), id)
if err != nil {
c.Status(http.StatusNotFound)
return
}
user, _ := c.Get("User")
status := ""
var watchlistIDs []int64
ep := 1
var cwSeconds float64
if u, ok := user.(*domain.User); ok {
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), u.ID, int64(id))
if err == nil {
status = entry.Status
watchlistIDs = []int64{entry.AnimeID}
}
cwEntry, err := h.watchlistSvc.GetContinueWatchingEntry(c.Request.Context(), u.ID, int64(id))
if err == nil && cwEntry.CurrentEpisode.Valid {
ep = int(cwEntry.CurrentEpisode.Int64)
cwSeconds = cwEntry.CurrentTimeSeconds
}
}
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"Anime": anime,
"CurrentPath": fmt.Sprintf("/anime/%d", id),
"User": user,
"Status": status,
"WatchlistIDs": watchlistIDs,
"ContinueWatchingEp": ep,
"ContinueWatchingTime": cwSeconds,
})
}
func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
id, _ := strconv.Atoi(c.Query("animeId"))
if id <= 0 {
c.Status(http.StatusBadRequest)
return
}
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
relationsCtx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
relations, err := h.svc.GetRelations(relationsCtx, id)
if err != nil {
log.Printf("failed to fetch relations for anime %d: %v", id, err)
c.Status(http.StatusNoContent)
return
}
relationAnimeIDs := make([]int64, 0, len(relations))
for _, relation := range relations {
if relation.Anime.MalID > 0 {
relationAnimeIDs = append(relationAnimeIDs, int64(relation.Anime.MalID))
}
}
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, relationAnimeIDs)
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"_fragment": "watch_order",
"Relations": relations,
"AnimeID": id,
"WatchlistMap": watchlistMap,
})
}
func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.JSON(http.StatusOK, []any{})
return
}
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, true, 1, 5)
if err != nil {
c.JSON(http.StatusOK, []any{})
return
}
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, res.Animes)
type quickSearchResult struct {
ID int `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Year int `json:"year"`
Image string `json:"image"`
InWatchlist bool `json:"in_watchlist"`
}
output := make([]quickSearchResult, len(res.Animes))
for i, anime := range res.Animes {
output[i] = quickSearchResult{
ID: anime.MalID,
Title: anime.DisplayTitle(),
Type: anime.Type,
Year: anime.Year,
Image: anime.ImageURL(),
InWatchlist: watchlistMap[int64(anime.MalID)],
}
}
c.JSON(http.StatusOK, output)
}
type commandPaletteItem struct {
ID string `json:"id"`
Type string `json:"type"`
Label string `json:"label"`
Subtitle string `json:"subtitle"`
Href string `json:"href"`
Image string `json:"image,omitempty"`
Icon string `json:"icon,omitempty"`
}
func (h *AnimeHandler) HandleCommandPalette(c *gin.Context) {
user, _ := c.Get("User")
u, ok := user.(*domain.User)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
query := strings.TrimSpace(c.Query("q"))
items := make([]commandPaletteItem, 0, 12)
if query != "" {
items = append(items, commandPaletteItem{
ID: "search:" + strings.ToLower(query),
Type: "search",
Label: fmt.Sprintf("Search anime for %q", query),
Subtitle: "Browse",
Href: "/browse?q=" + url.QueryEscape(query),
Icon: "search",
})
if len(query) >= 2 {
items = append(items, h.commandPaletteAnimeResults(c, query)...)
}
items = append(items, h.commandPaletteNavigationItems(query)...)
items = append(items, h.commandPaletteContinueItems(c, u.ID, query)...)
items = append(items, h.commandPalettePersonalItems(c, u.ID, query)...)
c.JSON(http.StatusOK, items)
return
}
items = append(items, h.commandPaletteContinueItems(c, u.ID, query)...)
items = append(items, h.commandPaletteNavigationItems(query)...)
items = append(items, h.commandPalettePersonalItems(c, u.ID, query)...)
c.JSON(http.StatusOK, items)
}
func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPaletteItem {
all := []commandPaletteItem{
{ID: "nav:discover", Type: "navigation", Label: "Go to Discover", Subtitle: "Navigation", Href: "/discover", Icon: "compass"},
{ID: "nav:watchlist", Type: "navigation", Label: "Go to Watchlist", Subtitle: "Navigation", Href: "/watchlist", Icon: "bookmark"},
{ID: "nav:popular", Type: "navigation", Label: "Browse popular", Subtitle: "Browse", Href: "/browse?order_by=popularity&sort=desc", Icon: "trending"},
{ID: "nav:airing", Type: "navigation", Label: "Currently airing", Subtitle: "Browse", Href: "/browse?status=airing&order_by=popularity&sort=desc", Icon: "play"},
}
if query == "" {
return all
}
filtered := make([]commandPaletteItem, 0, len(all))
for _, item := range all {
if commandPaletteMatches(query, item.Label, item.Subtitle) {
filtered = append(filtered, item)
}
}
return filtered
}
func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string) []commandPaletteItem {
searchCtx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
defer cancel()
res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, true, 1, 5)
if err != nil {
return nil
}
items := make([]commandPaletteItem, 0, len(res.Animes))
for _, anime := range res.Animes {
items = append(items, commandPaletteItem{
ID: fmt.Sprintf("anime:%d", anime.MalID),
Type: "anime",
Label: anime.DisplayTitle(),
Subtitle: strings.TrimSpace("Anime " + anime.Type),
Href: fmt.Sprintf("/anime/%d", anime.MalID),
Image: anime.ImageURL(),
})
}
return items
}
func (h *AnimeHandler) commandPalettePersonalItems(c *gin.Context, userID string, query string) []commandPaletteItem {
items := make([]commandPaletteItem, 0, 5)
watchlist, err := h.watchlistSvc.GetCommandPaletteWatchlist(c.Request.Context(), userID, query, 5)
if err != nil {
return items
}
for _, entry := range watchlist {
title := watchlistTitle(entry)
items = append(items, commandPaletteItem{
ID: fmt.Sprintf("watchlist:%d", entry.AnimeID),
Type: "watchlist",
Label: title,
Subtitle: watchlistStatusLabel(entry.Status),
Href: fmt.Sprintf("/anime/%d", entry.AnimeID),
Image: entry.ImageUrl,
})
if len(items) >= 5 {
return items
}
}
return items
}
func (h *AnimeHandler) commandPaletteContinueItems(c *gin.Context, userID string, query string) []commandPaletteItem {
items := make([]commandPaletteItem, 0, 5)
rows, err := h.watchlistSvc.GetCommandPaletteContinueWatching(c.Request.Context(), userID, query, 5)
if err != nil {
return items
}
for _, row := range rows {
title := continueWatchingTitle(row)
episode := ""
href := fmt.Sprintf("/anime/%d/watch", row.AnimeID)
if row.CurrentEpisode.Valid {
episode = fmt.Sprintf(" episode %d", row.CurrentEpisode.Int64)
href = fmt.Sprintf("%s?ep=%d", href, row.CurrentEpisode.Int64)
}
items = append(items, commandPaletteItem{
ID: fmt.Sprintf("continue:%d", row.AnimeID),
Type: "continue",
Label: "Continue watching " + title,
Subtitle: "Resume" + episode,
Href: href,
Image: row.ImageUrl,
})
if len(items) >= 5 {
return items
}
}
return items
}
func commandPaletteMatches(query string, values ...string) bool {
needle := strings.ToLower(strings.TrimSpace(query))
for _, value := range values {
if strings.Contains(strings.ToLower(value), needle) {
return true
}
}
return false
}
func continueWatchingTitle(row db.GetContinueWatchingEntriesRow) string {
if row.TitleEnglish.Valid && row.TitleEnglish.String != "" {
return row.TitleEnglish.String
}
return row.TitleOriginal
}
func watchlistTitle(row domain.UserWatchListRow) string {
if row.TitleEnglish.Valid && row.TitleEnglish.String != "" {
return row.TitleEnglish.String
}
return row.TitleOriginal
}
func watchlistStatusLabel(status string) string {
switch status {
case "watching":
return "Watching"
case "plan_to_watch":
return "Plan to Watch"
default:
return "Watchlist"
}
}
func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
anime, err := h.svc.GetRandomAnime(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch random anime"})
return
}
if anime.MalID == 0 {
c.JSON(http.StatusBadGateway, gin.H{"error": "Random anime unavailable"})
return
}
user, _ := c.Get("User")
inWatchlist := false
if u, ok := user.(*domain.User); ok {
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), u.ID, []int64{int64(anime.MalID)})
inWatchlist = watchlistMap[int64(anime.MalID)]
}
c.JSON(http.StatusOK, gin.H{
"data": anime,
"in_watchlist": inWatchlist,
})
}
func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
c.Status(http.StatusNotFound)
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), id, page)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
user, _ := c.Get("User")
if c.GetHeader("HX-Request") == "true" && page > 1 {
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
"_fragment": "review_cards",
"Reviews": reviews,
"NextPage": page + 1,
"HasNextPage": hasNextPage,
"AnimeID": id,
})
return
}
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
"CurrentPath": fmt.Sprintf("/anime/%d/reviews", id),
"Reviews": reviews,
"NextPage": page + 1,
"HasNextPage": hasNextPage,
"AnimeID": id,
"User": user,
})
}

View File

@@ -0,0 +1,124 @@
package anime
import (
"context"
"errors"
"mal/integrations/jikan"
"mal/internal/domain"
"testing"
)
type stubEpisodeService struct {
episodes domain.CanonicalEpisodeList
err error
forced bool
}
func (s *stubEpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.Anime, forceRefresh bool) (domain.CanonicalEpisodeList, error) {
s.forced = forceRefresh
if s.err != nil {
return domain.CanonicalEpisodeList{}, s.err
}
return s.episodes, nil
}
func (s *stubEpisodeService) RefreshTrackedDue(ctx context.Context, limit int) error {
return nil
}
func TestAnimeAudioAvailabilityLabel(t *testing.T) {
tests := []struct {
name string
episodes []domain.CanonicalEpisode
want string
}{
{
name: "dub availability",
episodes: []domain.CanonicalEpisode{
{Number: 1, HasSub: true, HasDub: true},
},
want: "Dub available",
},
{
name: "subtitled availability",
episodes: []domain.CanonicalEpisode{
{Number: 1, HasSub: true, SubOnly: true},
},
want: "Subtitled only",
},
{
name: "unknown availability",
episodes: []domain.CanonicalEpisode{{Number: 1}},
want: "",
},
{
name: "no episodes",
episodes: []domain.CanonicalEpisode{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := animeAudioAvailabilityLabel(tt.episodes)
if got != tt.want {
t.Fatalf("animeAudioAvailabilityLabel() = %q, want %q", got, tt.want)
}
})
}
}
func TestAnimeAudioAvailabilityRequiresAllAnimeSource(t *testing.T) {
tests := []struct {
name string
source string
err error
want string
}{
{
name: "allanime source",
source: "AllAnime",
want: "Dub available",
},
{
name: "jikan fallback source",
source: "jikan_fallback",
want: "",
},
{
name: "legacy source",
source: "legacy_disabled",
want: "",
},
{
name: "provider error",
err: errors.New("provider unavailable"),
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{
Source: tt.source,
Episodes: []domain.CanonicalEpisode{
{Number: 1, HasSub: true, HasDub: true},
},
},
err: tt.err,
}
handler := NewAnimeHandler(nil, nil, episodeSvc)
got := handler.animeAudioAvailability(context.Background(), domain.Anime{
Anime: jikan.Anime{MalID: 52991},
})
if got != tt.want {
t.Fatalf("animeAudioAvailability() = %q, want %q", got, tt.want)
}
if !episodeSvc.forced {
t.Fatal("animeAudioAvailability() did not force provider refresh")
}
})
}
}

View File

@@ -1,9 +1,7 @@
package anime
import (
"mal/internal/anime/handler"
"mal/internal/anime/repository"
"mal/internal/anime/service"
"mal/internal/domain"
"mal/internal/server"
"go.uber.org/fx"
@@ -11,12 +9,20 @@ import (
var Module = fx.Options(
fx.Provide(
repository.NewAnimeRepository,
service.NewAnimeService,
handler.NewAnimeHandler,
NewAnimeRepository,
fx.Annotate(
NewAnimeService,
fx.As(new(Service)),
fx.As(new(domain.AnimeCatalogService)),
fx.As(new(domain.AnimeDiscoverService)),
fx.As(new(domain.AnimeSearchService)),
fx.As(new(domain.AnimeDetailsService)),
fx.As(new(domain.AnimePlaybackService)),
),
NewAnimeHandler,
),
fx.Provide(
server.AsRouteRegister(func(h *handler.AnimeHandler) server.RouteRegister {
server.AsRouteRegister(func(h *AnimeHandler) server.RouteRegister {
return h
}),
),

View File

@@ -0,0 +1,503 @@
package anime
import (
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"math"
"slices"
"sort"
"strings"
"time"
)
const (
forYouMaxSeeds = 8
forYouMaxRecommendations = 10
forYouCandidateFetchLimit = 60
forYouResultLimit = 18
forYouFullResultLimit = 60
forYouProfileSearchLimit = 8
forYouProfileGenreSearches = 2
forYouProfileThemeSearches = 2
forYouCollaborativeWeight = 1.4
forYouProfileSearchWeight = 0.8
forYouSeedRecencyWindow = 180 * 24 * time.Hour
forYouFreshReleaseWindow = 540 * 24 * time.Hour
forYouGenreMatchWeight = 1.8
forYouThemeMatchWeight = 1.0
forYouStudioMatchWeight = 0.7
forYouDemographicMatchWeight = 0.9
forYouRecentDiversityWindow = 3
forYouGenreDiversityPenalty = 1.7
forYouThemeDiversityPenalty = 1.2
forYouDemoDiversityPenalty = 1.0
forYouStudioDiversityPenalty = 0.7
)
type 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
if candidate.Score > 0 {
score += min(candidate.Score/10.0, 1.0)
}
if candidate.Popularity > 0 {
score += 1.0 / math.Log(float64(candidate.Popularity)+8)
}
if profile.prefersAiring && candidate.Airing {
score += 0.5
}
if profile.prefersRecent && candidate.Year > 0 && now.Year()-candidate.Year <= 4 {
score += 0.45
}
if candidate.Year > 0 && now.Year()-candidate.Year > 15 {
score -= 0.2
}
if candidate.Status == "Not yet aired" {
score -= 0.35
}
if candidate.Aired.From != "" {
if airedAt, err := time.Parse(time.RFC3339, candidate.Aired.From); err == nil {
if now.Sub(airedAt) <= forYouFreshReleaseWindow {
score += 0.3
}
}
}
return recommendationCandidate{
anime: candidate,
score: score,
genreMatches: genreMatches,
themeMatches: themeMatches,
studioMatches: studioMatches,
demographicMatches: demographicMatches,
}
}
func weightedEntityMatch(weights map[int]float64, entities []jikan.NamedEntity) (int, float64) {
var (
matches int
score float64
)
for _, entity := range entities {
weight, ok := weights[entity.MalID]
if !ok {
continue
}
matches++
score += weight
}
return matches, score
}
func rerankRecommendationCandidates(candidates []recommendationCandidate, limit int) []domain.Anime {
selected := make([]domain.Anime, 0, min(limit, len(candidates)))
remaining := slices.Clone(candidates)
seenFeatures := newDiversityFeatureCounts()
recentFeatures := make([]diversityFeatureSet, 0, forYouRecentDiversityWindow)
for len(selected) < limit && len(remaining) > 0 {
bestIndex := bestDiverseCandidateIndex(remaining, seenFeatures, recentFeatures)
candidate := remaining[bestIndex]
remaining = slices.Delete(remaining, bestIndex, bestIndex+1)
if slices.ContainsFunc(selected, func(anime domain.Anime) bool {
return anime.MalID == candidate.anime.MalID
}) {
continue
}
selected = append(selected, domain.Anime{Anime: candidate.anime})
features := diversityFeatures(candidate.anime)
seenFeatures.add(features)
recentFeatures = append(recentFeatures, features)
if len(recentFeatures) > forYouRecentDiversityWindow {
recentFeatures = recentFeatures[1:]
}
}
return selected
}
type diversityFeatureSet struct {
genres map[int]struct{}
themes map[int]struct{}
demographics map[int]struct{}
studios map[int]struct{}
}
type diversityFeatureCounts struct {
genres map[int]int
themes map[int]int
demographics map[int]int
studios map[int]int
}
func newDiversityFeatureCounts() diversityFeatureCounts {
return diversityFeatureCounts{
genres: make(map[int]int),
themes: make(map[int]int),
demographics: make(map[int]int),
studios: make(map[int]int),
}
}
func (counts diversityFeatureCounts) add(features diversityFeatureSet) {
addDiversityCounts(counts.genres, features.genres)
addDiversityCounts(counts.themes, features.themes)
addDiversityCounts(counts.demographics, features.demographics)
addDiversityCounts(counts.studios, features.studios)
}
func addDiversityCounts(target map[int]int, features map[int]struct{}) {
for id := range features {
target[id]++
}
}
func bestDiverseCandidateIndex(
candidates []recommendationCandidate,
seen diversityFeatureCounts,
recent []diversityFeatureSet,
) int {
bestIndex := 0
bestScore := math.Inf(-1)
for i, candidate := range candidates {
score := candidate.score - diversityPenalty(diversityFeatures(candidate.anime), seen, recent)
if score == bestScore {
if candidate.score <= candidates[bestIndex].score {
continue
}
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
return bestIndex
}
func diversityFeatures(anime jikan.Anime) diversityFeatureSet {
return diversityFeatureSet{
genres: entityIDSet(anime.Genres),
themes: entityIDSet(anime.Themes),
demographics: entityIDSet(anime.Demographics),
studios: entityIDSet(anime.Studios),
}
}
func entityIDSet(entities []jikan.NamedEntity) map[int]struct{} {
ids := make(map[int]struct{}, len(entities))
for _, entity := range entities {
if entity.MalID <= 0 {
continue
}
ids[entity.MalID] = struct{}{}
}
return ids
}
func diversityPenalty(
features diversityFeatureSet,
seen diversityFeatureCounts,
recent []diversityFeatureSet,
) float64 {
penalty := 0.0
penalty += repeatedFeaturePenalty(features.genres, seen.genres, recentGenreCounts(recent), forYouGenreDiversityPenalty)
penalty += repeatedFeaturePenalty(features.themes, seen.themes, recentThemeCounts(recent), forYouThemeDiversityPenalty)
penalty += repeatedFeaturePenalty(
features.demographics,
seen.demographics,
recentDemographicCounts(recent),
forYouDemoDiversityPenalty,
)
penalty += repeatedFeaturePenalty(features.studios, seen.studios, recentStudioCounts(recent), forYouStudioDiversityPenalty)
return penalty
}
func repeatedFeaturePenalty(
features map[int]struct{},
seen map[int]int,
recent map[int]int,
weight float64,
) float64 {
total := 0.0
for id := range features {
total += float64(seen[id]) * weight * 0.35
total += float64(recent[id]) * weight
}
return total
}
func recentGenreCounts(recent []diversityFeatureSet) map[int]int {
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
return features.genres
})
}
func recentThemeCounts(recent []diversityFeatureSet) map[int]int {
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
return features.themes
})
}
func recentDemographicCounts(recent []diversityFeatureSet) map[int]int {
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
return features.demographics
})
}
func recentStudioCounts(recent []diversityFeatureSet) map[int]int {
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
return features.studios
})
}
func recentFeatureCounts(
recent []diversityFeatureSet,
selectFeatures func(diversityFeatureSet) map[int]struct{},
) map[int]int {
counts := make(map[int]int)
for _, features := range recent {
addDiversityCounts(counts, selectFeatures(features))
}
return counts
}

View File

@@ -0,0 +1,226 @@
package anime
import (
"database/sql"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"testing"
"time"
)
func TestRecommendationEntryWeightPrioritizesCommittedRecentHistory(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
completed := recommendationEntryWeight(now, db.GetUserWatchListRow{
Status: "completed",
UpdatedAt: now.Add(-24 * time.Hour),
CurrentEpisode: sql.NullInt64{Int64: 12, Valid: true},
})
planned := recommendationEntryWeight(now, db.GetUserWatchListRow{
Status: "plan_to_watch",
UpdatedAt: now.Add(-24 * time.Hour),
})
if completed <= planned {
t.Fatalf("expected completed history to outrank planned history, got completed=%f planned=%f", completed, planned)
}
}
func TestBuildRecommendationSeedsFiltersUnsupportedStatuses(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
seeds := buildRecommendationSeeds(now, []db.GetUserWatchListRow{
{AnimeID: 1, Status: "dropped", UpdatedAt: now},
{AnimeID: 2, Status: "watching", UpdatedAt: now},
{AnimeID: 3, Status: "completed", UpdatedAt: now},
})
if len(seeds) != 2 {
t.Fatalf("expected 2 valid seeds, got %d", len(seeds))
}
if seeds[0].animeID != 2 || seeds[1].animeID != 3 {
t.Fatalf("unexpected seed ordering: %+v", seeds)
}
}
func TestScoreRecommendationCandidateRewardsProfileOverlap(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
profile := userTasteProfile{
genres: map[int]float64{
1: 2.0,
},
themes: map[int]float64{},
studios: map[int]float64{},
demographics: map[int]float64{},
}
matching := scoreRecommendationCandidate(now, profile, jikan.Anime{
MalID: 10,
Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}},
Popularity: 100,
Score: 8.0,
}, 5.0, 0)
nonMatching := scoreRecommendationCandidate(now, profile, jikan.Anime{
MalID: 11,
Genres: []jikan.NamedEntity{{MalID: 2, Name: "Drama"}},
Popularity: 100,
Score: 8.0,
}, 5.0, 0)
if matching.score <= nonMatching.score {
t.Fatalf("expected matching candidate to score higher, got matching=%f nonMatching=%f", matching.score, nonMatching.score)
}
}
func TestBuildTasteProfileUsesSeedWeights(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
profile := buildTasteProfile(
now,
[]recommendationSeed{
{animeID: 1, weight: 2.0},
{animeID: 2, weight: 0.5},
},
[]jikan.Anime{
{
MalID: 1,
Airing: true,
Year: 2026,
Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}},
Themes: []jikan.NamedEntity{{MalID: 10, Name: "Team Sports"}},
Studios: []jikan.NamedEntity{{MalID: 20, Name: "Production I.G"}},
Demographics: []jikan.NamedEntity{{MalID: 30, Name: "Shounen"}},
},
{
MalID: 2,
Year: 2001,
Genres: []jikan.NamedEntity{{MalID: 2, Name: "Drama"}},
Themes: []jikan.NamedEntity{{MalID: 11, Name: "School"}},
Studios: []jikan.NamedEntity{{MalID: 21, Name: "Madhouse"}},
Demographics: []jikan.NamedEntity{{MalID: 31, Name: "Seinen"}},
},
},
)
if profile.genres[1] <= profile.genres[2] {
t.Fatalf("expected stronger seed genre to carry more weight, got profile=%+v", profile.genres)
}
if !profile.prefersAiring {
t.Fatal("expected weighted profile to prefer airing anime")
}
if !profile.prefersRecent {
t.Fatal("expected weighted profile to prefer recent anime")
}
}
func TestBuildProfileSearchQueriesIncludesTasteSignals(t *testing.T) {
profile := userTasteProfile{
genres: map[int]float64{
1: 2.0,
2: 1.5,
3: 0.2,
},
themes: map[int]float64{
10: 1.4,
},
studios: map[int]float64{
20: 1.0,
},
demographics: map[int]float64{
30: 1.2,
},
}
queries := buildProfileSearchQueries(profile)
if !hasGenreSearchQuery(queries, 1) {
t.Fatalf("expected strongest genre query, got %+v", queries)
}
if !hasGenreSearchQuery(queries, 10) {
t.Fatalf("expected theme query, got %+v", queries)
}
if !hasGenreSearchQuery(queries, 30) {
t.Fatalf("expected demographic query, got %+v", queries)
}
if !hasStudioSearchQuery(queries, 20) {
t.Fatalf("expected studio query, got %+v", queries)
}
}
func TestRerankRecommendationCandidatesSpreadsRepeatedGenres(t *testing.T) {
const sportsGenreID = 30
candidates := []recommendationCandidate{
{anime: testRecommendationAnime(1, sportsGenreID), score: 10},
{anime: testRecommendationAnime(2, sportsGenreID), score: 9.9},
{anime: testRecommendationAnime(3, sportsGenreID), score: 9.8},
{anime: testRecommendationAnime(4, sportsGenreID), score: 9.7},
{anime: testRecommendationAnime(5, sportsGenreID), score: 9.6},
{anime: testRecommendationAnime(6, 1), score: 9.5},
{anime: testRecommendationAnime(7, 2), score: 9.4},
{anime: testRecommendationAnime(8, 3), score: 9.3},
}
reranked := rerankRecommendationCandidates(candidates, 8)
if len(reranked) < 5 {
t.Fatalf("expected enough reranked candidates, got %d", len(reranked))
}
for i := 0; i <= len(reranked)-5; i++ {
if allHaveGenre(reranked[i:i+5], sportsGenreID) {
t.Fatalf("expected reranker to avoid five sports anime in a row, got %+v", animeIDs(reranked))
}
}
}
func testRecommendationAnime(id int, genreID int) jikan.Anime {
return jikan.Anime{
MalID: id,
Genres: []jikan.NamedEntity{{MalID: genreID, Name: "Genre"}},
}
}
func allHaveGenre(animes []domain.Anime, genreID int) bool {
for _, anime := range animes {
hasGenre := false
for _, genre := range anime.Genres {
if genre.MalID == genreID {
hasGenre = true
break
}
}
if !hasGenre {
return false
}
}
return true
}
func animeIDs(animes []domain.Anime) []int {
ids := make([]int, 0, len(animes))
for _, anime := range animes {
ids = append(ids, anime.MalID)
}
return ids
}
func hasGenreSearchQuery(queries []profileSearchQuery, genreID int) bool {
for _, query := range queries {
for _, id := range query.genreIDs {
if id == genreID {
return true
}
}
}
return false
}
func hasStudioSearchQuery(queries []profileSearchQuery, studioID int) bool {
for _, query := range queries {
if query.studioID == studioID {
return true
}
}
return false
}

View File

@@ -1,4 +1,4 @@
package repository
package anime
import (
"context"

121
internal/anime/schedule.go Normal file
View File

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

673
internal/anime/service.go Normal file
View File

@@ -0,0 +1,673 @@
// Package anime provides anime catalog, discovery, search, and details services.
package anime
import (
"context"
"errors"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"mal/internal/observability"
"math/rand"
"sort"
"strings"
"sync"
"time"
"golang.org/x/sync/errgroup"
)
type animeService struct {
jikan *jikan.Client
repo domain.AnimeRepository
}
func wrapAnimes(in []jikan.Anime) []domain.Anime {
out := make([]domain.Anime, 0, len(in))
for _, a := range in {
out = append(out, domain.Anime{Anime: a})
}
return out
}
func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) *animeService {
return &animeService{jikan: jikan, repo: repo}
}
func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (domain.CatalogSectionData, error) {
var (
res jikan.TopAnimeResult
cw []db.GetContinueWatchingEntriesRow
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
switch section {
case "Airing":
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
case "Popular":
res, err = s.jikan.GetTopAnime(gCtx, 1)
}
return err
})
if userID != "" && section == "Continue" {
g.Go(func() error {
var err error
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
return err
})
}
if err := g.Wait(); err != nil {
return domain.CatalogSectionData{}, err
}
animes := wrapAnimes(res.Animes)
if len(animes) > 6 {
animes = animes[:6]
}
return domain.CatalogSectionData{
Animes: animes,
ContinueWatching: cw,
}, nil
}
func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, section string) (domain.DiscoverSectionData, error) {
var res jikan.TopAnimeResult
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
switch section {
case "Trending":
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
case "Upcoming":
res, err = s.jikan.GetSeasonsUpcoming(gCtx, 1)
case "Top":
res, err = s.jikan.GetTopAnime(gCtx, 1)
}
return err
})
if err := g.Wait(); err != nil {
return domain.DiscoverSectionData{}, err
}
animes := wrapAnimes(res.Animes)
if len(animes) > 8 {
animes = animes[:8]
}
return domain.DiscoverSectionData{
Animes: animes,
}, nil
}
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
return s.getTopPicksForYou(ctx, userID, forYouResultLimit)
}
func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
return s.getTopPicksForYou(ctx, userID, forYouFullResultLimit)
}
func (s *animeService) getTopPicksForYou(
ctx context.Context,
userID string,
resultLimit int,
) (domain.CatalogSectionData, error) {
if strings.TrimSpace(userID) == "" {
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
}
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
if err != nil {
return domain.CatalogSectionData{}, err
}
now := time.Now()
seedPool := buildRecommendationSeeds(now, watchlist)
if len(seedPool) == 0 {
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
}
type rankedCandidate struct {
id int
collaborativeScore float64
profileSearchScore float64
anime jikan.Anime
hasAnime bool
}
watchlistAnimeIDs := make(map[int]struct{}, len(watchlist))
for _, entry := range watchlist {
if entry.AnimeID <= 0 {
continue
}
watchlistAnimeIDs[int(entry.AnimeID)] = struct{}{}
}
candidatesByID := map[int]rankedCandidate{}
var candidatesByIDMu sync.Mutex
upsertCandidate := func(candidate rankedCandidate) {
if candidate.id <= 0 {
return
}
if _, exists := watchlistAnimeIDs[candidate.id]; exists {
return
}
candidatesByIDMu.Lock()
defer candidatesByIDMu.Unlock()
current, ok := candidatesByID[candidate.id]
if !ok {
candidatesByID[candidate.id] = candidate
return
}
current.collaborativeScore += candidate.collaborativeScore
current.profileSearchScore += candidate.profileSearchScore
if candidate.hasAnime {
current.anime = candidate.anime
current.hasAnime = true
}
candidatesByID[candidate.id] = current
}
seedAnimes := make([]jikan.Anime, len(seedPool))
var seedFetchGroup errgroup.Group
seedFetchGroup.SetLimit(4)
for i, seed := range seedPool {
seedFetchGroup.Go(func() error {
anime, fetchErr := s.jikan.GetAnimeByID(ctx, seed.animeID)
if fetchErr != nil {
return fetchErr
}
seedAnimes[i] = anime
return nil
})
}
if err := seedFetchGroup.Wait(); err != nil {
return domain.CatalogSectionData{}, err
}
profile := buildTasteProfile(now, seedPool, seedAnimes)
var recommendationGroup errgroup.Group
recommendationGroup.SetLimit(4)
for _, seed := range seedPool {
recommendationGroup.Go(func() error {
recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
if recErr != nil {
return recErr
}
for i, rec := range recs {
if i >= forYouMaxRecommendations {
break
}
id := rec.Entry.MalID
if id <= 0 {
continue
}
if id == seed.animeID {
continue
}
upsertCandidate(rankedCandidate{
id: id,
collaborativeScore: float64(rec.Votes) * seed.weight,
})
}
return nil
})
}
if err := recommendationGroup.Wait(); err != nil {
return domain.CatalogSectionData{}, err
}
profileQueries := buildProfileSearchQueries(profile)
var profileSearchGroup errgroup.Group
profileSearchGroup.SetLimit(3)
for _, query := range profileQueries {
profileSearchGroup.Go(func() error {
res, searchErr := s.jikan.SearchAdvanced(
ctx,
"",
"",
"",
"score",
"desc",
query.genreIDs,
query.studioID,
true,
1,
forYouProfileSearchLimit,
)
if searchErr != nil {
observability.Warn(
"top_pick_profile_search_failed",
"anime",
"",
map[string]any{
"genres": query.genreIDs,
"studio_id": query.studioID,
},
searchErr,
)
return nil
}
for i, anime := range res.Animes {
if anime.MalID <= 0 {
continue
}
upsertCandidate(rankedCandidate{
id: anime.MalID,
profileSearchScore: query.weight * profileSearchRankWeight(i),
anime: anime,
hasAnime: true,
})
}
return nil
})
}
if err := profileSearchGroup.Wait(); err != nil {
return domain.CatalogSectionData{}, err
}
if len(candidatesByID) == 0 {
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
}
rankedIDs := make([]rankedCandidate, 0, len(candidatesByID))
for _, item := range candidatesByID {
rankedIDs = append(rankedIDs, item)
}
sort.Slice(rankedIDs, func(i, j int) bool {
left := rankedCandidateRetrievalScore(rankedIDs[i].collaborativeScore, rankedIDs[i].profileSearchScore)
right := rankedCandidateRetrievalScore(rankedIDs[j].collaborativeScore, rankedIDs[j].profileSearchScore)
if left == right {
return rankedIDs[i].id < rankedIDs[j].id
}
return left > right
})
limit := min(len(rankedIDs), forYouCandidateFetchLimit)
candidates := make([]recommendationCandidate, 0, limit)
var candidatesMu sync.Mutex
var detailGroup errgroup.Group
detailGroup.SetLimit(6)
for i := 0; i < limit; i++ {
item := rankedIDs[i]
detailGroup.Go(func() error {
anime := item.anime
if !item.hasAnime || !hasTasteMetadata(anime) {
fetchedAnime, fetchErr := s.jikan.GetAnimeByID(ctx, item.id)
if fetchErr != nil {
observability.Warn(
"recommendation_anime_fetch_failed",
"anime",
"",
map[string]any{"anime_id": item.id},
fetchErr,
)
return nil
}
anime = fetchedAnime
}
candidate := scoreRecommendationCandidate(
now,
profile,
anime,
item.collaborativeScore,
item.profileSearchScore,
)
candidatesMu.Lock()
candidates = append(candidates, candidate)
candidatesMu.Unlock()
return nil
})
}
if err := detailGroup.Wait(); err != nil {
return domain.CatalogSectionData{}, err
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].score == candidates[j].score {
return candidates[i].anime.MalID < candidates[j].anime.MalID
}
return candidates[i].score > candidates[j].score
})
return domain.CatalogSectionData{
Animes: rerankRecommendationCandidates(candidates, resultLimit),
}, nil
}
func (s *animeService) GetAiringSchedule(ctx context.Context, userID string) ([]domain.Anime, error) {
if strings.TrimSpace(userID) == "" {
return []domain.Anime{}, nil
}
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
if err != nil {
return nil, err
}
ids := make([]int, 0, 50)
for _, entry := range watchlist {
status := strings.TrimSpace(entry.Status)
if status != "watching" && status != "plan_to_watch" {
continue
}
if !entry.Airing.Valid || !entry.Airing.Bool {
continue
}
if entry.AnimeID <= 0 {
continue
}
ids = append(ids, int(entry.AnimeID))
if len(ids) >= 50 {
break
}
}
if len(ids) == 0 {
return []domain.Anime{}, nil
}
animes := make([]domain.Anime, 0, len(ids))
var g errgroup.Group
g.SetLimit(6)
var mu sync.Mutex
for _, id := range ids {
g.Go(func() error {
anime, fetchErr := s.jikan.GetAnimeByID(ctx, id)
if fetchErr != nil {
return fetchErr
}
mu.Lock()
animes = append(animes, domain.Anime{Anime: anime})
mu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
observability.Warn(
"schedule_partial_fetch_failed",
"anime",
"",
map[string]any{"user_id": userID, "count": len(ids)},
err,
)
return animes, nil
}
return animes, nil
}
func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) {
anime, err := s.jikan.GetAnimeByID(ctx, id)
if err != nil {
return domain.Anime{}, err
}
return domain.Anime{Anime: anime}, nil
}
func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error) {
return s.jikan.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, studioID, sfw, page, limit)
}
func (s *animeService) GetProducerNameByID(ctx context.Context, id int) (string, error) {
res, err := s.jikan.GetProducerByID(ctx, id)
if err != nil {
return "", err
}
for _, t := range res.Data.Titles {
if t.Title != "" {
return t.Title, nil
}
}
return "", nil
}
func (s *animeService) GetProducers(ctx context.Context, query string, page int, limit int) (jikan.ProducerListResult, error) {
return s.jikan.GetProducers(ctx, query, page, limit)
}
func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
genres, err := s.jikan.GetAnimeGenres(ctx)
if err != nil {
return nil, err
}
out := make([]domain.Genre, 0, len(genres))
for _, g := range genres {
if g.MalID <= 0 || strings.TrimSpace(g.Name) == "" {
continue
}
out = append(out, domain.Genre{MalID: g.MalID, Name: g.Name})
}
return out, nil
}
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
}
out := make([]domain.CharacterEntry, 0, len(items))
for _, it := range items {
var mapped domain.CharacterEntry
mapped.Character.MalID = it.Character.MalID
mapped.Character.URL = it.Character.URL
mapped.Character.Name = it.Character.Name
mapped.Character.Images.Jpg.ImageURL = it.Character.Images.Jpg.ImageURL
mapped.Character.Images.Webp.ImageURL = it.Character.Images.Webp.ImageURL
mapped.Character.Images.Webp.SmallImageURL = it.Character.Images.Webp.SmallImageURL
mapped.Role = it.Role
if len(it.VoiceActors) > 0 {
mapped.VoiceActors = make([]domain.CharacterVoiceActor, 0, len(it.VoiceActors))
for _, va := range it.VoiceActors {
var mappedVA domain.CharacterVoiceActor
mappedVA.Language = va.Language
mappedVA.Person.MalID = va.Person.MalID
mappedVA.Person.URL = va.Person.URL
mappedVA.Person.Name = va.Person.Name
mappedVA.Person.Images.Jpg.ImageURL = va.Person.Images.Jpg.ImageURL
mapped.VoiceActors = append(mapped.VoiceActors, mappedVA)
}
}
out = append(out, mapped)
}
return out, nil
}
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
}
out := make([]domain.RecommendationEntry, 0, len(items))
for _, it := range items {
var mapped domain.RecommendationEntry
mapped.Entry.MalID = it.Entry.MalID
mapped.Entry.URL = it.Entry.URL
mapped.Entry.Title = it.Entry.Title
mapped.Entry.Images.Webp.LargeImageURL = it.Entry.Images.Webp.LargeImageURL
mapped.URL = it.URL
mapped.Votes = it.Votes
out = append(out, mapped)
}
return out, nil
}
func (s *animeService) GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error) {
return s.jikan.GetFullRelations(ctx, id)
}
func (s *animeService) WarmDetailSections(id int) {
s.jikan.WarmAnimeRecommendations(id)
s.jikan.WarmFullRelations(id)
}
func (s *animeService) GetEpisodes(ctx context.Context, id int, page int) (jikan.EpisodesResponse, error) {
return s.jikan.GetEpisodes(ctx, id, page)
}
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
}
out := make([]domain.StaffEntry, 0, len(items))
for _, it := range items {
var mapped domain.StaffEntry
mapped.Person.MalID = it.Person.MalID
mapped.Person.URL = it.Person.URL
mapped.Person.Name = it.Person.Name
mapped.Person.Images.Jpg.ImageURL = it.Person.Images.Jpg.ImageURL
mapped.Positions = append([]string(nil), it.Positions...)
out = append(out, mapped)
}
return out, nil
}
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
}
out := domain.Statistics{
Watching: stats.Watching,
Completed: stats.Completed,
OnHold: stats.OnHold,
Dropped: stats.Dropped,
PlanToWatch: stats.PlanToWatch,
Total: stats.Total,
}
if len(stats.Scores) > 0 {
out.Scores = make([]domain.StatisticsScore, 0, len(stats.Scores))
for _, s := range stats.Scores {
out.Scores = append(out.Scores, domain.StatisticsScore{Score: s.Score, Votes: s.Votes, Percentage: s.Percentage})
}
}
return out, nil
}
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{
Openings: append([]string(nil), themes.Openings...),
Endings: append([]string(nil), themes.Endings...),
}, nil
}
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
}
out := make([]domain.ReviewEntry, 0, len(data))
for _, it := range data {
mapped := domain.ReviewEntry{
MalID: it.MalID,
URL: it.URL,
Type: it.Type,
Date: it.Date,
Review: it.Review,
Score: it.Score,
Tags: append([]string(nil), it.Tags...),
IsSpoiler: it.IsSpoiler,
IsPreliminary: it.IsPreliminary,
EpisodesSeen: it.EpisodesSeen,
Reactions: domain.ReviewReactions{
Overall: it.Reactions.Overall,
Nice: it.Reactions.Nice,
LoveIt: it.Reactions.LoveIt,
Funny: it.Reactions.Funny,
Confusing: it.Reactions.Confusing,
Informative: it.Reactions.Informative,
WellWritten: it.Reactions.WellWritten,
Creative: it.Reactions.Creative,
},
}
mapped.User.URL = it.User.URL
mapped.User.Username = it.User.Username
mapped.User.Images.Jpg.ImageURL = it.User.Images.Jpg.ImageURL
mapped.User.Images.Webp.ImageURL = it.User.Images.Webp.ImageURL
out = append(out, mapped)
}
return out, pag.HasNextPage, nil
}
func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error) {
randomCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
anime, err := s.jikan.GetRandomAnime(randomCtx)
if err == nil {
return domain.Anime{Anime: anime}, nil
}
for _, fallback := range []func(context.Context, int) (jikan.TopAnimeResult, error){
s.jikan.GetSeasonsNow,
s.jikan.GetTopAnime,
s.jikan.GetSeasonsUpcoming,
} {
res, fallbackErr := fallback(ctx, 1)
if fallbackErr != nil || len(res.Animes) == 0 {
continue
}
r := rand.New(rand.NewSource(time.Now().UnixNano()))
return domain.Anime{Anime: res.Animes[r.Intn(len(res.Animes))]}, nil
}
return domain.Anime{}, 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
}
result := make([]domain.EpisodeData, len(episodes))
for i, ep := range episodes {
result[i] = domain.EpisodeData{
MalID: ep.MalID,
Title: ep.Title,
IsFiller: ep.Filler,
IsRecap: ep.Recap,
}
}
return result, nil
}

View File

@@ -1,185 +0,0 @@
package service
import (
"context"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"math/rand"
"time"
"golang.org/x/sync/errgroup"
)
type animeService struct {
jikan *jikan.Client
repo domain.AnimeRepository
}
func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) domain.AnimeService {
return &animeService{jikan: jikan, repo: repo}
}
func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (domain.CatalogSectionData, error) {
var (
res jikan.TopAnimeResult
cw []db.GetContinueWatchingEntriesRow
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
switch section {
case "Airing":
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
case "Popular":
res, err = s.jikan.GetTopAnime(gCtx, 1)
}
return err
})
if userID != "" && section == "Continue" {
g.Go(func() error {
var err error
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
return err
})
}
if err := g.Wait(); err != nil {
return domain.CatalogSectionData{}, err
}
animes := res.Animes
if len(animes) > 6 {
animes = animes[:6]
}
return domain.CatalogSectionData{
Animes: animes,
ContinueWatching: cw,
}, nil
}
func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, section string) (domain.DiscoverSectionData, error) {
var res jikan.TopAnimeResult
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
switch section {
case "Trending":
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
case "Upcoming":
res, err = s.jikan.GetSeasonsUpcoming(gCtx, 1)
case "Top":
res, err = s.jikan.GetTopAnime(gCtx, 1)
}
return err
})
if err := g.Wait(); err != nil {
return domain.DiscoverSectionData{}, err
}
animes := res.Animes
if len(animes) > 8 {
animes = animes[:8]
}
return domain.DiscoverSectionData{
Animes: animes,
}, nil
}
func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) {
return s.jikan.GetAnimeByID(ctx, id)
}
func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error) {
return s.jikan.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, sfw, page, limit)
}
func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
return s.jikan.GetAnimeGenres(ctx)
}
func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.Character, error) {
return s.jikan.GetAnimeCharacters(ctx, id)
}
func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain.Recommendation, error) {
return s.jikan.GetAnimeRecommendations(ctx, id)
}
func (s *animeService) GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error) {
return s.jikan.GetFullRelations(ctx, id)
}
func (s *animeService) GetEpisodes(ctx context.Context, id int, page int) (jikan.EpisodesResponse, error) {
return s.jikan.GetEpisodes(ctx, id, page)
}
func (s *animeService) GetStaff(ctx context.Context, id int) ([]domain.StaffEntry, error) {
return s.jikan.GetAnimeStaff(ctx, id)
}
func (s *animeService) GetStatistics(ctx context.Context, id int) (domain.Statistics, error) {
return s.jikan.GetAnimeStatistics(ctx, id)
}
func (s *animeService) GetThemes(ctx context.Context, id int) (domain.ThemesData, error) {
return s.jikan.GetAnimeThemes(ctx, id)
}
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 data, pag.HasNextPage, nil
}
func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error) {
randomCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
anime, err := s.jikan.GetRandomAnime(randomCtx)
if err == nil {
return anime, nil
}
for _, fallback := range []func(context.Context, int) (jikan.TopAnimeResult, error){
s.jikan.GetSeasonsNow,
s.jikan.GetTopAnime,
s.jikan.GetSeasonsUpcoming,
} {
res, fallbackErr := fallback(ctx, 1)
if fallbackErr != nil || len(res.Animes) == 0 {
continue
}
r := rand.New(rand.NewSource(time.Now().UnixNano()))
return res.Animes[r.Intn(len(res.Animes))], nil
}
return domain.Anime{}, 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
}
result := make([]domain.EpisodeData, len(episodes))
for i, ep := range episodes {
result[i] = domain.EpisodeData{
MalID: ep.MalID,
Title: ep.Title,
IsFiller: ep.Filler,
IsRecap: ep.Recap,
}
}
return result, nil
}

View File

@@ -1,16 +1,19 @@
// Package app bootstraps and wires the application dependencies.
package app
import (
"mal/integrations/jikan"
"mal/integrations/playback/allanime"
"mal/internal/anime"
"mal/internal/audit"
"mal/internal/auth"
"mal/internal/config"
"mal/internal/database"
"mal/internal/episodes"
"mal/internal/playback"
"mal/internal/server"
"mal/internal/templates"
"mal/internal/watchlist"
"mal/templates"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/render"
@@ -19,7 +22,9 @@ import (
func NewApp() *fx.App {
return fx.New(
config.Module,
database.Module,
audit.Module,
jikan.Module,
allanime.Module,
episodes.Module,

35
internal/audit/context.go Normal file
View File

@@ -0,0 +1,35 @@
package audit
import "context"
type ctxKey string
const (
ctxKeyIP ctxKey = "audit_ip"
ctxKeyUserAgent ctxKey = "audit_user_agent"
)
func WithRequestInfo(ctx context.Context, ip string, userAgent string) context.Context {
if ctx == nil {
return nil
}
next := context.WithValue(ctx, ctxKeyIP, ip)
return context.WithValue(next, ctxKeyUserAgent, userAgent)
}
func RequestInfoFromContext(ctx context.Context) (ip string, userAgent string) {
if ctx == nil {
return "", ""
}
if v := ctx.Value(ctxKeyIP); v != nil {
if s, ok := v.(string); ok {
ip = s
}
}
if v := ctx.Value(ctxKeyUserAgent); v != nil {
if s, ok := v.(string); ok {
userAgent = s
}
}
return ip, userAgent
}

View File

@@ -0,0 +1,29 @@
package audit
import (
"net"
"strings"
"github.com/gin-gonic/gin"
)
func ContextMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
ip := clientIP(c.ClientIP())
userAgent := strings.TrimSpace(c.GetHeader("User-Agent"))
c.Request = c.Request.WithContext(WithRequestInfo(c.Request.Context(), ip, userAgent))
c.Next()
}
}
func clientIP(ip string) string {
trimmed := strings.TrimSpace(ip)
if trimmed == "" {
return ""
}
parsed := net.ParseIP(trimmed)
if parsed == nil {
return trimmed
}
return parsed.String()
}

9
internal/audit/module.go Normal file
View File

@@ -0,0 +1,9 @@
package audit
import (
"go.uber.org/fx"
)
var Module = fx.Options(
fx.Provide(NewAuditService),
)

73
internal/audit/service.go Normal file
View File

@@ -0,0 +1,73 @@
// Package audit provides audit logging for user actions.
package audit
import (
"context"
"database/sql"
"encoding/json"
"errors"
"mal/internal/db"
"mal/internal/domain"
"mal/internal/observability"
"strings"
"github.com/google/uuid"
)
type auditService struct {
queries *db.Queries
}
func NewAuditService(queries *db.Queries) domain.AuditService {
return &auditService{queries: queries}
}
func (s *auditService) Record(ctx context.Context, event domain.AuditEvent) error {
if s == nil || s.queries == nil {
return errors.New("audit service not configured")
}
action := strings.TrimSpace(event.Action)
if action == "" {
return errors.New("audit action missing")
}
ip, userAgent := RequestInfoFromContext(ctx)
if strings.TrimSpace(event.IP) != "" {
ip = event.IP
}
if strings.TrimSpace(event.UserAgent) != "" {
userAgent = event.UserAgent
}
metadataJSON := event.MetadataJSON
if len(metadataJSON) == 0 {
metadataJSON = json.RawMessage("null")
}
_, err := s.queries.CreateAuditLog(ctx, db.CreateAuditLogParams{
ID: uuid.New().String(),
UserID: sql.NullString{String: strings.TrimSpace(event.UserID), Valid: strings.TrimSpace(event.UserID) != ""},
Action: action,
ResourceType: sql.NullString{String: strings.TrimSpace(event.ResourceType), Valid: strings.TrimSpace(event.ResourceType) != ""},
ResourceID: sql.NullString{String: strings.TrimSpace(event.ResourceID), Valid: strings.TrimSpace(event.ResourceID) != ""},
Ip: sql.NullString{String: strings.TrimSpace(ip), Valid: strings.TrimSpace(ip) != ""},
UserAgent: sql.NullString{String: strings.TrimSpace(userAgent), Valid: strings.TrimSpace(userAgent) != ""},
MetadataJson: sql.NullString{String: string(metadataJSON), Valid: true},
})
if err != nil {
return err
}
observability.Info(
"audit",
"audit",
action,
map[string]any{
"user_id": event.UserID,
"resource_type": event.ResourceType,
"resource_id": event.ResourceID,
},
)
return nil
}

View File

@@ -0,0 +1,82 @@
package audit_test
import (
"context"
"encoding/json"
"os"
"testing"
"mal/internal/audit"
"mal/internal/database"
"mal/internal/db"
"mal/internal/domain"
)
func TestRecordInsertsAuditLog(t *testing.T) {
tmp, err := os.CreateTemp("", "mal-audit-*.db")
if err != nil {
t.Fatalf("CreateTemp: %v", err)
}
_ = tmp.Close()
t.Cleanup(func() { _ = os.Remove(tmp.Name()) })
sqlDB, err := db.Open(tmp.Name())
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err := database.RunMigrations(sqlDB); err != nil {
t.Fatalf("RunMigrations: %v", err)
}
queries := db.New(sqlDB)
svc := audit.NewAuditService(queries)
if _, err := sqlDB.Exec("INSERT INTO user (id, username, password_hash) VALUES (?, ?, ?)", "user-1", "test", "hash"); err != nil {
t.Fatalf("insert user: %v", err)
}
ctx := audit.WithRequestInfo(context.Background(), "127.0.0.1", "unit-test")
metadata, err := json.Marshal(struct {
Foo string `json:"foo"`
}{Foo: "bar"})
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
if err := svc.Record(ctx, domain.AuditEvent{
UserID: "user-1",
Action: "test_action",
ResourceType: "thing",
ResourceID: "123",
MetadataJSON: metadata,
}); err != nil {
t.Fatalf("Record: %v", err)
}
rows, err := sqlDB.Query("SELECT action, resource_type, resource_id, ip, user_agent, metadata_json FROM audit_log WHERE user_id = ?", "user-1")
if err != nil {
t.Fatalf("Query: %v", err)
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
t.Fatalf("expected audit row")
}
var action, resourceType, resourceID, ip, userAgent, metadataJSON string
if err := rows.Scan(&action, &resourceType, &resourceID, &ip, &userAgent, &metadataJSON); err != nil {
t.Fatalf("Scan: %v", err)
}
if action != "test_action" || resourceType != "thing" || resourceID != "123" {
t.Fatalf("unexpected row action=%q resourceType=%q resourceID=%q", action, resourceType, resourceID)
}
if ip != "127.0.0.1" || userAgent != "unit-test" {
t.Fatalf("unexpected request info ip=%q userAgent=%q", ip, userAgent)
}
if metadataJSON == "" || metadataJSON == "null" {
t.Fatalf("expected metadata_json, got %q", metadataJSON)
}
}

View File

@@ -1,4 +1,5 @@
package handler
// Package auth provides authentication and session management.
package auth
import (
"mal/internal/domain"

View File

@@ -1,4 +1,4 @@
package middleware
package auth
import (
"mal/internal/domain"
@@ -8,15 +8,52 @@ import (
"github.com/gin-gonic/gin"
)
type publicRoute struct {
method string
path string
prefix bool
}
var publicRoutes = []publicRoute{
// Pages.
{method: http.MethodGet, path: "/login"},
{method: http.MethodPost, path: "/login"},
{method: http.MethodGet, path: "/logout"},
// Static assets.
{path: "/static", prefix: true},
{path: "/dist", prefix: true},
// Observability endpoints.
{method: http.MethodGet, path: "/metrics"},
// Auth API.
{method: http.MethodPost, path: "/api/auth/login"},
}
func isPublicRequest(method string, path string) bool {
for _, r := range publicRoutes {
if r.method != "" && r.method != method {
continue
}
if r.prefix {
if strings.HasPrefix(path, r.path) {
return true
}
continue
}
if path == r.path {
return true
}
}
return false
}
func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
// Allow access to login, logout and static assets without authentication
if path == "/login" || path == "/logout" ||
strings.HasPrefix(path, "/static") ||
strings.HasPrefix(path, "/dist") ||
path == "/api/auth/login" {
if isPublicRequest(c.Request.Method, path) {
c.Next()
return
}

View File

@@ -1,10 +1,6 @@
package auth
import (
"mal/internal/auth/handler"
"mal/internal/auth/middleware"
"mal/internal/auth/repository"
"mal/internal/auth/service"
"mal/internal/domain"
"mal/internal/server"
@@ -14,15 +10,15 @@ import (
var Module = fx.Options(
fx.Provide(
repository.NewAuthRepository,
service.NewAuthService,
handler.NewAuthHandler,
NewAuthRepository,
NewAuthService,
NewAuthHandler,
func(svc domain.AuthService) gin.HandlerFunc {
return middleware.AuthMiddleware(svc)
return AuthMiddleware(svc)
},
),
fx.Provide(
server.AsRouteRegister(func(h *handler.AuthHandler) server.RouteRegister {
server.AsRouteRegister(func(h *AuthHandler) server.RouteRegister {
return h
}),
),

View File

@@ -1,4 +1,4 @@
package repository
package auth
import (
"context"
@@ -27,7 +27,7 @@ func (r *authRepository) GetUserByUsername(ctx context.Context, username string)
}
return nil, err
}
return &u, nil
return &domain.User{User: u}, nil
}
func (r *authRepository) GetUserByID(ctx context.Context, id string) (*domain.User, error) {
@@ -38,7 +38,7 @@ func (r *authRepository) GetUserByID(ctx context.Context, id string) (*domain.Us
}
return nil, err
}
return &u, nil
return &domain.User{User: u}, nil
}
func (r *authRepository) CreateSession(ctx context.Context, userID string, sessionID string) (*domain.Session, error) {
@@ -50,7 +50,7 @@ func (r *authRepository) CreateSession(ctx context.Context, userID string, sessi
if err != nil {
return nil, err
}
return &s, nil
return &domain.Session{Session: s}, nil
}
func (r *authRepository) GetSession(ctx context.Context, sessionID string) (*domain.Session, error) {
@@ -61,7 +61,7 @@ func (r *authRepository) GetSession(ctx context.Context, sessionID string) (*dom
}
return nil, err
}
return &s, nil
return &domain.Session{Session: s}, nil
}
func (r *authRepository) RefreshSession(ctx context.Context, sessionID string, expiresAt time.Time) error {
@@ -85,7 +85,7 @@ func (r *authRepository) CreateAPIToken(ctx context.Context, userID, tokenHash,
if err != nil {
return nil, err
}
return &t, nil
return &domain.APIToken{ApiToken: t}, nil
}
func (r *authRepository) GetAPITokenByHash(ctx context.Context, tokenHash string) (*domain.APIToken, error) {
@@ -96,7 +96,7 @@ func (r *authRepository) GetAPITokenByHash(ctx context.Context, tokenHash string
}
return nil, err
}
return &t, nil
return &domain.APIToken{ApiToken: t}, nil
}
func (r *authRepository) TouchAPITokenLastUsedAt(ctx context.Context, tokenID string) error {

View File

@@ -1,4 +1,4 @@
package service
package auth
import (
"context"
@@ -6,7 +6,9 @@ import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"mal/internal/domain"
"strings"
"time"
@@ -16,11 +18,12 @@ import (
)
type authService struct {
repo domain.AuthRepository
repo domain.AuthRepository
auditSvc domain.AuditService
}
func NewAuthService(repo domain.AuthRepository) domain.AuthService {
return &authService{repo: repo}
func NewAuthService(repo domain.AuthRepository, auditSvc domain.AuditService) domain.AuthService {
return &authService{repo: repo, auditSvc: auditSvc}
}
func (s *authService) Login(ctx context.Context, username, password string) (*domain.Session, error) {
@@ -58,11 +61,32 @@ func (s *authService) LoginForAPIToken(ctx context.Context, username, password,
trimmedName = "Firefox extension"
}
rawToken, tokenHash := newOpaqueToken()
rawToken, tokenHash, err := newOpaqueToken()
if err != nil {
return "", nil, err
}
if _, err := s.repo.CreateAPIToken(ctx, user.ID, tokenHash, trimmedName); err != nil {
return "", nil, err
}
metadataBytes, err := 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",
})
}
return rawToken, user, nil
}
@@ -120,15 +144,25 @@ func (s *authService) RevokeAllAPITokensForUser(ctx context.Context, userID stri
if strings.TrimSpace(userID) == "" {
return errors.New("user id missing")
}
return s.repo.RevokeAllAPITokensForUser(ctx, userID)
if err := s.repo.RevokeAllAPITokensForUser(ctx, userID); err != nil {
return err
}
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
UserID: userID,
Action: "api_token_revoked_all",
ResourceType: "api_token",
})
return nil
}
func newOpaqueToken() (token string, tokenHash string) {
func newOpaqueToken() (token string, tokenHash string, err error) {
buf := make([]byte, 32)
_, _ = rand.Read(buf)
if _, err := rand.Read(buf); err != nil {
return "", "", fmt.Errorf("generate token bytes: %w", err)
}
token = base64.RawURLEncoding.EncodeToString(buf)
sum := sha256.Sum256([]byte(token))
tokenHash = hex.EncodeToString(sum[:])
return token, tokenHash
return token, tokenHash, nil
}

11
internal/avatar.go Normal file
View File

@@ -0,0 +1,11 @@
package internal
import (
"net/url"
"strings"
)
func DefaultAvatarURL(username string) string {
seed := url.QueryEscape(strings.TrimSpace(username))
return "https://api.dicebear.com/9.x/dylan/svg?seed=" + seed
}

85
internal/config/config.go Normal file
View File

@@ -0,0 +1,85 @@
// Package config provides application configuration loading and access.
package config
import (
"errors"
"fmt"
"os"
"strings"
)
type EpisodeAvailabilityMode string
const (
EpisodeAvailabilityModeAuto EpisodeAvailabilityMode = "auto"
EpisodeAvailabilityModeLegacy EpisodeAvailabilityMode = "legacy"
EpisodeAvailabilityModeJikan EpisodeAvailabilityMode = "jikan"
)
type Config struct {
Port string
// GinMode maps to gin.SetMode. When empty, the server uses release mode by default.
GinMode string
DatabaseFile string
// Allow any Origin for CORS. Intended for local dev / reverse proxy setups only.
CORSAllowAll bool
EpisodeAvailabilityMode EpisodeAvailabilityMode
// Optional. When empty, proxy token signing is disabled.
PlaybackProxySecret string
// Optional debug toggle for Jikan client tracing.
JikanTrace bool
}
func Load() (Config, error) {
cfg := Config{
Port: firstNonEmpty(strings.TrimSpace(os.Getenv("PORT")), "3000"),
GinMode: strings.TrimSpace(os.Getenv("GIN_MODE")),
DatabaseFile: firstNonEmpty(strings.TrimSpace(os.Getenv("DATABASE_FILE")), "mal.db"),
CORSAllowAll: strings.TrimSpace(os.Getenv("MAL_CORS_ALLOW_ALL")) == "1",
PlaybackProxySecret: strings.TrimSpace(os.Getenv("PLAYBACK_PROXY_SECRET")),
JikanTrace: truthy(strings.TrimSpace(os.Getenv("MAL_JIKAN_TRACE"))),
EpisodeAvailabilityMode: EpisodeAvailabilityModeAuto,
}
if raw := strings.ToLower(strings.TrimSpace(os.Getenv("EPISODE_AVAILABILITY_MODE"))); raw != "" {
switch EpisodeAvailabilityMode(raw) {
case EpisodeAvailabilityModeAuto, EpisodeAvailabilityModeLegacy, EpisodeAvailabilityModeJikan:
cfg.EpisodeAvailabilityMode = EpisodeAvailabilityMode(raw)
default:
return Config{}, fmt.Errorf("invalid EPISODE_AVAILABILITY_MODE: %q (expected auto|legacy|jikan)", raw)
}
}
if strings.TrimSpace(cfg.Port) == "" {
return Config{}, errors.New("PORT must not be empty")
}
if strings.TrimSpace(cfg.DatabaseFile) == "" {
return Config{}, errors.New("DATABASE_FILE must not be empty")
}
return cfg, nil
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func truthy(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "y", "on":
return true
default:
return false
}
}

View File

@@ -0,0 +1,7 @@
package config
import "go.uber.org/fx"
var Module = fx.Options(
fx.Provide(Load),
)

View File

@@ -1,11 +1,13 @@
// Package database manages database schema migrations and fixes.
package database
import (
"database/sql"
"embed"
"fmt"
"log"
"mal/internal/config"
"mal/internal/db"
"mal/internal/observability"
"github.com/pressly/goose/v3"
"go.uber.org/fx"
@@ -19,12 +21,11 @@ var Module = fx.Options(
ProvideSQLDB,
ProvideQueries,
),
fx.Invoke(RunMigrations),
fx.Invoke(RunMigrationsAndFixes),
)
func ProvideSQLDB() (*sql.DB, error) {
dbPath := db.GetDBFile()
dbConn, err := db.Open(dbPath)
func ProvideSQLDB(cfg config.Config) (*sql.DB, error) {
dbConn, err := db.Open(cfg.DatabaseFile)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
@@ -42,10 +43,16 @@ func RunMigrations(sqlDB *sql.DB) error {
return fmt.Errorf("failed to set goose dialect: %w", err)
}
log.Println("Running database migrations...")
observability.Info("db_migrations_start", "database", "", nil)
if err := goose.Up(sqlDB, "migrations"); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
return nil
}
func RunMigrationsAndFixes(sqlDB *sql.DB) error {
if err := RunMigrations(sqlDB); err != nil {
return err
}
return RunDataFixes(sqlDB)
}

View File

@@ -12,7 +12,7 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer sqlDB.Close()
defer func() { _ = sqlDB.Close() }()
sqlDB.SetMaxOpenConns(1)
if err := RunMigrations(sqlDB); err != nil {

View File

@@ -0,0 +1,97 @@
package database
import (
"context"
"database/sql"
"fmt"
"time"
dbfixes "mal/internal/database/fixes"
"mal/internal/observability"
)
func RunDataFixes(sqlDB *sql.DB) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
fixes := dbfixes.All()
if len(fixes) == 0 {
return nil
}
if err := ensureDataFixTable(ctx, sqlDB); err != nil {
return err
}
applied, err := loadAppliedFixes(ctx, sqlDB)
if err != nil {
return err
}
for _, fix := range fixes {
if applied[fix.ID] {
continue
}
observability.Info(
"db_data_fix_start",
"database",
"",
map[string]any{
"id": fix.ID,
},
)
if err := fix.Apply(ctx, sqlDB); 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 nil
}
func ensureDataFixTable(ctx context.Context, sqlDB *sql.DB) error {
// Safety for cases where migrations weren't run (or in tests). This is intentionally tiny and idempotent.
_, err := sqlDB.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS data_fixes (
id TEXT PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`)
if err != nil {
return fmt.Errorf("ensure data_fixes table: %w", err)
}
return nil
}
func loadAppliedFixes(ctx context.Context, sqlDB *sql.DB) (map[string]bool, error) {
rows, err := sqlDB.QueryContext(ctx, `SELECT id FROM data_fixes`)
if err != nil {
return nil, fmt.Errorf("load applied data fixes: %w", err)
}
defer rows.Close()
applied := make(map[string]bool)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan data fix id: %w", err)
}
applied[id] = true
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate data fixes: %w", err)
}
return applied, nil
}
func markFixApplied(ctx context.Context, sqlDB *sql.DB, id string) error {
_, err := sqlDB.ExecContext(ctx, `INSERT OR IGNORE INTO data_fixes (id) VALUES (?)`, id)
if err != nil {
return fmt.Errorf("mark data fix applied id=%s: %w", id, err)
}
return nil
}

View File

@@ -0,0 +1,27 @@
package fixes
import (
"context"
"database/sql"
"fmt"
)
func init() {
Register(Fix{
ID: "20260526_episode_availability_backfill_next_refresh_at",
Apply: func(ctx context.Context, sqlDB *sql.DB) 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, `
UPDATE episode_availability_cache
SET next_refresh_at = datetime(CURRENT_TIMESTAMP, '+6 hours'),
updated_at = CURRENT_TIMESTAMP
WHERE next_refresh_at IS NULL;
`)
if err != nil {
return fmt.Errorf("backfill episode_availability_cache.next_refresh_at: %w", err)
}
return nil
},
})
}

View File

@@ -0,0 +1,46 @@
package fixes
import (
"context"
"database/sql"
"fmt"
"mal/internal"
)
func init() {
Register(Fix{
ID: "20260528_backfill_avatar_url",
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
rows, err := sqlDB.QueryContext(ctx, `SELECT id, username FROM user WHERE avatar_url = ''`)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
type userRow struct {
id string
username string
}
toUpdate := make([]userRow, 0, 64)
for rows.Next() {
var r userRow
if err := rows.Scan(&r.id, &r.username); err != nil {
return err
}
toUpdate = append(toUpdate, r)
}
if err := rows.Err(); err != nil {
return err
}
for _, u := range toUpdate {
avatarURL := internal.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)
}
}
return nil
},
})
}

View File

@@ -0,0 +1,81 @@
package fixes
import (
"context"
"database/sql"
"fmt"
"mal/integrations/jikan"
"mal/internal/config"
"mal/internal/db"
"mal/internal/observability"
)
func init() {
Register(Fix{
ID: "20260608_backfill_anime_duration_seconds",
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
rows, err := sqlDB.QueryContext(ctx, `
SELECT id, title_original, title_english, title_japanese, image_url, airing
FROM anime
WHERE duration_seconds IS NULL;
`)
if err != nil {
return fmt.Errorf("query anime rows missing duration_seconds: %w", err)
}
defer func() { _ = rows.Close() }()
client := jikan.NewClient(config.Config{}, db.New(sqlDB), observability.NewMetrics())
type animeRow struct {
id int64
titleOriginal string
}
var toUpdate []animeRow
for rows.Next() {
var row animeRow
var titleEnglish sql.NullString
var titleJapanese sql.NullString
var imageURL string
var airing sql.NullBool
if err := rows.Scan(
&row.id,
&row.titleOriginal,
&titleEnglish,
&titleJapanese,
&imageURL,
&airing,
); err != nil {
return fmt.Errorf("scan anime row missing duration_seconds: %w", err)
}
toUpdate = append(toUpdate, row)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate anime rows missing duration_seconds: %w", err)
}
for _, row := range toUpdate {
anime, err := client.GetAnimeByID(ctx, int(row.id))
if err != nil {
return fmt.Errorf("fetch anime %d for duration backfill: %w", row.id, err)
}
durationSeconds := anime.DurationSeconds()
if durationSeconds <= 0 {
continue
}
if _, err := sqlDB.ExecContext(
ctx,
`UPDATE anime SET duration_seconds = ? WHERE id = ? AND duration_seconds IS NULL`,
durationSeconds,
row.id,
); err != nil {
return fmt.Errorf("update anime %d duration_seconds: %w", row.id, err)
}
}
return nil
},
})
}

View File

@@ -0,0 +1,25 @@
// Package fixes implements one-off database migration fixes.
package fixes
import (
"context"
"database/sql"
"sort"
)
type Fix struct {
ID string
Apply func(ctx context.Context, sqlDB *sql.DB) error
}
var registered []Fix
func Register(fix Fix) {
registered = append(registered, fix)
}
func All() []Fix {
out := append([]Fix(nil), registered...)
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}

View File

@@ -1,6 +1,9 @@
-- +goose Up
-- +goose NO TRANSACTION
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE user_new (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
@@ -16,6 +19,8 @@ DROP TABLE user;
ALTER TABLE user_new RENAME TO user;
COMMIT;
PRAGMA foreign_keys = ON;
-- +goose Down

View File

@@ -1,5 +1,3 @@
-- +goose Up
ALTER TABLE user ADD COLUMN avatar_url TEXT NOT NULL DEFAULT '';
UPDATE user SET avatar_url = 'https://api.dicebear.com/9.x/dylan/svg?seed=' || username WHERE avatar_url = '';
-- +goose Down

View File

@@ -0,0 +1,8 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS data_fixes (
id TEXT PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- +goose Down
DROP TABLE IF EXISTS data_fixes;

View File

@@ -0,0 +1,18 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
user_id TEXT REFERENCES user(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
ip TEXT,
user_agent TEXT,
metadata_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id_occurred_at ON audit_log(user_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_action_occurred_at ON audit_log(action, occurred_at DESC);
-- +goose Down
DROP TABLE IF EXISTS audit_log;

View File

@@ -0,0 +1,62 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS recommendation_event (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
anime_id INTEGER,
event_type TEXT NOT NULL,
source TEXT,
metadata_json TEXT,
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
FOREIGN KEY(anime_id) REFERENCES anime(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_recommendation_event_user_occurred_at
ON recommendation_event(user_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_recommendation_event_user_event_type_occurred_at
ON recommendation_event(user_id, event_type, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_recommendation_event_anime_occurred_at
ON recommendation_event(anime_id, occurred_at DESC);
CREATE TABLE IF NOT EXISTS recommendation_impression (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
anime_id INTEGER NOT NULL,
rail TEXT NOT NULL,
position INTEGER NOT NULL,
request_id TEXT,
metadata_json TEXT,
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
FOREIGN KEY(anime_id) REFERENCES anime(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_recommendation_impression_user_occurred_at
ON recommendation_impression(user_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_recommendation_impression_request_id
ON recommendation_impression(request_id);
CREATE TABLE IF NOT EXISTS recommendation_profile_snapshot (
user_id TEXT PRIMARY KEY,
profile_json TEXT NOT NULL,
source_window_start DATETIME,
source_window_end DATETIME,
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE
);
-- +goose Down
DROP TABLE IF EXISTS recommendation_profile_snapshot;
DROP INDEX IF EXISTS idx_recommendation_impression_request_id;
DROP INDEX IF EXISTS idx_recommendation_impression_user_occurred_at;
DROP TABLE IF EXISTS recommendation_impression;
DROP INDEX IF EXISTS idx_recommendation_event_anime_occurred_at;
DROP INDEX IF EXISTS idx_recommendation_event_user_event_type_occurred_at;
DROP INDEX IF EXISTS idx_recommendation_event_user_occurred_at;
DROP TABLE IF EXISTS recommendation_event;

View File

@@ -44,7 +44,7 @@ LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
if err != nil {
return nil, err
}
defer rows.Close()
defer func() { _ = rows.Close() }()
items := make([]GetContinueWatchingEntriesRow, 0, int(limit))
for rows.Next() {
@@ -122,7 +122,7 @@ LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
if err != nil {
return nil, err
}
defer rows.Close()
defer func() { _ = rows.Close() }()
items := make([]GetUserWatchListRow, 0, int(limit))
for rows.Next() {

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.31.1
package db

View File

@@ -1,3 +1,4 @@
// Package db provides database access via sqlc-generated queries and helper functions.
package db
import "database/sql"
@@ -18,3 +19,7 @@ func DisplayTitle(titleEnglish, titleJapanese sql.NullString, titleOriginal stri
func (r GetUserWatchListRow) DisplayTitle() string {
return DisplayTitle(r.TitleEnglish, r.TitleJapanese, r.TitleOriginal)
}
func (r GetContinueWatchingEntriesRow) DisplayTitle() string {
return DisplayTitle(r.TitleEnglish, r.TitleJapanese, r.TitleOriginal)
}

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.31.1
package db
@@ -47,6 +47,18 @@ type ApiToken struct {
RevokedAt sql.NullTime `json:"revoked_at"`
}
type AuditLog struct {
ID string `json:"id"`
OccurredAt time.Time `json:"occurred_at"`
UserID sql.NullString `json:"user_id"`
Action string `json:"action"`
ResourceType sql.NullString `json:"resource_type"`
ResourceID sql.NullString `json:"resource_id"`
Ip sql.NullString `json:"ip"`
UserAgent sql.NullString `json:"user_agent"`
MetadataJson sql.NullString `json:"metadata_json"`
}
type ContinueWatchingEntry struct {
ID string `json:"id"`
UserID string `json:"user_id"`
@@ -58,6 +70,11 @@ type ContinueWatchingEntry struct {
DurationSeconds sql.NullFloat64 `json:"duration_seconds"`
}
type DataFix struct {
ID string `json:"id"`
AppliedAt time.Time `json:"applied_at"`
}
type EpisodeAvailabilityCache struct {
AnimeID int64 `json:"anime_id"`
Data string `json:"data"`

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.31.1
package db
@@ -11,6 +11,7 @@ import (
type Querier interface {
CountPendingAnimeFetchRetries(ctx context.Context) (int64, error)
CreateAPIToken(ctx context.Context, arg CreateAPITokenParams) (ApiToken, error)
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (AuditLog, error)
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
DeleteAnimeFetchRetry(ctx context.Context, animeID int64) error
DeleteContinueWatchingEntry(ctx context.Context, arg DeleteContinueWatchingEntryParams) error
@@ -22,8 +23,11 @@ type Querier interface {
GetAllCachedAnime(ctx context.Context) ([]string, error)
GetAnime(ctx context.Context, id int64) (Anime, error)
GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error)
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)
@@ -35,14 +39,18 @@ type Querier interface {
GetUser(ctx context.Context, id string) (User, error)
GetUserByUsername(ctx context.Context, username string) (User, error)
GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error)
GetUserWatchlistAnimeIDs(ctx context.Context, userID string, animeIDs []int64) ([]int64, error)
GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error)
GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error)
MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error
MarkEpisodeAvailabilityRefreshFailed(ctx context.Context, arg MarkEpisodeAvailabilityRefreshFailedParams) error
MarkRelationsSynced(ctx context.Context, id int64) error
RefreshSession(ctx context.Context, arg RefreshSessionParams) error
RevokeAllAPITokensForUser(ctx context.Context, userID string) error
SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error
SetJikanCache(ctx context.Context, arg SetJikanCacheParams) error
HasSkipSegmentOverrideTable(ctx context.Context) (bool, error)
ListSkipSegmentOverrides(ctx context.Context, userID string, animeID int64, episode int64) ([]SkipSegmentOverrideRow, error)
TouchAPITokenLastUsedAt(ctx context.Context, id string) error
UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusParams) error
UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error)
@@ -50,6 +58,7 @@ type Querier interface {
UpsertContinueWatchingEntry(ctx context.Context, arg UpsertContinueWatchingEntryParams) (ContinueWatchingEntry, error)
UpsertEpisodeAvailabilityCache(ctx context.Context, arg UpsertEpisodeAvailabilityCacheParams) error
UpsertEpisodeProviderMapping(ctx context.Context, arg UpsertEpisodeProviderMappingParams) error
UpsertSkipSegmentOverride(ctx context.Context, r SkipSegmentOverrideRow) error
UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error)
}

View File

@@ -1,6 +1,18 @@
-- name: GetUser :one
SELECT * FROM user WHERE id = ? LIMIT 1;
-- name: CreateAuditLog :one
INSERT INTO audit_log (id, user_id, action, resource_type, resource_id, ip, user_agent, metadata_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: GetAuditLogsForUser :many
SELECT *
FROM audit_log
WHERE user_id = ?
ORDER BY occurred_at DESC
LIMIT ?;
-- name: GetUserByUsername :one
SELECT * FROM user WHERE username = ? LIMIT 1;

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.31.1
// source: queries.sql
package db
@@ -57,6 +57,49 @@ func (q *Queries) CreateAPIToken(ctx context.Context, arg CreateAPITokenParams)
return i, err
}
const createAuditLog = `-- name: CreateAuditLog :one
INSERT INTO audit_log (id, user_id, action, resource_type, resource_id, ip, user_agent, metadata_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, occurred_at, user_id, "action", resource_type, resource_id, ip, user_agent, metadata_json
`
type CreateAuditLogParams struct {
ID string `json:"id"`
UserID sql.NullString `json:"user_id"`
Action string `json:"action"`
ResourceType sql.NullString `json:"resource_type"`
ResourceID sql.NullString `json:"resource_id"`
Ip sql.NullString `json:"ip"`
UserAgent sql.NullString `json:"user_agent"`
MetadataJson sql.NullString `json:"metadata_json"`
}
func (q *Queries) CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (AuditLog, error) {
row := q.db.QueryRowContext(ctx, createAuditLog,
arg.ID,
arg.UserID,
arg.Action,
arg.ResourceType,
arg.ResourceID,
arg.Ip,
arg.UserAgent,
arg.MetadataJson,
)
var i AuditLog
err := row.Scan(
&i.ID,
&i.OccurredAt,
&i.UserID,
&i.Action,
&i.ResourceType,
&i.ResourceID,
&i.Ip,
&i.UserAgent,
&i.MetadataJson,
)
return i, err
}
const createSession = `-- name: CreateSession :one
INSERT INTO session (id, user_id, expires_at)
VALUES (?, ?, ?)
@@ -124,22 +167,6 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error {
return err
}
const refreshSession = `-- name: RefreshSession :exec
UPDATE session
SET expires_at = ?
WHERE id = ?
`
type RefreshSessionParams struct {
ExpiresAt time.Time `json:"expires_at"`
ID string `json:"id"`
}
func (q *Queries) RefreshSession(ctx context.Context, arg RefreshSessionParams) error {
_, err := q.db.ExecContext(ctx, refreshSession, arg.ExpiresAt, arg.ID)
return err
}
const deleteWatchListEntry = `-- name: DeleteWatchListEntry :exec
DELETE FROM watch_list_entry
WHERE user_id = ? AND anime_id = ?
@@ -299,6 +326,52 @@ func (q *Queries) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNe
return items, nil
}
const getAuditLogsForUser = `-- name: GetAuditLogsForUser :many
SELECT id, occurred_at, user_id, "action", resource_type, resource_id, ip, user_agent, metadata_json
FROM audit_log
WHERE user_id = ?
ORDER BY occurred_at DESC
LIMIT ?
`
type GetAuditLogsForUserParams struct {
UserID sql.NullString `json:"user_id"`
Limit int64 `json:"limit"`
}
func (q *Queries) GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error) {
rows, err := q.db.QueryContext(ctx, getAuditLogsForUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AuditLog
for rows.Next() {
var i AuditLog
if err := rows.Scan(
&i.ID,
&i.OccurredAt,
&i.UserID,
&i.Action,
&i.ResourceType,
&i.ResourceID,
&i.Ip,
&i.UserAgent,
&i.MetadataJson,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getContinueWatchingEntries = `-- name: GetContinueWatchingEntries :many
SELECT
c.id,
@@ -918,6 +991,22 @@ func (q *Queries) MarkRelationsSynced(ctx context.Context, id int64) error {
return err
}
const refreshSession = `-- name: RefreshSession :exec
UPDATE session
SET expires_at = ?
WHERE id = ?
`
type RefreshSessionParams struct {
ExpiresAt time.Time `json:"expires_at"`
ID string `json:"id"`
}
func (q *Queries) RefreshSession(ctx context.Context, arg RefreshSessionParams) error {
_, err := q.db.ExecContext(ctx, refreshSession, arg.ExpiresAt, arg.ID)
return err
}
const revokeAllAPITokensForUser = `-- name: RevokeAllAPITokensForUser :exec
UPDATE api_token
SET revoked_at = CURRENT_TIMESTAMP

View File

@@ -3,6 +3,7 @@ package db
import (
"context"
"database/sql"
"errors"
"fmt"
)
@@ -67,6 +68,9 @@ func (q *Queries) HasSkipSegmentOverrideTable(ctx context.Context) (bool, error)
const query = `SELECT name FROM sqlite_master WHERE type='table' AND name='skip_segment_override' LIMIT 1;`
var name sql.NullString
if err := q.db.QueryRowContext(ctx, query).Scan(&name); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("check skip segment override table: %w", err)
}
return name.Valid && name.String != "", nil

View File

@@ -0,0 +1,25 @@
package db
import (
"context"
"database/sql"
"testing"
_ "github.com/mattn/go-sqlite3"
)
func TestHasSkipSegmentOverrideTableReturnsFalseWhenMissing(t *testing.T) {
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer func() { _ = sqlDB.Close() }()
ok, err := New(sqlDB).HasSkipSegmentOverrideTable(context.Background())
if err != nil {
t.Fatalf("HasSkipSegmentOverrideTable: %v", err)
}
if ok {
t.Fatalf("HasSkipSegmentOverrideTable returned true for missing table")
}
}

View File

@@ -3,24 +3,21 @@ package db
import (
"database/sql"
"fmt"
"os"
// sqlite3 driver.
_ "github.com/mattn/go-sqlite3"
)
// Open connects to a sqlite3 database with foreign keys enforced
func Open(dbFile string) (*sql.DB, error) {
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on", dbFile))
// 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))
if err != nil {
return nil, fmt.Errorf("failed to open db: %w", err)
}
// WAL improves concurrency between readers and writers.
_, _ = db.Exec("PRAGMA journal_mode=WAL;")
_, _ = db.Exec("PRAGMA busy_timeout=5000;")
return db, nil
}
// GetDBFile returns the database file path, checking DATABASE_FILE env var first
func GetDBFile() string {
if f := os.Getenv("DATABASE_FILE"); f != "" {
return f
}
return "mal.db"
}

View File

@@ -24,7 +24,7 @@ func (q *Queries) GetUserWatchlistAnimeIDs(ctx context.Context, userID string, a
if err != nil {
return nil, err
}
defer rows.Close()
defer func() { _ = rows.Close() }()
matches := make([]int64, 0, len(animeIDs))
for rows.Next() {

View File

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

25
internal/dbtx/tx.go Normal file
View File

@@ -0,0 +1,25 @@
package dbtx
import (
"context"
"database/sql"
)
func Run[T any](ctx context.Context, sqlDB *sql.DB, repo T, withTx func(*sql.Tx) T, fn func(context.Context, T) error) error {
if sqlDB == nil {
return fn(ctx, repo)
}
tx, err := sqlDB.BeginTx(ctx, nil)
if err != nil {
return err
}
txRepo := withTx(tx)
if err := fn(ctx, txRepo); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}

View File

@@ -1,3 +1,4 @@
// Package domain defines the core domain types and interfaces used across the application.
package domain
import (
@@ -6,24 +7,152 @@ import (
"mal/internal/db"
)
type Anime = jikan.Anime
type TopAnimeResult = jikan.TopAnimeResult
type Genre = jikan.Genre
type Character = jikan.CharacterEntry
type Recommendation = jikan.RecommendationEntry
type StaffEntry = jikan.StaffEntry
type Statistics = jikan.Statistics
type ThemesData = jikan.ThemesData
type ReviewEntry = jikan.ReviewEntry
type Anime struct {
jikan.Anime
}
type AnimeService interface {
type Genre struct {
MalID int
Name string
}
type CharacterPerson struct {
MalID int
URL string
Name string
Images struct {
Jpg struct {
ImageURL string
}
}
}
type CharacterVoiceActor struct {
Person CharacterPerson
Language string
}
type CharacterEntry struct {
Character struct {
MalID int
URL string
Name string
Images struct {
Jpg struct {
ImageURL string
}
Webp struct {
ImageURL string
SmallImageURL string
}
}
}
Role string
VoiceActors []CharacterVoiceActor
}
type RecommendationEntry struct {
Entry struct {
MalID int
URL string
Title string
Images struct {
Webp struct {
LargeImageURL string
}
}
}
URL string
Votes int
}
type StaffEntry struct {
Person CharacterPerson
Positions []string
}
type StatisticsScore struct {
Score int
Votes int
Percentage float64
}
type Statistics struct {
Watching int
Completed int
OnHold int
Dropped int
PlanToWatch int
Total int
Scores []StatisticsScore
}
type ThemesData struct {
Openings []string
Endings []string
}
type ReviewReactions struct {
Overall int
Nice int
LoveIt int
Funny int
Confusing int
Informative int
WellWritten int
Creative int
}
type ReviewUser struct {
URL string
Username string
Images struct {
Jpg struct {
ImageURL string
}
Webp struct {
ImageURL string
}
}
}
type ReviewEntry struct {
MalID int
URL string
Type string
Reactions ReviewReactions
Date string
Review string
Score int
Tags []string
IsSpoiler bool
IsPreliminary bool
EpisodesSeen int
User ReviewUser
}
type AnimeCatalogService interface {
GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error)
GetTopPickForYou(ctx context.Context, userID string) (CatalogSectionData, error)
GetTopPicksForYou(ctx context.Context, userID string) (CatalogSectionData, error)
}
type AnimeDiscoverService interface {
GetDiscoverSection(ctx context.Context, userID string, section string) (DiscoverSectionData, error)
GetAnimeByID(ctx context.Context, id int) (Anime, error)
SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error)
GetAiringSchedule(ctx context.Context, userID string) ([]Anime, error)
}
type AnimeSearchService interface {
SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error)
GetProducerNameByID(ctx context.Context, id int) (string, error)
GetProducers(ctx context.Context, query string, page int, limit int) (jikan.ProducerListResult, error)
GetGenres(ctx context.Context) ([]Genre, error)
GetCharacters(ctx context.Context, id int) ([]Character, error)
GetRecommendations(ctx context.Context, id int) ([]Recommendation, error)
}
type AnimeDetailsService interface {
GetAnimeByID(ctx context.Context, id int) (Anime, error)
GetCharacters(ctx context.Context, id int) ([]CharacterEntry, error)
GetRecommendations(ctx context.Context, id int) ([]RecommendationEntry, error)
GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error)
GetEpisodes(ctx context.Context, id int, page int) (jikan.EpisodesResponse, error)
GetAllEpisodes(ctx context.Context, id int) ([]EpisodeData, error)
@@ -34,6 +163,11 @@ type AnimeService interface {
GetReviews(ctx context.Context, id int, page int) ([]ReviewEntry, bool, error)
}
type AnimePlaybackService interface {
GetAnimeByID(ctx context.Context, id int) (Anime, error)
GetAllEpisodes(ctx context.Context, id int) ([]EpisodeData, error)
}
type CatalogSectionData struct {
Animes []Anime
ContinueWatching []db.GetContinueWatchingEntriesRow

20
internal/domain/audit.go Normal file
View File

@@ -0,0 +1,20 @@
package domain
import (
"context"
"encoding/json"
)
type AuditEvent struct {
UserID string
Action string
ResourceType string
ResourceID string
MetadataJSON json.RawMessage
IP string
UserAgent string
}
type AuditService interface {
Record(ctx context.Context, event AuditEvent) error
}

View File

@@ -6,9 +6,17 @@ import (
"time"
)
type User = db.User
type Session = db.Session
type APIToken = db.ApiToken
type User struct {
db.User
}
type Session struct {
db.Session
}
type APIToken struct {
db.ApiToken
}
const SessionLifetime = 90 * 24 * time.Hour

View File

@@ -9,7 +9,8 @@ type PlaybackService interface {
BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (WatchPageData, error)
SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error
CompleteAnime(ctx context.Context, userID string, animeID int64) error
ResolveProxyToken(token string) (string, string, error)
SignProxyToken(targetURL, referer, scope string) (string, error)
ResolveProxyToken(token string, scope string) (string, string, error)
UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error
}
@@ -38,18 +39,15 @@ type WatchData struct {
ModeSwitchedFrom string
AvailableModes []string
Segments []SkipSegment
Airing bool
}
type SubtitleItem struct {
Lang string `json:"lang"`
URL string `json:"url,omitempty"`
Referer string `json:"referer,omitempty"`
Token string `json:"token"`
Lang string `json:"lang"`
Token string `json:"token"`
}
type ModeSource struct {
URL string `json:"url,omitempty"`
Referer string `json:"referer,omitempty"`
Token string `json:"token"`
Subtitles []SubtitleItem `json:"subtitles"`
Qualities []string `json:"qualities,omitempty"`
@@ -89,6 +87,7 @@ type EpisodeData struct {
}
type PlaybackRepository interface {
InTx(ctx context.Context, fn func(ctx context.Context, repo PlaybackRepository) error) error
GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error)
GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error)
SaveWatchProgress(ctx context.Context, params db.SaveWatchProgressParams) error

View File

@@ -21,6 +21,7 @@ type WatchlistService interface {
}
type WatchlistRepository interface {
InTx(ctx context.Context, fn func(ctx context.Context, repo WatchlistRepository) error) error
UpsertAnime(ctx context.Context, arg db.UpsertAnimeParams) (db.Anime, error)
GetAnime(ctx context.Context, id int64) (db.Anime, error)
UpsertWatchListEntry(ctx context.Context, arg db.UpsertWatchListEntryParams) (db.WatchListEntry, error)

View File

@@ -1,31 +1,29 @@
// Package episodes manages episode availability checking and refresh scheduling.
package episodes
import (
"os"
"strings"
"mal/integrations/jikan"
"mal/integrations/playback/allanime"
"mal/internal/config"
"mal/internal/db"
"mal/internal/domain"
episodeService "mal/internal/episodes/service"
"mal/internal/observability"
"go.uber.org/fx"
)
func episodeAvailabilityEnabled() bool {
value := strings.ToLower(strings.TrimSpace(os.Getenv("EPISODE_AVAILABILITY_MODE")))
return value != "legacy" && value != "jikan"
func episodeAvailabilityEnabled(cfg config.Config) bool {
return cfg.EpisodeAvailabilityMode != config.EpisodeAvailabilityModeLegacy && cfg.EpisodeAvailabilityMode != config.EpisodeAvailabilityModeJikan
}
var Module = fx.Options(
fx.Provide(
episodeAvailabilityEnabled,
fx.Annotate(
func(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool) domain.EpisodeService {
return episodeService.NewEpisodeService(queries, jikanClient, providers, enabled)
func(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, metrics *observability.Metrics) domain.EpisodeService {
return episodeService.NewEpisodeService(queries, jikanClient, providers, enabled, metrics)
},
fx.ParamTags(``, ``, ``, ``),
),
),
fx.Provide(func(p *allanime.AllAnimeProvider) []domain.EpisodeAvailabilityProvider {

View File

@@ -1,3 +1,4 @@
// Package service provides episode availability checking logic.
package service
import (
@@ -6,18 +7,20 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"mal/internal/observability"
"sort"
"strconv"
"strings"
"time"
)
const (
retryInterval = 15 * time.Minute
retryWindow = 3 * time.Hour
retryInterval = 15 * time.Minute
retryWindow = 3 * time.Hour
airingFallbackRefreshInterval = 6 * time.Hour
)
type Clock interface {
@@ -34,19 +37,21 @@ 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) domain.EpisodeService {
return NewEpisodeServiceWithClock(queries, jikanClient, providers, enabled, realClock{})
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 NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock) *EpisodeService {
func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock, metrics *observability.Metrics) *EpisodeService {
return &EpisodeService{
queries: queries,
jikan: jikanClient,
providers: providers,
clock: clock,
enabled: enabled,
metrics: metrics,
}
}
@@ -56,7 +61,7 @@ func (s *EpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.
}
if !forceRefresh {
if cached, ok := s.getFreshCached(ctx, anime.MalID); ok {
if cached, ok := s.getFreshCached(ctx, anime); ok {
return cached, nil
}
}
@@ -77,14 +82,43 @@ func (s *EpisodeService) RefreshTrackedDue(ctx context.Context, limit int) error
return fmt.Errorf("get due tracked anime: %w", err)
}
for _, id := range ids {
for i, id := range ids {
if ctx.Err() != nil {
observability.Warn(
"episodes_worker_tick_interrupted",
"episodes",
"",
map[string]any{
"anime_id": id,
"remaining": len(ids) - i,
},
ctx.Err(),
)
break
}
anime, err := s.jikan.GetAnimeByID(ctx, int(id))
if err != nil {
log.Printf("episodes: failed to fetch anime for refresh anime_id=%d error=%v", id, err)
observability.Warn(
"episodes_refresh_fetch_anime_failed",
"episodes",
"",
map[string]any{
"anime_id": id,
},
err,
)
continue
}
if _, err := s.refresh(ctx, anime); err != nil {
log.Printf("episodes: refresh failed anime_id=%d error=%v", id, err)
if _, err := s.refresh(ctx, domain.Anime{Anime: anime}); err != nil {
observability.Warn(
"episodes_refresh_failed",
"episodes",
"",
map[string]any{
"anime_id": id,
},
err,
)
}
}
@@ -93,22 +127,53 @@ func (s *EpisodeService) RefreshTrackedDue(ctx context.Context, limit int) error
func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
now := s.clock.Now()
log.Printf("episodes: refresh start anime_id=%d title=%q airing=%t", anime.MalID, anime.DisplayTitle(), anime.Airing)
observability.Info(
"episodes_refresh_start",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"title": anime.DisplayTitle(),
"airing": anime.Airing,
},
)
jikanEpisodes, jikanErr := s.jikan.GetAllEpisodes(ctx, anime.MalID)
if jikanErr != nil {
log.Printf("episodes: jikan episode metadata failed anime_id=%d error=%v", anime.MalID, jikanErr)
observability.Warn(
"episodes_jikan_metadata_failed",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
},
jikanErr,
)
}
providerAvailability, source, providerErr := s.fetchProviderAvailability(ctx, anime)
if providerErr != nil {
s.markFailure(ctx, anime, providerErr)
if cached, ok := s.getCached(ctx, anime.MalID); ok {
log.Printf("episodes: serving stale cache after provider failure anime_id=%d error=%v", anime.MalID, providerErr)
observability.Warn(
"episodes_provider_failed_serving_stale_cache",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
},
providerErr,
)
return cached, nil
}
if jikanErr == nil {
return s.store(ctx, anime, jikanEpisodes, domain.EpisodeAvailability{}, "jikan_fallback", now, false)
storeCtx := ctx
if ctx.Err() != nil {
var cancel context.CancelFunc
storeCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
}
return s.store(storeCtx, anime, jikanEpisodes, domain.EpisodeAvailability{}, "jikan_fallback", now, false)
}
return domain.CanonicalEpisodeList{}, providerErr
}
@@ -121,16 +186,44 @@ func (s *EpisodeService) fetchProviderAvailability(ctx context.Context, anime do
for _, provider := range s.providers {
providerID, err := s.providerID(ctx, anime, provider, titles)
if err != nil {
log.Printf("episodes: provider id miss anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err)
observability.Warn(
"episodes_provider_id_miss",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"provider": provider.Name(),
},
err,
)
continue
}
available, err := provider.GetEpisodeAvailabilityByProviderID(ctx, providerID)
if err != nil {
log.Printf("episodes: provider availability miss anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err)
observability.Warn(
"episodes_provider_availability_miss",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"provider": provider.Name(),
},
err,
)
continue
}
log.Printf("episodes: provider availability hit anime_id=%d provider=%s sub=%d dub=%d", anime.MalID, provider.Name(), len(available.Sub), len(available.Dub))
observability.Info(
"episodes_provider_availability_hit",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"provider": provider.Name(),
"sub": len(available.Sub),
"dub": len(available.Dub),
},
)
return available, provider.Name(), nil
}
return domain.EpisodeAvailability{}, "", fmt.Errorf("no episode availability provider matched anime_id=%d", anime.MalID)
@@ -143,14 +236,38 @@ func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, pro
})
if err == nil {
if row.FailedUntil.Valid && row.FailedUntil.Time.After(s.clock.Now()) {
s.metrics.ObserveCache("episode_provider_mapping", "hit")
return "", fmt.Errorf("cached provider mapping failure active until %s: %s", row.FailedUntil.Time.Format(time.RFC3339), row.LastError)
}
if strings.TrimSpace(row.ProviderShowID) != "" {
log.Printf("episodes: provider id cache hit anime_id=%d provider=%s provider_id=%s", anime.MalID, provider.Name(), row.ProviderShowID)
s.metrics.ObserveCache("episode_provider_mapping", "hit")
observability.Info(
"episodes_provider_id_cache_hit",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"provider": provider.Name(),
"provider_id": row.ProviderShowID,
},
)
return row.ProviderShowID, nil
}
s.metrics.ObserveCache("episode_provider_mapping", "miss")
} else if !errors.Is(err, sql.ErrNoRows) {
log.Printf("episodes: provider id cache read failed anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err)
s.metrics.ObserveCache("episode_provider_mapping", "miss")
observability.Warn(
"episodes_provider_id_cache_read_failed",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"provider": provider.Name(),
},
err,
)
} else {
s.metrics.ObserveCache("episode_provider_mapping", "miss")
}
providerID, err := provider.ResolveEpisodeProviderID(ctx, anime.MalID, titles)
@@ -173,20 +290,51 @@ func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, pro
LastError: "",
})
if err != nil {
log.Printf("episodes: provider id cache write failed anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err)
observability.Warn(
"episodes_provider_id_cache_write_failed",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"provider": provider.Name(),
},
err,
)
}
log.Printf("episodes: provider id resolved anime_id=%d provider=%s provider_id=%s", anime.MalID, provider.Name(), providerID)
observability.Info(
"episodes_provider_id_resolved",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"provider": provider.Name(),
"provider_id": providerID,
},
)
return providerID, nil
}
func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, source string, now time.Time, providerSuccess bool) (domain.CanonicalEpisodeList, error) {
nextRefresh := nextBroadcastAfter(anime, now)
var nextRefreshSQL sql.NullTime
if anime.Airing && !nextRefresh.IsZero() {
nextRefreshSQL = sql.NullTime{Time: nextRefresh, Valid: true}
if anime.Airing {
// During the hours immediately following a broadcast time, providers can lag.
// Keep retrying for a short window, even if the provider request succeeded.
lastBroadcast := nextBroadcastBeforeOrAt(anime, now)
if !lastBroadcast.IsZero() && now.Before(lastBroadcast.Add(retryWindow)) {
nextRefreshSQL = sql.NullTime{Time: now.Add(retryInterval).UTC(), Valid: true}
} else {
next := nextBroadcastAfter(anime, now)
if !next.IsZero() {
nextRefreshSQL = sql.NullTime{Time: next, Valid: true}
} else {
// Broadcast metadata is often missing or wrong for currently airing shows.
// Avoid "never refresh again" caches by falling back to a fixed interval.
nextRefreshSQL = sql.NullTime{Time: now.Add(airingFallbackRefreshInterval).UTC(), Valid: true}
}
}
}
episodes := mergeEpisodes(jikanEpisodes, availability)
episodes := mergeEpisodes(jikanEpisodes, availability, anime.Episodes)
payload := domain.CanonicalEpisodeList{
AnimeID: anime.MalID,
Episodes: episodes,
@@ -217,11 +365,30 @@ func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpi
LastError: "",
})
if err != nil {
log.Printf("episodes: cache write failed anime_id=%d source=%s error=%v", anime.MalID, source, err)
observability.Warn(
"episodes_cache_write_failed",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"source": source,
},
err,
)
return payload, nil
}
log.Printf("episodes: refresh success anime_id=%d source=%s episodes=%d next_refresh=%s", anime.MalID, source, len(episodes), payload.NextRefreshAt)
observability.Info(
"episodes_refresh_success",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"source": source,
"episodes": len(episodes),
"next_refresh": payload.NextRefreshAt,
},
)
return payload, nil
}
@@ -239,7 +406,13 @@ func (s *EpisodeService) markFailure(ctx context.Context, anime domain.Anime, ca
nextSQL = sql.NullTime{Time: next, Valid: true}
}
err := s.queries.MarkEpisodeAvailabilityRefreshFailed(ctx, db.MarkEpisodeAvailabilityRefreshFailedParams{
writeCtx := ctx
if ctx.Err() != nil {
var cancel context.CancelFunc
writeCtx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
}
err := s.queries.MarkEpisodeAvailabilityRefreshFailed(writeCtx, db.MarkEpisodeAvailabilityRefreshFailedParams{
LastAttemptAt: sql.NullTime{Time: now, Valid: true},
LastError: truncate(cause.Error(), 400),
NextRefreshAt: nextSQL,
@@ -247,44 +420,146 @@ func (s *EpisodeService) markFailure(ctx context.Context, anime domain.Anime, ca
AnimeID: int64(anime.MalID),
})
if err != nil {
log.Printf("episodes: failed to mark refresh failure anime_id=%d error=%v", anime.MalID, err)
observability.Warn(
"episodes_mark_failure_failed",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
},
err,
)
return
}
log.Printf("episodes: refresh failure recorded anime_id=%d next_retry=%s error=%v", anime.MalID, next.Format(time.RFC3339), cause)
observability.Warn(
"episodes_refresh_failure_recorded",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"next_retry": next.Format(time.RFC3339),
},
cause,
)
}
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 {
log.Printf("episodes: invalid cached payload anime_id=%d error=%v", animeID, err)
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, animeID int) (domain.CanonicalEpisodeList, bool) {
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID))
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
}
if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(s.clock.Now()) {
log.Printf("episodes: cached availability due for refresh anime_id=%d next_refresh=%s", animeID, row.NextRefreshAt.Time.Format(time.RFC3339))
now := s.clock.Now()
if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(now) {
s.metrics.ObserveCache("episode_availability_fresh", "miss")
observability.Info(
"episodes_cache_due_for_refresh",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"next_refresh": row.NextRefreshAt.Time.Format(time.RFC3339),
},
)
return domain.CanonicalEpisodeList{}, 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",
"",
map[string]any{
"anime_id": anime.MalID,
"updated_at": row.UpdatedAt.Format(time.RFC3339),
},
)
return domain.CanonicalEpisodeList{}, false
}
var payload domain.CanonicalEpisodeList
if err := json.Unmarshal([]byte(row.Data), &payload); err != nil {
log.Printf("episodes: invalid cached payload anime_id=%d error=%v", animeID, err)
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
}
log.Printf("episodes: served cached availability anime_id=%d episodes=%d next_refresh=%s", animeID, len(payload.Episodes), payload.NextRefreshAt)
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",
"",
map[string]any{
"anime_id": anime.MalID,
"episodes": len(payload.Episodes),
"next_refresh": payload.NextRefreshAt,
},
)
return payload, true
}
func isCanonicalEpisodePayloadValid(payload domain.CanonicalEpisodeList, expectedCount int) bool {
if expectedCount <= 0 {
return true
}
if len(payload.Episodes) > expectedCount {
return false
}
for _, episode := range payload.Episodes {
if episode.Number <= 0 || episode.Number > expectedCount {
return false
}
}
return true
}
func (s *EpisodeService) jikanOnly(ctx context.Context, anime domain.Anime, source string) (domain.CanonicalEpisodeList, error) {
episodes, err := s.jikan.GetAllEpisodes(ctx, anime.MalID)
if err != nil {
@@ -292,7 +567,7 @@ func (s *EpisodeService) jikanOnly(ctx context.Context, anime domain.Anime, sour
}
return domain.CanonicalEpisodeList{
AnimeID: anime.MalID,
Episodes: mergeEpisodes(episodes, domain.EpisodeAvailability{}),
Episodes: mergeEpisodes(episodes, domain.EpisodeAvailability{}, anime.Episodes),
Source: source,
}, nil
}
@@ -313,7 +588,7 @@ func titleCandidates(anime domain.Anime) []string {
return out
}
func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability) []domain.CanonicalEpisode {
func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, expectedCount int) []domain.CanonicalEpisode {
type partial struct {
title string
filler bool
@@ -323,18 +598,22 @@ func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAva
}
byNumber := map[int]partial{}
for _, ep := range jikanEpisodes {
if ep.MalID <= 0 {
for i, ep := range jikanEpisodes {
if expectedCount > 0 && i >= expectedCount {
break
}
number, ok := jikanEpisodeNumber(ep, i)
if !ok || exceedsExpectedCount(number, expectedCount) {
continue
}
item := byNumber[ep.MalID]
item := byNumber[number]
item.title = strings.TrimSpace(ep.Title)
item.filler = ep.Filler
item.recap = ep.Recap
byNumber[ep.MalID] = item
byNumber[number] = item
}
for _, n := range availability.Sub {
if n <= 0 {
if n <= 0 || exceedsExpectedCount(n, expectedCount) {
continue
}
item := byNumber[n]
@@ -342,7 +621,7 @@ func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAva
byNumber[n] = item
}
for _, n := range availability.Dub {
if n <= 0 {
if n <= 0 || exceedsExpectedCount(n, expectedCount) {
continue
}
item := byNumber[n]
@@ -376,6 +655,21 @@ func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAva
return episodes
}
func jikanEpisodeNumber(ep jikan.Episode, index int) (int, bool) {
number, err := strconv.Atoi(strings.TrimSpace(ep.Episode))
if err == nil && number > 0 {
return number, true
}
if index < 0 {
return 0, false
}
return index + 1, true
}
func exceedsExpectedCount(number int, expectedCount int) bool {
return expectedCount > 0 && number > expectedCount
}
func nextRetryTime(anime domain.Anime, now time.Time) time.Time {
broadcast := nextBroadcastBeforeOrAt(anime, now)
if broadcast.IsZero() || now.After(broadcast.Add(retryWindow)) {
@@ -403,13 +697,31 @@ func nextBroadcastAfter(anime domain.Anime, after time.Time) time.Time {
if loaded, err := time.LoadLocation(tz); err == nil {
loc = loaded
} else {
log.Printf("episodes: failed to parse broadcast timezone anime_id=%d timezone=%q error=%v", anime.MalID, tz, err)
observability.Warn(
"episodes_broadcast_timezone_parse_failed",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"timezone": tz,
},
err,
)
}
}
hour, minute, ok := parseBroadcastTime(anime.Broadcast.Time)
if !ok {
log.Printf("episodes: failed to parse broadcast time anime_id=%d time=%q", anime.MalID, anime.Broadcast.Time)
observability.Warn(
"episodes_broadcast_time_parse_failed",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"time": anime.Broadcast.Time,
},
nil,
)
return time.Time{}
}

View File

@@ -9,13 +9,13 @@ import (
func TestMergeEpisodesUsesUnionAndSynthesizesProviderOnlyEntries(t *testing.T) {
episodes := mergeEpisodes([]jikan.Episode{
{MalID: 1, Title: "Start"},
{MalID: 2, Title: "Second", Filler: true},
{MalID: 5, Title: "Future", Recap: true},
{MalID: 101, Episode: "1", Title: "Start"},
{MalID: 102, Episode: "2", Title: "Second", Filler: true},
{MalID: 105, Episode: "5", Title: "Future", Recap: true},
}, domain.EpisodeAvailability{
Sub: []int{1, 2, 3, 6},
Dub: []int{1, 2, 3},
})
}, 0)
if len(episodes) != 5 {
t.Fatalf("len(episodes) = %d, want 5", len(episodes))
@@ -28,8 +28,66 @@ func TestMergeEpisodesUsesUnionAndSynthesizesProviderOnlyEntries(t *testing.T) {
assertEpisode(t, episodes[4], 6, "Episode 6", true, false, true, false, false)
}
func TestMergeEpisodesIgnoresInvalidJikanEpisodeNumbers(t *testing.T) {
episodes := mergeEpisodes([]jikan.Episode{
{MalID: 201, Episode: "", Title: "Missing"},
{MalID: 202, Episode: "Preview", Title: "Preview"},
{MalID: 203, Episode: "0", Title: "Zero"},
}, domain.EpisodeAvailability{}, 0)
if len(episodes) != 3 {
t.Fatalf("len(episodes) = %d, want 3", len(episodes))
}
assertEpisode(t, episodes[0], 1, "Missing", false, false, false, false, false)
assertEpisode(t, episodes[1], 2, "Preview", false, false, false, false, false)
assertEpisode(t, episodes[2], 3, "Zero", false, false, false, false, false)
}
func TestMergeEpisodesCapsMalformedJikanListsToDeclaredEpisodeCount(t *testing.T) {
episodes := mergeEpisodes([]jikan.Episode{
{MalID: 301, Episode: "", Title: "Rimuru's Busy Life"},
{MalID: 302, Episode: "", Title: "Trade with the Animal Kingdom"},
{MalID: 303, Episode: "", Title: "Paradise, Once More"},
{MalID: 304, Episode: "", Title: "The Scheming Kingdom of Falmuth"},
{MalID: 305, Episode: "", Title: "Prelude to the Disaster"},
{MalID: 306, Episode: "", Title: "The Beauty Makes Her Move"},
{MalID: 307, Episode: "", Title: "Despair"},
{MalID: 308, Episode: "", Title: "Hope"},
{MalID: 309, Episode: "", Title: "Putting Everything on the Line"},
{MalID: 310, Episode: "", Title: "Megiddo"},
{MalID: 311, Episode: "", Title: "Birth of a Demon Lord"},
{MalID: 312, Episode: "", Title: "The One Unleashed"},
{MalID: 313, Episode: "", Title: "The Visitors"},
}, domain.EpisodeAvailability{
Sub: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13},
Dub: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13},
}, 12)
if len(episodes) != 12 {
t.Fatalf("len(episodes) = %d, want 12", len(episodes))
}
assertEpisode(t, episodes[0], 1, "Rimuru's Busy Life", true, true, false, false, false)
assertEpisode(t, episodes[11], 12, "The One Unleashed", true, true, false, false, false)
}
func TestIsCanonicalEpisodePayloadValidRejectsOverflowingCachedPayload(t *testing.T) {
payload := domain.CanonicalEpisodeList{
Episodes: []domain.CanonicalEpisode{
{Number: 1, Title: "Episode 1"},
{Number: 2, Title: "Episode 2"},
{Number: 13, Title: "Episode 13"},
},
}
if isCanonicalEpisodePayloadValid(payload, 12) {
t.Fatal("expected cached payload to be rejected")
}
}
func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) {
anime := domain.Anime{MalID: 1}
anime := domain.Anime{Anime: jikan.Anime{MalID: 1}}
anime.Broadcast.Day = "Saturdays"
anime.Broadcast.Time = "23:00"
anime.Broadcast.Timezone = "Asia/Tokyo"
@@ -44,7 +102,7 @@ func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) {
}
func TestNextRetryTimeWithinAndAfterRetryWindow(t *testing.T) {
anime := domain.Anime{MalID: 1}
anime := domain.Anime{Anime: jikan.Anime{MalID: 1}}
anime.Broadcast.Day = "Saturdays"
anime.Broadcast.Time = "12:00"
anime.Broadcast.Timezone = "UTC"

View File

@@ -2,8 +2,8 @@ package episodes
import (
"context"
"log"
"mal/internal/domain"
"mal/internal/observability"
"time"
"go.uber.org/fx"
@@ -11,25 +11,44 @@ import (
const workerInterval = time.Minute
func RegisterWorker(lc fx.Lifecycle, svc domain.EpisodeService) {
func RegisterWorker(lc fx.Lifecycle, svc domain.EpisodeService, metrics *observability.Metrics) {
ctx, cancel := context.WithCancel(context.Background())
lc.Append(fx.Hook{
OnStart: func(context.Context) error {
OnStart: func(startCtx context.Context) error {
// Tie worker lifetime to fx lifecycle start context cancellation.
go func() {
log.Println("episodes: availability worker started")
<-startCtx.Done()
cancel()
}()
go func() {
observability.Info("episodes_worker_start", "episodes", "", nil)
ticker := time.NewTicker(workerInterval)
defer ticker.Stop()
for {
if err := svc.RefreshTrackedDue(ctx, 25); err != nil {
log.Printf("episodes: availability worker tick failed error=%v", err)
tickCtx, tickCancel := context.WithTimeout(ctx, 45*time.Second)
err := svc.RefreshTrackedDue(tickCtx, 25)
tickCancel()
if err != nil {
metrics.ObserveWorkerTick("episodes_availability", err)
observability.Warn(
"episodes_worker_tick_failed",
"episodes",
"",
map[string]any{
"worker": "episodes_availability",
},
err,
)
} else {
metrics.ObserveWorkerTick("episodes_availability", nil)
}
select {
case <-ticker.C:
case <-ctx.Done():
log.Println("episodes: availability worker stopped")
observability.Info("episodes_worker_stop", "episodes", "", nil)
return
}
}

View File

@@ -0,0 +1,15 @@
package observability
// Small helpers to keep logging consistent and low-friction across the codebase.
func Info(event string, component string, message string, fields map[string]any) {
LogJSON(LogLevelInfo, event, component, message, fields, nil)
}
func Warn(event string, component string, message string, fields map[string]any, err error) {
LogJSON(LogLevelWarn, event, component, message, fields, err)
}
func Error(event string, component string, message string, fields map[string]any, err error) {
LogJSON(LogLevelError, event, component, message, fields, err)
}

View File

@@ -0,0 +1,59 @@
// Package observability provides logging and metrics instrumentation.
package observability
import (
"encoding/json"
"log"
"time"
)
type LogLevel string
const (
LogLevelInfo LogLevel = "info"
LogLevelWarn LogLevel = "warn"
LogLevelError LogLevel = "error"
)
type LogEvent struct {
TS string `json:"ts"`
Level LogLevel `json:"level"`
Event string `json:"event"`
Message string `json:"message,omitempty"`
Fields map[string]any `json:"fields,omitempty"`
Error string `json:"error,omitempty"`
Component string `json:"component,omitempty"`
}
func LogJSON(level LogLevel, event string, component string, message string, fields map[string]any, err error) {
errorValue := ""
if err != nil {
errorValue = err.Error()
}
entry := LogEvent{
TS: time.Now().UTC().Format(time.RFC3339Nano),
Level: level,
Event: event,
Message: message,
Fields: fields,
Error: errorValue,
Component: component,
}
// Best-effort. If encoding fails, fall back to a minimal line.
bytes, marshalErr := json.Marshal(entry)
if marshalErr != nil {
// Keep output JSON-only even on failures by constructing a minimal entry.
// Marshal individual strings to ensure proper escaping.
tsBytes, _ := json.Marshal(time.Now().UTC().Format(time.RFC3339Nano))
levelBytes, _ := json.Marshal(level)
eventBytes, _ := json.Marshal("log_marshal_failed")
componentBytes, _ := json.Marshal(component)
errBytes, _ := json.Marshal(marshalErr.Error())
log.Printf(`{"ts":%s,"level":%s,"event":%s,"component":%s,"error":%s}`, tsBytes, levelBytes, eventBytes, componentBytes, errBytes)
return
}
log.Print(string(bytes))
}

View File

@@ -0,0 +1,297 @@
package observability
import (
"fmt"
"maps"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
)
var defaultDurationBuckets = []float64{
0.005,
0.01,
0.025,
0.05,
0.1,
0.25,
0.5,
1,
2.5,
5,
10,
}
type counterSample struct {
labels map[string]string
value uint64
}
type histogramSample struct {
labels map[string]string
buckets []uint64
count uint64
sum float64
}
type counterVec struct {
mu sync.Mutex
labelNames []string
samples map[string]*counterSample
}
type histogramVec struct {
mu sync.Mutex
labelNames []string
bounds []float64
samples map[string]*histogramSample
}
type Metrics struct {
httpRequests *counterVec
httpRequestLatency *histogramVec
jikanRequests *counterVec
jikanRequestErrors *counterVec
jikanLatency *histogramVec
workerTicks *counterVec
cacheOperations *counterVec
}
func NewMetrics() *Metrics {
return &Metrics{
httpRequests: newCounterVec("method", "route", "status"),
httpRequestLatency: newHistogramVec(defaultDurationBuckets, "method", "route", "status"),
jikanRequests: newCounterVec("endpoint", "status"),
jikanRequestErrors: newCounterVec("endpoint", "status"),
jikanLatency: newHistogramVec(defaultDurationBuckets, "endpoint", "status"),
workerTicks: newCounterVec("worker", "result"),
cacheOperations: newCounterVec("cache", "result"),
}
}
func (m *Metrics) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.WriteHeader(http.StatusOK)
m.writePrometheus(w)
})
}
func (m *Metrics) ObserveHTTPRequest(method string, route string, status int, duration time.Duration) {
statusLabel := strconv.Itoa(status)
m.httpRequests.Inc(method, route, statusLabel)
m.httpRequestLatency.Observe(duration.Seconds(), method, route, statusLabel)
}
func (m *Metrics) ObserveJikanRequest(endpoint string, status int, duration time.Duration, err error) {
statusLabel := strconv.Itoa(status)
m.jikanRequests.Inc(endpoint, statusLabel)
m.jikanLatency.Observe(duration.Seconds(), endpoint, statusLabel)
if err != nil || status >= http.StatusBadRequest {
m.jikanRequestErrors.Inc(endpoint, statusLabel)
}
}
func (m *Metrics) ObserveWorkerTick(worker string, err error) {
if err != nil {
m.workerTicks.Inc(worker, "failure")
return
}
m.workerTicks.Inc(worker, "success")
}
func (m *Metrics) ObserveCache(cache string, result string) {
m.cacheOperations.Inc(cache, result)
}
func (m *Metrics) writePrometheus(w http.ResponseWriter) {
writeCounterMetric(w, "mal_http_requests_total", "Total HTTP requests by method, route, and status.", m.httpRequests.snapshot())
writeHistogramMetric(w, "mal_http_request_duration_seconds", "HTTP request latency in seconds.", m.httpRequestLatency.snapshot(), m.httpRequestLatency.bounds)
writeCounterMetric(w, "mal_jikan_upstream_requests_total", "Total upstream Jikan requests by endpoint and status.", m.jikanRequests.snapshot())
writeCounterMetric(w, "mal_jikan_upstream_errors_total", "Total upstream Jikan errors by endpoint and status.", m.jikanRequestErrors.snapshot())
writeHistogramMetric(w, "mal_jikan_upstream_request_duration_seconds", "Upstream Jikan request latency in seconds.", m.jikanLatency.snapshot(), m.jikanLatency.bounds)
writeCounterMetric(w, "mal_worker_ticks_total", "Total background worker ticks by worker and result.", m.workerTicks.snapshot())
writeCounterMetric(w, "mal_cache_operations_total", "Total cache hits and misses by cache name.", m.cacheOperations.snapshot())
}
func newCounterVec(labelNames ...string) *counterVec {
return &counterVec{
labelNames: append([]string(nil), labelNames...),
samples: make(map[string]*counterSample),
}
}
func (c *counterVec) Inc(labelValues ...string) {
c.mu.Lock()
defer c.mu.Unlock()
key, labels := buildLabelKey(c.labelNames, labelValues)
if labels == nil {
return
}
sample, ok := c.samples[key]
if !ok {
sample = &counterSample{labels: labels}
c.samples[key] = sample
}
sample.value++
}
func (c *counterVec) snapshot() []counterSample {
c.mu.Lock()
defer c.mu.Unlock()
keys := sortedCounterSampleKeys(c.samples)
out := make([]counterSample, 0, len(keys))
for _, key := range keys {
sample := c.samples[key]
out = append(out, counterSample{
labels: copyLabels(sample.labels),
value: sample.value,
})
}
return out
}
func newHistogramVec(bounds []float64, labelNames ...string) *histogramVec {
return &histogramVec{
labelNames: append([]string(nil), labelNames...),
bounds: append([]float64(nil), bounds...),
samples: make(map[string]*histogramSample),
}
}
func (h *histogramVec) Observe(value float64, labelValues ...string) {
h.mu.Lock()
defer h.mu.Unlock()
key, labels := buildLabelKey(h.labelNames, labelValues)
if labels == nil {
return
}
sample, ok := h.samples[key]
if !ok {
sample = &histogramSample{
labels: labels,
buckets: make([]uint64, len(h.bounds)),
}
h.samples[key] = sample
}
sample.count++
sample.sum += value
for idx, bound := range h.bounds {
if value <= bound {
sample.buckets[idx]++
}
}
}
func (h *histogramVec) snapshot() []histogramSample {
h.mu.Lock()
defer h.mu.Unlock()
keys := sortedHistogramSampleKeys(h.samples)
out := make([]histogramSample, 0, len(keys))
for _, key := range keys {
sample := h.samples[key]
buckets := make([]uint64, len(sample.buckets))
copy(buckets, sample.buckets)
out = append(out, histogramSample{
labels: copyLabels(sample.labels),
buckets: buckets,
count: sample.count,
sum: sample.sum,
})
}
return out
}
func buildLabelKey(labelNames []string, labelValues []string) (string, map[string]string) {
if len(labelNames) != len(labelValues) {
return "", nil
}
labels := make(map[string]string, len(labelNames))
parts := make([]string, 0, len(labelNames)*2)
for idx, name := range labelNames {
value := labelValues[idx]
labels[name] = value
parts = append(parts, name, value)
}
return strings.Join(parts, "\xff"), labels
}
func copyLabels(labels map[string]string) map[string]string {
out := make(map[string]string, len(labels))
maps.Copy(out, labels)
return out
}
func sortedCounterSampleKeys(samples map[string]*counterSample) []string {
keys := make([]string, 0, len(samples))
for key := range samples {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func sortedHistogramSampleKeys(samples map[string]*histogramSample) []string {
keys := make([]string, 0, len(samples))
for key := range samples {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func writeCounterMetric(w http.ResponseWriter, name string, help string, samples []counterSample) {
_, _ = fmt.Fprintf(w, "# HELP %s %s\n", name, help)
_, _ = fmt.Fprintf(w, "# TYPE %s counter\n", name)
for _, sample := range samples {
_, _ = fmt.Fprintf(w, "%s%s %d\n", name, formatLabels(sample.labels), sample.value)
}
}
func writeHistogramMetric(w http.ResponseWriter, name string, help string, samples []histogramSample, bounds []float64) {
_, _ = fmt.Fprintf(w, "# HELP %s %s\n", name, help)
_, _ = fmt.Fprintf(w, "# TYPE %s histogram\n", name)
for _, sample := range samples {
for idx, bound := range bounds {
labels := copyLabels(sample.labels)
labels["le"] = formatFloat(bound)
_, _ = fmt.Fprintf(w, "%s_bucket%s %d\n", name, formatLabels(labels), sample.buckets[idx])
}
labels := copyLabels(sample.labels)
labels["le"] = "+Inf"
_, _ = fmt.Fprintf(w, "%s_bucket%s %d\n", name, formatLabels(labels), sample.count)
_, _ = fmt.Fprintf(w, "%s_sum%s %s\n", name, formatLabels(sample.labels), formatFloat(sample.sum))
_, _ = fmt.Fprintf(w, "%s_count%s %d\n", name, formatLabels(sample.labels), sample.count)
}
}
func formatLabels(labels map[string]string) string {
if len(labels) == 0 {
return ""
}
keys := make([]string, 0, len(labels))
for key := range labels {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, fmt.Sprintf(`%s=%q`, key, labels[key]))
}
return "{" + strings.Join(parts, ",") + "}"
}
func formatFloat(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64)
}

View File

@@ -0,0 +1,47 @@
package observability
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestMetricsHandlerRendersPrometheusFamilies(t *testing.T) {
metrics := NewMetrics()
metrics.ObserveHTTPRequest(http.MethodGet, "/anime/:id", http.StatusOK, 125*time.Millisecond)
metrics.ObserveJikanRequest("/anime/{id}", http.StatusTooManyRequests, 800*time.Millisecond, assertErr{})
metrics.ObserveWorkerTick("episodes_availability", nil)
metrics.ObserveCache("jikan", "hit")
metrics.ObserveCache("episode_availability", "miss")
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rec := httptest.NewRecorder()
metrics.Handler().ServeHTTP(rec, req)
body, err := io.ReadAll(rec.Result().Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
text := string(body)
assertContains(t, text, `mal_http_requests_total{method="GET",route="/anime/:id",status="200"} 1`)
assertContains(t, text, `mal_http_request_duration_seconds_count{method="GET",route="/anime/:id",status="200"} 1`)
assertContains(t, text, `mal_jikan_upstream_requests_total{endpoint="/anime/{id}",status="429"} 1`)
assertContains(t, text, `mal_jikan_upstream_errors_total{endpoint="/anime/{id}",status="429"} 1`)
assertContains(t, text, `mal_worker_ticks_total{result="success",worker="episodes_availability"} 1`)
assertContains(t, text, `mal_cache_operations_total{cache="episode_availability",result="miss"} 1`)
}
type assertErr struct{}
func (assertErr) Error() string { return "boom" }
func assertContains(t *testing.T, text string, want string) {
t.Helper()
if !strings.Contains(text, want) {
t.Fatalf("missing metric line %q in:\n%s", want, text)
}
}

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