Compare commits

...

207 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
153 changed files with 7609 additions and 3424 deletions

View File

@@ -1,4 +1,4 @@
version: '2'
version: "2"
linters:
default: none

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

@@ -1,32 +0,0 @@
# Conflicts / Remaining Issues
1. **God interface (`AnimeService`)**
- `internal/domain/anime.go` still defines a large `AnimeService` interface (catalog + discover + search + details + staff/stats/reviews).
- Needs to be split into smaller interfaces (ISP) and rewired through handlers/services.
2. **Domain layer still leaks external models**
- While `domain.User` and `domain.Anime` are now real types, many other domain types are still direct aliases to integration/DB types (e.g. `Genre`, `Recommendation`, etc. in `internal/domain/anime.go`).
- Goal is a stable domain model that does not break if Jikan/DB structs change.
3. **No real DB transactions for multi-write operations**
- Multi-step writes (e.g. playback completion / watchlist updates) still do not run inside a database transaction.
- Errors are no longer swallowed in several places, but atomicity is still not guaranteed.
4. **DiceBear URL duplication**
- Default avatar URL logic is duplicated in `cmd/user/main.go` and `internal/database/migrations/016_add_avatar_url.sql`.
- Needs centralization (or migration updated to match single source of truth).
5. **AllAnime package-level shared HTTP client**
- `integrations/playback/allanime/client.go` still has a package-level mutable `http.Client` (`allAnimeUTLSClient`).
- Should be instance-owned or injected to avoid cross-test/env coupling.
6. **Regex-based parsing of upstream JSON-ish responses**
- `integrations/playback/allanime/extractor.go` still parses provider responses using regex.
- Should be replaced with real JSON decoding (or a more robust parser) where possible.
7. **Template duplication / drift risk**
- `templates/watchlist.gohtml` and `templates/watchlist_partial.gohtml` are still separate with overlapping markup.
- Inline JS was removed, but the duplication itself remains and can still drift.
8. **Remaining handler consistency**
- Some modules still have duplicated user extraction patterns and could be unified (e.g. `currentUser()` helper usage beyond playback).

View File

@@ -39,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
@@ -54,6 +55,7 @@ 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

View File

@@ -40,10 +40,28 @@ The frontend is Tailwind CSS v4 with HTMX handling pagination, infinite scroll,
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
just dev
```
## 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 .
```
## Contributing
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.

326
bun.lock
View File

@@ -4,47 +4,23 @@
"workspaces": {
"": {
"name": "myanimelist-ui",
"dependencies": {
"htmx.org": "1.9.12",
},
"devDependencies": {
"@tailwindcss/cli": "^4.2.4",
"@tailwindcss/cli": "^4.3.0",
"@types/node": "^24.0.0",
"@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",
"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=="],
@@ -55,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=="],
@@ -83,150 +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.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="],
"@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=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
"@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=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"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=="],
@@ -249,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=="],
@@ -275,90 +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=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"@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=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"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

@@ -4,12 +4,14 @@ package main
import (
"bufio"
"database/sql"
"errors"
"fmt"
"os"
"strings"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"mal/internal"
"mal/internal/config"
"mal/internal/database"
"mal/internal/db"
@@ -30,75 +32,156 @@ func main() {
}
defer func() { _ = dbConn.Close() }()
if len(os.Args) == 2 {
switch os.Args[1] {
case "update-avatar":
updateAvatars(dbConn)
return
case "run-fixes":
runFixes(dbConn)
return
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 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 {
observability.Warn("cli_usage", "cmd/user", "invalid arguments", map[string]any{"argc": len(os.Args)}, nil)
_, _ = fmt.Fprintln(os.Stderr, "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")
os.Exit(2)
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 {
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)
os.Exit(1)
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 {
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
os.Exit(1)
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 {
observability.Error("cli_user_password_update_failed", "cmd/user", "", map[string]any{"username": username}, err)
os.Exit(1)
}
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 {
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
os.Exit(1)
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 {
observability.Error("cli_user_create_failed", "cmd/user", "", map[string]any{"username": username}, err)
os.Exit(1)
return err
}
fmt.Printf("User '%s' was created successfully!\n", username)
return nil
}
func updateAvatars(dbConn *sql.DB) {
@@ -117,7 +200,7 @@ func updateAvatars(dbConn *sql.DB) {
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 {
observability.Error("cli_user_avatar_update_failed", "cmd/user", "", map[string]any{"username": username}, err)

View File

@@ -1,40 +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';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const tsconfigRootDir = path.dirname(fileURLToPath(import.meta.url));
export default [
{
ignores: ['dist/**', 'node_modules/**', 'server', '*.js'],
},
{
files: ['static/**/*.ts'],
plugins: {
'@typescript-eslint': tseslint,
prettier,
},
languageOptions: {
parser: tsParser,
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir,
},
},
rules: {
...eslintConfigPrettier.rules,
...tseslint.configs.recommended.rules,
...tseslint.configs.stylistic.rules,
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
'prettier/prettier': 'error',
},
},
];

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

@@ -16,7 +16,7 @@ import (
"mal/internal/config"
"mal/internal/db"
"mal/internal/observability"
"mal/pkg/net/useragent"
netutil "mal/pkg/net"
"golang.org/x/sync/singleflight"
)
@@ -410,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:
@@ -422,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)
}()
}
@@ -483,7 +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", useragent.Generic)
req.Header.Set("User-Agent", netutil.Generic)
resp, err := c.httpClient.Do(req)
if err != nil {

View File

@@ -1,3 +1,4 @@
// Package jikan provides a client for the Jikan v4 API.
package jikan
import "time"

View File

@@ -43,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()
@@ -109,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)
@@ -230,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

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

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

View File

@@ -1,39 +1,50 @@
package handler
package anime
import (
"context"
"fmt"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"mal/internal/observability"
"mal/internal/server"
"net/http"
"net/url"
"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 domain.AnimeService
svc Service
watchlistSvc domain.WatchlistService
episodeSvc domain.EpisodeService
scheduleCacheMu sync.Mutex
scheduleCache map[string]cachedWeekSchedule
}
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
type Service interface {
domain.AnimeCatalogService
domain.AnimeDiscoverService
domain.AnimeSearchService
domain.AnimeDetailsService
WarmDetailSections(id int)
}
func NewAnimeHandler(svc domain.AnimeService, watchlistSvc domain.WatchlistService) *AnimeHandler {
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService) *AnimeHandler {
return &AnimeHandler{
svc: svc,
watchlistSvc: watchlistSvc,
svc: svc,
watchlistSvc: watchlistSvc,
episodeSvc: episodeSvc,
scheduleCache: map[string]cachedWeekSchedule{},
}
}
@@ -59,17 +70,61 @@ func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, an
return watchlistMap
}
func (h *AnimeHandler) Register(r *gin.Engine) {
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("/api/discover/for-you", h.HandleDiscoverForYou)
r.GET("/schedule", h.HandleSchedule)
r.GET("/api/schedule", h.HandleScheduleSection)
r.GET("/browse", h.HandleBrowse)
@@ -177,7 +232,7 @@ func (h *AnimeHandler) HandleProducers(c *gin.Context) {
}
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
user, _ := c.Get("User")
user := server.CurrentUser(c)
c.HTML(http.StatusOK, "index.gohtml", gin.H{
"CurrentPath": "/",
@@ -198,20 +253,16 @@ 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)
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(
"catalog_section_fetch_failed",
"top_pick_for_you_fetch_failed",
"anime",
"",
map[string]any{
"section": section,
"user_id": userID,
},
err,
@@ -222,6 +273,22 @@ func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
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
@@ -229,13 +296,44 @@ func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
}
func (h *AnimeHandler) HandleDiscover(c *gin.Context) {
user, _ := c.Get("User")
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")
}
@@ -248,55 +346,11 @@ func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) {
h.renderDiscoverSection(c, "Top")
}
func (h *AnimeHandler) HandleDiscoverForYou(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
data, err := h.svc.GetDiscoverForYou(c.Request.Context(), userID)
if err != nil {
observability.Warn(
"discover_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 = "ForYou"
data.Fragment = "discover_row"
data.WatchlistMap = watchlistMap
c.HTML(http.StatusOK, "discover.gohtml", data)
}
func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
userID := server.CurrentUserID(c)
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)
if err != nil {
observability.Warn(
"discover_section_fetch_failed",
"anime",
"",
map[string]any{
"section": section,
"user_id": userID,
},
err,
)
c.AbortWithStatus(http.StatusInternalServerError)
h.abortSectionFetch(c, "discover_section_fetch_failed", userID, section, err)
return
}
@@ -308,42 +362,77 @@ func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
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, _ := c.Get("User")
user := server.CurrentUser(c)
year, week := parseYearWeek(c)
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
"CurrentPath": "/schedule",
"User": user,
"CurrentPath": "/schedule",
"User": user,
"ScheduleYear": year,
"ScheduleWeek": week,
})
}
func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
year, week := parseYearWeek(c)
timezone := scheduleTimezone(c)
animes, err := h.svc.GetAiringSchedule(c.Request.Context(), userID)
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(
"schedule_fetch_failed",
"animeschedule_fetch_failed",
"anime",
"",
map[string]any{
"user_id": userID,
"year": year,
"week": week,
"timezone": timezone,
},
err,
)
c.AbortWithStatus(http.StatusInternalServerError)
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
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
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",
"Animes": animes,
"WatchlistMap": watchlistMap,
"_fragment": "schedule_section_scraped",
"ScheduleDays": days,
"ScheduleYear": schedule.Year,
"ScheduleWeek": schedule.Week,
"PrevYear": prevYear,
"PrevWeek": prevWeek,
"NextYear": nextYear,
"NextWeek": nextWeek,
})
}
@@ -408,11 +497,8 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
return
}
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
user := server.CurrentUser(c)
userID := server.CurrentUserID(c)
animes := wrapAnimes(res.Animes)
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
@@ -445,31 +531,7 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
}
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,
"Studio": studioID,
"StudioName": studioName,
"SFW": sfw,
"GenresList": genresList,
"Animes": animes,
"HasNextPage": res.HasNextPage,
"NextPage": page + 1,
"User": user,
"WatchlistMap": watchlistMap,
})
return
}
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
browseData := gin.H{
"CurrentPath": "/browse",
"Query": q,
"Type": animeType,
@@ -486,7 +548,15 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
"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) {
@@ -498,7 +568,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
section := c.Query("section")
if section != "" && c.GetHeader("HX-Request") == "true" {
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), 4*time.Second)
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), animeSectionTimeout)
defer cancel()
var data any
@@ -531,6 +601,13 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
},
err,
)
if section == "recommendations" {
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"_fragment": "anime_recommendations_loading",
"AnimeID": id,
})
return
}
c.Status(http.StatusNoContent)
return
}
@@ -548,27 +625,32 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
return
}
user, _ := c.Get("User")
h.svc.WarmDetailSections(id)
user := server.CurrentUser(c)
status := ""
var watchlistIDs []int64
ep := 0
var cwSeconds float64
if u, ok := user.(*domain.User); ok {
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), u.ID, int64(id))
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(), u.ID, int64(id))
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,
@@ -585,13 +667,9 @@ func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
return
}
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
userID := server.CurrentUserID(c)
relationsCtx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
relationsCtx, cancel := context.WithTimeout(c.Request.Context(), watchOrderTimeout)
defer cancel()
relations, err := h.svc.GetRelations(relationsCtx, id)
@@ -605,7 +683,10 @@ func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
},
err,
)
c.Status(http.StatusNoContent)
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"_fragment": "watch_order_loading",
"AnimeID": id,
})
return
}
@@ -638,11 +719,7 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
return
}
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
userID := server.CurrentUserID(c)
animes := wrapAnimes(res.Animes)
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
@@ -669,191 +746,6 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
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, 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 {
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()
@@ -876,10 +768,10 @@ func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
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)})
userID := server.CurrentUserID(c)
if userID != "" {
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, []int64{int64(anime.MalID)})
inWatchlist = watchlistMap[int64(anime.MalID)]
}
@@ -919,7 +811,7 @@ func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) {
return
}
user, _ := c.Get("User")
user := server.CurrentUser(c)
if c.GetHeader("HX-Request") == "true" && page > 1 {
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{

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,390 +0,0 @@
package service
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) 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 := 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) GetDiscoverForYou(ctx context.Context, userID string) (domain.DiscoverSectionData, error) {
if strings.TrimSpace(userID) == "" {
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
}
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
if err != nil {
return domain.DiscoverSectionData{}, err
}
seedIDs := make([]int, 0, 5)
for _, entry := range watchlist {
status := strings.TrimSpace(entry.Status)
if status != "watching" && status != "completed" {
continue
}
if entry.AnimeID <= 0 {
continue
}
seedIDs = append(seedIDs, int(entry.AnimeID))
if len(seedIDs) >= 5 {
break
}
}
if len(seedIDs) == 0 {
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
}
type ranked struct {
id int
votes int
}
recommended := map[int]ranked{}
var g errgroup.Group
g.SetLimit(4)
for _, seedID := range seedIDs {
g.Go(func() error {
recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seedID)
if recErr != nil {
return recErr
}
for _, rec := range recs {
id := rec.Entry.MalID
if id <= 0 {
continue
}
if id == seedID {
continue
}
current, ok := recommended[id]
if !ok {
recommended[id] = ranked{id: id, votes: rec.Votes}
continue
}
current.votes += rec.Votes
recommended[id] = current
}
return nil
})
}
if err := g.Wait(); err != nil {
return domain.DiscoverSectionData{}, err
}
if len(recommended) == 0 {
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
}
rankedIDs := make([]ranked, 0, len(recommended))
for _, item := range recommended {
rankedIDs = append(rankedIDs, item)
}
sort.Slice(rankedIDs, func(i, j int) bool {
if rankedIDs[i].votes == rankedIDs[j].votes {
return rankedIDs[i].id < rankedIDs[j].id
}
return rankedIDs[i].votes > rankedIDs[j].votes
})
limit := min(len(rankedIDs), 12)
animes := make([]domain.Anime, 0, limit)
for i := range limit {
anime, fetchErr := s.jikan.GetAnimeByID(ctx, rankedIDs[i].id)
if fetchErr != nil {
observability.Warn(
"recommendation_anime_fetch_failed",
"anime",
"",
map[string]any{"anime_id": rankedIDs[i].id},
fetchErr,
)
continue
}
animes = append(animes, domain.Anime{Anime: anime})
}
return domain.DiscoverSectionData{Animes: animes}, 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) {
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 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,3 +1,4 @@
// Package app bootstraps and wires the application dependencies.
package app
import (

View File

@@ -1,4 +1,4 @@
package auditctx
package audit
import "context"

View File

@@ -5,14 +5,13 @@ import (
"strings"
"github.com/gin-gonic/gin"
"mal/internal/auditctx"
)
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(auditctx.WithRequestInfo(c.Request.Context(), ip, userAgent))
c.Request = c.Request.WithContext(WithRequestInfo(c.Request.Context(), ip, userAgent))
c.Next()
}
}

View File

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

View File

@@ -1,11 +1,11 @@
package service
// Package audit provides audit logging for user actions.
package audit
import (
"context"
"database/sql"
"encoding/json"
"errors"
"mal/internal/auditctx"
"mal/internal/db"
"mal/internal/domain"
"mal/internal/observability"
@@ -31,7 +31,7 @@ func (s *auditService) Record(ctx context.Context, event domain.AuditEvent) erro
return errors.New("audit action missing")
}
ip, userAgent := auditctx.RequestInfoFromContext(ctx)
ip, userAgent := RequestInfoFromContext(ctx)
if strings.TrimSpace(event.IP) != "" {
ip = event.IP
}

View File

@@ -1,4 +1,4 @@
package service_test
package audit_test
import (
"context"
@@ -6,8 +6,7 @@ import (
"os"
"testing"
"mal/internal/audit/service"
"mal/internal/auditctx"
"mal/internal/audit"
"mal/internal/database"
"mal/internal/db"
"mal/internal/domain"
@@ -32,13 +31,13 @@ func TestRecordInsertsAuditLog(t *testing.T) {
}
queries := db.New(sqlDB)
svc := service.NewAuditService(queries)
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 := auditctx.WithRequestInfo(context.Background(), "127.0.0.1", "unit-test")
ctx := audit.WithRequestInfo(context.Background(), "127.0.0.1", "unit-test")
metadata, err := json.Marshal(struct {
Foo string `json:"foo"`
}{Foo: "bar"})

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"

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"

View File

@@ -1,4 +1,4 @@
package service
package auth
import (
"context"

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
}

View File

@@ -1,3 +1,4 @@
// Package config provides application configuration loading and access.
package config
import (

View File

@@ -1,3 +1,4 @@
// Package database manages database schema migrations and fixes.
package database
import (

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

@@ -1,3 +1,4 @@
// Package fixes implements one-off database migration fixes.
package fixes
import (

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

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

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

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 (
@@ -9,27 +10,149 @@ import (
type Anime struct {
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 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)
GetDiscoverForYou(ctx context.Context, userID string) (DiscoverSectionData, error)
GetAiringSchedule(ctx context.Context, userID string) ([]Anime, error)
GetAnimeByID(ctx context.Context, id int) (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)
@@ -40,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

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,3 +1,4 @@
// Package episodes manages episode availability checking and refresh scheduling.
package episodes
import (

View File

@@ -1,3 +1,4 @@
// Package service provides episode availability checking logic.
package service
import (
@@ -11,6 +12,7 @@ import (
"mal/internal/domain"
"mal/internal/observability"
"sort"
"strconv"
"strings"
"time"
)
@@ -80,7 +82,20 @@ 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 {
observability.Warn(
@@ -152,7 +167,13 @@ func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domai
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
}
@@ -313,7 +334,7 @@ func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpi
}
}
episodes := mergeEpisodes(jikanEpisodes, availability)
episodes := mergeEpisodes(jikanEpisodes, availability, anime.Episodes)
payload := domain.CanonicalEpisodeList{
AnimeID: anime.MalID,
Episodes: episodes,
@@ -385,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,
@@ -490,6 +517,20 @@ func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime)
)
return domain.CanonicalEpisodeList{}, false
}
if !isCanonicalEpisodePayloadValid(payload, anime.Episodes) {
s.metrics.ObserveCache("episode_availability_fresh", "miss")
observability.Info(
"episodes_cached_payload_rejected",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"expected_count": anime.Episodes,
"cached_episodes": len(payload.Episodes),
},
)
return domain.CanonicalEpisodeList{}, false
}
s.metrics.ObserveCache("episode_availability_fresh", "hit")
observability.Info(
"episodes_cache_served",
@@ -504,6 +545,21 @@ func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime)
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 {
@@ -511,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
}
@@ -532,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
@@ -542,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]
@@ -561,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]
@@ -595,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)) {

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,6 +28,64 @@ 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{Anime: jikan.Anime{MalID: 1}}
anime.Broadcast.Day = "Saturdays"

View File

@@ -1,3 +1,4 @@
// Package observability provides logging and metrics instrumentation.
package observability
import (

View File

@@ -1,14 +1,15 @@
// Package handler provides the HTTP handler for playback endpoints.
package handler
import (
"context"
"fmt"
"io"
"mal/internal/domain"
"mal/internal/server"
"mal/pkg/net/limits"
"mal/pkg/net/proxytransport"
"mal/pkg/net/useragent"
netutil "mal/pkg/net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -18,19 +19,19 @@ import (
type PlaybackHandler struct {
svc domain.PlaybackService
animeSvc domain.AnimeService
animeSvc domain.AnimePlaybackService
proxyClient *http.Client
streamingClient *http.Client
subtitleCache *subtitleCache
}
func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimeService) *PlaybackHandler {
func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimePlaybackService) *PlaybackHandler {
return &PlaybackHandler{
svc: svc,
animeSvc: animeSvc,
proxyClient: proxytransport.NewClient(),
streamingClient: proxytransport.NewStreamingClient(),
proxyClient: netutil.NewClient(),
streamingClient: netutil.NewStreamingClient(),
subtitleCache: newSubtitleCache(10*time.Minute, 256),
}
}
@@ -52,11 +53,8 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
ep := c.DefaultQuery("ep", "1")
mode := c.DefaultQuery("mode", "sub")
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
user := server.CurrentUser(c)
userID := server.CurrentUserID(c)
data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID)
if err != nil {
@@ -66,7 +64,7 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
Anime: anime,
Episodes: []domain.CanonicalEpisode{},
CurrentPath: c.Request.URL.Path,
User: currentUser(user),
User: user,
CurrentEpID: ep,
WatchData: domain.WatchData{
Episodes: []domain.CanonicalEpisode{},
@@ -76,7 +74,7 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
return
}
data.User = currentUser(user)
data.User = user
data.CurrentPath = c.Request.URL.Path
c.HTML(http.StatusOK, "watch.gohtml", data)
@@ -99,11 +97,7 @@ func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
mode := c.DefaultQuery("mode", "sub")
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
userID := server.CurrentUserID(c)
data, err := h.svc.BuildWatchData(c.Request.Context(), animeID, []string{}, episode, mode, userID)
if err != nil {
@@ -142,19 +136,8 @@ func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
})
}
func currentUser(value any) *domain.User {
if user, ok := value.(*domain.User); ok {
return user
}
return nil
}
func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
userID := server.CurrentUserID(c)
if userID == "" {
// Avoid spamming 500s for anonymous playback; progress is user-scoped.
server.RespondHTMLOrJSONError(c, http.StatusUnauthorized, "unauthorized")
@@ -190,10 +173,10 @@ func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
}
func (h *PlaybackHandler) HandleWatchComplete(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
userID := server.CurrentUserID(c)
if userID == "" {
server.RespondHTMLOrJSONError(c, http.StatusUnauthorized, "unauthorized")
return
}
var req struct {
@@ -224,13 +207,9 @@ func (h *PlaybackHandler) HandleWatchComplete(c *gin.Context) {
}
func (h *PlaybackHandler) HandleUpsertSkipSegment(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
userID := server.CurrentUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "login required"})
server.RespondHTMLOrJSONError(c, http.StatusUnauthorized, "unauthorized")
return
}
@@ -304,33 +283,22 @@ func (h *PlaybackHandler) HandleEpisodeThumbnails(c *gin.Context) {
}
func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) {
token := c.Query("token")
if token == "" {
c.Status(http.StatusBadRequest)
targetURL, referer, ok := h.resolveProxyRequestTarget(c, "stream")
if !ok {
return
}
targetURL, referer, err := h.svc.ResolveProxyToken(token)
if err != nil {
c.Status(http.StatusForbidden)
return
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, targetURL, nil)
req, err := newProxyRequest(c.Request.Context(), targetURL, referer)
if err != nil {
c.Status(http.StatusBadGateway)
return
}
if referer != "" {
req.Header.Set("Referer", referer)
}
if rangeHeader := c.GetHeader("Range"); rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
if ifRangeHeader := c.GetHeader("If-Range"); ifRangeHeader != "" {
req.Header.Set("If-Range", ifRangeHeader)
}
req.Header.Set("User-Agent", useragent.Firefox121)
resp, err := h.streamingClient.Do(req)
if err != nil {
@@ -339,11 +307,117 @@ func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) {
}
defer func() { _ = resp.Body.Close() }()
if isHLSPlaylistResponse(targetURL, resp.Header) {
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
if err != nil {
c.Status(http.StatusBadGateway)
return
}
rewritten, err := h.rewriteHLSPlaylist(string(body), targetURL, referer)
if err != nil {
c.Status(http.StatusBadGateway)
return
}
copyProxyHeaders(c.Writer.Header(), resp.Header)
c.Writer.Header().Del("Content-Length")
c.Data(resp.StatusCode, "application/vnd.apple.mpegurl", []byte(rewritten))
return
}
copyProxyHeaders(c.Writer.Header(), resp.Header)
c.Status(resp.StatusCode)
_, _ = io.Copy(c.Writer, resp.Body)
}
func isHLSPlaylistResponse(targetURL string, headers http.Header) bool {
contentType := strings.ToLower(headers.Get("Content-Type"))
if strings.Contains(contentType, "mpegurl") || strings.Contains(contentType, "x-mpegurl") {
return true
}
parsed, err := url.Parse(targetURL)
if err != nil {
return strings.Contains(strings.ToLower(targetURL), ".m3u8")
}
return strings.Contains(strings.ToLower(parsed.Path), ".m3u8")
}
func (h *PlaybackHandler) rewriteHLSPlaylist(body string, playlistURL string, referer string) (string, error) {
baseURL, err := url.Parse(playlistURL)
if err != nil {
return "", err
}
lines := strings.SplitAfter(body, "\n")
var out strings.Builder
for _, line := range lines {
lineBody := strings.TrimSuffix(line, "\n")
newline := ""
if strings.HasSuffix(line, "\n") {
newline = "\n"
lineBody = strings.TrimSuffix(lineBody, "\r")
if strings.HasSuffix(line, "\r\n") {
newline = "\r\n"
}
}
trimmed := strings.TrimSpace(lineBody)
rewritten := lineBody
if trimmed != "" {
if strings.HasPrefix(trimmed, "#") {
rewritten, err = h.rewriteHLSQuotedURIs(lineBody, baseURL, referer)
} else {
rewritten, err = h.proxyPlaylistURI(trimmed, baseURL, referer)
}
if err != nil {
return "", err
}
}
out.WriteString(rewritten)
out.WriteString(newline)
}
return out.String(), nil
}
func (h *PlaybackHandler) rewriteHLSQuotedURIs(line string, baseURL *url.URL, referer string) (string, error) {
const marker = `URI="`
var out strings.Builder
remaining := line
for {
idx := strings.Index(remaining, marker)
if idx < 0 {
out.WriteString(remaining)
return out.String(), nil
}
out.WriteString(remaining[:idx+len(marker)])
remaining = remaining[idx+len(marker):]
end := strings.Index(remaining, `"`)
if end < 0 {
out.WriteString(remaining)
return out.String(), nil
}
proxied, err := h.proxyPlaylistURI(remaining[:end], baseURL, referer)
if err != nil {
return "", err
}
out.WriteString(proxied)
remaining = remaining[end:]
}
}
func (h *PlaybackHandler) proxyPlaylistURI(rawURI string, baseURL *url.URL, referer string) (string, error) {
target, err := baseURL.Parse(rawURI)
if err != nil {
return "", err
}
token, err := h.svc.SignProxyToken(target.String(), referer, "stream")
if err != nil {
return "", err
}
return "/watch/proxy/stream?token=" + url.QueryEscape(token), nil
}
func copyProxyHeaders(dst http.Header, src http.Header) {
// Skip hop-by-hop headers; see RFC 7230 section 6.1.
// We intentionally preserve multi-value headers by copying the full slice.
@@ -359,16 +433,39 @@ func copyProxyHeaders(dst http.Header, src http.Header) {
}
}
func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
func (h *PlaybackHandler) resolveProxyRequestTarget(c *gin.Context, scope string) (string, string, bool) {
token := c.Query("token")
if token == "" {
c.Status(http.StatusBadRequest)
return
return "", "", false
}
targetURL, referer, err := h.svc.ResolveProxyToken(token)
targetURL, referer, err := h.svc.ResolveProxyToken(token, scope)
if err != nil {
c.Status(http.StatusForbidden)
return "", "", false
}
return targetURL, referer, true
}
func newProxyRequest(ctx context.Context, targetURL string, referer string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
return nil, err
}
if referer != "" {
req.Header.Set("Referer", referer)
}
req.Header.Set("User-Agent", netutil.Firefox121)
return req, nil
}
func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
targetURL, referer, ok := h.resolveProxyRequestTarget(c, "subtitle")
if !ok {
return
}
@@ -377,15 +474,11 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
return
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, targetURL, nil)
req, err := newProxyRequest(c.Request.Context(), targetURL, referer)
if err != nil {
c.Status(http.StatusBadGateway)
return
}
if referer != "" {
req.Header.Set("Referer", referer)
}
req.Header.Set("User-Agent", useragent.Firefox121)
resp, err := h.proxyClient.Do(req)
if err != nil {
@@ -394,7 +487,7 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, limits.MiB2))
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
if err != nil {
c.Status(http.StatusBadGateway)
return

View File

@@ -0,0 +1,83 @@
package handler
import (
"context"
"fmt"
"mal/internal/domain"
"strings"
"testing"
)
type rewritePlaybackService struct {
targets []string
}
func (s *rewritePlaybackService) BuildWatchData(context.Context, int, []string, string, string, string) (domain.WatchPageData, error) {
return domain.WatchPageData{}, nil
}
func (s *rewritePlaybackService) SaveProgress(context.Context, string, int64, int, float64) error {
return nil
}
func (s *rewritePlaybackService) CompleteAnime(context.Context, string, int64) error {
return nil
}
func (s *rewritePlaybackService) SignProxyToken(targetURL, _ string, _ string) (string, error) {
s.targets = append(s.targets, targetURL)
return fmt.Sprintf("token-%d", len(s.targets)), nil
}
func (s *rewritePlaybackService) ResolveProxyToken(string, string) (string, string, error) {
return "", "", nil
}
func (s *rewritePlaybackService) UpsertSkipSegmentOverride(context.Context, string, int64, int, string, float64, float64) error {
return nil
}
func TestRewriteHLSPlaylistProxiesSegmentAndKeyURIs(t *testing.T) {
svc := &rewritePlaybackService{}
h := &PlaybackHandler{svc: svc}
body := strings.Join([]string{
"#EXTM3U",
`#EXT-X-KEY:METHOD=AES-128,URI="keys/key.bin"`,
"#EXTINF:4.0,",
"segments/seg-1.ts",
"#EXTINF:4.0,",
"https://cdn.example.test/video/seg-2.ts",
"",
}, "\n")
got, err := h.rewriteHLSPlaylist(body, "https://origin.example.test/hls/master/index.m3u8", "https://referer.example.test")
if err != nil {
t.Fatalf("rewriteHLSPlaylist returned error: %v", err)
}
if strings.Contains(got, "origin.example.test") || strings.Contains(got, "cdn.example.test") || strings.Contains(got, "keys/key.bin") || strings.Contains(got, "segments/seg-1.ts") {
t.Fatalf("rewritten playlist leaked upstream data:\n%s", got)
}
for _, token := range []string{"token-1", "token-2", "token-3"} {
if !strings.Contains(got, "/watch/proxy/stream?token="+token) {
t.Fatalf("rewritten playlist missing %s:\n%s", token, got)
}
}
wantTargets := []string{
"https://origin.example.test/hls/master/keys/key.bin",
"https://origin.example.test/hls/master/segments/seg-1.ts",
"https://cdn.example.test/video/seg-2.ts",
}
if strings.Join(svc.targets, "\n") != strings.Join(wantTargets, "\n") {
t.Fatalf("targets = %#v, want %#v", svc.targets, wantTargets)
}
}
func TestIsHLSPlaylistResponse(t *testing.T) {
if !isHLSPlaylistResponse("https://example.test/master.m3u8?token=abc", nil) {
t.Fatal("expected .m3u8 URL to be treated as playlist")
}
}

View File

@@ -6,26 +6,24 @@ import (
"mal/internal/config"
"mal/internal/domain"
"mal/internal/playback/handler"
"mal/internal/playback/repository"
"mal/internal/playback/service"
"mal/internal/server"
"go.uber.org/fx"
)
func provideProxyTokenKey(cfg config.Config) service.ProxyTokenKey {
return service.ProxyTokenKey(cfg.PlaybackProxySecret)
func provideProxyTokenKey(cfg config.Config) ProxyTokenKey {
return ProxyTokenKey(cfg.PlaybackProxySecret)
}
var Module = fx.Options(
fx.Provide(
repository.NewPlaybackRepository,
NewPlaybackRepository,
fx.Annotate(
func(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client, episodeSvc domain.EpisodeService, auditSvc domain.AuditService, proxyTokenKey service.ProxyTokenKey) domain.PlaybackService {
return service.NewPlaybackService(repo, providers, jikan, episodeSvc, auditSvc, proxyTokenKey)
func(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client, episodeSvc domain.EpisodeService, auditSvc domain.AuditService, proxyTokenKey ProxyTokenKey) domain.PlaybackService {
return NewPlaybackService(repo, providers, jikan, episodeSvc, auditSvc, proxyTokenKey)
},
),
func(svc domain.PlaybackService, animeSvc domain.AnimeService) *handler.PlaybackHandler {
func(svc domain.PlaybackService, animeSvc domain.AnimePlaybackService) *handler.PlaybackHandler {
return handler.NewPlaybackHandler(svc, animeSvc)
},
),

View File

@@ -1,17 +1,26 @@
package repository
package playback
import (
"context"
"database/sql"
"mal/internal/db"
"mal/internal/dbtx"
"mal/internal/domain"
)
type playbackRepository struct {
sqlDB *sql.DB
queries *db.Queries
}
func NewPlaybackRepository(queries *db.Queries) domain.PlaybackRepository {
return &playbackRepository{queries: queries}
func NewPlaybackRepository(sqlDB *sql.DB, queries *db.Queries) domain.PlaybackRepository {
return &playbackRepository{sqlDB: sqlDB, queries: queries}
}
func (r *playbackRepository) InTx(ctx context.Context, fn func(ctx context.Context, repo domain.PlaybackRepository) error) error {
return dbtx.Run(ctx, r.sqlDB, domain.PlaybackRepository(r), func(tx *sql.Tx) domain.PlaybackRepository {
return &playbackRepository{sqlDB: nil, queries: r.queries.WithTx(tx)}
}, fn)
}
func (r *playbackRepository) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) {

View File

@@ -1,9 +1,9 @@
package service
// Package playback manages video playback, including episode sources and subtitle management.
package playback
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
@@ -13,13 +13,13 @@ import (
"mal/internal/db"
"mal/internal/domain"
"mal/internal/observability"
"mal/pkg/net/limits"
"mal/pkg/net/useragent"
netutil "mal/pkg/net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
@@ -32,16 +32,70 @@ type playbackService struct {
episodes domain.EpisodeService
httpClient *http.Client
proxyTokenKey string
proxyTokens *proxyTokenStore
auditSvc domain.AuditService
}
type ProxyTokenKey string
type proxyTokenPayload struct {
TargetURL string `json:"u"`
Referer string `json:"r,omitempty"`
Scope string `json:"s"`
ExpiresAt int64 `json:"exp"`
type proxyTokenTarget struct {
targetURL string
referer string
scope string
expiresAt time.Time
}
type proxyTokenStore struct {
mu sync.Mutex
tokens map[string]proxyTokenTarget
}
func newProxyTokenStore() *proxyTokenStore {
return &proxyTokenStore{
tokens: make(map[string]proxyTokenTarget),
}
}
func (s *proxyTokenStore) create(targetURL, referer, scope string, ttl time.Duration, now time.Time) (string, error) {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", fmt.Errorf("generate proxy token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
s.mu.Lock()
defer s.mu.Unlock()
s.pruneExpiredLocked(now)
s.tokens[token] = proxyTokenTarget{
targetURL: targetURL,
referer: referer,
scope: scope,
expiresAt: now.Add(ttl),
}
return token, nil
}
func (s *proxyTokenStore) resolve(token string, now time.Time) (proxyTokenTarget, error) {
s.mu.Lock()
defer s.mu.Unlock()
target, ok := s.tokens[token]
if !ok {
return proxyTokenTarget{}, fmt.Errorf("invalid proxy token")
}
if !target.expiresAt.After(now) {
delete(s.tokens, token)
return proxyTokenTarget{}, fmt.Errorf("proxy token expired")
}
return target, nil
}
func (s *proxyTokenStore) pruneExpiredLocked(now time.Time) {
for token, target := range s.tokens {
if !target.expiresAt.After(now) {
delete(s.tokens, token)
}
}
}
func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client, episodes domain.EpisodeService, auditSvc domain.AuditService, proxyTokenKey ProxyTokenKey) domain.PlaybackService {
@@ -53,6 +107,7 @@ func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provi
auditSvc: auditSvc,
httpClient: &http.Client{Timeout: 10 * time.Second},
proxyTokenKey: string(proxyTokenKey),
proxyTokens: newProxyTokenStore(),
}
}
@@ -60,66 +115,21 @@ func (s *playbackService) SignProxyToken(targetURL, referer, scope string) (stri
if s.proxyTokenKey == "" {
return "", nil
}
payload := proxyTokenPayload{
TargetURL: targetURL,
Referer: referer,
Scope: scope,
ExpiresAt: time.Now().Add(2 * time.Hour).Unix(),
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, []byte(s.proxyTokenKey))
if _, err := mac.Write(body); err != nil {
return "", fmt.Errorf("sign proxy token: %w", err)
}
signature := mac.Sum(nil)
encodedBody := base64.RawURLEncoding.EncodeToString(body)
encodedSignature := base64.RawURLEncoding.EncodeToString(signature)
return encodedBody + "." + encodedSignature, nil
return s.proxyTokens.create(targetURL, referer, scope, 2*time.Hour, time.Now())
}
func (s *playbackService) VerifyProxyToken(token string) (proxyTokenPayload, error) {
func (s *playbackService) ResolveProxyToken(token string, scope string) (string, string, error) {
if s.proxyTokenKey == "" {
return proxyTokenPayload{}, fmt.Errorf("proxy token key not configured")
return "", "", fmt.Errorf("proxy token key not configured")
}
parts := strings.Split(token, ".")
if len(parts) != 2 {
return proxyTokenPayload{}, fmt.Errorf("invalid token format")
}
body, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return proxyTokenPayload{}, err
}
decodedSig, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return proxyTokenPayload{}, fmt.Errorf("invalid signature encoding: %w", err)
}
mac := hmac.New(sha256.New, []byte(s.proxyTokenKey))
if _, err := mac.Write(body); err != nil {
return proxyTokenPayload{}, fmt.Errorf("verify proxy token: %w", err)
}
expectedSig := mac.Sum(nil)
if !hmac.Equal(expectedSig, decodedSig) {
return proxyTokenPayload{}, fmt.Errorf("invalid signature")
}
var payload proxyTokenPayload
if err := json.Unmarshal(body, &payload); err != nil {
return proxyTokenPayload{}, err
}
if payload.ExpiresAt < time.Now().Unix() {
return proxyTokenPayload{}, fmt.Errorf("token expired")
}
return payload, nil
}
func (s *playbackService) ResolveProxyToken(token string) (string, string, error) {
payload, err := s.VerifyProxyToken(token)
target, err := s.proxyTokens.resolve(token, time.Now())
if err != nil {
return "", "", err
}
return payload.TargetURL, payload.Referer, nil
if target.scope != scope {
return "", "", fmt.Errorf("invalid proxy token scope")
}
return target.targetURL, target.referer, nil
}
func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (domain.WatchPageData, error) {
@@ -181,8 +191,6 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
streamToken, _ := s.SignProxyToken(res.URL, res.Referer, "stream")
modeSources[m] = domain.ModeSource{
URL: res.URL,
Referer: res.Referer,
Token: streamToken,
Subtitles: subItems,
}
@@ -216,6 +224,8 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
watchlistIDs = []int64{entry.AnimeID}
if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode {
startTime = entry.CurrentTimeSeconds
} else if anime.Episodes > 0 && episode == strconv.Itoa(anime.Episodes) && entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 == int64(anime.Episodes) {
startTime = entry.CurrentTimeSeconds
}
}
@@ -225,8 +235,12 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
UserID: userID,
AnimeID: int64(animeID),
})
if err == nil && cwEntry.CurrentEpisode.Valid && strconv.FormatInt(cwEntry.CurrentEpisode.Int64, 10) == episode {
startTime = cwEntry.CurrentTimeSeconds
if err == nil {
if cwEntry.CurrentEpisode.Valid && strconv.FormatInt(cwEntry.CurrentEpisode.Int64, 10) == episode {
startTime = cwEntry.CurrentTimeSeconds
} else if anime.Episodes > 0 && episode == strconv.Itoa(anime.Episodes) && cwEntry.CurrentEpisode.Valid && cwEntry.CurrentEpisode.Int64 == int64(anime.Episodes) {
startTime = cwEntry.CurrentTimeSeconds
}
}
}
}
@@ -235,7 +249,6 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
streams := []domain.ProviderStream{
{
Name: "Primary",
URL: result.URL,
Quality: "Auto",
MalID: animeID,
IsCurrent: true,
@@ -287,6 +300,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
return modes
}(),
Segments: segments,
Airing: anime.Airing,
}
return domain.WatchPageData{
@@ -301,38 +315,30 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
}
func (s *playbackService) CompleteAnime(ctx context.Context, userID string, animeID int64) error {
entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{
UserID: userID,
AnimeID: animeID,
})
if err != nil || entry.Status != "completed" {
_, err = s.repo.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{
ID: uuid.New().String(),
UserID: userID,
AnimeID: animeID,
Status: "completed",
CurrentEpisode: sql.NullInt64{Valid: false},
CurrentTimeSeconds: 0,
if err := s.repo.InTx(ctx, func(txCtx context.Context, repo domain.PlaybackRepository) error {
entry, err := repo.GetWatchListEntry(txCtx, db.GetWatchListEntryParams{
UserID: userID,
AnimeID: animeID,
})
if err != nil {
return err
if err != nil || entry.Status != "completed" {
_, err = repo.UpsertWatchListEntry(txCtx, db.UpsertWatchListEntryParams{
ID: uuid.New().String(),
UserID: userID,
AnimeID: animeID,
Status: "completed",
CurrentEpisode: entry.CurrentEpisode,
CurrentTimeSeconds: entry.CurrentTimeSeconds,
})
if err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
if err := s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{
UserID: userID,
AnimeID: animeID,
}); err != nil {
return err
}
if err := s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{
UserID: userID,
AnimeID: animeID,
CurrentEpisode: sql.NullInt64{Valid: false},
CurrentTimeSeconds: 0,
}); err != nil {
return err
}
if err := s.auditSvc.Record(ctx, domain.AuditEvent{
UserID: userID,
Action: "watch_completed",
@@ -383,6 +389,12 @@ func (s *playbackService) SaveProgress(ctx context.Context, userID string, anime
ResourceID: strconv.FormatInt(animeID, 10),
})
}
observability.Info("watch_progress_saved", "playback", "", map[string]any{
"anime_id": animeID,
"episode": episode,
"time_seconds": timeSeconds,
"user_id": userID,
})
return nil
}
@@ -430,11 +442,11 @@ func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string,
endpoint := fmt.Sprintf("https://api.aniskip.com/v1/skip-times/%s/%s?types=op&types=ed", url.PathEscape(strconv.Itoa(malID)), url.PathEscape(episode))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err == nil {
req.Header.Set("User-Agent", useragent.Generic)
req.Header.Set("User-Agent", netutil.Generic)
if resp, err := s.httpClient.Do(req); err == nil {
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusOK {
if body, err := io.ReadAll(io.LimitReader(resp.Body, limits.KiB512)); err == nil {
if body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.KiB512)); err == nil {
type resultItem struct {
SkipType string `json:"skip_type"`
Interval struct {
@@ -528,7 +540,7 @@ func (s *playbackService) warmStreamURL(targetURL, referer string) {
if referer != "" {
req.Header.Set("Referer", referer)
}
req.Header.Set("User-Agent", useragent.Firefox121)
req.Header.Set("User-Agent", netutil.Firefox121)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

View File

@@ -1,3 +1,4 @@
// Package server provides the HTTP server, routing, and middleware setup.
package server
import (

26
internal/server/user.go Normal file
View File

@@ -0,0 +1,26 @@
package server
import (
"mal/internal/domain"
"github.com/gin-gonic/gin"
)
func CurrentUser(c *gin.Context) *domain.User {
if c == nil {
return nil
}
user, _ := c.Get("User")
if u, ok := user.(*domain.User); ok {
return u
}
return nil
}
func CurrentUserID(c *gin.Context) string {
u := CurrentUser(c)
if u == nil {
return ""
}
return u.ID
}

View File

@@ -1,4 +1,5 @@
package handler
// Package watchlist manages user watchlist entries and related operations.
package watchlist
import (
"mal/internal/domain"
@@ -25,11 +26,7 @@ func (h *WatchlistHandler) Register(r *gin.Engine) {
}
func (h *WatchlistHandler) HandleUpdateWatchlist(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
userID := server.CurrentUserID(c)
var body struct {
AnimeID int64 `json:"animeId"`
@@ -58,20 +55,14 @@ func (h *WatchlistHandler) HandleUpdateWatchlist(c *gin.Context) {
}
func (h *WatchlistHandler) HandleDeleteWatchlist(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
userID := server.CurrentUserID(c)
animeID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || animeID <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
animeID, ok := parseAnimeIDParam(c)
if !ok {
return
}
err = h.svc.RemoveEntry(c.Request.Context(), userID, animeID)
err := h.svc.RemoveEntry(c.Request.Context(), userID, animeID)
if err != nil {
server.RespondError(
c,
@@ -89,20 +80,14 @@ func (h *WatchlistHandler) HandleDeleteWatchlist(c *gin.Context) {
}
func (h *WatchlistHandler) HandleDeleteContinueWatching(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
userID := server.CurrentUserID(c)
animeID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || animeID <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
animeID, ok := parseAnimeIDParam(c)
if !ok {
return
}
err = h.svc.DeleteContinueWatching(c.Request.Context(), userID, animeID)
err := h.svc.DeleteContinueWatching(c.Request.Context(), userID, animeID)
if err != nil {
server.RespondError(
c,
@@ -119,13 +104,20 @@ func (h *WatchlistHandler) HandleDeleteContinueWatching(c *gin.Context) {
c.Status(http.StatusOK)
}
func (h *WatchlistHandler) HandleGetWatchlist(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
func parseAnimeIDParam(c *gin.Context) (int64, bool) {
animeID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || animeID <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
return 0, false
}
return animeID, true
}
func (h *WatchlistHandler) HandleGetWatchlist(c *gin.Context) {
user := server.CurrentUser(c)
userID := server.CurrentUserID(c)
entries, err := h.svc.GetWatchlist(c.Request.Context(), userID)
if err != nil {
server.RespondError(

View File

@@ -2,21 +2,18 @@ package watchlist
import (
"mal/internal/server"
"mal/internal/watchlist/handler"
"mal/internal/watchlist/repository"
"mal/internal/watchlist/service"
"go.uber.org/fx"
)
var Module = fx.Options(
fx.Provide(
repository.NewWatchlistRepository,
service.NewWatchlistService,
handler.NewWatchlistHandler,
NewWatchlistRepository,
NewWatchlistService,
NewWatchlistHandler,
),
fx.Provide(
server.AsRouteRegister(func(h *handler.WatchlistHandler) server.RouteRegister {
server.AsRouteRegister(func(h *WatchlistHandler) server.RouteRegister {
return h
}),
),

View File

@@ -1,17 +1,26 @@
package repository
package watchlist
import (
"context"
"database/sql"
"mal/internal/db"
"mal/internal/dbtx"
"mal/internal/domain"
)
type watchlistRepository struct {
sqlDB *sql.DB
queries *db.Queries
}
func NewWatchlistRepository(queries *db.Queries) domain.WatchlistRepository {
return &watchlistRepository{queries: queries}
func NewWatchlistRepository(sqlDB *sql.DB, queries *db.Queries) domain.WatchlistRepository {
return &watchlistRepository{sqlDB: sqlDB, queries: queries}
}
func (r *watchlistRepository) InTx(ctx context.Context, fn func(ctx context.Context, repo domain.WatchlistRepository) error) error {
return dbtx.Run(ctx, r.sqlDB, domain.WatchlistRepository(r), func(tx *sql.Tx) domain.WatchlistRepository {
return &watchlistRepository{sqlDB: nil, queries: r.queries.WithTx(tx)}
}, fn)
}
func (r *watchlistRepository) UpsertAnime(ctx context.Context, arg db.UpsertAnimeParams) (db.Anime, error) {

View File

@@ -1,4 +1,4 @@
package service
package watchlist
import (
"context"
@@ -20,31 +20,48 @@ func NewWatchlistService(repo domain.WatchlistRepository, jikan *jikan.Client) d
}
func (s *watchlistService) UpdateEntry(ctx context.Context, userID string, animeID int64, status string) error {
_, err := s.repo.GetAnime(ctx, animeID)
if err != nil {
anime, err := s.jikan.GetAnimeByID(ctx, int(animeID))
if err != nil {
return err
}
if _, err := s.repo.UpsertAnime(ctx, db.UpsertAnimeParams{
ID: int64(anime.MalID),
TitleOriginal: anime.Title,
TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""},
TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""},
ImageUrl: anime.ImageURL(),
Airing: sql.NullBool{Bool: anime.Airing, Valid: true},
}); err != nil {
return err
}
anime, fetchErr := s.jikan.GetAnimeByID(ctx, int(animeID))
if fetchErr != nil {
// still allow status updates for already-known anime rows
anime = jikan.Anime{}
}
_, err = s.repo.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{
ID: uuid.New().String(),
UserID: userID,
AnimeID: animeID,
Status: status,
return s.repo.InTx(ctx, func(txCtx context.Context, repo domain.WatchlistRepository) error {
_, err := repo.GetAnime(txCtx, animeID)
if err != nil && fetchErr == nil {
durationSeconds := anime.DurationSeconds()
duration := sql.NullFloat64{Valid: durationSeconds > 0}
if duration.Valid {
duration.Float64 = durationSeconds
}
if _, err := repo.UpsertAnime(txCtx, db.UpsertAnimeParams{
ID: int64(anime.MalID),
TitleOriginal: anime.Title,
TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""},
TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""},
ImageUrl: anime.ImageURL(),
Airing: sql.NullBool{Bool: anime.Airing, Valid: true},
DurationSeconds: duration,
}); err != nil {
return err
}
}
existing, _ := repo.GetWatchListEntry(txCtx, db.GetWatchListEntryParams{
UserID: userID,
AnimeID: animeID,
})
_, err = repo.UpsertWatchListEntry(txCtx, db.UpsertWatchListEntryParams{
ID: uuid.New().String(),
UserID: userID,
AnimeID: animeID,
Status: status,
CurrentEpisode: existing.CurrentEpisode,
CurrentTimeSeconds: existing.CurrentTimeSeconds,
})
return err
})
return err
}
func (s *watchlistService) RemoveEntry(ctx context.Context, userID string, animeID int64) error {
@@ -99,16 +116,18 @@ func (s *watchlistService) GetContinueWatchingEntry(ctx context.Context, userID
}
func (s *watchlistService) DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error {
if err := s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{
UserID: userID,
AnimeID: animeID,
}); err != nil {
return err
}
return s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{
UserID: userID,
AnimeID: animeID,
CurrentEpisode: sql.NullInt64{Valid: false},
CurrentTimeSeconds: 0,
return s.repo.InTx(ctx, func(txCtx context.Context, repo domain.WatchlistRepository) error {
if err := repo.DeleteContinueWatchingEntry(txCtx, db.DeleteContinueWatchingEntryParams{
UserID: userID,
AnimeID: animeID,
}); err != nil {
return err
}
return repo.SaveWatchProgress(txCtx, db.SaveWatchProgressParams{
UserID: userID,
AnimeID: animeID,
CurrentEpisode: sql.NullInt64{Valid: false},
CurrentTimeSeconds: 0,
})
})
}

View File

@@ -23,7 +23,8 @@ build-css:
bunx @tailwindcss/cli -i ./static/assets/style.css -o ./dist/tailwind.css
build-ts:
bun build ./static/player/main.ts --outdir ./dist/static/player --target browser --splitting && bun build ./static/*.ts --outdir ./dist/static --target browser
bun build ./static/player/main.ts --outdir ./dist/static/player --target browser --splitting
bun build ./static/*.ts --outdir ./dist/static --target browser --root ./static --entry-naming "[name].js"
build: build-go build-css build-ts

View File

@@ -1,23 +1,18 @@
{
'$schema': 'https://json.schemastore.org/lefthook.json',
'pre-commit':
"$schema": "https://json.schemastore.org/lefthook.json",
"pre-commit":
{
'commands':
"commands":
{
'prettier': { 'run': 'bunx prettier . --write' },
'eslint': { 'run': 'bunx eslint . --fix' },
},
},
'pre-push':
{
'commands':
{
'go-fmt': { 'run': 'go fmt ./...' },
'go-lint': { 'run': 'bun run lint:go' },
'go-test': { 'run': 'go test ./...' },
'ts-typecheck': { 'run': 'bunx tsc -p tsconfig.json --noEmit' },
'build-assets': { 'run': 'bun run build:assets' },
'go-build': { 'run': 'go build -o server ./cmd/server' },
"format": { "run": "bunx oxfmt" },
"lint:ts":
{ "run": "bunx oxlint --ignore-path .oxlintignore static --max-warnings 0 --fix" },
"go-fmt": { "run": "go fmt ./..." },
"go-lint": { "run": "bun run lint:go" },
"go-test": { "run": "go test ./..." },
"ts-typecheck": { "run": "bunx tsc -p tsconfig.json --noEmit" },
"build-assets": { "run": "bun run build:assets" },
"go-build": { "run": "go build -o server ./cmd/server" },
},
},
}

View File

@@ -5,28 +5,28 @@
"scripts": {
"build:css": "bunx @tailwindcss/cli -i ./static/assets/style.css -o ./dist/tailwind.css",
"watch:css": "bunx @tailwindcss/cli -i ./static/assets/style.css -o ./dist/tailwind.css --watch",
"build:ts": "bun build ./static/player/main.ts --outdir ./dist/static/player --target browser --splitting && bun build ./static/*.ts --outdir ./dist/static --target browser --root ./static --entry-naming \"[name].js\"",
"build:ts": "bun build ./static/player/main.ts --outdir ./dist/static/player --target browser --splitting && bun build ./static/*.ts --outdir ./dist/static --target browser --root ./static --entry-naming \"[name].js\" && cp ./node_modules/htmx.org/dist/htmx.min.js ./dist/static/htmx-lib.js",
"typecheck": "bunx tsc -p tsconfig.json --noEmit",
"build:assets": "bun run build:css && bun run build:ts",
"format": "bunx prettier . --write",
"format": "bunx oxfmt",
"format:check": "bunx oxfmt --check",
"lint": "bun run lint:ts && bun run lint:go",
"lint:ts": "bunx eslint . --max-warnings 0",
"lint:ts:fix": "bunx eslint . --fix",
"lint:ts": "bunx oxlint --ignore-path .oxlintignore static --tsconfig ./tsconfig.json --type-aware --max-warnings 0",
"lint:ts:fix": "bunx oxlint --ignore-path .oxlintignore static --tsconfig ./tsconfig.json --type-aware --max-warnings 0 --fix",
"lint:go": "golangci-lint run ./..."
},
"dependencies": {
"htmx.org": "1.9.12"
},
"devDependencies": {
"@tailwindcss/cli": "^4.2.4",
"@tailwindcss/cli": "^4.3.0",
"@types/node": "^24.0.0",
"@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",
"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"
},
"dependencies": {}
}
}

80
pkg/graphql.go Normal file
View File

@@ -0,0 +1,80 @@
// Package graphql provides a GraphQL client for the AniList API.
package graphql
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
type Error struct {
Message string `json:"message"`
}
type Response[T any] struct {
Data T `json:"data"`
Errors []Error `json:"errors"`
}
type PostOptions struct {
Headers map[string]string
BodyMax int64
}
func Post[T any](ctx context.Context, httpClient *http.Client, url string, query string, variables any, opts PostOptions) (T, error) {
var zero T
payload := map[string]any{
"query": query,
"variables": variables,
}
body, err := json.Marshal(payload)
if err != nil {
return zero, fmt.Errorf("graphql: marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return zero, fmt.Errorf("graphql: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
for k, v := range opts.Headers {
req.Header.Set(k, v)
}
resp, err := httpClient.Do(req)
if err != nil {
return zero, fmt.Errorf("graphql: execute request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
max := opts.BodyMax
if max <= 0 {
max = 2 << 20
}
respBody, err := io.ReadAll(io.LimitReader(resp.Body, max))
if err != nil {
return zero, fmt.Errorf("graphql: read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return zero, fmt.Errorf("graphql: status %d", resp.StatusCode)
}
var parsed Response[T]
if err := json.Unmarshal(respBody, &parsed); err != nil {
return zero, fmt.Errorf("graphql: decode response: %w", err)
}
if len(parsed.Errors) > 0 {
return zero, fmt.Errorf("graphql: %s", parsed.Errors[0].Message)
}
return parsed.Data, nil
}

49
pkg/net/document.go Normal file
View File

@@ -0,0 +1,49 @@
package netutil
import (
"context"
"fmt"
"io"
"net/http"
"github.com/PuerkitoBio/goquery"
)
func FetchHTMLDocument(
ctx context.Context,
httpClient *http.Client,
url string,
prepareRequest func(*http.Request),
buildStatusError func(*http.Response, []byte) error,
) (*goquery.Document, *http.Response, error) {
client := httpClient
if client == nil {
client = http.DefaultClient
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", err)
}
if prepareRequest != nil {
prepareRequest(request)
}
response, err := client.Do(request)
if err != nil {
return nil, nil, fmt.Errorf("request failed: %w", err)
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(response.Body, Bytes512))
return nil, response, buildStatusError(response, body)
}
document, err := goquery.NewDocumentFromReader(response.Body)
if err != nil {
return nil, response, fmt.Errorf("failed to parse html: %w", err)
}
return document, response, nil
}

11
pkg/net/headers.go Normal file
View File

@@ -0,0 +1,11 @@
package netutil
import "net/http"
func SetBrowserHTMLHeaders(request *http.Request, referer string) {
request.Header.Set("User-Agent", 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", referer)
request.Header.Set("Cache-Control", "no-cache")
}

View File

@@ -1,4 +1,5 @@
package limits
// Package netutil provides HTTP networking utilities including rate limiting and proxy support.
package netutil
// Common size limits used when reading upstream responses.

View File

@@ -1,4 +1,4 @@
package proxytransport
package netutil
import (
"context"

View File

@@ -1,4 +1,4 @@
package useragent
package netutil
// Keep these centralized so we don't end up with many drifting UA strings.

View File

@@ -1,4 +1,4 @@
package utls
package netutil
import (
"bufio"

View File

@@ -1,17 +1,17 @@
import { mkdir, writeFile, access } from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import path from 'node:path';
import { mkdir, writeFile, access } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import path from "node:path";
function toSlug(raw: string): string {
const trimmed = raw.trim().toLowerCase();
const slug = trimmed.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
const slug = trimmed.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
return slug;
}
function formatYYYYMMDD(date: Date): string {
const year = String(date.getFullYear());
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}${month}${day}`;
}
@@ -25,14 +25,14 @@ async function fileExists(filePath: string): Promise<boolean> {
}
async function main(): Promise<void> {
const rawName = process.argv[2] ?? '';
const rawName = process.argv[2] ?? "";
const slug = toSlug(rawName);
if (slug.length === 0) {
throw new Error('usage: bun scripts/new-data-fix.ts <name>');
throw new Error("usage: bun scripts/new-data-fix.ts <name>");
}
const id = `${formatYYYYMMDD(new Date())}_${slug}`;
const dir = path.join(process.cwd(), 'internal', 'database', 'fixes');
const dir = path.join(process.cwd(), "internal", "database", "fixes");
const filePath = path.join(dir, `${id}.go`);
await mkdir(dir, { recursive: true });
@@ -62,7 +62,7 @@ func init() {
}
`;
await writeFile(filePath, contents, { encoding: 'utf8' });
await writeFile(filePath, contents, { encoding: "utf8" });
process.stdout.write(`${filePath}\n`);
}

View File

@@ -1,12 +1,12 @@
version: '2'
version: "2"
sql:
- engine: 'sqlite'
queries: 'internal/db/queries.sql'
schema: 'internal/database/migrations/'
- engine: "sqlite"
queries: "internal/db/queries.sql"
schema: "internal/database/migrations/"
gen:
go:
package: 'db'
out: 'internal/db'
package: "db"
out: "internal/db"
emit_json_tags: true
emit_prepared_queries: false
emit_interface: true

View File

@@ -1,84 +1,80 @@
import { parseClassList } from './utils';
import { closestFocusable, onReady } from "./utils";
const initSynopsisToggle = (): void => {
document.addEventListener('click', e => {
const target = e.target;
document.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const btn = target.closest<HTMLButtonElement>('[data-synopsis-toggle]');
if (!btn) return;
const container = document.getElementById('synopsis-container');
const button = target.closest<HTMLButtonElement>("[data-synopsis-toggle]");
if (!button) return;
const section = button.closest("section");
const container = section?.querySelector<HTMLElement>("[data-synopsis-container]");
if (!container) return;
const isClamped = container.classList.contains('line-clamp-6');
if (isClamped) {
container.classList.remove('line-clamp-6');
btn.textContent = 'Show less';
return;
}
container.classList.add('line-clamp-6');
btn.textContent = 'Read more';
const isClamped = container.classList.contains("line-clamp-6");
container.classList.toggle("line-clamp-6", !isClamped);
button.textContent = isClamped ? "Read more" : "Show less";
});
};
const initThemesDialog = (): void => {
onReady(() => {
const dialog = document.querySelector<HTMLElement>("[data-themes-dialog]");
const openButton = document.querySelector<HTMLButtonElement>("[data-themes-open]");
const closeButton = document.querySelector<HTMLButtonElement>("[data-themes-close]");
const content = document.querySelector<HTMLElement>("[data-themes-content]");
const loader = document.querySelector<HTMLElement>("[data-themes-loader]");
if (!dialog || !openButton || !content || !loader) return;
let themesRequested = false;
let lastFocused: HTMLElement | null = null;
const open = (): void => {
lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
dialog.classList.remove("hidden");
dialog.classList.add("flex");
dialog.setAttribute("aria-hidden", "false");
closestFocusable(dialog)?.focus();
if (themesRequested) return;
themesRequested = true;
content.textContent = "Loading theme songs...";
const htmxApi = (
window as Window & { htmx?: { trigger: (target: Element, name: string) => void } }
).htmx;
htmxApi?.trigger(document.body, "theme-songs:load");
};
const close = (): void => {
dialog.classList.add("hidden");
dialog.classList.remove("flex");
dialog.setAttribute("aria-hidden", "true");
lastFocused?.focus();
};
openButton.addEventListener("click", open);
closeButton?.addEventListener("click", close);
dialog.addEventListener("click", (event) => {
if (event.target === dialog) {
close();
}
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
if (dialog.classList.contains("hidden")) return;
event.preventDefault();
close();
});
loader.addEventListener("htmx:responseError", () => {
themesRequested = false;
});
loader.addEventListener("htmx:sendError", () => {
themesRequested = false;
});
});
};
initSynopsisToggle();
const setDropdownMenuState = (menu: HTMLElement, isOpen: boolean): void => {
// data attributes store the class lists to add/remove
const openClasses = parseClassList(menu.getAttribute('data-dropdown-open-classes'));
const closedClasses = parseClassList(menu.getAttribute('data-dropdown-closed-classes'));
if (isOpen) {
menu.classList.remove(...closedClasses);
menu.classList.add(...openClasses);
return;
}
menu.classList.remove(...openClasses);
menu.classList.add(...closedClasses);
};
const setWatchlistDropdownState = (isOpen: boolean): void => {
const dropdown = document.getElementById('watchlist-dropdown');
if (!dropdown) {
return;
}
dropdown.classList.toggle('open', isOpen);
const menu = dropdown.querySelector('[data-dropdown-menu]');
if (menu instanceof HTMLElement) {
setDropdownMenuState(menu, isOpen);
}
};
const toggleWatchlistDropdown = (): void => {
const dropdown = document.getElementById('watchlist-dropdown');
if (!dropdown) {
return;
}
setWatchlistDropdownState(!dropdown.classList.contains('open'));
};
const closeDropdownOnOutsideClick = (event: MouseEvent): void => {
const dropdown = document.getElementById('watchlist-dropdown');
if (!dropdown) {
return;
}
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (!dropdown.contains(target)) {
setWatchlistDropdownState(false);
}
};
const initWatchlistDropdown = (): void => {
(window as Window & { toggleDropdown?: () => void }).toggleDropdown = toggleWatchlistDropdown;
document.addEventListener('click', closeDropdownOnOutsideClick);
};
initWatchlistDropdown();
initThemesDialog();

16
static/app.ts Normal file
View File

@@ -0,0 +1,16 @@
import "./theme";
import "./toast";
import "./htmx";
import "./dropdown";
import "./discover";
import "./anime";
import "./timezone";
import "./search";
import "./sort_filter";
import "./dedupe";
import "./shell";
import "./watchlist";
import "./top_pick_carousel";
import "./continue_watching_carousel";
import "./login";
import "./schedule";

View File

@@ -1,4 +1,4 @@
@import 'tailwindcss';
@import "tailwindcss";
@source "../../templates/**/*.gohtml";
@source "../**/*.ts";
@@ -17,6 +17,8 @@
:root {
color-scheme: light dark;
--skeleton-base: light-dark(#e5e5e5, #1f1f1f);
--skeleton-highlight: light-dark(#d4d4d4, #2a2a2a);
--bg: var(--color-background);
--panel: light-dark(#f7f7f7, #181818);
--panel-soft: light-dark(#ececec, #202020);
@@ -33,6 +35,8 @@
--surface-select: light-dark(#ffffff, #181818);
--text-on-accent: light-dark(#fafaf9, #0c0a09);
--overlay-subtle: light-dark(rgba(0, 0, 0, 0.04), rgba(255, 255, 255, 0.04));
--border: light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.07));
--border-light: light-dark(rgba(0, 0, 0, 0.04), rgba(255, 255, 255, 0.035));
--shadow-subtle: light-dark(0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18));
--shadow-card: light-dark(0 2px 8px rgba(0, 0, 0, 0.04), 0 2px 10px rgba(0, 0, 0, 0.28));
--shadow-card-hover: light-dark(0 6px 18px rgba(0, 0, 0, 0.06), 0 6px 20px rgba(0, 0, 0, 0.34));
@@ -47,24 +51,57 @@
--space-6: 1.5rem;
--space-8: 2rem;
--poster-max-height: 360px;
--font: 'DM Sans', 'Segoe UI', system-ui, sans-serif;
--font-serif: 'Newsreader', ui-serif, Georgia, serif;
--font-mono: ui-monospace, 'SF Mono', 'Geist Mono', 'JetBrains Mono', monospace;
--font: "DM Sans", "Segoe UI", system-ui, sans-serif;
--font-serif: "Newsreader", ui-serif, Georgia, serif;
--font-mono: ui-monospace, "SF Mono", "Geist Mono", "JetBrains Mono", monospace;
--radius: 0px;
}
html[data-theme="light"] {
color-scheme: light;
}
html[data-theme="dark"] {
color-scheme: dark;
}
html,
body {
background-color: var(--color-background);
color: var(--text);
}
.skeleton {
background: linear-gradient(
90deg,
var(--skeleton-base) 25%,
var(--skeleton-highlight) 50%,
var(--skeleton-base) 75%
);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
}
.skeleton-subtle {
opacity: 0.7;
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
[data-watchlist-toggle] .watchlist-icon,
[data-watchlist-toggle] .watchlist-icon path {
fill: none;
}
[data-watchlist-toggle][data-watchlist-state='in'] .watchlist-icon,
[data-watchlist-toggle][data-watchlist-state='in'] .watchlist-icon path {
[data-watchlist-toggle][data-watchlist-state="in"] .watchlist-icon,
[data-watchlist-toggle][data-watchlist-state="in"] .watchlist-icon path {
fill: currentColor;
}

View File

@@ -0,0 +1,152 @@
import { onHtmxLoad, onReady } from "./utils";
const carouselScrollEpsilon = 2;
const fallbackCarouselOverlap = 96;
const itemOverlapRatio = 0.45;
const minimumArrowItems = 5;
type ContinueWatchingCarousel = {
track: HTMLElement;
previous: HTMLButtonElement;
next: HTMLButtonElement;
previousFade: HTMLElement;
nextFade: HTMLElement;
};
const getContinueWatchingCarousel = (root: HTMLElement): ContinueWatchingCarousel | null => {
const track = root.querySelector<HTMLElement>("[data-continue-watching-track]");
const previous = root.querySelector<HTMLButtonElement>("[data-continue-watching-prev]");
const next = root.querySelector<HTMLButtonElement>("[data-continue-watching-next]");
const previousFade = root.querySelector<HTMLElement>("[data-continue-watching-prev-fade]");
const nextFade = root.querySelector<HTMLElement>("[data-continue-watching-next-fade]");
if (!track || !previous || !next || !previousFade || !nextFade) {
return null;
}
return { track, previous, next, previousFade, nextFade };
};
const continueWatchingCarousels = (root: ParentNode = document): HTMLElement[] =>
Array.from(root.querySelectorAll<HTMLElement>("[data-continue-watching-carousel]"));
const maxScrollLeft = (track: HTMLElement): number =>
Math.max(0, track.scrollWidth - track.clientWidth);
const setControlState = (button: HTMLButtonElement, fade: HTMLElement, visible: boolean): void => {
button.classList.toggle("hidden", !visible);
button.classList.toggle("inline-flex", visible);
button.setAttribute("aria-hidden", String(!visible));
button.tabIndex = visible ? 0 : -1;
fade.classList.toggle("hidden", !visible);
};
const updateContinueWatchingCarousel = (root: HTMLElement): void => {
const carousel = getContinueWatchingCarousel(root);
if (!carousel) {
return;
}
const items = carousel.track.querySelectorAll("[data-continue-watching-item]");
const maxScroll = maxScrollLeft(carousel.track);
const canScroll = maxScroll > carouselScrollEpsilon;
const allowArrows = canScroll && items.length >= minimumArrowItems;
const hasPrevious = allowArrows && carousel.track.scrollLeft > carouselScrollEpsilon;
const hasNext = allowArrows && carousel.track.scrollLeft < maxScroll - carouselScrollEpsilon;
setControlState(carousel.previous, carousel.previousFade, hasPrevious);
setControlState(carousel.next, carousel.nextFade, hasNext);
};
const updateContinueWatchingCarousels = (root: ParentNode = document): void => {
continueWatchingCarousels(root).forEach(updateContinueWatchingCarousel);
};
const carouselScrollAmount = (track: HTMLElement): number => {
const firstItem = track.querySelector<HTMLElement>("[data-continue-watching-item]");
if (!firstItem) {
return Math.max(160, track.clientWidth - fallbackCarouselOverlap);
}
const itemWidth = firstItem.getBoundingClientRect().width;
const overlap = Math.max(fallbackCarouselOverlap, itemWidth * itemOverlapRatio);
return Math.max(itemWidth, track.clientWidth - Math.min(itemWidth, overlap));
};
const scrollContinueWatchingCarousel = (root: HTMLElement, direction: -1 | 1): void => {
const carousel = getContinueWatchingCarousel(root);
if (!carousel) {
return;
}
const currentScroll = carousel.track.scrollLeft;
const targetScroll =
direction < 0
? Math.max(0, currentScroll - carouselScrollAmount(carousel.track))
: Math.min(
maxScrollLeft(carousel.track),
currentScroll + carouselScrollAmount(carousel.track),
);
carousel.track.scrollTo({
left: targetScroll,
behavior: "smooth",
});
window.setTimeout(() => updateContinueWatchingCarousel(root), 350);
};
document.addEventListener(
"click",
(event: MouseEvent): void => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const previous = target.closest("[data-continue-watching-prev]");
if (previous) {
event.preventDefault();
event.stopPropagation();
const root = previous.closest<HTMLElement>("[data-continue-watching-carousel]");
if (root) {
scrollContinueWatchingCarousel(root, -1);
}
return;
}
const next = target.closest("[data-continue-watching-next]");
if (!next) {
return;
}
event.preventDefault();
event.stopPropagation();
const root = next.closest<HTMLElement>("[data-continue-watching-carousel]");
if (root) {
scrollContinueWatchingCarousel(root, 1);
}
},
true,
);
document.addEventListener(
"scroll",
(event: Event): void => {
const target = event.target;
if (!(target instanceof HTMLElement) || !target.matches("[data-continue-watching-track]")) {
return;
}
const root = target.closest<HTMLElement>("[data-continue-watching-carousel]");
if (root) {
updateContinueWatchingCarousel(root);
}
},
true,
);
onReady(() => updateContinueWatchingCarousels());
onHtmxLoad((root) => updateContinueWatchingCarousels(root));
window.addEventListener("resize", () => updateContinueWatchingCarousels());

View File

@@ -1,26 +1,65 @@
const dedupe = (): void => {
const seen = new Set<string>();
const elements = document.querySelectorAll('[data-id]');
import { onHtmxLoad, onReady } from "./utils";
elements.forEach(item => {
const id = item.getAttribute('data-id');
const dedupeWithin = (root: ParentNode): void => {
const seen = new Set<string>();
const elements = root.querySelectorAll<HTMLElement>(":scope > [data-id]");
elements.forEach((item) => {
const id = item.dataset.id;
if (!id) {
return;
}
if (seen.has(id)) {
item.remove(); // duplicate, remove it
} else {
seen.add(id);
item.remove();
return;
}
seen.add(id);
});
};
// run on DOM ready or immediately if already loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', dedupe);
} else {
dedupe();
}
const dedupe = (root: ParentNode = document): void => {
const containers = new Set<ParentNode>();
const elements = root.querySelectorAll<HTMLElement>("[data-id]");
// also run on load as a fallback (e.g. htmx swaps)
window.addEventListener('load', dedupe);
elements.forEach((item) => {
if (item.parentElement) {
containers.add(item.parentElement);
}
});
containers.forEach((container) => {
dedupeWithin(container);
});
};
const dedupeSwapTarget = (target: EventTarget | null): void => {
if (!(target instanceof HTMLElement)) {
return;
}
if (target.matches("[data-id]")) {
const parent = target.parentElement;
if (parent) {
dedupeWithin(parent);
}
return;
}
const containers = new Set<ParentNode>();
const elements = target.querySelectorAll<HTMLElement>("[data-id]");
elements.forEach((item) => {
if (item.parentElement) {
containers.add(item.parentElement);
}
});
containers.forEach((container) => {
dedupeWithin(container);
});
};
onReady(() => dedupe());
window.addEventListener("load", () => dedupe());
onHtmxLoad((root) => dedupeSwapTarget(root));

View File

@@ -1,4 +1,4 @@
import { parseClassList } from './utils';
import { parseClassList } from "./utils";
const setActiveDiscoverTab = (clickedTab: Element): void => {
const group = clickedTab.closest('[data-tab-group="discover"]');
@@ -7,17 +7,17 @@ const setActiveDiscoverTab = (clickedTab: Element): void => {
}
// reset all tabs in group
const triggers = group.querySelectorAll('[data-tab-trigger]');
triggers.forEach(tab => {
const activeClasses = parseClassList(tab.getAttribute('data-tab-active-classes'));
const inactiveClasses = parseClassList(tab.getAttribute('data-tab-inactive-classes'));
const triggers = group.querySelectorAll("[data-tab-trigger]");
triggers.forEach((tab) => {
const activeClasses = parseClassList(tab.getAttribute("data-tab-active-classes"));
const inactiveClasses = parseClassList(tab.getAttribute("data-tab-inactive-classes"));
tab.classList.remove(...activeClasses);
tab.classList.add(...inactiveClasses);
});
// mark clicked tab as active
const activeClasses = parseClassList(clickedTab.getAttribute('data-tab-active-classes'));
const inactiveClasses = parseClassList(clickedTab.getAttribute('data-tab-inactive-classes'));
const activeClasses = parseClassList(clickedTab.getAttribute("data-tab-active-classes"));
const inactiveClasses = parseClassList(clickedTab.getAttribute("data-tab-inactive-classes"));
clickedTab.classList.remove(...inactiveClasses);
clickedTab.classList.add(...activeClasses);
};
@@ -28,7 +28,7 @@ const onDiscoverTabClick = (event: MouseEvent): void => {
return;
}
const trigger = target.closest('[data-tab-trigger]');
const trigger = target.closest("[data-tab-trigger]");
if (!trigger) {
return;
}
@@ -37,7 +37,7 @@ const onDiscoverTabClick = (event: MouseEvent): void => {
};
const initDiscoverTabs = (): void => {
document.addEventListener('click', onDiscoverTabClick);
document.addEventListener("click", onDiscoverTabClick);
};
initDiscoverTabs();
@@ -48,46 +48,46 @@ const initSurpriseMe = (): void => {
const onClick = async (): Promise<void> => {
if (isFetchingRandom) return;
const btn = document.getElementById('surprise-btn') as HTMLButtonElement | null;
const btn = document.getElementById("surprise-btn") as HTMLButtonElement | null;
if (!btn) return;
isFetchingRandom = true;
const spinner = document.getElementById('surprise-spinner');
const text = document.getElementById('surprise-text');
const icon = document.getElementById('surprise-icon');
const spinner = document.getElementById("surprise-spinner");
const text = document.getElementById("surprise-text");
const icon = document.getElementById("surprise-icon");
btn.disabled = true;
spinner?.classList.remove('hidden');
icon?.classList.add('hidden');
if (text) text.textContent = 'Picking…';
spinner?.classList.remove("hidden");
icon?.classList.add("hidden");
if (text) text.textContent = "Picking…";
try {
const res = await fetch(`/api/jikan/random/anime?t=${Date.now()}`, { cache: 'no-store' });
if (!res.ok) throw new Error('Failed to fetch random anime');
const res = await fetch(`/api/jikan/random/anime?t=${Date.now()}`, { cache: "no-store" });
if (!res.ok) throw new Error("Failed to fetch random anime");
const json = (await res.json()) as unknown;
const data = (json as { data?: unknown }).data as { mal_id?: unknown } | undefined;
const malId = typeof data?.mal_id === 'number' ? data.mal_id : 0;
const malId = typeof data?.mal_id === "number" ? data.mal_id : 0;
if (malId > 0) {
window.location.href = `/anime/${malId}`;
return;
}
throw new Error('Random anime missing mal_id');
throw new Error("Random anime missing mal_id");
} catch (error) {
console.error(error);
alert('Could not pick a random anime right now. Please try again.');
alert("Could not pick a random anime right now. Please try again.");
} finally {
isFetchingRandom = false;
btn.disabled = false;
spinner?.classList.add('hidden');
icon?.classList.remove('hidden');
if (text) text.textContent = 'Surprise Me';
spinner?.classList.add("hidden");
icon?.classList.remove("hidden");
if (text) text.textContent = "Surprise Me";
}
};
document.addEventListener('click', e => {
document.addEventListener("click", (e) => {
const target = e.target;
if (!(target instanceof Element)) return;
const surprise = target.closest('[data-surprise-me]');
const surprise = target.closest("[data-surprise-me]");
if (!surprise) return;
void onClick();
});

View File

@@ -1,88 +1,173 @@
import { closestFocusable, onHtmxLoad } from "./utils";
class UIDropdown extends HTMLElement {
isOpen = false;
triggerEl: HTMLElement | null = null;
contentEl: HTMLElement | null = null;
isClosing = false; // debounce flag
previouslyFocused: HTMLElement | null = null;
constructor() {
super();
this.toggle = this.toggle.bind(this);
this.handleClickOutside = this.handleClickOutside.bind(this);
this.onTriggerClick = this.onTriggerClick.bind(this);
this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.handleKeydown = this.handleKeydown.bind(this);
}
connectedCallback(): void {
const trigger = this.querySelector('[data-trigger]');
this.contentEl = this.querySelector('[data-content]');
this.triggerEl = this.querySelector("[data-trigger]");
this.contentEl = this.querySelector("[data-content]");
if (trigger) {
trigger.addEventListener('click', this.toggle);
if (this.contentEl) {
this.contentEl.classList.add("hidden");
this.contentEl.setAttribute("aria-hidden", "true");
}
document.addEventListener('click', this.handleClickOutside);
const triggerButton = this.triggerButton();
triggerButton?.setAttribute("aria-expanded", "false");
this.triggerEl?.addEventListener("click", this.onTriggerClick);
document.addEventListener("click", this.handleDocumentClick);
document.addEventListener("keydown", this.handleKeydown);
}
disconnectedCallback(): void {
const trigger = this.querySelector('[data-trigger]');
if (trigger) {
trigger.removeEventListener('click', this.toggle);
this.triggerEl?.removeEventListener("click", this.onTriggerClick);
document.removeEventListener("click", this.handleDocumentClick);
document.removeEventListener("keydown", this.handleKeydown);
}
triggerButton(): HTMLButtonElement | null {
const button = this.triggerEl?.querySelector("button");
return button instanceof HTMLButtonElement ? button : null;
}
open(): void {
if (!this.contentEl || this.isOpen) return;
document.querySelectorAll<UIDropdown>("ui-dropdown").forEach((dropdown) => {
if (dropdown !== this) {
dropdown.close();
}
});
this.isOpen = true;
this.previouslyFocused =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
this.contentEl.classList.remove("hidden");
this.contentEl.setAttribute("aria-hidden", "false");
this.triggerButton()?.setAttribute("aria-expanded", "true");
closestFocusable(this.contentEl)?.focus();
}
close(options: { restoreFocus?: boolean } = {}): void {
if (!this.contentEl || !this.isOpen) return;
this.isOpen = false;
this.contentEl.classList.add("hidden");
this.contentEl.setAttribute("aria-hidden", "true");
this.triggerButton()?.setAttribute("aria-expanded", "false");
if (options.restoreFocus !== false) {
this.previouslyFocused?.focus();
}
document.removeEventListener('click', this.handleClickOutside);
}
toggle(): void {
if (this.isClosing) {
return;
}
this.isOpen = !this.isOpen;
if (this.contentEl) {
if (this.isOpen) {
this.contentEl.classList.remove('hidden');
} else {
this.contentEl.classList.add('hidden');
}
}
}
close(): void {
if (this.isClosing) {
return;
}
this.isClosing = true;
this.isOpen = false;
if (this.contentEl) {
this.contentEl.classList.add('hidden');
}
setTimeout(() => {
this.isClosing = false;
}, 100); // delay prevents rapid open/close flicker
}
handleClickOutside(event: MouseEvent): void {
if (!this.contains(event.target as Node)) {
if (this.isOpen) {
this.close();
return;
}
this.open();
}
onTriggerClick(event: Event): void {
event.preventDefault();
this.toggle();
}
handleDocumentClick(event: MouseEvent): void {
if (!this.isOpen) return;
if (!(event.target instanceof Node)) return;
if (this.contains(event.target)) return;
this.close({ restoreFocus: false });
}
handleKeydown(event: KeyboardEvent): void {
if (!this.isOpen) return;
if (event.key !== "Escape") return;
event.preventDefault();
this.close();
}
}
customElements.define('ui-dropdown', UIDropdown);
customElements.define("ui-dropdown", UIDropdown);
const initStudioDropdown = (): void => {
document.addEventListener('click', e => {
document.addEventListener("click", (e) => {
const target = e.target;
if (!(target instanceof Element)) return;
const btn = target.closest<HTMLButtonElement>('button[data-studio-select]');
const btn = target.closest<HTMLButtonElement>("button[data-studio-select]");
if (!btn) return;
const input = document.getElementById('studio-input') as HTMLInputElement | null;
const form = document.getElementById('browse-search-form') as HTMLFormElement | null;
if (!input || !form) return;
const input = document.getElementById("studio-input");
const form = document.getElementById("browse-search-form");
if (!(input instanceof HTMLInputElement) || !(form instanceof HTMLFormElement)) return;
input.value = btn.dataset.studioSelect ?? '';
input.value = btn.dataset.studioSelect ?? "";
form.requestSubmit();
const dropdown = btn.closest('ui-dropdown') as { close?: () => void } | null;
dropdown?.close?.();
const dropdown = btn.closest("ui-dropdown");
if (dropdown instanceof UIDropdown) {
dropdown.close({ restoreFocus: false });
}
});
};
const initCheckboxVisuals = (): void => {
const syncCheckboxVisual = (input: HTMLInputElement): void => {
const box = input.nextElementSibling;
if (!(box instanceof HTMLElement)) return;
const icon = box.querySelector("svg");
icon?.classList.toggle("hidden", !input.checked);
if (input.matches("[data-genre-visual]")) {
box.classList.toggle("border-accent", input.checked);
box.classList.toggle("bg-foreground-muted/12", input.checked);
box.classList.toggle("border-white/45", !input.checked);
box.classList.toggle("bg-transparent", !input.checked);
return;
}
if (input.matches("[data-sfw-checkbox]")) {
box.classList.toggle("border-accent", input.checked);
box.classList.toggle("bg-foreground-muted/12", input.checked);
box.classList.toggle("border-white/45", !input.checked);
box.classList.toggle("bg-transparent", !input.checked);
const value = input.form?.querySelector<HTMLInputElement>("[data-sfw-value]");
if (value) {
value.value = String(input.checked);
}
}
};
document.addEventListener("change", (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) return;
if (!target.matches("[data-checkbox-visual], [data-sfw-checkbox], [data-genre-visual]")) {
return;
}
syncCheckboxVisual(target);
});
onHtmxLoad((root) => {
root
.querySelectorAll<HTMLInputElement>(
"[data-checkbox-visual], [data-sfw-checkbox], [data-genre-visual]",
)
.forEach(syncCheckboxVisual);
});
};
initStudioDropdown();
initCheckboxVisuals();

View File

@@ -1,10 +1,12 @@
export {};
import { onReady } from "./utils";
type ToastFn = (opts: { message: string; duration?: number }) => void;
const getToast = (): ToastFn | null => {
const anyWindow = window as unknown as { showToast?: ToastFn };
return typeof anyWindow.showToast === 'function' ? anyWindow.showToast : null;
return typeof anyWindow.showToast === "function" ? anyWindow.showToast : null;
};
const toast = (message: string): void => {
@@ -13,15 +15,15 @@ const toast = (message: string): void => {
const setBusy = (el: Element | null, busy: boolean): void => {
if (!(el instanceof HTMLElement)) return;
el.toggleAttribute('aria-busy', busy);
el.dataset.htmxLoading = busy ? 'true' : 'false';
el.toggleAttribute("aria-busy", busy);
el.dataset.htmxLoading = busy ? "true" : "false";
if (el instanceof HTMLButtonElement) {
el.disabled = busy;
}
if (busy) {
el.dataset.htmxBusy = 'true';
el.dataset.htmxBusy = "true";
return;
}
@@ -33,39 +35,38 @@ const getTriggerFromHtmxEvent = (event: Event): Element | null => {
return detail.detail?.elt ?? null;
};
const onReady = (fn: () => void): void => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn, { once: true });
return;
}
fn();
};
onReady(() => {
document.addEventListener('htmx:beforeRequest', event => {
document.addEventListener("htmx:beforeRequest", (event) => {
setBusy(getTriggerFromHtmxEvent(event), true);
});
document.addEventListener('htmx:afterRequest', event => {
document.addEventListener("htmx:afterRequest", (event) => {
setBusy(getTriggerFromHtmxEvent(event), false);
const remaining = document.querySelectorAll('.continue-watching-item').length;
const remaining = document.querySelectorAll(".continue-watching-item").length;
if (remaining !== 0) return;
const section = document.getElementById('continue-watching-section');
const section = document.getElementById("continue-watching-section");
section?.remove();
});
document.addEventListener('htmx:responseError', () => {
toast('Something went wrong');
document.addEventListener("htmx:responseError", () => {
toast("Something went wrong");
});
document.addEventListener('htmx:sendError', () => {
toast('Network error');
document.addEventListener("htmx:afterSwap", (event) => {
const detail = event as CustomEvent<{ target?: EventTarget | null }>;
const target = detail.detail?.target;
if (!(target instanceof HTMLElement)) return;
if (!target.classList.contains("error")) return;
toast("Failed to load content");
});
document.addEventListener('htmx:timeout', () => {
toast('Request timed out');
document.addEventListener("htmx:sendError", () => {
toast("Network error");
});
document.addEventListener("htmx:timeout", () => {
toast("Request timed out");
});
});

22
static/login.ts Normal file
View File

@@ -0,0 +1,22 @@
const initPasswordToggle = (): void => {
document.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const button = target.closest<HTMLButtonElement>("[data-toggle-password]");
if (!button) return;
const field = button.closest("form")?.querySelector<HTMLInputElement>("#password");
const openEye = button.querySelector<SVGElement>("[data-eye-open]");
const closedEye = button.querySelector<SVGElement>("[data-eye-closed]");
if (!(field instanceof HTMLInputElement) || !openEye || !closedEye) return;
const showPassword = field.type === "password";
field.type = showPassword ? "text" : "password";
button.setAttribute("aria-label", showPassword ? "Hide password" : "Show password");
openEye.classList.toggle("hidden", showPassword);
closedEye.classList.toggle("hidden", !showPassword);
});
};
initPasswordToggle();

View File

@@ -1,23 +1,23 @@
import { state } from './state';
import { saveProgress } from './progress';
import { safeLocalStorage } from './storage';
import { state } from "./state";
import { saveProgress } from "./progress";
import { safeLocalStorage } from "./storage";
export const formatTime = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
if (!Number.isFinite(seconds) || seconds < 0) return "00:00";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
};
/**
* Shows the controls overlay and schedules auto-hide after 2s if playing.
*/
export const showControls = (): void => {
state.container.classList.add('show-controls');
state.container.classList.add("show-controls");
window.clearTimeout(state.playerControlsTimeout);
state.playerControlsTimeout = window.setTimeout(() => {
if (!state.isScrubbing && !state.video.paused) {
state.container.classList.remove('show-controls');
state.container.classList.remove("show-controls");
}
}, 2000);
};
@@ -27,7 +27,7 @@ export const seekBy = (delta: number): void => {
if (state.video.duration <= 0) return;
state.video.currentTime = Math.max(
0,
Math.min(state.video.duration, state.video.currentTime + delta)
Math.min(state.video.duration, state.video.currentTime + delta),
);
showControls();
};
@@ -73,13 +73,13 @@ export const syncVolumeUI = (): void => {
const value = state.video.muted ? 0 : Math.round(state.video.volume * 100);
if (volumeRange) {
volumeRange.value = String(value);
volumeRange.style.setProperty('--volume-percent', `${value}%`);
volumeRange.style.setProperty("--volume-percent", `${value}%`);
}
if (volumeUnderline) volumeUnderline.style.height = `${value}%`;
updateMuteIcons(state.video.muted || state.video.volume === 0);
};
const VOLUME_STORAGE_KEY = 'player-volume';
const VOLUME_STORAGE_KEY = "player-volume";
const parseStoredVolume = (raw: string | null): number | null => {
if (!raw) return null;
@@ -132,35 +132,35 @@ const getControls = (): Controls => {
if (controlsCache) return controlsCache;
const c = state.container;
controlsCache = {
playPause: c.querySelector('[data-play-pause]'),
muteBtn: c.querySelector('[data-mute]'),
volumePanel: c.querySelector('[data-volume-panel]'),
volumeRange: c.querySelector('[data-volume-range]'),
volumeUnderline: c.querySelector('[data-volume-underline]'),
backwardBtn: c.querySelector('[data-backward]'),
forwardBtn: c.querySelector('[data-forward]'),
fullscreenBtn: c.querySelector('[data-fullscreen]'),
iconPlay: c.querySelector('[data-icon-play]'),
iconPause: c.querySelector('[data-icon-pause]'),
iconVolume: c.querySelector('[data-icon-volume]'),
iconMuted: c.querySelector('[data-icon-muted]'),
skipSegmentBtn: c.querySelector('[data-skip]'),
subtitleText: c.querySelector('[data-subtitle-text]'),
autoplayBtn: document.querySelector('[data-autoplay]'),
playPause: c.querySelector("[data-play-pause]"),
muteBtn: c.querySelector("[data-mute]"),
volumePanel: c.querySelector("[data-volume-panel]"),
volumeRange: c.querySelector("[data-volume-range]"),
volumeUnderline: c.querySelector("[data-volume-underline]"),
backwardBtn: c.querySelector("[data-backward]"),
forwardBtn: c.querySelector("[data-forward]"),
fullscreenBtn: c.querySelector("[data-fullscreen]"),
iconPlay: c.querySelector("[data-icon-play]"),
iconPause: c.querySelector("[data-icon-pause]"),
iconVolume: c.querySelector("[data-icon-volume]"),
iconMuted: c.querySelector("[data-icon-muted]"),
skipSegmentBtn: c.querySelector("[data-skip]"),
subtitleText: c.querySelector("[data-subtitle-text]"),
autoplayBtn: document.querySelector("[data-autoplay]"),
};
return controlsCache;
};
const updatePlayPauseIcons = (isPlaying: boolean): void => {
const { iconPlay, iconPause } = getControls();
iconPlay?.classList.toggle('hidden', isPlaying);
iconPause?.classList.toggle('hidden', !isPlaying);
iconPlay?.classList.toggle("hidden", isPlaying);
iconPause?.classList.toggle("hidden", !isPlaying);
};
const updateMuteIcons = (isMuted: boolean): void => {
const { iconVolume, iconMuted } = getControls();
iconVolume?.classList.toggle('hidden', isMuted);
iconMuted?.classList.toggle('hidden', !isMuted);
iconVolume?.classList.toggle("hidden", isMuted);
iconMuted?.classList.toggle("hidden", !isMuted);
};
/**
@@ -182,69 +182,69 @@ export const setupControls = (): void => {
} = getControls();
// play/pause on button and video click
playPause?.addEventListener('click', () => {
playPause?.addEventListener("click", () => {
togglePlayPause();
showControls();
});
state.video.addEventListener('click', () => {
state.video.addEventListener("click", () => {
togglePlayPause();
showControls();
});
muteBtn?.addEventListener('click', () => {
muteBtn?.addEventListener("click", () => {
toggleMute();
showControls();
});
// volume slider
volumeRange?.addEventListener('input', () => {
volumeRange?.addEventListener("input", () => {
const value = Number(volumeRange.value) / 100;
setVolume(value);
showControls();
});
// dragging class for visual feedback
volumeRange?.addEventListener('pointerdown', () => volumePanel?.classList.add('is-dragging'));
window.addEventListener('pointerup', () => volumePanel?.classList.remove('is-dragging'));
volumeRange?.addEventListener("pointerdown", () => volumePanel?.classList.add("is-dragging"));
window.addEventListener("pointerup", () => volumePanel?.classList.remove("is-dragging"));
backwardBtn?.addEventListener('click', () => seekBy(-10));
forwardBtn?.addEventListener('click', () => seekBy(10));
backwardBtn?.addEventListener("click", () => seekBy(-10));
forwardBtn?.addEventListener("click", () => seekBy(10));
fullscreenBtn?.addEventListener('click', () => {
fullscreenBtn?.addEventListener("click", () => {
toggleFullscreen();
showControls();
});
// skip intro/outro button
skipSegmentBtn?.addEventListener('click', () => {
skipSegmentBtn?.addEventListener("click", () => {
if (!state.activeSkipSegment) return;
state.video.currentTime = state.activeSkipSegment.end + 0.01;
showControls();
});
// fullscreen change handler
document.addEventListener('fullscreenchange', () => {
document.addEventListener("fullscreenchange", () => {
state.isFullscreen = !!document.fullscreenElement;
state.container.classList.toggle('fullscreen', state.isFullscreen);
state.container.classList.toggle("fullscreen", state.isFullscreen);
if (state.isFullscreen) showControls();
});
// icon sync on state changes
state.video.addEventListener('play', () => {
state.video.addEventListener("play", () => {
updatePlayPauseIcons(true);
showControls();
});
state.video.addEventListener('pause', () => {
state.video.addEventListener("pause", () => {
updatePlayPauseIcons(false);
showControls();
void saveProgress();
});
state.video.addEventListener('volumechange', () => {
state.video.addEventListener("volumechange", () => {
syncVolumeUI();
schedulePersistVolume();
});
// mouse move in container shows controls
state.container.addEventListener('mousemove', showControls);
state.container.addEventListener("mousemove", showControls);
// initial sync — check actual video state since inline script may have started playback
updatePlayPauseIcons(!state.video.paused);

View File

@@ -1,13 +1,13 @@
import { state } from '../state';
import { state } from "../state";
export const completeAnime = async (episodeNumber: number): Promise<void> => {
if (state.completionSent || !state.malID || !episodeNumber) return;
state.completionSent = true;
try {
const res = await fetch('/api/watch-complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
const res = await fetch("/api/watch-complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
keepalive: true,
body: JSON.stringify({ mal_id: state.malID, episode: episodeNumber }),
});
@@ -21,12 +21,12 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
return;
}
const trigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null;
const trigger = document.querySelector("[data-dropdown-trigger]") as HTMLButtonElement | null;
if (trigger) {
trigger.textContent = 'Completed ';
const caret = document.createElement('span');
caret.className = 'text-xs';
caret.textContent = '▾';
trigger.textContent = "Completed ";
const caret = document.createElement("span");
caret.className = "text-xs";
caret.textContent = "▾";
trigger.appendChild(caret);
}
} catch {

View File

@@ -1,12 +1,13 @@
import { state } from '../state';
import type { SkipSegment } from '../types';
import { resolveActiveSegments, renderSegments } from '../skip/segments';
import { updateSubtitleOptions } from '../subtitles';
import { updateQualityOptions } from '../quality';
import { updateModeButtons } from '../mode';
import { updateOverlay, isAutoplayEnabled, switchEpisodeRange } from './ui';
import { markEpisodeTransition } from '../progress';
import { safeLocalStorage } from '../storage';
import { state, showEndState, hideEndState } from "../state";
import type { SkipSegment } from "../types";
import { resolveActiveSegments, renderSegments } from "../skip/segments";
import { updateSubtitleOptions } from "../subtitles";
import { updateQualityOptions } from "../quality";
import { updateModeButtons } from "../mode";
import { updateOverlay, isAutoplayEnabled, switchEpisodeRange } from "./ui";
import { markEpisodeTransition } from "../progress";
import { safeLocalStorage } from "../storage";
import { completeAnime } from "./complete";
/**
* Handles video end: either marks complete or loads next episode.
@@ -16,28 +17,42 @@ export const goToNextEpisode = async (): Promise<void> => {
const currentEp = Number.parseInt(state.currentEpisode, 10);
if (!currentEp) return;
// final episode: trigger completion flow
const navigateToEpisode = (episode: number): void => {
const url = new URL(window.location.href);
url.searchParams.set("ep", String(episode));
window.location.href = url.toString();
};
const fallbackToEpisodeNavigation = (episode: number): void => {
sessionStorage.setItem("mal:autoplay-next", "true");
navigateToEpisode(episode);
};
// final episode: trigger completion flow or just stop if airing
if (state.totalEpisodes > 0 && currentEp >= state.totalEpisodes) {
import('./complete').then(m => m.completeAnime(currentEp));
if (!state.isAiring) {
void completeAnime(currentEp);
}
showEndState();
return;
}
// skip if autoplay disabled
if (!isAutoplayEnabled()) return;
if (!isAutoplayEnabled()) {
showEndState();
return;
}
const nextEp = currentEp + 1;
markEpisodeTransition(nextEp);
try {
const res = await fetch(
`/api/watch/episode/${state.malID}/${nextEp}?mode=${encodeURIComponent(state.currentMode)}`
`/api/watch/episode/${state.malID}/${nextEp}?mode=${encodeURIComponent(state.currentMode)}`,
);
if (!res.ok) {
// fallback: full page navigation
sessionStorage.setItem('mal:autoplay-next', 'true');
const url = new URL(window.location.href);
url.searchParams.set('ep', String(nextEp));
window.location.href = url.toString();
fallbackToEpisodeNavigation(nextEp);
return;
}
@@ -47,21 +62,22 @@ export const goToNextEpisode = async (): Promise<void> => {
state.modeSources = data.mode_sources ?? {};
state.availableModes = data.available_modes ?? [];
const backendMode = typeof data.initial_mode === 'string' ? data.initial_mode : '';
const backendMode = typeof data.initial_mode === "string" ? data.initial_mode : "";
const fallback = state.modeSources[backendMode]?.token
? backendMode
: state.availableModes.find(m => state.modeSources[m]?.token);
: state.availableModes.find((m) => state.modeSources[m]?.token);
if (!fallback) {
sessionStorage.setItem('mal:autoplay-next', 'true');
const url = new URL(window.location.href);
url.searchParams.set('ep', String(nextEp));
window.location.href = url.toString();
fallbackToEpisodeNavigation(nextEp);
return;
}
state.currentEpisode = String(nextEp);
state.currentMode = fallback;
if (data.mode_switched_from === 'dub' && fallback === 'sub') {
state.endedProgressSaved = false;
hideEndState();
if (data.mode_switched_from === "dub" && fallback === "sub") {
window.showToast?.({
message: `Episode ${nextEp} is only available in sub, switched from dub.`,
});
@@ -72,8 +88,8 @@ export const goToNextEpisode = async (): Promise<void> => {
state.container.dataset.startTimeSeconds = String(state.startTimeSeconds);
// load new video (keep preferences)
const preferredQuality = safeLocalStorage.getItem('mal:preferred-quality') || 'best';
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`;
const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best";
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}${preferredQuality !== "best" ? `&quality=${encodeURIComponent(preferredQuality)}` : ""}`;
state.video.load();
if (!state.video.paused) {
state.video.play().catch(() => undefined);
@@ -88,7 +104,7 @@ export const goToNextEpisode = async (): Promise<void> => {
updateSubtitleOptions();
updateQualityOptions();
updateModeButtons();
updateOverlay(state.currentEpisode, data.episode_title ?? '');
updateOverlay(state.currentEpisode, data.episode_title ?? "");
// update skip segments
if (data.segments?.length) {
@@ -101,28 +117,25 @@ export const goToNextEpisode = async (): Promise<void> => {
// highlight new episode in list/grid
state.episodeList
?.querySelectorAll('[data-episode-id]')
.forEach(el => el.classList.remove('bg-accent/20'));
?.querySelectorAll("[data-episode-id]")
.forEach((el) => el.classList.remove("bg-accent/20"));
const newListEl = state.episodeList?.querySelector(`[data-episode-id="${nextEp}"]`);
newListEl?.classList.add('bg-accent/20');
newListEl?.classList.add("bg-accent/20");
if (state.episodeGrid) {
state.episodeGrid.querySelectorAll('[data-episode-id]').forEach(el => {
el.classList.remove('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent');
state.episodeGrid.querySelectorAll("[data-episode-id]").forEach((el) => {
el.classList.remove("bg-accent/20", "ring-2", "ring-accent", "text-accent");
});
switchEpisodeRange(Math.floor((nextEp - 1) / 100));
const newGridEl = state.episodeGrid.querySelector(`[data-episode-id="${nextEp}"]`);
newGridEl?.classList.add('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent');
newGridEl?.classList.add("bg-accent/20", "ring-2", "ring-accent", "text-accent");
}
// update URL without reload
const url = new URL(window.location.href);
url.searchParams.set('ep', String(nextEp));
history.pushState(null, '', url.toString());
url.searchParams.set("ep", String(nextEp));
history.pushState(null, "", url.toString());
} catch {
sessionStorage.setItem('mal:autoplay-next', 'true');
const url = new URL(window.location.href);
url.searchParams.set('ep', String(nextEp));
window.location.href = url.toString();
fallbackToEpisodeNavigation(nextEp);
}
};

View File

@@ -1,4 +1,4 @@
import { state } from '../state';
import { state } from "../state";
/**
* Fetches episode thumbnails and titles from API.
@@ -9,25 +9,25 @@ export const setupThumbnails = (): void => {
if (!episodeList) return;
fetch(`/api/watch/thumbnails/${state.malID}`)
.then(res => res.json())
.then((res) => res.json())
.then((data: { mal_id: number; url: string; title?: string }[]) => {
data.forEach(item => {
data.forEach((item) => {
const card = episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`);
if (!card) return;
// inject thumbnail image
if (item.url) {
const imgContainer = card.querySelector('.relative.aspect-video');
const imgContainer = card.querySelector(".relative.aspect-video");
if (imgContainer) {
let img = imgContainer.querySelector('img');
let img = imgContainer.querySelector("img");
if (!img) {
// replace placeholder with actual image
img = document.createElement('img');
img = document.createElement("img");
img.className =
'h-full w-full object-cover transition-transform group-hover:scale-105';
img.loading = 'lazy';
"h-full w-full object-cover transition-transform group-hover:scale-105";
img.loading = "lazy";
imgContainer
.querySelector('.flex.h-full.w-full.items-center.justify-center')
.querySelector(".flex.h-full.w-full.items-center.justify-center")
?.remove();
imgContainer.prepend(img);
}
@@ -38,10 +38,10 @@ export const setupThumbnails = (): void => {
// inject title text
if (item.title) {
const titleEl = card.querySelector('[data-episode-title]');
const titleEl = card.querySelector("[data-episode-title]");
if (titleEl) titleEl.textContent = item.title;
}
});
})
.catch(err => console.error('Failed to fetch thumbnails:', err));
.catch((err) => console.error("Failed to fetch thumbnails:", err));
};

View File

@@ -1,26 +1,26 @@
import { state } from '../state';
import { qs } from '../../q';
import { safeLocalStorage } from '../storage';
import { state } from "../state";
import { qs } from "../../q";
import { safeLocalStorage } from "../storage";
/**
* Syncs autoplay checkbox with localStorage on init.
* Default is enabled (not 'false').
*/
export const setupAutoplayButton = (): void => {
const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
const btn = document.querySelector("[data-autoplay]") as HTMLInputElement | null;
if (!btn) return;
btn.checked = safeLocalStorage.getItem('mal:autoplay-enabled') !== 'false';
btn.checked = safeLocalStorage.getItem("mal:autoplay-enabled") !== "false";
};
export const isAutoplayEnabled = (): boolean =>
safeLocalStorage.getItem('mal:autoplay-enabled') !== 'false';
safeLocalStorage.getItem("mal:autoplay-enabled") !== "false";
/**
* Updates video overlay text (shown briefly on episode change).
*/
export const updateOverlay = (episode: string, title: string): void => {
if (!state.videoOverlay) return;
const p = state.videoOverlay.querySelector('p');
const p = state.videoOverlay.querySelector("p");
if (!p) return;
p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`;
};
@@ -30,8 +30,8 @@ const getEpisodeEls = () => {
const grid = state.episodeGrid;
const list = state.episodeList;
return {
gridEls: grid ? Array.from(grid.querySelectorAll('[data-episode-id]')) : [],
listEls: list ? Array.from(list.querySelectorAll('[data-episode-id]')) : [],
gridEls: grid ? Array.from(grid.querySelectorAll("[data-episode-id]")) : [],
listEls: list ? Array.from(list.querySelectorAll("[data-episode-id]")) : [],
};
};
@@ -42,17 +42,17 @@ const getEpisodeEls = () => {
export const updateEpisodeHighlight = (num: number): void => {
const { gridEls, listEls } = getEpisodeEls();
// clear old highlights
[...gridEls, ...listEls].forEach(el =>
el.classList.remove('ring-2', 'ring-accent', 'bg-accent/20', 'text-accent')
[...gridEls, ...listEls].forEach((el) =>
el.classList.remove("ring-2", "ring-accent", "bg-accent/20", "text-accent"),
);
// apply new highlight
const gridEl = state.episodeGrid?.querySelector(`[data-episode-id="${num}"]`);
const listEl = state.episodeList?.querySelector(`[data-episode-id="${num}"]`);
gridEl?.classList.add('ring-2', 'ring-accent');
listEl?.classList.add('ring-2', 'ring-accent');
gridEl?.classList.add("ring-2", "ring-accent");
listEl?.classList.add("ring-2", "ring-accent");
// scroll into view
(gridEl ?? listEl)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
(gridEl ?? listEl)?.scrollIntoView({ behavior: "smooth", block: "center" });
};
/**
@@ -60,23 +60,23 @@ export const updateEpisodeHighlight = (num: number): void => {
* Updates dropdown label and hides/shows episode cards.
*/
export const switchEpisodeRange = (idx: number): void => {
const dropdown = qs<HTMLElement>('[data-episode-dropdown]');
const dropdown = qs<HTMLElement>("[data-episode-dropdown]");
if (!dropdown) return;
const btns = Array.from(dropdown.querySelectorAll('.episode-range-btn')) as HTMLButtonElement[];
const btns = Array.from(dropdown.querySelectorAll(".episode-range-btn")) as HTMLButtonElement[];
const target = btns[idx];
if (!target) return;
const start = Number.parseInt(target.dataset.rangeStart ?? '1', 10);
const end = Number.parseInt(target.dataset.rangeEnd ?? '100', 10);
const start = Number.parseInt(target.dataset.rangeStart ?? "1", 10);
const end = Number.parseInt(target.dataset.rangeEnd ?? "100", 10);
// update label (e.g., "01-100")
const label = dropdown.querySelector('[data-dropdown-label]') as HTMLElement | null;
const label = dropdown.querySelector("[data-dropdown-label]") as HTMLElement | null;
if (label)
label.textContent = `${String(start).padStart(2, '0')}-${String(end).padStart(2, '0')}`;
label.textContent = `${String(start).padStart(2, "0")}-${String(end).padStart(2, "0")}`;
// show/hide episodes in range
state.episodeGrid?.querySelectorAll('[data-episode-id]').forEach(el => {
const n = Number.parseInt((el as HTMLElement).dataset.episodeId ?? '0', 10);
el.classList.toggle('hidden', n < start || n > end);
state.episodeGrid?.querySelectorAll("[data-episode-id]").forEach((el) => {
const n = Number.parseInt((el as HTMLElement).dataset.episodeId ?? "0", 10);
el.classList.toggle("hidden", n < start || n > end);
});
};

View File

@@ -1,5 +1,5 @@
import { state } from './state';
import { absoluteTimeFromRatio, getBounds } from './timeline';
import { state } from "./state";
import { absoluteTimeFromRatio, getBounds } from "./timeline";
import {
showControls,
toggleMute,
@@ -7,54 +7,54 @@ import {
toggleFullscreen,
seekBy,
setVolume,
} from './controls';
import { saveProgress } from './progress';
} from "./controls";
import { saveProgress } from "./progress";
/**
* Sets up keyboard shortcuts for player control.
* Ignores input/textarea to allow typing.
*/
export const setupKeyboard = (): void => {
document.addEventListener('keydown', e => {
document.addEventListener("keydown", (e) => {
const target = e.target as HTMLElement;
// ignore when typing in form fields
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)
return;
switch (e.code) {
case 'Space':
case 'KeyK':
case "Space":
case "KeyK":
e.preventDefault();
togglePlayPause();
showControls();
void saveProgress();
break;
case 'ArrowLeft':
case 'KeyJ':
case "ArrowLeft":
case "KeyJ":
e.preventDefault();
seekBy(-10);
break;
case 'ArrowRight':
case 'KeyL':
case "ArrowRight":
case "KeyL":
e.preventDefault();
seekBy(10);
break;
case 'ArrowUp':
case "ArrowUp":
e.preventDefault();
setVolume(state.video.volume + 0.05);
showControls();
break;
case 'ArrowDown':
case "ArrowDown":
e.preventDefault();
setVolume(state.video.volume - 0.05);
showControls();
break;
case 'KeyM':
case "KeyM":
e.preventDefault();
toggleMute();
showControls();
break;
case 'KeyF':
case "KeyF":
e.preventDefault();
toggleFullscreen();
showControls();

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