Compare commits

..

1 Commits

Author SHA1 Message Date
Gitea Action
33ea6935c5 chore(deploy): update image to latest 2026-05-23 01:13:32 +00:00
317 changed files with 9539 additions and 24057 deletions

View File

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

View File

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

View File

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

4
.gitignore vendored
View File

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

View File

@@ -1,97 +0,0 @@
version: "2"
linters:
default: none
enable:
- bodyclose
- copyloopvar
- cyclop
- dogsled
- dupl
- errcheck
- funlen
- gocognit
- gocritic
- gocyclo
- govet
- ineffassign
- maintidx
- makezero
- nakedret
- nilerr
- noctx
- prealloc
- predeclared
- revive
- staticcheck
- unconvert
- unparam
- unused
- usestdlibvars
- wastedassign
- whitespace
settings:
gocritic:
disable-all: true
enabled-checks:
- appendCombine
- boolExprSimplify
- commentedOutCode
- commentedOutImport
- deferUnlambda
- dupBranchBody
- dupImport
- dupSubExpr
- emptyDecl
- emptyFallthrough
- emptyStringTest
- equalFold
- redundantSprint
- regexpPattern
- stringConcatSimplify
- typeUnparen
- underef
- unlambda
- unnecessaryBlock
- unnecessaryDefer
- unslice
revive:
enable-all-rules: false
rules:
- name: blank-imports
- name: context-as-argument
- name: context-keys-type
- name: early-return
- name: error-naming
- name: error-return
- name: if-return
- name: increment-decrement
- name: range
- name: receiver-naming
- name: time-naming
- name: unnecessary-stmt
- name: var-declaration
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
- node_modules/
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

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

View File

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

View File

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

View File

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

11
.prettierrc Normal file
View File

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

View File

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

View File

@@ -5,16 +5,11 @@ WORKDIR /app
# Enable CGO for sqlite3 # Enable CGO for sqlite3
ENV CGO_ENABLED=1 ENV CGO_ENABLED=1
RUN apt-get update && apt-get install -y --no-install-recommends \ # Install sqlc for code generation
ca-certificates \ RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0
curl \
unzip \
gcc \
libc6-dev \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
# Install bun (for building frontend assets) # Install build dependencies for bun + assets
RUN apt-get update && apt-get install -y ca-certificates sqlite3 curl unzip && rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://bun.sh/install | bash RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}" ENV PATH="/root/.bun/bin:${PATH}"
@@ -29,11 +24,13 @@ RUN bun install --frozen-lockfile
COPY . . COPY . .
# Ensure dist is clean at build time (belt + suspenders) # Ensure dist is clean at build time (belt + suspenders)
RUN rm -rf dist/ && bun run build:assets && bun run build:ts RUN rm -rf dist/ && bun run build:assets
# Generate sqlc code
RUN sqlc generate
# Build the server and CLI tools # Build the server and CLI tools
RUN go build -ldflags="-s -w" -o main_server ./cmd/server RUN go build -ldflags="-s -w" -o main_server ./cmd/server
RUN go build -ldflags="-s -w" -o user_admin ./cmd/user
FROM debian:bookworm-slim FROM debian:bookworm-slim
@@ -49,15 +46,12 @@ RUN mkdir -p /app/data
ENV DATABASE_FILE=/app/data/mal.db ENV DATABASE_FILE=/app/data/mal.db
COPY --from=builder /app/main_server . COPY --from=builder /app/main_server .
COPY --from=builder /app/user_admin .
COPY --from=builder /app/templates ./templates COPY --from=builder /app/templates ./templates
COPY --from=builder /app/static ./static COPY --from=builder /app/static ./static
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY --from=builder /app/internal/database/migrations ./migrations COPY --from=builder /app/internal/database/migrations ./migrations
COPY docker/entrypoint.sh ./entrypoint.sh
RUN printf '%s\n' '#!/bin/sh' 'set -e' 'exec /app/user_admin "$@"' > /app/create-user \
&& chmod +x /app/create-user
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["/app/main_server"] ENTRYPOINT ["./entrypoint.sh"]

241
README.md
View File

@@ -1,189 +1,136 @@
# MyAnimeList # MyAnimeList
<p align="center"> <table align="center">
<img src="/static/assets/logo.png" alt="MyAnimeList logo" width="120" /> <tr>
</p> <td>
<picture>
<p align="center"> <source media="(prefers-color-scheme: dark)" srcset="/static/assets/readme-logo-dark.svg" />
<strong>A local-first anime catalog, watchlist, recommendation, and playback app.</strong> <img src="/static/assets/readme-logo-light.svg" alt="MyAnimeList logo" width="140" />
</p> </picture>
</td>
<td>
<strong>MyAnimeList</strong><br />
My personal anime tracker, built because nothing else felt right.
</td>
</tr>
</table>
<p align="center"> <p align="center">
<img alt="Go" src="https://img.shields.io/badge/go-1.25-00ADD8?style=flat-square&logo=go" /> <img alt="Go" src="https://img.shields.io/badge/go-1.25-00ADD8?style=flat-square&logo=go" />
<img alt="SQLite" src="https://img.shields.io/badge/database-sqlite-003B57?style=flat-square&logo=sqlite" /> <img alt="SQLite" src="https://img.shields.io/badge/database-sqlite-003B57?style=flat-square&logo=sqlite" />
<img alt="Bun" src="https://img.shields.io/badge/runtime-bun-000000?style=flat-square&logo=bun" /> <img alt="Tailwind" src="https://img.shields.io/badge/tailwind-4-06B6D4?style=flat-square&logo=tailwindcss" />
<img alt="Tailwind" src="https://img.shields.io/badge/tailwind-4-06D6D4?style=flat-square&logo=tailwindcss" />
<img alt="HTMX" src="https://img.shields.io/badge/htmx-partial--updates-3366CC?style=flat-square" /> <img alt="HTMX" src="https://img.shields.io/badge/htmx-partial--updates-3366CC?style=flat-square" />
<img alt="License" src="https://img.shields.io/badge/license-MIT-green?style=flat-square" />
</p> </p>
MyAnimeList is a self-hosted media app for browsing anime, managing a watchlist, resuming episodes, ---
and playing streams through a browser-based player. It collects the parts of an anime workflow that
usually live across several products and keeps them in one small Go application backed by SQLite.
I built it as a portfolio project, but the goal was never to make a disposable demo. The interesting ## Why this project exists
part of the project is the product shape: server-rendered pages, a local database, provider
integrations, playback proxying, recommendations, migrations, tests, and a TypeScript player that
only appears where browser state actually earns its place.
> [!NOTE] I built this for myself.
> This is a personal, local-first project. It is written to demonstrate product engineering choices,
> not to present itself as an official MyAnimeList client or a hosted streaming platform.
### Contents I was frustrated with the UI and UX of every tracker I tried. Even when something looked decent, it still felt awkward to use day-to-day, or it was missing pieces I considered essential. I wanted one place that matched how I actually watch anime: search fast, get context fast, update status fast, and move on.
- [What This Project Is](#what-this-project-is) So this project is personal first and public second. I put it on GitHub because I like shipping in the open, not because it was originally designed as a general-purpose product for everyone.
- [What It Includes](#what-it-includes)
- [How It Is Built](#how-it-is-built)
- [Working Locally](#working-locally)
- [Repository Map](#repository-map)
### What This Project Is Technically, I also wanted to prove that a small, server-rendered Go app could stay reliable even when upstream anime APIs are inconsistent. A lot of this code exists because real APIs rate-limit, timeout, and occasionally fail at the worst possible moment.
This project started from a simple idea: anime tracking becomes more interesting when catalog data, ## What the application offers
personal progress, and playback live in the same interface. A user should be able to discover a
title, inspect its metadata, add it to a watchlist, watch an episode, come back later, and continue
from the right place without stitching that flow together manually.
That makes the app a useful playground for real application concerns. It has authentication, For my own workflow, MyAnimeList combines catalog browsing, seasonal discovery, quick search, detail pages with recommendations and relations, watchlist management, continue-watching, and in-app playback in one server-rendered interface.
long-lived user state, external APIs, background refresh behavior, migrations, data fixes, cache
boundaries, provider-specific code, and enough frontend complexity to justify TypeScript without
turning the whole product into a single-page app.
The project is also intentionally modest. It uses a single Go server and a SQLite database because The interface is minimal and functional, featuring a dark theme and quick access to tracking tools.
those choices make the system easy to run, inspect, and reason about. The architecture is more about
clear ownership than novelty: feature packages own their handlers and services, integrations stay at
the edges, and the UI is mostly rendered by the server.
### What It Includes ## Technical approach
| Area | What it does | The application is written in Go and rendered on the server with `html/template`, with SQLite as the primary datastore and `sqlc` for typed query generation. Styling uses Tailwind CSS v4. HTMX and small TypeScript modules handle incremental interactions, which keeps the interface responsive without moving the entire product into a heavy client-side architecture.
| --------------- | ------------------------------------------------------------------------------------------------------------ |
| Catalog | Browse, search, and inspect anime metadata from external catalog sources. |
| Details | Render synopsis, reviews, characters, statistics, relations, themes, and watch-order data. |
| Watchlist | Store local user state for saved titles, statuses, and progress-driven flows. |
| Playback | Serve watch pages, proxy streams/subtitles, rewrite playlists, and track progress. |
| Player | Handle HLS playback, quality selection, subtitles, keyboard controls, episode navigation, and skip segments. |
| Recommendations | Generate personal top picks from watchlist signals and recommendation data. |
| Maintenance | Run migrations, startup fixes, local user commands, and data repair scripts. |
<details> The external anime data source is Jikan (`https://api.jikan.moe/v4`). Because reliability is a first-class concern, the client layer includes request pacing, bounded retries, backoff behavior, stale-cache fallback, and a persisted retry queue for failed fetches. Playback proxying uses uTLS to bypass Cloudflare protections.
<summary><strong>Implementation notes</strong></summary>
The backend is written in Go with Gin for HTTP routing and Fx for module wiring. SQLite is used for Upstream APIs can fail transiently with `429` and `5xx` responses, so the app favors graceful degradation over hard failure. Cached values are used when fresh requests fail, retryable failures are persisted and replayed in a background worker, and relation synchronization is incremental so one bad fetch does not block the rest of the graph.
local persistence, with migrations and data fixes committed alongside the application. Templates are
rendered on the server, HTMX handles small partial updates, and TypeScript powers the interactive
parts of the browser experience.
The most stateful frontend code lives under `static/player`, where the app handles playback mode, ## Repository structure
source loading, progress storage, subtitles, timelines, quality changes, keyboard shortcuts, skip
segments, episode completion, and thumbnail navigation.
</details> The codebase follows standard Go project layout conventions.
### How It Is Built | Path | Purpose |
| ----------------- | ------------------------------------------------ |
| `api/*` | Feature routes: anime, auth, playback, watchlist |
| `cmd/server` | Application entrypoint and CLI commands |
| `integrations/*` | External API clients and scraping |
| `internal/*` | Core services: db, middleware, server, worker |
| `pkg/middleware` | Generic HTTP middleware |
| `templates/*` | Server-rendered HTML templates |
| `migrations` | Schema evolution |
| `static` / `dist` | Frontend assets |
The application is organized around product boundaries rather than framework layers. ## Getting started
`internal/anime` owns catalog-facing behavior, `internal/watchlist` owns saved user state,
`internal/playback` owns watch data and proxy behavior, and `integrations` contains provider
clients. This keeps the core app from depending directly on the details of a specific metadata or
playback source.
Server-rendered templates are the default because most pages are content-heavy and benefit from Requires Go `1.25+`, Bun, and [just](https://github.com/casey/just) (`brew install just`).
simple request-response rendering. TypeScript is used where the browser has real ongoing state:
search interactions, theme handling, carousels, watchlist actions, toast messages, and especially
the video player.
The result is a codebase that behaves like a small product rather than a tutorial project: it has a
repeatable toolchain, database evolution, local maintenance commands, focused tests, and a clear
split between app code and external integrations.
### Working Locally
The local workflow assumes [`mise`](https://mise.jdx.dev/) for tool versions and `just` for common
commands.
```bash
mise install
bun install
just dev
```
The development server runs on `http://localhost:3000` by default. `just dev` uses Air to rebuild
the Go server and frontend assets when relevant files change.
Playback proxying requires a local `PLAYBACK_PROXY_SECRET` so the server can mint stream and
subtitle proxy tokens. Generate a strong value and add it to `.env` before using playback:
```bash
echo "PLAYBACK_PROXY_SECRET=$(openssl rand -base64 32)" >> .env
```
Create a local user with:
```bash ```bash
git clone https://github.com/mkelvers/mal.git && cd mal
openssl rand -base32 32
PLAYBACK_PROXY_SECRET="your-32-char-secret" go run ./cmd/server
go run ./cmd/user <username> <password> go run ./cmd/user <username> <password>
``` ```
#### Commands The app runs at `http://localhost:3000`.
| Command | Use it for | ### Tasks
| ------------------------------- | --------------------------------------------------- |
| `just setup` | Install pinned tools and Bun dependencies. |
| `just dev` | Run the app locally with live rebuilds. |
| `just build` | Build the Go binary, CSS, and TypeScript assets. |
| `just test` | Run the Go test suite. |
| `just check` | Run linting, tests, typechecking, and a full build. |
| `just lint-go` / `just lint-ts` | Run backend or frontend linting separately. |
| `just typecheck` | Run TypeScript without emitting files. |
| `just run` | Build and run the compiled server. |
| `just clean` | Remove generated build output. |
<details> The justfile automates common tasks:
<summary><strong>Configuration</strong></summary>
Configuration is loaded from environment variables, and a local `.env` file is read automatically. ```bash
just fmt # format go code
just lint # go fmt && go vet
just test # run go tests
just build # build go binary + frontend
just check # lint, test, typecheck, build
just dev # build and run
just install-hooks # install pre-push hooks
```
| Variable | Default | Purpose | ### Docker
| --------------------------- | --------------- | -------------------------------------------------------------------------- |
| `PORT` | `3000` | HTTP port for the server. |
| `DATABASE_FILE` | `mal.db` | SQLite database path. |
| `GIN_MODE` | release default | Gin runtime mode. |
| `MAL_CORS_ALLOW_ALL` | disabled | Allows any origin when set to `1`; intended for local/proxy setups. |
| `PLAYBACK_PROXY_SECRET` | empty | Secret used to mint playback proxy tokens; required for playback proxying. |
| `EPISODE_AVAILABILITY_MODE` | `auto` | Episode availability strategy: `auto`, `legacy`, or `jikan`. |
| `MAL_JIKAN_TRACE` | disabled | Enables optional Jikan client tracing when truthy. |
</details> ```bash
docker build -t mal .
docker run --rm -p 3000:3000 -e PLAYBACK_PROXY_SECRET="$(openssl rand -base32 32)" mal
<details> # persistent data
<summary><strong>Maintenance commands</strong></summary> docker run --rm -p 3000:3000 \
-e DATABASE_FILE=/app/data/mal.db \
-e PLAYBACK_PROXY_SECRET="your-secret" \
-v "$(pwd)/data:/app/data" \
mal
| Command | Use it for | docker exec mal ./cmd/user <username> <password>
| ------------------------ | ---------------------------------------------------------- | ```
| `just new-data-fix name` | Scaffold a new data-fix file. |
| `just run-fixes` | Run registered data fixes through `cmd/user`. |
| `just fix-all` | Run the Bun maintenance script for data fixes. |
| `bun run format` | Format TypeScript and related frontend files with `oxfmt`. |
</details> ## Configuration
### Repository Map | Variable | Default | Description |
| ----------------------- | ------------------- | ----------------------------------------------------------- |
| `PORT` | `3000` | HTTP listen port |
| `DATABASE_FILE` | `mal.db` | SQLite database file path |
| `ENV` | _(empty)_ | Set to `production` to enable secure session cookies |
| `MIGRATIONS_DIR` | _(auto-discovered)_ | Optional explicit path to migration files |
| `PLAYBACK_PROXY_SECRET` | _(required)_ | HMAC secret for signed playback proxy tokens (min 32 chars) |
| `MAL_JIKAN_TRACE` | `false` | Log all Jikan cache/upstream timings when enabled |
| Path | Responsibility | ## Testing
| -------------------------------- | --------------------------------------------------------------- |
| `cmd/server` | Web server entry point. |
| `cmd/user` | Local user and maintenance commands. |
| `internal/anime` | Catalog, details, browse, search, reviews, and recommendations. |
| `internal/auth` | Authentication, middleware, and local user handling. |
| `internal/watchlist` | Watchlist handlers, service logic, and persistence. |
| `internal/playback` | Watch data, progress, proxy tokens, and skip segments. |
| `internal/episodes` | Episode refresh and provider mapping. |
| `internal/database` | SQLite setup, migrations, and startup data fixes. |
| `integrations/jikan` | Jikan API client and catalog types. |
| `integrations/playback/allanime` | Playback provider client and extraction logic. |
| `templates` | Server-rendered pages and reusable components. |
| `static` | TypeScript source for client-side behavior. |
| `scripts` | Bun-powered development and maintenance scripts. |
Released under the [MIT License](LICENSE). Run locally with `just check` or manually:
```bash
go test ./...
```
Migrations run automatically on startup.
## Security
Keep secrets out of version control, do not publish real credentials in documentation or screenshots, and report security issues privately before public disclosure.
## License
This project is released under the MIT License. See `LICENSE` for details.

View File

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

338
bun.lock
View File

@@ -3,25 +3,51 @@
"configVersion": 1, "configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "mal", "name": "myanimelist-ui",
"dependencies": { "dependencies": {
"hls.js": "^1.6.16", "dompurify": "^3.4.1",
"htmx.org": "1.9.12",
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1", "@tailwindcss/cli": "^4.2.4",
"@tailwindcss/cli": "^4.3.0", "@toolwind/anchors": "^1.0.10",
"@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", "lefthook": "^2.1.6",
"oxfmt": "^0.52.0", "prettier": "^3.8.3",
"oxlint": "^1.67.0", "tailwindcss": "^4.2.4",
"oxlint-tsgolint": "^0.23.0",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
}, },
}, },
}, },
"packages": { "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/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=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -32,94 +58,6 @@
"@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=="], "@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": ["@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=="], "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
@@ -148,58 +86,154 @@
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
"@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="], "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
"@tailwindcss/cli": ["@tailwindcss/cli@4.3.0", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "enhanced-resolve": "^5.21.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.3.0" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-X9kdlqyMopO9fewbgHsEeuy31YzMHbdZ9VsKt004tB+mxSg1CNbyhZYCzvhciN0AM4R4b5lvIprPjtNq7iQxpQ=="], "@tailwindcss/cli": ["@tailwindcss/cli@4.2.4", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "enhanced-resolve": "^5.19.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.2.4" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-e87GGhuXxnyQPyA0TS8an/3wNpj+OUmx8u0F4BicYr48TF72032AIu5917rRYaWm7HorXi3GSZ/uG+ohqP6AKA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], "@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="], "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="], "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="], "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="], "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="], "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="], "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="], "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="], "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="], "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="], "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="], "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="], "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="],
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], "@toolwind/anchors": ["@toolwind/anchors@1.0.10", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0" } }, "sha512-F3J/lxGGPUy+GIpT49NmYMF1X7l0d7UzdDASni29il2ro5sT4cYfPBFHBAfOM0lpgKOr/HnqINlomngt8BcvnA=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="], "dompurify": ["dompurify@3.4.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "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=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"hls.js": ["hls.js@1.6.16", "", {}, "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA=="], "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"htmx.org": ["htmx.org@1.9.12", "", {}, "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-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=="], "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": ["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=="], "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ=="],
@@ -222,6 +256,8 @@
"lefthook-windows-x64": ["lefthook-windows-x64@2.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-q4z2n3xucLscoWiyMwFViEj3N8MDSkPulMwcJYuCYFHoPhP1h+icqNu7QRLGYj6AnVrCQweiUJY3Tb2X+GbD/A=="], "lefthook-windows-x64": ["lefthook-windows-x64@2.1.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": ["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=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -246,48 +282,88 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], "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=="], "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=="], "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=="], "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"oxfmt": ["oxfmt@0.52.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.52.0", "@oxfmt/binding-android-arm64": "0.52.0", "@oxfmt/binding-darwin-arm64": "0.52.0", "@oxfmt/binding-darwin-x64": "0.52.0", "@oxfmt/binding-freebsd-x64": "0.52.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.52.0", "@oxfmt/binding-linux-arm-musleabihf": "0.52.0", "@oxfmt/binding-linux-arm64-gnu": "0.52.0", "@oxfmt/binding-linux-arm64-musl": "0.52.0", "@oxfmt/binding-linux-ppc64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-musl": "0.52.0", "@oxfmt/binding-linux-s390x-gnu": "0.52.0", "@oxfmt/binding-linux-x64-gnu": "0.52.0", "@oxfmt/binding-linux-x64-musl": "0.52.0", "@oxfmt/binding-openharmony-arm64": "0.52.0", "@oxfmt/binding-win32-arm64-msvc": "0.52.0", "@oxfmt/binding-win32-ia32-msvc": "0.52.0", "@oxfmt/binding-win32-x64-msvc": "0.52.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug=="], "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=="],
"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-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"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=="], "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=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], "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=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], "synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], "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=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "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=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "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/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.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.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.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/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.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=="], "@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=="],
} }
} }

8
cmd/README.md Normal file
View File

@@ -0,0 +1,8 @@
# cmd
Executables live here.
| binary | purpose |
| ------------ | ----------------- |
| `cmd/server` | web server |
| `cmd/user` | user creation CLI |

View File

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

View File

@@ -1,195 +1,108 @@
// Package main provides local user administration commands.
package main package main
import ( import (
"bufio" "bufio"
"context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"io" "log"
"mal/internal"
"mal/internal/config"
"mal/internal/db"
"mal/internal/observability"
"os" "os"
"strings" "strings"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/joho/godotenv"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"golang.org/x/term" "mal/internal/db"
) )
func main() { func main() {
if err := godotenv.Load(); err != nil { dbConn, err := db.Open(db.GetDBFile())
observability.Warn("env_file_load_failed", "user", "", nil, err) if err != nil {
log.Fatalf("failed to open db: %v", err)
}
defer func() { _ = dbConn.Close() }()
if len(os.Args) == 2 && os.Args[1] == "update-avatar" {
updateAvatars(dbConn)
return
} }
if err := run(os.Args[1:]); err != nil { if len(os.Args) != 3 {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) log.Fatalf("Usage: go run cmd/user/main.go <username> <password>\n go run cmd/user/main.go update-avatar")
os.Exit(1)
} }
username := os.Args[1]
password := os.Args[2]
var existingID string
err = dbConn.QueryRow("SELECT id FROM user WHERE username = ?", username).Scan(&existingID)
if err != nil && err != sql.ErrNoRows {
log.Fatalf("database error: %v", err)
}
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" {
fmt.Println("Operation cancelled.")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
log.Fatalf("failed to hash password: %v", err)
}
_, err = dbConn.Exec("UPDATE user SET password_hash = ? WHERE id = ?", string(hash), existingID)
if err != nil {
log.Fatalf("failed to update user: %v", err)
}
fmt.Printf("Password for '%s' updated successfully!\n", username)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
log.Fatalf("failed to hash password: %v", 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)
if err != nil {
log.Fatalf("failed to create user: %v", err)
}
fmt.Printf("User '%s' was created successfully!\n", username)
} }
func run(args []string) error { func updateAvatars(dbConn *sql.DB) {
if len(args) == 1 && args[0] == "run-fixes" { rows, err := dbConn.Query("SELECT id, username FROM user")
return runFixes()
}
if len(args) != 1 && len(args) != 2 {
return errors.New("usage: create-user <username> [password]")
}
username := strings.TrimSpace(args[0])
password := ""
if len(args) == 2 {
password = args[1]
}
if username == "" {
return errors.New("username must not be empty")
}
sqlDB, err := openDatabase()
if err != nil { if err != nil {
return err log.Fatalf("failed to fetch users: %v", err)
} }
defer sqlDB.Close() defer func() { _ = rows.Close() }()
if err := internal.RunMigrationsAndFixes(sqlDB); err != nil { count := 0
return fmt.Errorf("prepare database: %w", err) for rows.Next() {
var id, username string
if err := rows.Scan(&id, &username); err != nil {
log.Fatalf("failed to scan user: %v", err)
}
avatarURL := fmt.Sprintf("https://api.dicebear.com/9.x/dylan/svg?seed=%s", username)
_, err := dbConn.Exec("UPDATE user SET avatar_url = ? WHERE id = ?", avatarURL, id)
if err != nil {
log.Fatalf("failed to update avatar for %s: %v", username, err)
}
count++
} }
return createOrUpdateUser(sqlDB, username, password) if err := rows.Err(); err != nil {
} log.Fatalf("iteration error: %v", err)
}
func runFixes() error {
sqlDB, err := openDatabase() fmt.Printf("Updated avatars for %d user(s)\n", count)
if err != nil {
return err
}
defer sqlDB.Close()
if err := internal.RunMigrationsAndFixes(sqlDB); err != nil {
return fmt.Errorf("run migrations and fixes: %w", err)
}
fmt.Println("Database migrations and fixes complete")
return nil
}
func openDatabase() (*sql.DB, error) {
cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("load config: %w", err)
}
sqlDB, err := db.Open(cfg.DatabaseFile)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
return sqlDB, nil
}
func createOrUpdateUser(sqlDB *sql.DB, username, password string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var userID string
err := sqlDB.QueryRowContext(ctx, `SELECT id FROM user WHERE username = ? LIMIT 1`, username).Scan(&userID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("check user: %w", err)
}
userExists := err == nil
if !userExists {
return createUser(ctx, sqlDB, username, password)
}
update, err := confirmPasswordUpdate(username)
if err != nil {
return err
}
if !update {
fmt.Println("No changes made")
return nil
}
return updateUserPassword(ctx, sqlDB, userID, username, password)
}
func createUser(ctx context.Context, sqlDB *sql.DB, username, password string) error {
password, err := resolvePassword(password)
if err != nil {
return err
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
_, err = sqlDB.ExecContext(
ctx,
`INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)`,
uuid.NewString(), username, string(passwordHash), internal.DefaultAvatarURL(username),
)
if err != nil {
return fmt.Errorf("create user: %w", err)
}
fmt.Printf("Created user %q\n", username)
return nil
}
func updateUserPassword(ctx context.Context, sqlDB *sql.DB, userID, username, password string) error {
password, err := resolvePassword(password)
if err != nil {
return err
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
if _, err := sqlDB.ExecContext(ctx, `UPDATE user SET password_hash = ? WHERE id = ?`, string(passwordHash), userID); err != nil {
return fmt.Errorf("update password: %w", err)
}
fmt.Printf("Updated password for user %q\n", username)
return nil
}
func resolvePassword(password string) (string, error) {
if password != "" {
return password, nil
}
fmt.Print("Password: ")
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", fmt.Errorf("read password: %w", err)
}
if len(passwordBytes) == 0 {
return "", errors.New("password must not be empty")
}
return string(passwordBytes), nil
}
func confirmPasswordUpdate(username string) (bool, error) {
fmt.Printf("User %q already exists. Change password? [Y/n] ", username)
answer, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return false, fmt.Errorf("read confirmation: %w", err)
}
switch strings.ToLower(strings.TrimSpace(answer)) {
case "", "y", "yes":
return true, nil
case "n", "no":
return false, nil
default:
return false, errors.New("invalid response; enter y or n")
}
} }

View File

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

View File

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

View File

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

11
docker/entrypoint.sh Executable file
View File

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

29
eslint.config.ts Normal file
View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 685 B

View File

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

View File

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

View File

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

View File

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

3
go.mod
View File

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

6
go.sum
View File

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

View File

@@ -1,489 +0,0 @@
// 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, 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

@@ -1,101 +0,0 @@
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

@@ -3,9 +3,6 @@ package jikan
import ( import (
"context" "context"
"fmt" "fmt"
"mal/internal/observability"
"net/url"
"strconv"
"time" "time"
) )
@@ -35,54 +32,6 @@ func (c *Client) GetAnimeRecommendations(ctx context.Context, id int) ([]Recomme
return resp.Data, nil 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
if err := c.getWithCache(ctx, cacheKey, 24*time.Hour, url, &resp); err != nil {
c.EnqueueAnimeFetchRetry(ctx, id, err)
}
})
}
// GetTopAnime returns the top-rated anime list for a given page.
func (c *Client) GetTopAnime(ctx context.Context, page int) (TopAnimeResult, error) {
if page < 1 {
page = 1
}
cacheKey := fmt.Sprintf("top:%d", page)
var result TopAnimeResponse
params := url.Values{}
params.Set("page", strconv.Itoa(page))
reqURL := buildRequestURL(c.baseURL, "/top/anime", params)
if err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result); err != nil {
return TopAnimeResult{}, err
}
return TopAnimeResult{
Animes: result.Data,
HasNextPage: result.Pagination.HasNextPage,
}, nil
}
// GetAnimeGenres returns list of all anime genres, cached long-term.
func (c *Client) GetAnimeGenres(ctx context.Context) ([]Genre, error) {
const cacheKey = "anime_genres"
var result GenresResponse
reqURL := fmt.Sprintf("%s/genres/anime", c.baseURL)
if err := c.getWithCache(ctx, cacheKey, longCacheTTL, reqURL, &result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetAnimeByID returns full anime details; finished series cached 30 days, airing cached 1 day. // 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) { func (c *Client) GetAnimeByID(ctx context.Context, id int) (Anime, error) {
cacheKey := fmt.Sprintf("anime:%d", id) cacheKey := fmt.Sprintf("anime:%d", id)
@@ -112,7 +61,7 @@ func (c *Client) GetAnimeByID(ctx context.Context, id int) (Anime, error) {
func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) { func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
cacheKey := fmt.Sprintf("anime:%d", id) cacheKey := fmt.Sprintf("anime:%d", id)
value, err, shared := c.sf.Do("refresh:"+cacheKey, func() (any, error) { value, err, _ := c.sf.Do("refresh:"+cacheKey, func() (any, error) {
var cached Anime var cached Anime
if c.getCache(ctx, cacheKey, &cached) && cached.MalID != 0 { if c.getCache(ctx, cacheKey, &cached) && cached.MalID != 0 {
return cached, nil return cached, nil
@@ -136,14 +85,6 @@ func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
if err != nil { if err != nil {
return Anime{}, err return Anime{}, err
} }
if shared {
observability.Info(
"jikan_anime_refresh_shared",
"jikan",
"",
map[string]any{"anime_id": id, "cache_key": cacheKey},
)
}
if anime, ok := value.(Anime); ok && anime.MalID != 0 { if anime, ok := value.(Anime); ok && anime.MalID != 0 {
return anime, nil return anime, nil
@@ -153,9 +94,18 @@ func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
} }
func (c *Client) refreshAnimeByIDAsync(id int) { func (c *Client) refreshAnimeByIDAsync(id int) {
c.runAsyncRefresh(func(ctx context.Context) { select {
if _, err := c.refreshAnimeByID(ctx, id); err != nil { case c.refreshSem <- struct{}{}:
c.EnqueueAnimeFetchRetry(ctx, id, err) default:
} return
}) }
go func() {
defer func() { <-c.refreshSem }()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
_, _ = c.refreshAnimeByID(ctx, id)
}()
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,13 +4,11 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http" "log"
"sort" "sort"
"strings" "strings"
"time" "time"
"mal/internal/observability"
"mal/integrations/watchorder" "mal/integrations/watchorder"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
@@ -21,22 +19,6 @@ const chiakiWatchOrderURL = "https://chiaki.site/?/tools/watch_order/id/%d"
const watchOrderCacheTTL = time.Hour * 24 const watchOrderCacheTTL = time.Hour * 24
const maxWatchOrderEntries = 120 // cap to prevent huge relation chains const maxWatchOrderEntries = 120 // cap to prevent huge relation chains
type WatchOrderMode string
const (
WatchOrderModeMain WatchOrderMode = "main"
WatchOrderModeComplete WatchOrderMode = "complete"
)
func NormalizeWatchOrderMode(value string) WatchOrderMode {
switch WatchOrderMode(strings.ToLower(strings.TrimSpace(value))) {
case WatchOrderModeComplete:
return WatchOrderModeComplete
default:
return WatchOrderModeMain
}
}
// watchOrderTypeLabel normalizes watch order type to display-friendly labels. // watchOrderTypeLabel normalizes watch order type to display-friendly labels.
func watchOrderTypeLabel(value string) string { func watchOrderTypeLabel(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value)) normalized := strings.ToLower(strings.TrimSpace(value))
@@ -45,110 +27,61 @@ func watchOrderTypeLabel(value string) string {
return "TV" return "TV"
case "movie": case "movie":
return "Movie" return "Movie"
case "ona":
return "ONA"
case "ova":
return "OVA"
default: default:
return strings.TrimSpace(value) return strings.TrimSpace(value)
} }
} }
func (c *Client) refreshWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) { // isAllowedWatchOrderType returns true only for TV and Movie types (filters out specials, etc).
cacheKey := fmt.Sprintf("relations:watch-order:%d", id) func isAllowedWatchOrderType(value string) bool {
watchOrderURL := fmt.Sprintf(chiakiWatchOrderURL, id) normalized := strings.ToLower(strings.TrimSpace(value))
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second) return normalized == "tv" || normalized == "movie"
defer cancel()
result, err := watchorder.FetchWatchOrder(requestCtx, c.fetcher.HTTPClient, watchOrderURL)
if err != nil {
var statusError *watchorder.HTTPStatusError
if errors.As(err, &statusError) && statusError.StatusCode == http.StatusNotFound {
return watchorder.WatchOrderResult{}, watchorder.ErrWatchOrderNotFound
}
if errors.Is(err, watchorder.ErrWatchOrderMarkupNotFound) {
observability.Warn(
"relations_watch_order_markup_missing",
"jikan",
"",
map[string]any{
"anime_id": id,
"url": watchOrderURL,
},
err,
)
} else if errors.As(err, &statusError) {
observability.Warn(
"relations_watch_order_http_error",
"jikan",
"",
map[string]any{
"anime_id": id,
"url": watchOrderURL,
"status": statusError.StatusCode,
"server": statusError.Server,
"cf_ray": statusError.CFRay,
"location": statusError.Location,
"content_type": statusError.ContentType,
"body_preview": statusError.BodyPreview,
},
err,
)
} else {
observability.Warn(
"relations_watch_order_fetch_failed",
"jikan",
"",
map[string]any{
"anime_id": id,
"url": watchOrderURL,
},
err,
)
}
return watchorder.WatchOrderResult{}, err
}
c.setCache(ctx, cacheKey, result, watchOrderCacheTTL)
return result, nil
} }
func (c *Client) refreshWatchOrderAsync(id int) { func relationCacheKey(id int) string {
c.runAsyncRefresh(func(ctx context.Context) { return fmt.Sprintf("relations:watch-order:%d", id)
if _, err := c.refreshWatchOrder(ctx, id); err != nil {
observability.Warn(
"relations_watch_order_async_refresh_failed",
"jikan",
"",
map[string]any{"anime_id": id},
err,
)
}
})
} }
// getWatchOrder fetches watch order from chiaki, caches result for 24h. // 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) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
cacheKey := fmt.Sprintf("relations:watch-order:%d", id) cacheKey := relationCacheKey(id)
var cached watchorder.WatchOrderResult var cached watchorder.WatchOrderResult
if c.getCache(ctx, cacheKey, &cached) { if c.getCache(ctx, cacheKey, &cached) {
return cached, nil return cached, nil
} }
if c.getStaleCache(ctx, cacheKey, &cached) { watchOrderURL := fmt.Sprintf(chiakiWatchOrderURL, id)
c.refreshWatchOrderAsync(id) requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
return cached, nil defer cancel()
}
result, err := c.refreshWatchOrder(ctx, id) result, err := watchorder.FetchWatchOrder(requestCtx, c.httpClient, watchOrderURL)
if err != nil { if err != nil {
if c.getStaleCache(ctx, cacheKey, &cached) { var statusError *watchorder.HTTPStatusError
return cached, nil if errors.As(err, &statusError) && statusError.StatusCode == 404 {
return watchorder.WatchOrderResult{}, watchorder.ErrWatchOrderNotFound
}
if errors.Is(err, watchorder.ErrWatchOrderMarkupNotFound) {
log.Printf("relations: watch-order markup missing for %d (%s): %v", id, watchOrderURL, err)
} else if errors.As(err, &statusError) {
log.Printf(
"relations: watch-order http error for %d (%s): status=%d server=%q cf_ray=%q location=%q content_type=%q body=%q",
id,
watchOrderURL,
statusError.StatusCode,
statusError.Server,
statusError.CFRay,
statusError.Location,
statusError.ContentType,
statusError.BodyPreview,
)
} else {
log.Printf("relations: watch-order fetch failed for %d (%s): %v", id, watchOrderURL, err)
} }
return watchorder.WatchOrderResult{}, err return watchorder.WatchOrderResult{}, err
} }
c.setCache(ctx, cacheKey, result, watchOrderCacheTTL)
return result, nil return result, nil
} }
@@ -167,182 +100,99 @@ func (c *Client) currentOnlyRelation(ctx context.Context, id int) ([]RelationEnt
}}, nil }}, nil
} }
func (c *Client) handleWatchOrderError(ctx context.Context, id int, err error) ([]RelationEntry, error) { // GetFullRelations returns related anime based on watch order, with parallel fetching (3 concurrent).
if errors.Is(err, watchorder.ErrWatchOrderNotFound) { func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, error) {
result, err := c.getWatchOrder(ctx, id)
if err != nil {
if errors.Is(err, watchorder.ErrWatchOrderNotFound) {
return c.currentOnlyRelation(ctx, id)
}
log.Printf("relations: using current-only fallback for %d: %v", id, err)
return c.currentOnlyRelation(ctx, id) return c.currentOnlyRelation(ctx, id)
} }
observability.Warn( type fetchResult struct {
"relations_watch_order_fallback_current_only", index int
"jikan", anime Anime
"", entry watchorder.WatchOrderEntry
map[string]any{
"anime_id": id,
},
err,
)
return c.currentOnlyRelation(ctx, id)
}
// relation filter
func allowedWatchOrder(result watchorder.WatchOrderResult, mode WatchOrderMode) ([]watchorder.WatchOrderEntry, map[int]bool) {
allowedEntries := make([]watchorder.WatchOrderEntry, 0, len(result.WatchOrder))
seen := make(map[int]bool)
hasTVEntry := false
for _, entry := range result.WatchOrder {
if strings.EqualFold(strings.TrimSpace(entry.Type), "tv") {
hasTVEntry = true
break
}
} }
allTypes := mode == WatchOrderModeComplete || !hasTVEntry
var allowedEntries []watchorder.WatchOrderEntry
seen := make(map[int]bool)
for _, entry := range result.WatchOrder { for _, entry := range result.WatchOrder {
if len(allowedEntries) >= maxWatchOrderEntries { if len(allowedEntries) >= maxWatchOrderEntries {
break break
} }
if seen[entry.ID] { if !isAllowedWatchOrderType(entry.Type) || seen[entry.ID] {
continue continue
} }
typ := strings.ToLower(strings.TrimSpace(entry.Type))
if !allTypes && typ != "tv" && typ != "movie" {
continue
}
seen[entry.ID] = true seen[entry.ID] = true
allowedEntries = append(allowedEntries, entry) allowedEntries = append(allowedEntries, entry)
} }
return allowedEntries, seen
}
func (c *Client) fetchEntries(ctx context.Context, entries []watchorder.WatchOrderEntry) chan fetchResult {
g, gCtx := errgroup.WithContext(ctx) g, gCtx := errgroup.WithContext(ctx)
g.SetLimit(3) g.SetLimit(3)
results := make(chan fetchResult, len(entries)) results := make(chan fetchResult, len(allowedEntries))
for i, entry := range entries { for i, entry := range allowedEntries {
g.Go(func() error { g.Go(func() error {
anime, err := c.GetAnimeByID(gCtx, entry.ID) anime, err := c.GetAnimeByID(gCtx, entry.ID)
if err != nil { if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil return nil
} }
observability.Warn(
"relations_fetch_entry_failed",
"jikan",
"",
map[string]any{
"anime_id": entry.ID,
"index": i,
},
err,
)
c.EnqueueAnimeFetchRetry(gCtx, entry.ID, err) c.EnqueueAnimeFetchRetry(gCtx, entry.ID, err)
return nil return nil
} }
select { select {
case results <- fetchResult{index: i, anime: anime, entry: entry}: case results <- fetchResult{index: i, anime: anime, entry: entry}:
case <-gCtx.Done(): case <-gCtx.Done():
} }
return nil return nil
}) })
} }
go func() { go func() {
if err := g.Wait(); err != nil { _ = g.Wait()
observability.Warn("relations_fetch_group_failed", "jikan", "", nil, err)
}
close(results) close(results)
}() }()
return results fetched := make([]fetchResult, 0, len(allowedEntries))
}
func (c *Client) fetchResults(ctx context.Context, entries []watchorder.WatchOrderEntry) []fetchResult {
results := c.fetchEntries(ctx, entries)
fetched := make([]fetchResult, 0, len(entries))
for res := range results { for res := range results {
fetched = append(fetched, res) fetched = append(fetched, res)
} }
if len(fetched) < len(entries) { // Re-sort because they might have finished out of order
observability.Warn(
"relations_fetch_incomplete",
"jikan",
"",
map[string]any{
"expected": len(entries),
"fetched": len(fetched),
"missing": len(entries) - len(fetched),
},
nil,
)
}
sort.Slice(fetched, func(i, j int) bool { sort.Slice(fetched, func(i, j int) bool {
return fetched[i].index < fetched[j].index return fetched[i].index < fetched[j].index
}) })
return fetched relations := make([]RelationEntry, 0, len(fetched)+1)
} for _, res := range fetched {
func buildRelations(results []fetchResult, id int) []RelationEntry {
relations := make([]RelationEntry, 0, len(results)+1)
for _, res := range results {
relations = append(relations, RelationEntry{ relations = append(relations, RelationEntry{
Anime: res.anime, Anime: res.anime,
Relation: watchOrderTypeLabel(res.entry.Type), Relation: watchOrderTypeLabel(res.entry.Type),
IsCurrent: res.entry.ID == id, IsCurrent: res.entry.ID == id,
IsExtra: false, IsExtra: false,
}) })
if res.entry.ID == id {
relations[len(relations)-1].Relation = "Current"
}
} }
return relations if !seen[id] {
} currentAnime, err := c.GetAnimeByID(ctx, id)
if err != nil {
return nil, err
}
func (c *Client) ensureCurrent(ctx context.Context, id int, seen map[int]bool, relations []RelationEntry) ([]RelationEntry, error) { relations = append([]RelationEntry{{
if seen[id] { Anime: currentAnime,
return relations, nil Relation: "Current",
} IsCurrent: true,
IsExtra: false,
currentAnime, err := c.GetAnimeByID(ctx, id) }}, relations...)
if err != nil {
return nil, err
}
return append([]RelationEntry{{
Anime: currentAnime,
Relation: "Current",
IsCurrent: true,
IsExtra: false,
}}, relations...), nil
}
type fetchResult struct {
index int
anime Anime
entry watchorder.WatchOrderEntry
}
// GetFullRelations returns related anime based on watch order, with parallel fetching (3 concurrent).
func (c *Client) GetFullRelations(ctx context.Context, id int, mode WatchOrderMode) ([]RelationEntry, error) {
result, err := c.getWatchOrder(ctx, id)
if err != nil {
return c.handleWatchOrderError(ctx, id, err)
}
allowedEntries, seen := allowedWatchOrder(result, mode)
fetched := c.fetchResults(ctx, allowedEntries)
relations := buildRelations(fetched, id)
relations, err = c.ensureCurrent(ctx, id, seen, relations)
if err != nil {
return nil, err
} }
if len(relations) == 0 { if len(relations) == 0 {
@@ -351,17 +201,3 @@ func (c *Client) GetFullRelations(ctx context.Context, id int, mode WatchOrderMo
return relations, nil return relations, nil
} }
func (c *Client) WarmFullRelations(id int) {
c.runAsyncRefresh(func(ctx context.Context) {
if _, err := c.GetFullRelations(ctx, id, WatchOrderModeMain); err != nil {
observability.Warn(
"relations_warm_full_failed",
"jikan",
"",
map[string]any{"anime_id": id},
err,
)
}
})
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
package jikan
import ()
type ProducerResponse struct {
Data struct {
MalID int `json:"mal_id"`
Titles []struct {
Type string `json:"type"`
Title string `json:"title"`
} `json:"titles"`
Images struct {
Jpg struct {
ImageURL string `json:"image_url"`
} `json:"jpg"`
} `json:"images"`
Favorites int `json:"favorites"`
Established string `json:"established"`
About string `json:"about"`
Count int `json:"count"`
External []struct {
Name string `json:"name"`
URL string `json:"url"`
} `json:"external"`
} `json:"data"`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,326 +0,0 @@
package allanime
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
)
const episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"
type sourceReference struct {
URL string
Name string
}
func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) {
episodeQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) {
sourceUrls
}
}`
result, err := c.graphqlRequestWithHash(ctx, showID, episode, mode)
if err == nil {
sources := c.sourcesFrom(ctx, result)
if len(sources) > 0 {
return sources, nil
}
}
result, err = c.graphqlRequest(ctx, episodeQuery, map[string]any{
"showId": showID,
"translationType": mode,
"episodeString": episode,
})
if err != nil {
return nil, err
}
data, ok := result["data"].(map[string]any)
if !ok {
return nil, errors.New("invalid source response")
}
rawSourceURLs, ok := data["episode"].(map[string]any)
if !ok {
return nil, errors.New("invalid episode response")
}
sourceURLs, ok := rawSourceURLs["sourceUrls"].([]any)
if !ok || len(sourceURLs) == 0 {
return nil, errors.New("no source urls")
}
references := sourceRefs(sourceURLs)
if len(references) == 0 {
return nil, errors.New("no source references")
}
out := c.resolveRefs(ctx, references)
if len(out) == 0 {
return nil, errors.New("no playable sources extracted")
}
return out, nil
}
func (c *AllAnimeProvider) sourcesFrom(ctx context.Context, data map[string]any) []StreamSource {
episodeData, ok := data["episode"].(map[string]any)
if !ok {
return nil
}
sourceURLs, ok := episodeData["sourceUrls"].([]any)
if !ok || len(sourceURLs) == 0 {
return nil
}
references := sourceRefs(sourceURLs)
if len(references) == 0 {
return nil
}
return c.resolveRefs(ctx, references)
}
func (c *AllAnimeProvider) resolveRefs(ctx context.Context, references []sourceReference) []StreamSource {
out := make([]StreamSource, 0, len(references))
for _, ref := range references {
if source, ok := directSource(ref); ok {
out = append(out, source)
return out
}
extracted := c.resolveExtracted(ctx, ref)
if len(extracted) > 0 {
out = append(out, extracted...)
return out
}
}
return out
}
func directSource(ref sourceReference) (StreamSource, bool) {
target := strings.TrimSpace(ref.URL)
if target == "" {
return StreamSource{}, false
}
if isHTTPURL(target) {
if detectEmbedType(target) == "embed" {
return StreamSource{}, false
}
return buildStreamSource(target, detectSourceType(target), ref.Name), true
}
decoded := decodeSourceURL(target)
if !isHTTPURL(decoded) {
return StreamSource{}, false
}
if detectEmbedType(decoded) == "embed" {
return StreamSource{}, false
}
return buildStreamSource(decoded, detectSourceType(decoded), ref.Name), true
}
func (c *AllAnimeProvider) resolveExtracted(ctx context.Context, ref sourceReference) []StreamSource {
rawURL := strings.TrimSpace(ref.URL)
decoded := decodeSourceURL(rawURL)
if decoded == "" {
return nil
}
if isHTTPURL(decoded) {
extracted, err := c.extractor.ExtractEmbedVideoLinks(ctx, decoded)
if err != nil {
return nil
}
return extracted
}
if !strings.HasPrefix(decoded, "/") {
decoded = "/" + decoded
}
extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded)
if err != nil {
return nil
}
return extracted
}
func detectSourceType(sourceURL string) string {
sourceType := detectStreamType(sourceURL)
if sourceType != "unknown" {
return sourceType
}
return detectEmbedType(sourceURL)
}
func isHTTPURL(value string) bool {
return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://")
}
func buildStreamSource(url, sourceType, provider string) StreamSource {
return StreamSource{
URL: url,
Provider: provider,
Type: sourceType,
Referer: allAnimeReferer,
}
}
// source priority
func sourceRefs(rawSourceURLs []any) []sourceReference {
priorityOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
prioritySet := map[string]struct{}{"default": {}, "yt-mp4": {}, "s-mp4": {}, "luf-mp4": {}}
prioritized := make(map[string]sourceReference)
fallback := make([]sourceReference, 0, len(rawSourceURLs))
seen := make(map[string]struct{})
for _, source := range rawSourceURLs {
item, ok := source.(map[string]any)
if !ok {
continue
}
sourceURL, ok := stringMapValue(item, "sourceUrl")
if !ok {
continue
}
sourceName, _ := stringMapValue(item, "sourceName")
sourceURL = strings.TrimSpace(sourceURL)
sourceName = strings.TrimSpace(sourceName)
if sourceURL == "" {
continue
}
if _, exists := seen[sourceURL]; exists {
continue
}
seen[sourceURL] = struct{}{}
ref := sourceReference{URL: sourceURL, Name: sourceName}
normalized := strings.ToLower(sourceName)
if _, priority := prioritySet[normalized]; priority {
if _, exists := prioritized[normalized]; !exists {
prioritized[normalized] = ref
}
continue
}
fallback = append(fallback, ref)
}
ordered := make([]sourceReference, 0, len(prioritized)+len(fallback))
for _, provider := range priorityOrder {
if ref, ok := prioritized[provider]; ok {
ordered = append(ordered, ref)
}
}
ordered = append(ordered, fallback...)
return ordered
}
func stringMapValue(item map[string]any, key string) (string, bool) {
value, ok := item[key].(string)
return value, ok
}
func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) {
req, err := newHashRequest(ctx, showID, episode, mode)
if err != nil {
return nil, fmt.Errorf("create GET request: %w", err)
}
req.Header.Set("User-Agent", defaultUserAgent)
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
req.Header.Set("Accept-Encoding", "identity")
req.Header.Set("Referer", allAnimeReferer)
req.Header.Set("Origin", allAnimeOrigin)
req.Header.Set("Sec-Fetch-Dest", "empty")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Site", "cross-site")
statusCode, respBody, err := executeAndReadResponse(c.utlsClient, req, "execute GET request", "read response")
if err != nil {
return nil, err
}
if statusCode != http.StatusOK {
return nil, fmt.Errorf("GET status %d: %s", statusCode, string(respBody))
}
parsed, err := parseGraphQLResponse(respBody, "decode response")
if err != nil {
return nil, err
}
data, ok := parsed["data"].(map[string]any)
if !ok {
return nil, errors.New("no data in response")
}
decrypted, err := responseFromTobeparsed(data)
if err != nil {
return nil, err
}
if decrypted != nil {
return decrypted, nil
}
if len(nestedSlice(data, "episode", "sourceUrls")) > 0 {
return parsed, nil
}
return nil, errors.New("no usable data in response")
}
func newHashRequest(ctx context.Context, showID, episode, mode string) (*http.Request, error) {
varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, strings.ToLower(mode), episode)
extJSON := fmt.Sprintf(`{"persistedQuery":{"version":1,"sha256Hash":"%s"}}`, episodeQueryHash)
params := url.Values{}
params.Set("variables", varsJSON)
params.Set("extensions", extJSON)
return http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api?%s", allAnimeBaseURL, params.Encode()), nil)
}
func detectStreamType(sourceURL string) string {
lower := strings.ToLower(sourceURL)
if strings.Contains(lower, ".m3u8") || strings.Contains(lower, "master.m3u8") {
return "m3u8"
}
if strings.Contains(lower, ".mp4") {
return "mp4"
}
return "unknown"
}
func detectEmbedType(rawURL string) string {
lower := strings.ToLower(rawURL)
embedHosts := []string{"streamwish", "streamsb", "mp4upload", "ok.ru", "gogoplay", "streamlare"}
for _, host := range embedHosts {
if strings.Contains(lower, host) {
return "embed"
}
}
return "unknown"
}

View File

@@ -1,4 +1,3 @@
// Package watchorder provides anime watch order data from various sources.
package watchorder package watchorder
import ( import (
@@ -6,8 +5,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
errlog "mal/pkg" "mal/pkg/net/limits"
netutil "mal/pkg/net" "mal/pkg/net/useragent"
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
@@ -83,12 +82,36 @@ func parseRootID(url string) (int, error) {
} }
func addCommonHeaders(request *http.Request) { func addCommonHeaders(request *http.Request) {
netutil.SetBrowserHTMLHeaders(request, "https://chiaki.site/") 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")
} }
func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, error) { func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, error) {
document, _, err := netutil.FetchHTMLDocument(ctx, httpClient, url, addCommonHeaders, func(response *http.Response, body []byte) error { client := httpClient
return &HTTPStatusError{ 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{
StatusCode: response.StatusCode, StatusCode: response.StatusCode,
URL: url, URL: url,
Server: strings.TrimSpace(response.Header.Get("Server")), Server: strings.TrimSpace(response.Header.Get("Server")),
@@ -97,8 +120,14 @@ func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*g
ContentType: strings.TrimSpace(response.Header.Get("Content-Type")), ContentType: strings.TrimSpace(response.Header.Get("Content-Type")),
BodyPreview: strings.Join(strings.Fields(strings.TrimSpace(string(body))), " "), BodyPreview: strings.Join(strings.Fields(strings.TrimSpace(string(body))), " "),
} }
}) }
return document, err
document, err := goquery.NewDocumentFromReader(response.Body)
if err != nil {
return nil, fmt.Errorf("failed to parse html: %w", err)
}
return document, nil
} }
func extractTypeLabelsByID(doc *goquery.Document) map[int]string { func extractTypeLabelsByID(doc *goquery.Document) map[int]string {
@@ -156,19 +185,23 @@ func extractRows(doc *goquery.Document) []watchOrderRow {
} }
title := strings.TrimSpace(selection.Find(".wo_title").First().Text()) title := strings.TrimSpace(selection.Find(".wo_title").First().Text())
alt := strings.TrimSpace(selection.Find(".uk-text-small").First().Text()) alternativeTitle := strings.TrimSpace(selection.Find(".uk-text-small").First().Text())
rows = append(rows, watchOrderRow{ rows = append(rows, watchOrderRow{
id: id, id: id,
typeID: typeID, typeID: typeID,
title: title, title: title,
alternativeTitle: alt, alternativeTitle: alternativeTitle,
}) })
}) })
return rows return rows
} }
func hasWatchOrderTable(doc *goquery.Document) bool {
return doc.Find("#wo_list").Length() > 0
}
// shouldTryProxy returns true for transient errors where the Jina proxy may help // shouldTryProxy returns true for transient errors where the Jina proxy may help
// (e.g. Cloudflare blocking, rate limits) // (e.g. Cloudflare blocking, rate limits)
func shouldTryProxy(err error) bool { func shouldTryProxy(err error) bool {
@@ -202,15 +235,13 @@ func fetchProxyText(ctx context.Context, httpClient *http.Client, url string) (s
if err != nil { if err != nil {
return "", fmt.Errorf("proxy request failed: %w", err) return "", fmt.Errorf("proxy request failed: %w", err)
} }
defer func() { defer func() { _ = response.Body.Close() }()
errlog.Log("failed to close watch order proxy response body", response.Body.Close())
}()
if response.StatusCode != http.StatusOK { if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("proxy status %d", response.StatusCode) return "", fmt.Errorf("proxy status %d", response.StatusCode)
} }
body, err := io.ReadAll(io.LimitReader(response.Body, netutil.MiB2)) body, err := io.ReadAll(io.LimitReader(response.Body, limits.MiB2))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read proxy response: %w", err) return "", fmt.Errorf("failed to read proxy response: %w", err)
} }
@@ -354,7 +385,7 @@ func FetchWatchOrder(ctx context.Context, httpClient *http.Client, url string) (
} }
// empty table indicates JS-rendered content; need proxy // empty table indicates JS-rendered content; need proxy
if doc.Find("#wo_list").Length() == 0 { if !hasWatchOrderTable(doc) {
return fetchViaProxy(ctx, httpClient, url, rootID) return fetchViaProxy(ctx, httpClient, url, rootID)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,73 +0,0 @@
package anime
import (
"context"
"mal/internal/domain"
"sync"
"github.com/gin-gonic/gin"
)
type AnimeHandler struct {
svc Service
watchlistSvc domain.WatchlistService
episodeSvc domain.EpisodeService
scheduleCache map[string]cachedWeekSchedule
sync.Mutex
}
type Service interface {
domain.AnimeCatalogService
domain.AnimeSearchService
domain.AnimeDetailsService
WarmDetailSections(id int)
}
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService) *AnimeHandler {
return &AnimeHandler{
svc: svc,
watchlistSvc: watchlistSvc,
episodeSvc: episodeSvc,
scheduleCache: make(map[string]cachedWeekSchedule),
}
}
func (h *AnimeHandler) watchlistMapForAnimes(ctx context.Context, userID string, animes []domain.Anime) map[int64]bool {
animeIDs := make([]int64, 0, len(animes))
for _, anime := range animes {
if anime.MalID > 0 {
animeIDs = append(animeIDs, int64(anime.MalID))
}
}
return h.watchlistMapForIDs(ctx, userID, animeIDs)
}
func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, animeIDs []int64) map[int64]bool {
if userID == "" || len(animeIDs) == 0 {
return map[int64]bool{}
}
watchlistMap, err := h.watchlistSvc.GetWatchlistMap(ctx, userID, animeIDs)
if err != nil {
return map[int64]bool{}
}
return watchlistMap
}
func (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("/search", h.HandleSearch)
r.GET("/top-picks", h.HandleTopPicks)
r.GET("/browse", h.HandleBrowse)
r.GET("/anime/:id", h.HandleAnimeDetails)
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
r.GET("/api/watch-order", h.HandleHTMLWatchOrder)
r.GET("/api/search-quick", h.HandleQuickSearch)
r.GET("/api/search", h.HandleSearchAPI)
r.GET("/api/jikan/random/anime", h.HandleRandomAnime)
r.GET("/api/jikan/producers", h.HandleProducers)
}

View File

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

View File

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

View File

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

View File

@@ -1,15 +0,0 @@
package anime
import (
"context"
"mal/internal/anime/recommendations"
"mal/internal/domain"
)
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, recommendations.TopPickLimit)
}
func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, recommendations.TopPicksLimit)
}

View File

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

View File

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

View File

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

View File

@@ -1,237 +0,0 @@
package recommendations
import (
"database/sql"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"slices"
"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 TestCandidateScoreLimitTracksRequestedResultSize(t *testing.T) {
if got := candidateScoreLimit(TopPickLimit); got != TopPickLimit+candidateFetchBuffer {
t.Fatalf("expected top-pick scoring to fetch a small oversample, got %d", got)
}
if got := candidateScoreLimit(TopPicksLimit); got != candidateFetchLimit {
t.Fatalf("expected full top-picks scoring to keep existing cap, got %d", got)
}
if got := candidateScoreLimit(0); got != 0 {
t.Fatalf("expected zero result limit to skip scoring, got %d", got)
}
}
func testRecommendationAnime(id int, genreID int) jikan.Anime {
return jikan.Anime{
MalID: id,
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 {
if slices.Contains(query.genreIDs, 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,167 +0,0 @@
package recommendations
import (
"mal/integrations/jikan"
"mal/internal/domain"
"math"
"slices"
)
func rerankRecommendationCandidates(candidates []recommendationCandidate, limit int) []domain.Anime {
selected := make([]domain.Anime, 0, min(limit, len(candidates)))
remaining := slices.Clone(candidates)
seen := newDiversityFeatureCounts()
recent := make([]diversityFeatureSet, 0, recentDiversityWindow)
for len(selected) < limit && len(remaining) > 0 {
bestIndex := bestDiverseCandidateIndex(remaining, seen, recent)
candidate := remaining[bestIndex]
remaining = slices.Delete(remaining, bestIndex, bestIndex+1)
if slices.ContainsFunc(selected, func(anime domain.Anime) bool {
return anime.MalID == candidate.anime.MalID
}) {
continue
}
selected = append(selected, domain.Anime{Anime: candidate.anime})
features := diversityFeatures(candidate.anime)
seen.add(features)
recent = append(recent, features)
if len(recent) > recentDiversityWindow {
recent = recent[1:]
}
}
return selected
}
type diversityFeatureSet struct {
genres map[int]struct{}
themes map[int]struct{}
demographics map[int]struct{}
studios map[int]struct{}
}
type diversityFeatureCounts struct {
genres map[int]int
themes map[int]int
demographics map[int]int
studios map[int]int
}
func newDiversityFeatureCounts() diversityFeatureCounts {
return diversityFeatureCounts{
genres: make(map[int]int),
themes: make(map[int]int),
demographics: make(map[int]int),
studios: make(map[int]int),
}
}
func (counts diversityFeatureCounts) add(features diversityFeatureSet) {
addDiversityCounts(counts.genres, features.genres)
addDiversityCounts(counts.themes, features.themes)
addDiversityCounts(counts.demographics, features.demographics)
addDiversityCounts(counts.studios, features.studios)
}
func addDiversityCounts(target map[int]int, features map[int]struct{}) {
for id := range features {
target[id]++
}
}
func bestDiverseCandidateIndex(candidates []recommendationCandidate, seen diversityFeatureCounts, recent []diversityFeatureSet) int {
bestIndex := 0
bestScore := math.Inf(-1)
for i, candidate := range candidates {
score := candidate.score - diversityPenalty(diversityFeatures(candidate.anime), seen, recent)
if score == bestScore {
if candidate.score <= candidates[bestIndex].score {
continue
}
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
return bestIndex
}
func diversityFeatures(anime jikan.Anime) diversityFeatureSet {
return diversityFeatureSet{
genres: entityIDSet(anime.Genres),
themes: entityIDSet(anime.Themes),
demographics: entityIDSet(anime.Demographics),
studios: entityIDSet(anime.Studios),
}
}
func entityIDSet(entities []jikan.NamedEntity) map[int]struct{} {
ids := make(map[int]struct{}, len(entities))
for _, entity := range entities {
if entity.MalID <= 0 {
continue
}
ids[entity.MalID] = struct{}{}
}
return ids
}
func diversityPenalty(features diversityFeatureSet, seen diversityFeatureCounts, recent []diversityFeatureSet) float64 {
penalty := 0.0
penalty += repeatedFeaturePenalty(features.genres, seen.genres, recentGenreCounts(recent), genreDiversityPenalty)
penalty += repeatedFeaturePenalty(features.themes, seen.themes, recentThemeCounts(recent), themeDiversityPenalty)
penalty += repeatedFeaturePenalty(features.demographics, seen.demographics, recentDemographicCounts(recent), demoDiversityPenalty)
penalty += repeatedFeaturePenalty(features.studios, seen.studios, recentStudioCounts(recent), studioDiversityPenalty)
return penalty
}
func repeatedFeaturePenalty(features map[int]struct{}, seen map[int]int, recent map[int]int, weight float64) float64 {
total := 0.0
for id := range features {
total += float64(seen[id]) * weight * 0.35
total += float64(recent[id]) * weight
}
return total
}
func recentGenreCounts(recent []diversityFeatureSet) map[int]int {
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
return features.genres
})
}
func recentThemeCounts(recent []diversityFeatureSet) map[int]int {
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
return features.themes
})
}
func recentDemographicCounts(recent []diversityFeatureSet) map[int]int {
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
return features.demographics
})
}
func recentStudioCounts(recent []diversityFeatureSet) map[int]int {
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
return features.studios
})
}
func recentFeatureCounts(
recent []diversityFeatureSet,
selectFeatures func(diversityFeatureSet) map[int]struct{},
) map[int]int {
counts := make(map[int]int)
for _, features := range recent {
addDiversityCounts(counts, selectFeatures(features))
}
return counts
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,327 +0,0 @@
// Package anime provides anime catalog, search, and details services.
package anime
import (
"context"
"fmt"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"math/rand"
"strings"
"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)
}
if err != nil {
return fmt.Errorf("get catalog section %q: %w", section, err)
}
return nil
})
if userID != "" && section == "Continue" {
g.Go(func() error {
var err error
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
if err != nil {
return fmt.Errorf("get continue watching entries for %q: %w", userID, err)
}
return nil
})
}
if err := g.Wait(); err != nil {
return domain.CatalogSectionData{}, fmt.Errorf("wait for catalog section %q: %w", section, err)
}
animes := wrapAnimes(res.Animes)
if len(animes) > 6 {
animes = animes[:6]
}
return domain.CatalogSectionData{
Animes: animes,
ContinueWatching: cw,
}, 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{}, fmt.Errorf("get anime by id: %w", 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 "", fmt.Errorf("get producer name: %w", 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, fmt.Errorf("get genres: %w", 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, fmt.Errorf("get characters: %w", 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, fmt.Errorf("get recommendations: %w", 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, mode jikan.WatchOrderMode) ([]jikan.RelationEntry, error) {
return s.jikan.GetFullRelations(ctx, id, mode)
}
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, fmt.Errorf("get staff: %w", 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{}, fmt.Errorf("get statistics: %w", 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{}, fmt.Errorf("get themes: %w", 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, fmt.Errorf("get reviews: %w", 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{}, fmt.Errorf("get random anime: %w", err)
}
func (s *animeService) GetAllEpisodes(ctx context.Context, id int) ([]domain.EpisodeData, error) {
episodes, err := s.jikan.GetAllEpisodes(ctx, id)
if err != nil {
return nil, fmt.Errorf("get all episodes: %w", 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

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

View File

@@ -1,19 +1,16 @@
package internal package app
import ( import (
"mal/integrations/jikan" "mal/integrations/jikan"
"mal/integrations/playback/allanime" "mal/integrations/playback/allanime"
"mal/internal/anime" "mal/internal/anime"
"mal/internal/audit"
"mal/internal/auth" "mal/internal/auth"
"mal/internal/config"
"mal/internal/database" "mal/internal/database"
"mal/internal/episodes" "mal/internal/episodes"
"mal/internal/observability"
"mal/internal/playback" "mal/internal/playback"
"mal/internal/server" "mal/internal/server"
"mal/internal/templates"
"mal/internal/watchlist" "mal/internal/watchlist"
"mal/templates"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/render" "github.com/gin-gonic/gin/render"
@@ -22,10 +19,7 @@ import (
func NewApp() *fx.App { func NewApp() *fx.App {
return fx.New( return fx.New(
fx.WithLogger(observability.NewFxLogger),
config.Module,
database.Module, database.Module,
audit.Module,
jikan.Module, jikan.Module,
allanime.Module, allanime.Module,
episodes.Module, episodes.Module,
@@ -35,7 +29,6 @@ func NewApp() *fx.App {
playback.Module, playback.Module,
templates.Module, templates.Module,
server.Module, server.Module,
fx.Invoke(RunMigrationsAndFixes),
fx.Provide(func(r *templates.Renderer) render.HTMLRender { fx.Provide(func(r *templates.Renderer) render.HTMLRender {
return r return r
}), }),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,118 +0,0 @@
package auth
import (
"mal/internal/domain"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
type publicRoute struct {
method string
path string
prefix bool
}
var publicRoutes = []publicRoute{
// Pages.
{method: http.MethodGet, path: "/login"},
{method: http.MethodPost, path: "/login"},
{method: http.MethodGet, path: "/logout"},
// Static assets.
{path: "/static", prefix: true},
{path: "/dist", prefix: true},
// Auth API.
{method: http.MethodPost, path: "/api/auth/login"},
}
func isPublicRequest(method string, path string) bool {
for _, r := range publicRoutes {
if r.method != "" && r.method != method {
continue
}
if r.prefix {
if strings.HasPrefix(path, r.path) {
return true
}
continue
}
if path == r.path {
return true
}
}
return false
}
func authenticateAPIRequest(c *gin.Context, svc domain.AuthService) (*domain.User, string, bool, error) {
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
token := strings.TrimSpace(authHeader[7:])
user, err := svc.ValidateAPIToken(c.Request.Context(), token)
return user, "", false, err
}
sessionID, err := c.Cookie("session_id")
if err != nil {
return nil, "", false, err
}
user, err := svc.ValidateSession(c.Request.Context(), sessionID)
return user, sessionID, true, err
}
func authenticatePageRequest(c *gin.Context, svc domain.AuthService) (*domain.User, string, error) {
sessionID, err := c.Cookie("session_id")
if err != nil {
return nil, "", err
}
user, err := svc.ValidateSession(c.Request.Context(), sessionID)
return user, sessionID, err
}
func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
if isPublicRequest(c.Request.Method, path) {
c.Next()
return
}
var user *domain.User
var err error
var sessionID string
var usesCookieSession bool
// API routes can authenticate via Bearer token OR cookie session.
if strings.HasPrefix(path, "/api/") {
user, sessionID, usesCookieSession, err = authenticateAPIRequest(c, svc)
if err != nil || user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
c.Abort()
return
}
} else {
// Non-API routes only use cookie sessions and redirect to /login.
user, sessionID, err = authenticatePageRequest(c, svc)
usesCookieSession = true
if err != nil || user == nil {
c.Redirect(http.StatusSeeOther, "/login")
c.Abort()
return
}
}
if usesCookieSession {
if refreshErr := svc.RefreshSession(c.Request.Context(), sessionID); refreshErr == nil {
c.SetCookie("session_id", sessionID, int(domain.SessionLifetime.Seconds()), "/", "", false, true)
}
}
c.Set("User", user)
c.Next()
}
}

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