Compare commits
370 Commits
dev
...
12076f4cbb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12076f4cbb | ||
| 30a00eb348 | |||
| 2e26a82aa7 | |||
| b319b2d93d | |||
| e13330367d | |||
| 600c8dec2e | |||
| 162265a3f3 | |||
| 9e3185c04e | |||
| b8a89b7d2d | |||
| 295afa6b59 | |||
| 633ed066d4 | |||
| 15ac8e4065 | |||
| f12df9b515 | |||
| b81bc63042 | |||
| 4e375adcee | |||
| b87a8feb1b | |||
| 7142e7745e | |||
| 5311640056 | |||
| 24d77cfe98 | |||
| 5c10bd1a5a | |||
| 550d594f00 | |||
| a328d72665 | |||
| 97477807d4 | |||
| 731b13a2aa | |||
| b01eec3925 | |||
| ac02fb9b71 | |||
| 44786455b4 | |||
| 037a8abd1b | |||
| 33b0d4b3c6 | |||
| 9b2846af33 | |||
| 81966520a1 | |||
| 072f565c1b | |||
| c9b3df573e | |||
| d6390acf3c | |||
| 103b6acb9a | |||
| cd38bbad16 | |||
| 407bda720e | |||
| 26509e6741 | |||
| 6c5bfd95c1 | |||
| 2b7aef0072 | |||
| 0482a43ac7 | |||
| 61218c2676 | |||
| 64d62e79ce | |||
| 77971d611c | |||
| 7d3aea8625 | |||
| 0cd8f8563d | |||
| 31a59b60b8 | |||
| cd55def040 | |||
| 388a1623aa | |||
| ce9b6efe46 | |||
| b2f6db8ae1 | |||
| 2df8b7863d | |||
| 351640e604 | |||
| 41be0fc923 | |||
| e235f36a45 | |||
| 8e66581f6c | |||
| 9b92f37cb1 | |||
| ed48aa340c | |||
| c13895b7cd | |||
| 7ebfe4807b | |||
| 1327cb3b86 | |||
| 16ee2ed0ee | |||
| 91e0280ec7 | |||
| f880205f5c | |||
| fcdfd0a623 | |||
| 32d7301788 | |||
| 136afa05a5 | |||
| 63802bfc5a | |||
| 3f482b69be | |||
| b0429ead6e | |||
| d15e1a33b6 | |||
| d82eeecfc0 | |||
| 41be636c4d | |||
| 95b1e2b93e | |||
| 1e6e619a3f | |||
| 8c0f345bde | |||
| 322cdac21d | |||
| b7f10e71da | |||
| 2863c3e7ef | |||
| e269d15199 | |||
| 4a1467467c | |||
| 34f52428a2 | |||
| b9ad50b67a | |||
| be27625a3c | |||
| 085fe3e83d | |||
| 0e92c2ce25 | |||
| 9e4e3214f7 | |||
| fa078c7de6 | |||
| 0cc9207755 | |||
| b9e1cc9aeb | |||
| 04b7a1e3ee | |||
| 433ed28514 | |||
| b35acfcce3 | |||
| 0f85c1b405 | |||
| 7c548c4d31 | |||
| 6253bc5b63 | |||
| 28c453847a | |||
| 399f68a7f2 | |||
| f818bd4395 | |||
| d77952522a | |||
| ab519a5361 | |||
| 6303d3c83c | |||
| cc2b885c76 | |||
| e3051d8860 | |||
| 5cf7fe7e8e | |||
| 555c4f2f9b | |||
| 65405402a8 | |||
| 2f7af1f739 | |||
| be7994b806 | |||
| e200fa5fa6 | |||
| fbc9eeeb86 | |||
| 704b03655b | |||
| 9383e132e7 | |||
| 420e748bab | |||
| 0bb4da858b | |||
| 8c3ff3df94 | |||
| c044c30bd8 | |||
| faf0a4db9f | |||
| 9e8d479ce0 | |||
| 0d25099b91 | |||
| 532e03d354 | |||
| 0a0b4895de | |||
| bf28c307c9 | |||
| 8454d01b09 | |||
| 324dcc29b5 | |||
| 0fd478cadb | |||
| 23e7a417b2 | |||
| 089d79bc5f | |||
| 0ec987f39f | |||
| e0126c964e | |||
| 7ff407bafa | |||
| b6604629fc | |||
| 8a207d383c | |||
| 59b1e0513b | |||
| 10c2d50d23 | |||
| cd26b24252 | |||
| 9c8075eedd | |||
| 6bb9b06ebf | |||
| 198786d743 | |||
| d6b96068fb | |||
| 4aac57d40d | |||
| 219dbe0f4b | |||
| a71fab0c35 | |||
| f80a52b171 | |||
| e6ab45da74 | |||
| bc90145fca | |||
| 7a6765c1bd | |||
| 0695fb7472 | |||
| 3853e4a327 | |||
| 5909a46803 | |||
| 2068e6b0b7 | |||
| 2091f0f365 | |||
| 7e6153afa1 | |||
| 4b690ebd99 | |||
| 5b8988ff14 | |||
| e3fe31fff7 | |||
| cef7d1055a | |||
| c2831f8aca | |||
| 47a7aa8e81 | |||
| 3b6d1b6439 | |||
| 34b8b96a62 | |||
| 2df19af6ad | |||
| d528f6b372 | |||
| 86586ed344 | |||
| 4a4ed6ef02 | |||
| 3accf85f99 | |||
| 931ee7f493 | |||
| a57b0b79de | |||
| 5a11343a19 | |||
| ea63544998 | |||
| 95a434cd04 | |||
| c2650aae07 | |||
| e500af6102 | |||
| df1e65f5c2 | |||
| 1c4ade5e6c | |||
| 4c2c54229b | |||
| 2172d32dc6 | |||
| d66eb79295 | |||
| 3c121cb1ac | |||
| bd979cdb68 | |||
| fbf94970fa | |||
| ecd11f70c3 | |||
| 6a5cf4f375 | |||
| 7aff463580 | |||
| 1536590fa2 | |||
| c2afb6eafc | |||
| a92d2b46c8 | |||
| fdfe082e45 | |||
| 44563959ca | |||
| a3a9b01794 | |||
| 003c94f62f | |||
| dba96e6713 | |||
| b4c31b04dd | |||
| 78378f79fa | |||
| 193c8d78a1 | |||
| a4f46c67a2 | |||
| 429974dc33 | |||
| 1df47ccc02 | |||
| e25aba4d70 | |||
| 580b17e5b9 | |||
| 2b167a8df8 | |||
| f44d6def6b | |||
| 23eb2f9a1b | |||
| a92bb0287c | |||
| 73cad8f7d5 | |||
| c23b298f26 | |||
| 318de9cb74 | |||
| 228003b013 | |||
| feeeb89cfc | |||
|
|
f04b148b43 | ||
|
|
6f3ca3e21b | ||
| 331d6fbbb9 | |||
| 6450233fea | |||
| 25bd91934c | |||
| 95116de349 | |||
| 91db8a5fe0 | |||
| f70e2e4bcd | |||
| eb9e682b75 | |||
| 509ce93904 | |||
| 447f540b44 | |||
| a5fdd8b999 | |||
| 95ca4dd892 | |||
| e9576d7584 | |||
| 5a054d250e | |||
| 65a7b0f50d | |||
| b8521d2219 | |||
| edbd83f8e8 | |||
| c9059be57b | |||
| afbe74d975 | |||
| 9938bf6c57 | |||
| 91bf399ebc | |||
| b63a5c48a2 | |||
| 2a266c6b1e | |||
| 28df1fc5f7 | |||
| 1165458cfa | |||
| 8bed032a44 | |||
| f2a319af4d | |||
| 627421255d | |||
| cce840e7f5 | |||
| 7279eac949 | |||
| 4ffa6af298 | |||
| 7bff60f08a | |||
| 4e8ba7205b | |||
| c6090604ef | |||
| 30441c3e1f | |||
| 6da80df655 | |||
| 083c0ee0c9 | |||
| 8785c19b66 | |||
| 3e79f62805 | |||
| 50159286b4 | |||
| 749a275dc0 | |||
| 71dd130744 | |||
| f2b4a7994a | |||
| 518370842c | |||
| 68225cbb52 | |||
| e24ae1d113 | |||
| 9c3636f31a | |||
| ff8f760750 | |||
| 5f4010901a | |||
| 57be9a5d70 | |||
| 6dd84976de | |||
| a303c131f1 | |||
| dfe3c6b7d8 | |||
| 51bfc9d2af | |||
| 90e7a9323a | |||
| 1feee731cf | |||
| fa91c2a22d | |||
| f196862aeb | |||
| 118c028873 | |||
| 28251876e1 | |||
| 3331c96c06 | |||
| 4fc79bc692 | |||
| 96307d2979 | |||
| e08a0e1f71 | |||
| d64dbaf7df | |||
| d787625435 | |||
| 3f496ac65c | |||
| 8daad49061 | |||
| e99070c6d4 | |||
| 513bfe07f2 | |||
| 1e9874a482 | |||
| 26ff84d70f | |||
| 82072b256d | |||
| f8ba6db3d6 | |||
| a190ca417d | |||
| 4bf31fb511 | |||
| 46cff45d0e | |||
| ab5476d3d2 | |||
| f4061c0213 | |||
| 1eb28dad64 | |||
| 76a32e1dc4 | |||
| 4af68021f6 | |||
| 36213edd60 | |||
| f5dfb91ffe | |||
| f5fd50d472 | |||
| 698fcc9b5b | |||
| b95427998c | |||
| b6e06870aa | |||
| 246fa7439d | |||
| 53abdace1c | |||
| 76a92894e8 | |||
| 3a0e04dda9 | |||
| 29c0c0bb18 | |||
| e54d6b8142 | |||
| f4a9453514 | |||
| a9dfb77bc4 | |||
| 48b5523d95 | |||
| 345c3b05f7 | |||
| 585b02b37a | |||
| c480a9be1f | |||
| fe39e094d8 | |||
| f9c1fc9391 | |||
| 900e56d7ca | |||
| 019a519b81 | |||
| 28bfbe5257 | |||
| 6932d4b8d0 | |||
| 83f64a1dfe | |||
| 44a36e3fb7 | |||
| 931398fa67 | |||
| f13f7b7fc6 | |||
| e0749066ec | |||
| 233beb609c | |||
| e87b79bbe1 | |||
| 624a02c49d | |||
| 5d7518afd9 | |||
| 4606c790f1 | |||
| 05e963151c | |||
| 6012ba824f | |||
| 2324d2a8e6 | |||
| 36f1961c9e | |||
| aa650068b1 | |||
| 0edc8feb8d | |||
| 258c676e89 | |||
| fc1883a6c3 | |||
| e022b60920 | |||
| ea831b3e2d | |||
| 6e41bb2789 | |||
| 650b2e614a | |||
| 7c1045df93 | |||
| 31b763b714 | |||
| 679c26e43f | |||
| bdf09ccdb7 | |||
| ae0ac66c2a | |||
| 2cf5bc2017 | |||
| e25b0acf7d | |||
| 54aca51e2b | |||
| 3cd7302c9c | |||
| df0c00a2f9 | |||
| 125b2e2510 | |||
| 7e3e138fee | |||
| 79a518d941 | |||
| cfaf6e6640 | |||
| da9bb56d80 | |||
| 4403301f72 | |||
| c0606ef938 | |||
| 2ac8660435 | |||
| 9da9edae7f | |||
| 323c503581 | |||
| 0e1bf7a36f | |||
| f6f95bc164 | |||
| 391a4f750c | |||
| 905e00ef6a | |||
| 07a6b6e4aa | |||
| ad3817dfee | |||
| 065e3fd7d6 | |||
| bfb8cc0274 | |||
| 7a18461ca6 | |||
| f33c2e18af | |||
| c2e4cae253 | |||
| 767e056aad |
@@ -25,7 +25,6 @@ jobs:
|
||||
http = false
|
||||
insecure = true
|
||||
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -53,11 +52,6 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Install Kustomize
|
||||
run: |
|
||||
curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
|
||||
sudo mv kustomize /usr/local/bin/
|
||||
|
||||
- name: Update Kustomize
|
||||
run: |
|
||||
IMAGE_TAG=$(echo '${{ steps.meta.outputs.json }}' | jq -r '.tags[] | select(startswith("reg.milasholsting.dk/apps/mal:sha-"))' | cut -d: -f2)
|
||||
|
||||
54
.golangci.yml
Normal file
54
.golangci.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- copyloopvar
|
||||
- errcheck
|
||||
- govet
|
||||
- ineffassign
|
||||
- revive
|
||||
- staticcheck
|
||||
- unconvert
|
||||
- unused
|
||||
settings:
|
||||
revive:
|
||||
enable-all-rules: false
|
||||
rules:
|
||||
- name: blank-imports
|
||||
- name: context-as-argument
|
||||
- name: context-keys-type
|
||||
- name: early-return
|
||||
- name: error-naming
|
||||
- name: error-return
|
||||
- name: if-return
|
||||
- name: increment-decrement
|
||||
- name: range
|
||||
- name: receiver-naming
|
||||
- name: time-naming
|
||||
- name: unnecessary-stmt
|
||||
- name: var-declaration
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- node_modules$
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
4
.oxfmtrc.json
Normal file
4
.oxfmtrc.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"ignorePatterns": []
|
||||
}
|
||||
4
.oxlintignore
Normal file
4
.oxlintignore
Normal file
@@ -0,0 +1,4 @@
|
||||
dist/**
|
||||
node_modules/**
|
||||
server
|
||||
*.js
|
||||
15
.oxlintrc.json
Normal file
15
.oxlintrc.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "unicorn", "oxc"],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
},
|
||||
"rules": {
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/no-base-to-string": "off",
|
||||
"typescript/no-floating-promises": "off"
|
||||
},
|
||||
"env": {
|
||||
"builtin": true
|
||||
}
|
||||
}
|
||||
20
Dockerfile
20
Dockerfile
@@ -5,14 +5,22 @@ WORKDIR /app
|
||||
# Enable CGO for sqlite3
|
||||
ENV CGO_ENABLED=1
|
||||
|
||||
# Install sqlc for code generation
|
||||
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
unzip \
|
||||
gcc \
|
||||
libc6-dev \
|
||||
libsqlite3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install build dependencies for bun + assets
|
||||
RUN apt-get update && apt-get install -y ca-certificates sqlite3 curl unzip && rm -rf /var/lib/apt/lists/*
|
||||
# Install bun (for building frontend assets)
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
ENV PATH="/root/.bun/bin:${PATH}"
|
||||
|
||||
# Install sqlc for code generation
|
||||
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0
|
||||
|
||||
ENV GOPROXY=direct
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
@@ -31,6 +39,7 @@ RUN sqlc generate
|
||||
|
||||
# Build the server and CLI tools
|
||||
RUN go build -ldflags="-s -w" -o main_server ./cmd/server
|
||||
RUN go build -ldflags="-s -w" -o create-user ./cmd/user
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -46,11 +55,12 @@ RUN mkdir -p /app/data
|
||||
ENV DATABASE_FILE=/app/data/mal.db
|
||||
|
||||
COPY --from=builder /app/main_server .
|
||||
COPY --from=builder /app/create-user .
|
||||
COPY --from=builder /app/templates ./templates
|
||||
COPY --from=builder /app/static ./static
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/internal/database/migrations ./migrations
|
||||
COPY docker/entrypoint.sh ./entrypoint.sh
|
||||
COPY entrypoint.sh ./entrypoint.sh
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
127
README.md
127
README.md
@@ -1,136 +1,71 @@
|
||||
# MyAnimeList
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="/static/assets/readme-logo-dark.svg" />
|
||||
<img src="/static/assets/readme-logo-light.svg" alt="MyAnimeList logo" width="140" />
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<strong>MyAnimeList</strong><br />
|
||||
My personal anime tracker, built because nothing else felt right.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="/static/assets/readme-logo-dark.svg" />
|
||||
<img src="/static/assets/readme-logo-light.svg" alt="MyAnimeList logo" width="120" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/go-1.25-00ADD8?style=flat-square&logo=go" />
|
||||
<img alt="SQLite" src="https://img.shields.io/badge/database-sqlite-003B57?style=flat-square&logo=sqlite" />
|
||||
<img alt="Tailwind" src="https://img.shields.io/badge/tailwind-4-06B6D4?style=flat-square&logo=tailwindcss" />
|
||||
<img alt="Tailwind" src="https://img.shields.io/badge/tailwind-4-06D6D4?style=flat-square&logo=tailwindcss" />
|
||||
<img alt="HTMX" src="https://img.shields.io/badge/htmx-partial--updates-3366CC?style=flat-square" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Why this project exists
|
||||
I built this because nothing else felt right. Every tracker I tried had decent pieces but the whole never clicked — awkward UI, missing features, or it just got in the way of actually watching anime. So I built one that fits how I work.
|
||||
|
||||
I built this for myself.
|
||||
It is a self-hosted Go server that streams anime through a proxy layer, catalogs metadata, and tracks your progress.
|
||||
|
||||
I was frustrated with the UI and UX of every tracker I tried. Even when something looked decent, it still felt awkward to use day-to-day, or it was missing pieces I considered essential. I wanted one place that matched how I actually watch anime: search fast, get context fast, update status fast, and move on.
|
||||
|
||||
So this project is personal first and public second. I put it on GitHub because I like shipping in the open, not because it was originally designed as a general-purpose product for everyone.
|
||||
|
||||
Technically, I also wanted to prove that a small, server-rendered Go app could stay reliable even when upstream anime APIs are inconsistent. A lot of this code exists because real APIs rate-limit, timeout, and occasionally fail at the worst possible moment.
|
||||
|
||||
## What the application offers
|
||||
|
||||
For my own workflow, MyAnimeList combines catalog browsing, seasonal discovery, quick search, detail pages with recommendations and relations, watchlist management, continue-watching, and in-app playback in one server-rendered interface.
|
||||
|
||||
The interface is minimal and functional, featuring a dark theme and quick access to tracking tools.
|
||||
|
||||
## Technical approach
|
||||
|
||||
The application is written in Go and rendered on the server with `html/template`, with SQLite as the primary datastore and `sqlc` for typed query generation. Styling uses Tailwind CSS v4. HTMX and small TypeScript modules handle incremental interactions, which keeps the interface responsive without moving the entire product into a heavy client-side architecture.
|
||||
|
||||
The external anime data source is Jikan (`https://api.jikan.moe/v4`). Because reliability is a first-class concern, the client layer includes request pacing, bounded retries, backoff behavior, stale-cache fallback, and a persisted retry queue for failed fetches. Playback proxying uses uTLS to bypass Cloudflare protections.
|
||||
|
||||
Upstream APIs can fail transiently with `429` and `5xx` responses, so the app favors graceful degradation over hard failure. Cached values are used when fresh requests fail, retryable failures are persisted and replayed in a background worker, and relation synchronization is incremental so one bad fetch does not block the rest of the graph.
|
||||
The frontend is Tailwind CSS v4 with HTMX handling pagination, infinite scroll, search, and watchlist interactions. TypeScript only steps in where HTMX cannot — the video player, command palette bound to Cmd+K, skip segment editor, theme toggling with system preference detection, and custom UI components. Everything lives in one process, one SQLite database, one deployment.
|
||||
|
||||
## Repository structure
|
||||
|
||||
The codebase follows standard Go project layout conventions.
|
||||
|
||||
| Path | Purpose |
|
||||
| ----------------- | ------------------------------------------------ |
|
||||
| `api/*` | Feature routes: anime, auth, playback, watchlist |
|
||||
| `cmd/server` | Application entrypoint and CLI commands |
|
||||
| `cmd/user` | User management CLI (create, update, delete) |
|
||||
| `integrations/*` | External API clients and scraping |
|
||||
| `internal/*` | Core services: db, middleware, server, worker |
|
||||
| `pkg/middleware` | Generic HTTP middleware |
|
||||
| `templates/*` | Server-rendered HTML templates |
|
||||
| `migrations` | Schema evolution |
|
||||
| `migrations` | Schema evolution (20 migrations) |
|
||||
| `static` / `dist` | Frontend assets |
|
||||
|
||||
## Getting started
|
||||
## Running locally
|
||||
|
||||
Requires Go `1.25+`, Bun, and [just](https://github.com/casey/just) (`brew install just`).
|
||||
Requires Go `1.25+`, Bun, and [just](https://github.com/casey/just). Migrations run on startup. Configuration lives in environment variables — see `cmd/server/main.go` for the full list.
|
||||
|
||||
An optional API key from [animeschedule.net](https://animeschedule.net) can be used for the schedule board to enable English titles and improve performance. Create an account, generate a token under your profile, and set it as `ANIMESCHEDULE_API_TOKEN`.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mkelvers/mal.git && cd mal
|
||||
openssl rand -base32 32
|
||||
PLAYBACK_PROXY_SECRET="your-32-char-secret" go run ./cmd/server
|
||||
go run ./cmd/user <username> <password>
|
||||
just dev
|
||||
```
|
||||
|
||||
The app runs at `http://localhost:3000`.
|
||||
|
||||
### Tasks
|
||||
|
||||
The justfile automates common tasks:
|
||||
|
||||
```bash
|
||||
just fmt # format go code
|
||||
just lint # go fmt && go vet
|
||||
just test # run go tests
|
||||
just build # build go binary + frontend
|
||||
just check # lint, test, typecheck, build
|
||||
just dev # build and run
|
||||
just install-hooks # install pre-push hooks
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker build -t mal .
|
||||
docker run --rm -p 3000:3000 -e PLAYBACK_PROXY_SECRET="$(openssl rand -base32 32)" mal
|
||||
|
||||
# persistent data
|
||||
docker run --rm -p 3000:3000 \
|
||||
-e DATABASE_FILE=/app/data/mal.db \
|
||||
-e PLAYBACK_PROXY_SECRET="your-secret" \
|
||||
-v "$(pwd)/data:/app/data" \
|
||||
mal
|
||||
|
||||
docker exec mal ./cmd/user <username> <password>
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ----------------------- | ------------------- | ----------------------------------------------------------- |
|
||||
| `PORT` | `3000` | HTTP listen port |
|
||||
| `DATABASE_FILE` | `mal.db` | SQLite database file path |
|
||||
| `ENV` | _(empty)_ | Set to `production` to enable secure session cookies |
|
||||
| `MIGRATIONS_DIR` | _(auto-discovered)_ | Optional explicit path to migration files |
|
||||
| `PLAYBACK_PROXY_SECRET` | _(required)_ | HMAC secret for signed playback proxy tokens (min 32 chars) |
|
||||
| `MAL_JIKAN_TRACE` | `false` | Log all Jikan cache/upstream timings when enabled |
|
||||
|
||||
## Testing
|
||||
|
||||
Run locally with `just check` or manually:
|
||||
## Quality checks
|
||||
|
||||
```bash
|
||||
gofmt -l .
|
||||
go test ./...
|
||||
go build -o server ./cmd/server
|
||||
golangci-lint run ./...
|
||||
go mod tidy
|
||||
go test -race ./...
|
||||
bunx oxfmt --check
|
||||
bun run lint:ts
|
||||
bun run typecheck
|
||||
bun run build:assets
|
||||
docker build -t mal:ci .
|
||||
```
|
||||
|
||||
Migrations run automatically on startup.
|
||||
## Contributing
|
||||
|
||||
## Security
|
||||
|
||||
Keep secrets out of version control, do not publish real credentials in documentation or screenshots, and report security issues privately before public disclosure.
|
||||
Bug reports and pull requests are welcome. This is a personal project, so there is no strict roadmap or issue triage cycle. If something is broken or missing, open an issue or send a PR.
|
||||
|
||||
## License
|
||||
|
||||
This project is released under the MIT License. See `LICENSE` for details.
|
||||
MIT. See `LICENSE`.
|
||||
|
||||
333
bun.lock
333
bun.lock
@@ -5,49 +5,22 @@
|
||||
"": {
|
||||
"name": "myanimelist-ui",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.1",
|
||||
"htmx.org": "1.9.12",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.2.4",
|
||||
"@toolwind/anchors": "^1.0.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
||||
"@typescript-eslint/parser": "^8.59.2",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"@tailwindcss/cli": "^4.3.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"jiti": "^2.7.0",
|
||||
"lefthook": "^2.1.6",
|
||||
"prettier": "^3.8.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"oxfmt": "^0.52.0",
|
||||
"oxlint": "^1.67.0",
|
||||
"oxlint-tsgolint": "^0.23.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
|
||||
|
||||
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -58,6 +31,94 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.52.0", "", { "os": "android", "cpu": "arm" }, "sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ=="],
|
||||
|
||||
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.52.0", "", { "os": "android", "cpu": "arm64" }, "sha512-A2G1IdwGEW2lLJkIxcvuirRH1CzSl/e0NX11zTlW1gvxJThfwbI/BEoaKrTNpm7M2FchvIf6guvIQU7d5iz+OQ=="],
|
||||
|
||||
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.52.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-f9+bLvOYxy7NttCLFTvQ7afmqDOWY4wIP9xdvfj5trQ1qj6f2UFAGwZESlfsMjvJNTyRpXfIlOanCI9FOvoeQA=="],
|
||||
|
||||
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.52.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-YSTB9sJ5nnQd/Q0ddHkgof0ZCHPAnWZT1IW2SJ8omz7CP7KluJhO1fNHrpqdxCtpztJwSs4hY1uAee35wKxxaw=="],
|
||||
|
||||
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.52.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-NIrRNTTPCs4UbmVs0bxLSCDlLCtIRMJIXklNKaXa5Oj2/K1UIMBvgE8+uPVo01Io3N9HF0+GAX+aAHjUgZS7vA=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JXUCde8mn3GpgQouz2PXUokgy/uT1QrRJBL2s983VWcSQp62wTFYiNXgTKdeo1Jgbr0IgUnKKvzIk/YBlj/nVQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-psbUXaRZ+V8DaXz10Qf7LSHtdtdKAmC8fxXgeU608jjzrmWK4quamZMOpl6sf+dikoFHA85uE93Q0BqxrCdQrQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Jw7MgWUU9lcLCcy82updISP3EthTlfvAwR6gWNxPzqly7+fLvOi2gHQE9xXQjpqaVLm/8P+gOzlv9ODuoVlaaw=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg=="],
|
||||
|
||||
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.52.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA=="],
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA=="],
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw=="],
|
||||
|
||||
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.52.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg=="],
|
||||
|
||||
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA=="],
|
||||
|
||||
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw=="],
|
||||
|
||||
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.52.0", "", { "os": "none", "cpu": "arm64" }, "sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ=="],
|
||||
|
||||
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.52.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-k2sz6gWQdMfh5HPpIS+Bw/0UEV/kaK2xuqJRrWL233sEHx9WLlsmvlPFM4HUNThkYbSN0U0vPW7LVKZWDS8hPQ=="],
|
||||
|
||||
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.52.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-rhke69GTcArodLHpjMTfNnvjTEBryDeZcUCKK/VjXDMtfTULl6QRh0ymX5/hbCUv2WjYm9h/QbW++q2vE15gWQ=="],
|
||||
|
||||
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.52.0", "", { "os": "win32", "cpu": "x64" }, "sha512-q5xL7oeXkZdEtNZWBdvehJcmt+GRu9l2bK40yJs1jJXlqq+r0Hygb1rTjq+FM2o/2xyt4cufH6KRplHp3Jjsvw=="],
|
||||
|
||||
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.23.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw=="],
|
||||
|
||||
"@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.23.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA=="],
|
||||
|
||||
"@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.23.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw=="],
|
||||
|
||||
"@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.23.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA=="],
|
||||
|
||||
"@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.23.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw=="],
|
||||
|
||||
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.23.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ=="],
|
||||
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.67.0", "", { "os": "android", "cpu": "arm" }, "sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw=="],
|
||||
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.67.0", "", { "os": "android", "cpu": "arm64" }, "sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ=="],
|
||||
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.67.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg=="],
|
||||
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.67.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg=="],
|
||||
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.67.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w=="],
|
||||
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.67.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.67.0", "", { "os": "linux", "cpu": "none" }, "sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.67.0", "", { "os": "linux", "cpu": "none" }, "sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA=="],
|
||||
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.67.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg=="],
|
||||
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.67.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g=="],
|
||||
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.67.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA=="],
|
||||
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.67.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg=="],
|
||||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.67.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ=="],
|
||||
|
||||
"@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
|
||||
|
||||
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
|
||||
@@ -86,154 +147,52 @@
|
||||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
|
||||
|
||||
"@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
|
||||
"@tailwindcss/cli": ["@tailwindcss/cli@4.3.0", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "enhanced-resolve": "^5.21.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.3.0" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-X9kdlqyMopO9fewbgHsEeuy31YzMHbdZ9VsKt004tB+mxSg1CNbyhZYCzvhciN0AM4R4b5lvIprPjtNq7iQxpQ=="],
|
||||
|
||||
"@tailwindcss/cli": ["@tailwindcss/cli@4.2.4", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "enhanced-resolve": "^5.19.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.2.4" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-e87GGhuXxnyQPyA0TS8an/3wNpj+OUmx8u0F4BicYr48TF72032AIu5917rRYaWm7HorXi3GSZ/uG+ohqP6AKA=="],
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="],
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="],
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="],
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="],
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="],
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="],
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="],
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="],
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="],
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="],
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="],
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="],
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="],
|
||||
|
||||
"@toolwind/anchors": ["@toolwind/anchors@1.0.10", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0" } }, "sha512-F3J/lxGGPUy+GIpT49NmYMF1X7l0d7UzdDASni29il2ro5sT4cYfPBFHBAfOM0lpgKOr/HnqINlomngt8BcvnA=="],
|
||||
|
||||
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@10.3.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw=="],
|
||||
|
||||
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
|
||||
|
||||
"eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.5", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.12" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
|
||||
|
||||
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
|
||||
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
"htmx.org": ["htmx.org@1.9.12", "", {}, "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"lefthook": ["lefthook@2.1.6", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.6", "lefthook-darwin-x64": "2.1.6", "lefthook-freebsd-arm64": "2.1.6", "lefthook-freebsd-x64": "2.1.6", "lefthook-linux-arm64": "2.1.6", "lefthook-linux-x64": "2.1.6", "lefthook-openbsd-arm64": "2.1.6", "lefthook-openbsd-x64": "2.1.6", "lefthook-windows-arm64": "2.1.6", "lefthook-windows-x64": "2.1.6" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-w9sBoR0mdN+kJc3SB85VzpiAAl451/rxdCRcZlwW71QLjkeH3EBQFgc4VMj5apePychYDHAlqEWTB8J8JK/j1Q=="],
|
||||
|
||||
"lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ=="],
|
||||
@@ -256,8 +215,6 @@
|
||||
|
||||
"lefthook-windows-x64": ["lefthook-windows-x64@2.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-q4z2n3xucLscoWiyMwFViEj3N8MDSkPulMwcJYuCYFHoPhP1h+icqNu7QRLGYj6AnVrCQweiUJY3Tb2X+GbD/A=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
@@ -282,88 +239,44 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
"oxfmt": ["oxfmt@0.52.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.52.0", "@oxfmt/binding-android-arm64": "0.52.0", "@oxfmt/binding-darwin-arm64": "0.52.0", "@oxfmt/binding-darwin-x64": "0.52.0", "@oxfmt/binding-freebsd-x64": "0.52.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.52.0", "@oxfmt/binding-linux-arm-musleabihf": "0.52.0", "@oxfmt/binding-linux-arm64-gnu": "0.52.0", "@oxfmt/binding-linux-arm64-musl": "0.52.0", "@oxfmt/binding-linux-ppc64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-musl": "0.52.0", "@oxfmt/binding-linux-s390x-gnu": "0.52.0", "@oxfmt/binding-linux-x64-gnu": "0.52.0", "@oxfmt/binding-linux-x64-musl": "0.52.0", "@oxfmt/binding-openharmony-arm64": "0.52.0", "@oxfmt/binding-win32-arm64-msvc": "0.52.0", "@oxfmt/binding-win32-ia32-msvc": "0.52.0", "@oxfmt/binding-win32-x64-msvc": "0.52.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
"oxlint": ["oxlint@1.67.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.67.0", "@oxlint/binding-android-arm64": "1.67.0", "@oxlint/binding-darwin-arm64": "1.67.0", "@oxlint/binding-darwin-x64": "1.67.0", "@oxlint/binding-freebsd-x64": "1.67.0", "@oxlint/binding-linux-arm-gnueabihf": "1.67.0", "@oxlint/binding-linux-arm-musleabihf": "1.67.0", "@oxlint/binding-linux-arm64-gnu": "1.67.0", "@oxlint/binding-linux-arm64-musl": "1.67.0", "@oxlint/binding-linux-ppc64-gnu": "1.67.0", "@oxlint/binding-linux-riscv64-gnu": "1.67.0", "@oxlint/binding-linux-riscv64-musl": "1.67.0", "@oxlint/binding-linux-s390x-gnu": "1.67.0", "@oxlint/binding-linux-x64-gnu": "1.67.0", "@oxlint/binding-linux-x64-musl": "1.67.0", "@oxlint/binding-openharmony-arm64": "1.67.0", "@oxlint/binding-win32-arm64-msvc": "1.67.0", "@oxlint/binding-win32-ia32-msvc": "1.67.0", "@oxlint/binding-win32-x64-msvc": "1.67.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
"oxlint-tsgolint": ["oxlint-tsgolint@0.23.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.23.0", "@oxlint-tsgolint/darwin-x64": "0.23.0", "@oxlint-tsgolint/linux-arm64": "0.23.0", "@oxlint-tsgolint/linux-x64": "0.23.0", "@oxlint-tsgolint/win32-arm64": "0.23.0", "@oxlint-tsgolint/win32-x64": "0.23.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
||||
|
||||
"prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
|
||||
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# cmd
|
||||
|
||||
Executables live here.
|
||||
Application entrypoints.
|
||||
|
||||
| binary | purpose |
|
||||
| ------------ | ----------------- |
|
||||
| `cmd/server` | web server |
|
||||
| `cmd/user` | user creation CLI |
|
||||
| binary | purpose |
|
||||
| ------------ | -------------------------------- |
|
||||
| `cmd/server` | HTTP server and worker processes |
|
||||
| `cmd/user` | User management CLI |
|
||||
|
||||
## Conventions
|
||||
|
||||
- Each subdirectory is a `package main` that compiles to a standalone binary.
|
||||
- Shared logic lives in `internal/` or `pkg/`, not in `cmd/`.
|
||||
- Configuration is read from environment variables — see each binary's `main.go` for the full list.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package main runs the MAL web server.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
228
cmd/user/main.go
228
cmd/user/main.go
@@ -1,87 +1,194 @@
|
||||
// Package main provides small CLI utilities for local admin tasks.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"mal/internal"
|
||||
"mal/internal/config"
|
||||
"mal/internal/database"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dbConn, err := db.Open(db.GetDBFile())
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to open db: %v", err)
|
||||
observability.Error("cli_config_load_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dbConn, err := db.Open(cfg.DatabaseFile)
|
||||
if err != nil {
|
||||
observability.Error("cli_db_open_failed", "cmd/user", "", map[string]any{"db_file": cfg.DatabaseFile}, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = dbConn.Close() }()
|
||||
|
||||
if len(os.Args) == 2 && os.Args[1] == "update-avatar" {
|
||||
os.Exit(run(dbConn, os.Args))
|
||||
}
|
||||
|
||||
func run(dbConn *sql.DB, args []string) int {
|
||||
cmd, err := parseArgs(args)
|
||||
if err != nil {
|
||||
observability.Warn("cli_usage", "cmd/user", "invalid arguments", map[string]any{"argc": len(args)}, err)
|
||||
_, _ = fmt.Fprintln(os.Stderr, usage())
|
||||
return 2
|
||||
}
|
||||
|
||||
switch cmd.kind {
|
||||
case commandUpdateAvatar:
|
||||
updateAvatars(dbConn)
|
||||
return
|
||||
return 0
|
||||
case commandRunFixes:
|
||||
runFixes(dbConn)
|
||||
return 0
|
||||
case commandCreateOrUpdateUser:
|
||||
if err := createOrUpdateUser(dbConn, cmd.username, cmd.password); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
observability.Error("cli_command_unreachable", "cmd/user", "", map[string]any{"kind": cmd.kind}, errors.New("unhandled command"))
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
type commandKind string
|
||||
|
||||
const (
|
||||
commandUpdateAvatar commandKind = "update-avatar"
|
||||
commandRunFixes commandKind = "run-fixes"
|
||||
commandCreateOrUpdateUser commandKind = "create-or-update-user"
|
||||
)
|
||||
|
||||
type command struct {
|
||||
kind commandKind
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func parseArgs(args []string) (command, error) {
|
||||
if len(args) == 2 {
|
||||
switch args[1] {
|
||||
case string(commandUpdateAvatar):
|
||||
return command{kind: commandUpdateAvatar}, nil
|
||||
case string(commandRunFixes):
|
||||
return command{kind: commandRunFixes}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(os.Args) != 3 {
|
||||
log.Fatalf("Usage: go run cmd/user/main.go <username> <password>\n go run cmd/user/main.go update-avatar")
|
||||
if len(args) == 3 {
|
||||
return command{
|
||||
kind: commandCreateOrUpdateUser,
|
||||
username: args[1],
|
||||
password: args[2],
|
||||
}, nil
|
||||
}
|
||||
|
||||
username := os.Args[1]
|
||||
password := os.Args[2]
|
||||
return command{}, errors.New("invalid arguments")
|
||||
}
|
||||
|
||||
var existingID string
|
||||
err = dbConn.QueryRow("SELECT id FROM user WHERE username = ?", username).Scan(&existingID)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
log.Fatalf("database error: %v", err)
|
||||
func usage() string {
|
||||
return "Usage: go run cmd/user/main.go <username> <password>\n go run cmd/user/main.go update-avatar\n go run cmd/user/main.go run-fixes"
|
||||
}
|
||||
|
||||
func createOrUpdateUser(dbConn *sql.DB, username string, password string) error {
|
||||
existingID, err := lookupUserID(dbConn, username)
|
||||
if err != nil {
|
||||
observability.Error("cli_user_lookup_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
fmt.Printf("User '%s' already exists. Do you want to overwrite their password? [y/N]: ", username)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
|
||||
if response != "y" && response != "yes" {
|
||||
if existingID != "" {
|
||||
if !promptConfirmOverwrite(username) {
|
||||
fmt.Println("Operation cancelled.")
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to hash password: %v", err)
|
||||
if err := updateUserPassword(dbConn, existingID, username, password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = dbConn.Exec("UPDATE user SET password_hash = ? WHERE id = ?", string(hash), existingID)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to update user: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Password for '%s' updated successfully!\n", username)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := createUser(dbConn, username, password); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("User '%s' was created successfully!\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupUserID(dbConn *sql.DB, username string) (string, error) {
|
||||
var id string
|
||||
err := dbConn.QueryRow("SELECT id FROM user WHERE username = ?", username).Scan(&id)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
func promptConfirmOverwrite(username string) bool {
|
||||
fmt.Printf("User '%s' already exists. Do you want to overwrite their password? [y/N]: ", username)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
return response == "y" || response == "yes"
|
||||
}
|
||||
|
||||
func updateUserPassword(dbConn *sql.DB, userID string, username string, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to hash password: %v", err)
|
||||
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = dbConn.Exec("UPDATE user SET password_hash = ? WHERE id = ?", string(hash), userID)
|
||||
if err != nil {
|
||||
observability.Error("cli_user_password_update_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createUser(dbConn *sql.DB, username string, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
|
||||
return err
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
avatarURL := fmt.Sprintf("https://api.dicebear.com/9.x/dylan/svg?seed=%s", username)
|
||||
_, err = dbConn.Exec("INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)", id, username, string(hash), avatarURL)
|
||||
avatarURL := internal.DefaultAvatarURL(username)
|
||||
_, err = dbConn.Exec(
|
||||
"INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)",
|
||||
id,
|
||||
username,
|
||||
string(hash),
|
||||
avatarURL,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create user: %v", err)
|
||||
observability.Error("cli_user_create_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("User '%s' was created successfully!\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateAvatars(dbConn *sql.DB) {
|
||||
rows, err := dbConn.Query("SELECT id, username FROM user")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to fetch users: %v", err)
|
||||
observability.Error("cli_users_list_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
@@ -89,20 +196,55 @@ func updateAvatars(dbConn *sql.DB) {
|
||||
for rows.Next() {
|
||||
var id, username string
|
||||
if err := rows.Scan(&id, &username); err != nil {
|
||||
log.Fatalf("failed to scan user: %v", err)
|
||||
observability.Error("cli_user_scan_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
avatarURL := fmt.Sprintf("https://api.dicebear.com/9.x/dylan/svg?seed=%s", username)
|
||||
avatarURL := internal.DefaultAvatarURL(username)
|
||||
_, err := dbConn.Exec("UPDATE user SET avatar_url = ? WHERE id = ?", avatarURL, id)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to update avatar for %s: %v", username, err)
|
||||
observability.Error("cli_user_avatar_update_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Fatalf("iteration error: %v", err)
|
||||
observability.Error("cli_users_iter_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Updated avatars for %d user(s)\n", count)
|
||||
}
|
||||
|
||||
func runFixes(dbConn *sql.DB) {
|
||||
if err := database.RunMigrationsAndFixes(dbConn); err != nil {
|
||||
observability.Error("cli_run_migrations_and_fixes_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
rows, err := dbConn.Query("SELECT id, applied_at FROM data_fixes ORDER BY id ASC")
|
||||
if err != nil {
|
||||
observability.Error("cli_data_fixes_list_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var appliedAt string
|
||||
if err := rows.Scan(&id, &appliedAt); err != nil {
|
||||
observability.Error("cli_data_fix_scan_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("%s applied_at=%s\n", id, appliedAt)
|
||||
count++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
observability.Error("cli_data_fixes_iter_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Applied fixes: %d\n", count)
|
||||
}
|
||||
|
||||
@@ -9,4 +9,4 @@ spec:
|
||||
destination:
|
||||
name: mal
|
||||
create: true
|
||||
refreshAfter: 1h
|
||||
refreshAfter: 1h
|
||||
|
||||
@@ -8,7 +8,7 @@ spec:
|
||||
app: mal
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: mal
|
||||
protocol: TCP
|
||||
port: 3000
|
||||
targetPort: 3000
|
||||
- name: mal
|
||||
protocol: TCP
|
||||
port: 3000
|
||||
targetPort: 3000
|
||||
|
||||
@@ -17,4 +17,4 @@ namespace: mal
|
||||
images:
|
||||
- name: main
|
||||
newName: reg.milasholsting.dk/apps/mal
|
||||
newTag: latest
|
||||
newTag: sha-30a00eb
|
||||
|
||||
@@ -9,3 +9,4 @@ if [ ! -x /app/main_server ]; then
|
||||
fi
|
||||
|
||||
exec /app/main_server
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import tseslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import prettier from 'eslint-plugin-prettier';
|
||||
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', 'server', '*.js'],
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint,
|
||||
prettier,
|
||||
},
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
rules: {
|
||||
...eslintConfigPrettier.rules,
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||
],
|
||||
'prettier/prettier': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,12 +0,0 @@
|
||||
# MAL Firefox Extension (dev)
|
||||
|
||||
## Load in Firefox
|
||||
|
||||
1. Open `about:debugging#/runtime/this-firefox`
|
||||
2. Click **Load Temporary Add-on…**
|
||||
3. Select `extensions/mal-firefox/manifest.json`
|
||||
|
||||
## Usage
|
||||
|
||||
- Click the toolbar icon to open the popup and log in.
|
||||
- After login, select text on any page → right click → **MyAnimeList** → **Add to Watchlist** → pick a status.
|
||||
@@ -1,103 +0,0 @@
|
||||
const MENU_ROOT_ID = 'mal-root';
|
||||
const MENU_WATCHLIST_ID = 'mal-watchlist';
|
||||
const MENU_STATUS_PREFIX = 'mal-status:';
|
||||
const STATUSES = [
|
||||
{ value: 'watching', label: 'Watching' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'on_hold', label: 'On Hold' },
|
||||
{ value: 'dropped', label: 'Dropped' },
|
||||
{ value: 'plan_to_watch', label: 'Plan to Watch' },
|
||||
];
|
||||
|
||||
async function getSettings() {
|
||||
const { authToken, apiBaseUrl } = await browser.storage.local.get(['authToken', 'apiBaseUrl']);
|
||||
return {
|
||||
authToken: authToken || '',
|
||||
apiBaseUrl: apiBaseUrl || 'https://mal.mkelvers.tech',
|
||||
};
|
||||
}
|
||||
|
||||
async function apiFetch(path, init = {}) {
|
||||
const { authToken, apiBaseUrl } = await getSettings();
|
||||
const url = apiBaseUrl.replace(/\/+$/, '') + path;
|
||||
const headers = new Headers(init.headers || {});
|
||||
if (authToken) headers.set('Authorization', `Bearer ${authToken}`);
|
||||
const res = await fetch(url, { ...init, headers });
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => '');
|
||||
throw new Error(msg || `HTTP ${res.status}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function ensureContextMenu() {
|
||||
const { authToken } = await getSettings();
|
||||
await browser.contextMenus.removeAll();
|
||||
if (!authToken) return;
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: MENU_ROOT_ID,
|
||||
title: 'MyAnimeList',
|
||||
contexts: ['selection'],
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: MENU_WATCHLIST_ID,
|
||||
parentId: MENU_ROOT_ID,
|
||||
title: 'Add to Watchlist',
|
||||
contexts: ['selection'],
|
||||
});
|
||||
|
||||
for (const s of STATUSES) {
|
||||
browser.contextMenus.create({
|
||||
id: MENU_STATUS_PREFIX + s.value,
|
||||
parentId: MENU_WATCHLIST_ID,
|
||||
title: s.label,
|
||||
contexts: ['selection'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
browser.runtime.onInstalled.addListener(() => {
|
||||
ensureContextMenu();
|
||||
});
|
||||
|
||||
browser.runtime.onStartup.addListener(() => {
|
||||
ensureContextMenu();
|
||||
});
|
||||
|
||||
browser.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== 'local') return;
|
||||
if (changes.authToken) ensureContextMenu();
|
||||
});
|
||||
|
||||
browser.contextMenus.onClicked.addListener(async info => {
|
||||
if (typeof info.menuItemId !== 'string') return;
|
||||
if (!info.menuItemId.startsWith(MENU_STATUS_PREFIX)) return;
|
||||
|
||||
const status = info.menuItemId.slice(MENU_STATUS_PREFIX.length);
|
||||
const text = (info.selectionText || '').trim().replace(/\s+/g, ' ').slice(0, 120);
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
const searchRes = await apiFetch(`/api/search-quick?q=${encodeURIComponent(text)}`);
|
||||
const items = await searchRes.json();
|
||||
const top = items && items[0];
|
||||
if (!top || !top.id) {
|
||||
await browser.notifications?.create?.({
|
||||
type: 'basic',
|
||||
title: 'MyAnimeList',
|
||||
message: `No matches for: ${text}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await apiFetch('/api/watchlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ animeId: top.id, status }),
|
||||
});
|
||||
} catch {
|
||||
// Silent failure by default; can be extended with notifications later.
|
||||
}
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="35%" cy="35%" r="75%">
|
||||
<stop offset="0%" style="stop-color: var(--accent, #0466c8)" />
|
||||
<stop offset="100%" style="stop-color: var(--accent-dark, #1d4ed8)" />
|
||||
</radialGradient>
|
||||
<clipPath id="clip">
|
||||
<circle cx="50" cy="50" r="45" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<!-- Base -->
|
||||
<circle cx="50" cy="50" r="45" fill="url(#bg)" />
|
||||
|
||||
<!-- Crescent moon cutout -->
|
||||
<g clip-path="url(#clip)">
|
||||
<path
|
||||
d="M70 50a25 25 0 1 1 -25 -25 20 20 0 1 0 25 25z"
|
||||
fill="#FFF7ED"
|
||||
transform="translate(-2 -2)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 685 B |
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "MyAnimeList",
|
||||
"version": "0.1.0",
|
||||
"description": "Right-click selected anime titles and add them to your watchlist.",
|
||||
"permissions": ["contextMenus", "storage"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"scripts": ["background.js"]
|
||||
},
|
||||
"action": {
|
||||
"default_title": "MAL Watchlist",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"icons": {
|
||||
"48": "icon.svg"
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #0b0f1a;
|
||||
--card: rgba(255, 255, 255, 0.06);
|
||||
--border: rgba(255, 255, 255, 0.12);
|
||||
--text: rgba(255, 255, 255, 0.92);
|
||||
--muted: rgba(255, 255, 255, 0.65);
|
||||
--accent: #6ea8fe;
|
||||
--danger: #ff6b6b;
|
||||
--ok: #4ade80;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f6f7fb;
|
||||
--card: rgba(0, 0, 0, 0.03);
|
||||
--border: rgba(0, 0, 0, 0.1);
|
||||
--text: rgba(0, 0, 0, 0.88);
|
||||
--muted: rgba(0, 0, 0, 0.6);
|
||||
--accent: #1f6feb;
|
||||
--danger: #b42318;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font:
|
||||
14px/1.4 system-ui,
|
||||
-apple-system,
|
||||
Segoe UI,
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 380px;
|
||||
min-width: 380px;
|
||||
}
|
||||
|
||||
#app {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brandIcon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.link {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 0;
|
||||
padding: 6px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: transparent;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 9px 10px;
|
||||
border-radius: 0;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border: 1px solid var(--border);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
background: rgba(110, 168, 254, 0.18);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: rgba(255, 107, 107, 0.18);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.body {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.login {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.statusRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 44px;
|
||||
height: 62px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metaTitle {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.metaSub {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mini {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
background: rgba(110, 168, 254, 0.18);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MAL Watchlist</title>
|
||||
<link rel="stylesheet" href="popup.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<section class="panel">
|
||||
<header class="header">
|
||||
<div class="brand">
|
||||
<img class="brandIcon" src="icon.svg" alt="" />
|
||||
<div class="title">MyAnimeList</div>
|
||||
</div>
|
||||
<button id="logoutBtn" class="link" hidden>Log out</button>
|
||||
</header>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="body">
|
||||
Select an anime title on any page, then right click to open the context menu. Under
|
||||
“MyAnimeList”, choose “Add to Watchlist” and pick a status to save it to your watchlist.
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div id="loggedIn" class="statusRow" hidden>
|
||||
<div class="statusDot"></div>
|
||||
<div class="statusText">Signed in — context menu enabled</div>
|
||||
</div>
|
||||
|
||||
<div id="login" class="login" hidden>
|
||||
<label class="label">
|
||||
Username
|
||||
<input id="username" class="input" autocomplete="username" />
|
||||
</label>
|
||||
<label class="label">
|
||||
Password
|
||||
<input id="password" class="input" type="password" autocomplete="current-password" />
|
||||
</label>
|
||||
<button id="loginBtn" class="btn">Log in</button>
|
||||
<div id="loginErr" class="error" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,74 +0,0 @@
|
||||
function qs(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
async function getSettings() {
|
||||
const { authToken, apiBaseUrl } = await browser.storage.local.get(['authToken', 'apiBaseUrl']);
|
||||
return {
|
||||
authToken: authToken || '',
|
||||
apiBaseUrl: apiBaseUrl || 'https://mal.mkelvers.tech',
|
||||
};
|
||||
}
|
||||
|
||||
async function setSettings(patch) {
|
||||
await browser.storage.local.set(patch);
|
||||
}
|
||||
|
||||
function show(el, on) {
|
||||
el.hidden = !on;
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const settings = await getSettings();
|
||||
document.body.dataset.state = settings.authToken ? 'in' : 'out';
|
||||
|
||||
const logoutBtn = qs('logoutBtn');
|
||||
logoutBtn.addEventListener('click', async () => {
|
||||
await setSettings({ authToken: '' });
|
||||
await render();
|
||||
});
|
||||
|
||||
const hasToken = !!settings.authToken;
|
||||
show(logoutBtn, hasToken);
|
||||
show(qs('login'), !hasToken);
|
||||
show(qs('loggedIn'), hasToken);
|
||||
|
||||
if (!hasToken) {
|
||||
setupLogin();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function setupLogin() {
|
||||
const loginErr = qs('loginErr');
|
||||
show(loginErr, false);
|
||||
|
||||
qs('loginBtn').onclick = async () => {
|
||||
show(loginErr, false);
|
||||
const username = qs('username').value.trim();
|
||||
const password = qs('password').value;
|
||||
if (!username || !password) {
|
||||
loginErr.textContent = 'Missing username or password';
|
||||
show(loginErr, true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { apiBaseUrl } = await getSettings();
|
||||
const res = await fetch(apiBaseUrl.replace(/\/+$/, '') + '/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, name: 'Firefox extension' }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Invalid username or password');
|
||||
const data = await res.json();
|
||||
await setSettings({ authToken: data.token });
|
||||
await render();
|
||||
} catch (e) {
|
||||
loginErr.textContent = e.message || 'Login failed';
|
||||
show(loginErr, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
render();
|
||||
489
integrations/animeschedule/animeschedule.go
Normal file
489
integrations/animeschedule/animeschedule.go
Normal file
@@ -0,0 +1,489 @@
|
||||
// Package animeschedule provides an integration with the animeschedule.net API.
|
||||
package animeschedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
netutil "mal/pkg/net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
type AirType string
|
||||
|
||||
const (
|
||||
AirTypeJPN AirType = "JPN"
|
||||
AirTypeSUB AirType = "SUB"
|
||||
AirTypeDUB AirType = "DUB"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Title string
|
||||
AnimeURL string
|
||||
ImageURL string
|
||||
EpisodeText string
|
||||
AirType AirType
|
||||
AirsAt time.Time
|
||||
LocalTime string
|
||||
DateLabel string
|
||||
Weekday time.Weekday
|
||||
}
|
||||
|
||||
type WeekSchedule struct {
|
||||
Year int
|
||||
Week int
|
||||
Days map[time.Weekday][]Entry
|
||||
}
|
||||
|
||||
type HTTPStatusError struct {
|
||||
StatusCode int
|
||||
URL string
|
||||
ContentType string
|
||||
BodyPreview string
|
||||
}
|
||||
|
||||
func (e *HTTPStatusError) Error() string {
|
||||
return fmt.Sprintf("unexpected status %d for %s", e.StatusCode, e.URL)
|
||||
}
|
||||
|
||||
var reWeek = regexp.MustCompile(`(?i)[?&]week=(\d+)`)
|
||||
var reYear = regexp.MustCompile(`(?i)[?&]year=(\d+)`)
|
||||
|
||||
func scheduleLocation(timezone string) (*time.Location, error) {
|
||||
timezone = strings.TrimSpace(timezone)
|
||||
if timezone == "" {
|
||||
timezone = "UTC"
|
||||
}
|
||||
location, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load schedule timezone %q: %w", timezone, err)
|
||||
}
|
||||
return location, nil
|
||||
}
|
||||
|
||||
func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int, timezone string) (WeekSchedule, error) {
|
||||
apiToken := strings.TrimSpace(os.Getenv("ANIMESCHEDULE_API_TOKEN"))
|
||||
|
||||
if apiToken != "" {
|
||||
return fetchWeekAPI(ctx, httpClient, apiToken, year, week, timezone)
|
||||
}
|
||||
|
||||
location, err := scheduleLocation(timezone)
|
||||
if err != nil {
|
||||
return WeekSchedule{}, err
|
||||
}
|
||||
|
||||
u, _ := url.Parse("https://animeschedule.net/")
|
||||
q := u.Query()
|
||||
if year > 0 {
|
||||
q.Set("year", strconv.Itoa(year))
|
||||
}
|
||||
if week > 0 {
|
||||
q.Set("week", strconv.Itoa(week))
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
doc, finalURL, err := fetchDocument(ctx, httpClient, u.String())
|
||||
if err != nil {
|
||||
return WeekSchedule{}, err
|
||||
}
|
||||
|
||||
resolvedYear := year
|
||||
resolvedWeek := week
|
||||
if resolvedWeek == 0 {
|
||||
if match := reWeek.FindStringSubmatch(finalURL); len(match) == 2 {
|
||||
if v, err := strconv.Atoi(match[1]); err == nil {
|
||||
resolvedWeek = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if resolvedYear == 0 {
|
||||
if match := reYear.FindStringSubmatch(finalURL); len(match) == 2 {
|
||||
if v, err := strconv.Atoi(match[1]); err == nil {
|
||||
resolvedYear = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := WeekSchedule{
|
||||
Year: resolvedYear,
|
||||
Week: resolvedWeek,
|
||||
Days: map[time.Weekday][]Entry{},
|
||||
}
|
||||
|
||||
doc.Find(".timetable-column").Each(func(_ int, column *goquery.Selection) {
|
||||
h1 := column.Find("h1.timetable-column-date").First()
|
||||
rawHeader := strings.Join(strings.Fields(strings.TrimSpace(h1.Text())), " ")
|
||||
weekday := parseWeekdayFromHeader(rawHeader)
|
||||
if weekday == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dayEntries := make([]Entry, 0, 16)
|
||||
|
||||
column.Find(".timetable-column-show").Each(func(_ int, show *goquery.Selection) {
|
||||
if selectionHasClass(show, "filtered-out") {
|
||||
return
|
||||
}
|
||||
|
||||
a := show.Find("a.show-link").First()
|
||||
title := strings.TrimSpace(a.Find("h2").First().Text())
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(a.Text())
|
||||
}
|
||||
href, _ := a.Attr("href")
|
||||
animeURL := absolutizeURL("https://animeschedule.net", href)
|
||||
|
||||
imageURL := ""
|
||||
if img := a.Find("img").First(); img != nil && img.Length() == 1 {
|
||||
if src, ok := img.Attr("data-src"); ok {
|
||||
imageURL = strings.TrimSpace(src)
|
||||
}
|
||||
if imageURL == "" {
|
||||
if src, ok := img.Attr("src"); ok && !strings.HasPrefix(src, "data:") {
|
||||
imageURL = strings.TrimSpace(src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
meta := show.Find("h3.time-bar").First()
|
||||
metaText := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ")
|
||||
|
||||
epText, _, airType := parseMeta(metaText)
|
||||
localTime, airsAt, _, _ := parseLocalTime(meta, location)
|
||||
if title == "" || animeURL == "" || localTime == "" || airType != AirTypeSUB {
|
||||
return
|
||||
}
|
||||
|
||||
dayEntries = append(dayEntries, Entry{
|
||||
Title: title,
|
||||
AnimeURL: animeURL,
|
||||
ImageURL: imageURL,
|
||||
EpisodeText: epText,
|
||||
AirType: airType,
|
||||
AirsAt: airsAt,
|
||||
LocalTime: localTime,
|
||||
DateLabel: rawHeader,
|
||||
Weekday: *weekday,
|
||||
})
|
||||
})
|
||||
|
||||
if len(dayEntries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
out.Days[*weekday] = append(out.Days[*weekday], preferredReleaseEntries(dayEntries)...)
|
||||
})
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func selectionHasClass(selection *goquery.Selection, className string) bool {
|
||||
raw, ok := selection.Attr("class")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(strings.Fields(raw), className)
|
||||
}
|
||||
|
||||
func parseWeekdayFromHeader(header string) *time.Weekday {
|
||||
lower := strings.ToLower(header)
|
||||
candidates := []struct {
|
||||
key string
|
||||
val time.Weekday
|
||||
}{
|
||||
{"monday", time.Monday},
|
||||
{"tuesday", time.Tuesday},
|
||||
{"wednesday", time.Wednesday},
|
||||
{"thursday", time.Thursday},
|
||||
{"friday", time.Friday},
|
||||
{"saturday", time.Saturday},
|
||||
{"sunday", time.Sunday},
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if strings.Contains(lower, c.key) {
|
||||
v := c.val
|
||||
return &v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseMeta(meta string) (episodeText string, localTime string, airType AirType) {
|
||||
// Example: "Ep 8 04:00 PM SUB"
|
||||
parts := strings.Fields(meta)
|
||||
if len(parts) < 4 {
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
// Find the time token(s)
|
||||
var timeIdx = -1
|
||||
for i := range parts {
|
||||
if strings.Contains(parts[i], ":") && len(parts[i]) >= 4 {
|
||||
timeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if timeIdx == -1 || timeIdx+2 >= len(parts) {
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
localTime = strings.TrimSpace(parts[timeIdx] + " " + parts[timeIdx+1])
|
||||
typeRaw := strings.TrimSpace(parts[timeIdx+2])
|
||||
switch strings.ToUpper(typeRaw) {
|
||||
case "JPN":
|
||||
airType = AirTypeJPN
|
||||
case "SUB":
|
||||
airType = AirTypeSUB
|
||||
case "DUB":
|
||||
airType = AirTypeDUB
|
||||
default:
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
episodeText = strings.TrimSpace(strings.Join(parts[:timeIdx], " "))
|
||||
return episodeText, localTime, airType
|
||||
}
|
||||
|
||||
func preferredReleaseEntries(entries []Entry) []Entry {
|
||||
type keyedEntry struct {
|
||||
index int
|
||||
entry Entry
|
||||
}
|
||||
|
||||
selected := map[string]keyedEntry{}
|
||||
for i, entry := range entries {
|
||||
key := entry.AnimeURL + "\x00" + entry.EpisodeText
|
||||
current, ok := selected[key]
|
||||
if !ok || airTypePriority(entry.AirType) > airTypePriority(current.entry.AirType) {
|
||||
selected[key] = keyedEntry{index: i, entry: entry}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]keyedEntry, 0, len(selected))
|
||||
for _, entry := range selected {
|
||||
out = append(out, entry)
|
||||
}
|
||||
slices.SortFunc(out, func(a keyedEntry, b keyedEntry) int {
|
||||
return a.index - b.index
|
||||
})
|
||||
|
||||
preferred := make([]Entry, 0, len(out))
|
||||
for _, entry := range out {
|
||||
preferred = append(preferred, entry.entry)
|
||||
}
|
||||
return preferred
|
||||
}
|
||||
|
||||
func airTypePriority(airType AirType) int {
|
||||
switch airType {
|
||||
case AirTypeSUB:
|
||||
return 3
|
||||
case AirTypeDUB:
|
||||
return 2
|
||||
case AirTypeJPN:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func parseLocalTime(meta *goquery.Selection, location *time.Location) (localTime string, airsAt time.Time, rawDatetime string, rawRenderedTime string) {
|
||||
// AnimeSchedule updates rendered time client-side based on the viewer's timezone.
|
||||
// The server-rendered HTML can show a different time string, so we prefer the `datetime`
|
||||
// attribute when available.
|
||||
t := meta.Find("time").First()
|
||||
if t.Length() == 1 {
|
||||
rawRenderedTime = strings.Join(strings.Fields(strings.TrimSpace(t.Text())), " ")
|
||||
if raw, ok := t.Attr("datetime"); ok {
|
||||
rawDatetime = raw
|
||||
if parsed, err := parseScheduleDatetime(rawDatetime); err == nil {
|
||||
airsAt = parsed.In(location)
|
||||
localTime = airsAt.Format("15:04")
|
||||
return localTime, airsAt, rawDatetime, rawRenderedTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fallback := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ")
|
||||
_, parsedTime, _ := parseMeta(fallback)
|
||||
return parsedTime, time.Time{}, "", ""
|
||||
}
|
||||
|
||||
func parseScheduleDatetime(value string) (time.Time, error) {
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04Z07:00"} {
|
||||
parsed, err := time.Parse(layout, strings.TrimSpace(value))
|
||||
if err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("parse schedule datetime %q", value)
|
||||
}
|
||||
|
||||
func absolutizeURL(base string, href string) string {
|
||||
href = strings.TrimSpace(href)
|
||||
if href == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
|
||||
return href
|
||||
}
|
||||
return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(href, "/")
|
||||
}
|
||||
|
||||
func addCommonHeaders(request *http.Request) {
|
||||
netutil.SetBrowserHTMLHeaders(request, "https://animeschedule.net/")
|
||||
}
|
||||
|
||||
func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, string, error) {
|
||||
document, response, err := netutil.FetchHTMLDocument(ctx, httpClient, url, addCommonHeaders, func(response *http.Response, body []byte) error {
|
||||
return &HTTPStatusError{
|
||||
StatusCode: response.StatusCode,
|
||||
URL: url,
|
||||
ContentType: strings.TrimSpace(response.Header.Get("Content-Type")),
|
||||
BodyPreview: strings.Join(strings.Fields(strings.TrimSpace(string(body))), " "),
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, url, err
|
||||
}
|
||||
|
||||
return document, response.Request.URL.String(), nil
|
||||
}
|
||||
|
||||
type timetableAnimeAPI struct {
|
||||
Title string `json:"title"`
|
||||
English string `json:"english"`
|
||||
Route string `json:"route"`
|
||||
EpisodeDate time.Time `json:"episodeDate"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
SubtractedEpisodeNumber int `json:"subtractedEpisodeNumber"`
|
||||
AirType string `json:"airType"`
|
||||
ImageVersionRoute string `json:"imageVersionRoute"`
|
||||
}
|
||||
|
||||
func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, year int, week int, timezone string) (WeekSchedule, error) {
|
||||
client := httpClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
|
||||
location, err := scheduleLocation(timezone)
|
||||
if err != nil {
|
||||
return WeekSchedule{}, err
|
||||
}
|
||||
|
||||
u, _ := url.Parse("https://animeschedule.net/api/v3/timetables/sub")
|
||||
q := u.Query()
|
||||
if year > 0 && week > 0 {
|
||||
q.Set("year", strconv.Itoa(year))
|
||||
q.Set("week", strconv.Itoa(week))
|
||||
}
|
||||
q.Set("tz", location.String())
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return WeekSchedule{}, fmt.Errorf("create api request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", netutil.Chrome135)
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return WeekSchedule{}, fmt.Errorf("api request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(res.Body, netutil.Bytes512))
|
||||
return WeekSchedule{}, &HTTPStatusError{
|
||||
StatusCode: res.StatusCode,
|
||||
URL: u.String(),
|
||||
ContentType: strings.TrimSpace(res.Header.Get("Content-Type")),
|
||||
BodyPreview: strings.Join(strings.Fields(strings.TrimSpace(string(body))), " "),
|
||||
}
|
||||
}
|
||||
|
||||
var payload []timetableAnimeAPI
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
return WeekSchedule{}, fmt.Errorf("decode timetables api: %w", err)
|
||||
}
|
||||
|
||||
resolvedYear := year
|
||||
resolvedWeek := week
|
||||
if resolvedYear == 0 || resolvedWeek == 0 {
|
||||
resolvedYear, resolvedWeek = time.Now().In(time.Local).ISOWeek()
|
||||
}
|
||||
|
||||
out := WeekSchedule{
|
||||
Year: resolvedYear,
|
||||
Week: resolvedWeek,
|
||||
Days: map[time.Weekday][]Entry{},
|
||||
}
|
||||
|
||||
for _, item := range payload {
|
||||
title := strings.TrimSpace(item.English)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(item.Title)
|
||||
}
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
episodeNumber := item.EpisodeNumber
|
||||
subtracted := item.SubtractedEpisodeNumber
|
||||
episodeText := ""
|
||||
switch {
|
||||
case subtracted > 0 && subtracted < episodeNumber:
|
||||
episodeText = fmt.Sprintf("Ep %d-%d", subtracted, episodeNumber)
|
||||
case episodeNumber > 0:
|
||||
episodeText = fmt.Sprintf("Ep %d", episodeNumber)
|
||||
default:
|
||||
episodeText = "Ep ?"
|
||||
}
|
||||
|
||||
airType := AirType(strings.ToUpper(strings.TrimSpace(item.AirType)))
|
||||
if airType != AirTypeSUB {
|
||||
continue
|
||||
}
|
||||
|
||||
episodeTime := item.EpisodeDate.In(location)
|
||||
weekday := episodeTime.Weekday()
|
||||
localTime := episodeTime.Format("15:04")
|
||||
|
||||
imageURL := ""
|
||||
if strings.TrimSpace(item.ImageVersionRoute) != "" {
|
||||
imageURL = "https://img.animeschedule.net/production/assets/public/img/" + strings.TrimLeft(strings.TrimSpace(item.ImageVersionRoute), "/")
|
||||
}
|
||||
|
||||
animeURL := ""
|
||||
if strings.TrimSpace(item.Route) != "" {
|
||||
animeURL = "https://animeschedule.net/anime/" + strings.TrimLeft(strings.TrimSpace(item.Route), "/")
|
||||
}
|
||||
|
||||
out.Days[weekday] = append(out.Days[weekday], Entry{
|
||||
Title: title,
|
||||
AnimeURL: animeURL,
|
||||
ImageURL: imageURL,
|
||||
EpisodeText: episodeText,
|
||||
AirType: airType,
|
||||
AirsAt: episodeTime,
|
||||
LocalTime: localTime,
|
||||
Weekday: weekday,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
101
integrations/animeschedule/animeschedule_test.go
Normal file
101
integrations/animeschedule/animeschedule_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package animeschedule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
func TestParseLocalTimeUsesRequestedTimezone(t *testing.T) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(`
|
||||
<h3 class="time-bar">
|
||||
<span class="show-episode">Ep 9</span>
|
||||
<time datetime="2026-06-05T16:00+01:00" class="show-air-time">04:00 PM</time>
|
||||
<span>SUB</span>
|
||||
</h3>
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse document: %v", err)
|
||||
}
|
||||
|
||||
location, err := time.LoadLocation("Europe/Copenhagen")
|
||||
if err != nil {
|
||||
t.Fatalf("load location: %v", err)
|
||||
}
|
||||
|
||||
localTime, airsAt, _, rendered := parseLocalTime(doc.Find(".time-bar").First(), location)
|
||||
|
||||
if localTime != "17:00" {
|
||||
t.Fatalf("localTime = %q, want %q", localTime, "17:00")
|
||||
}
|
||||
if rendered != "04:00 PM" {
|
||||
t.Fatalf("rendered = %q, want %q", rendered, "04:00 PM")
|
||||
}
|
||||
if airsAt.Location().String() != "Europe/Copenhagen" {
|
||||
t.Fatalf("airsAt location = %q, want Europe/Copenhagen", airsAt.Location().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLocalTimeUsesExactAngelNextDoorSubRelease(t *testing.T) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(`
|
||||
<h3 class="time-bar">
|
||||
<span class="show-episode">Ep 10</span>
|
||||
<time datetime="2026-06-05T15:30+01:00" class="show-air-time">03:30 PM</time>
|
||||
<span>SUB</span>
|
||||
</h3>
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse document: %v", err)
|
||||
}
|
||||
|
||||
location, err := time.LoadLocation("Europe/Copenhagen")
|
||||
if err != nil {
|
||||
t.Fatalf("load location: %v", err)
|
||||
}
|
||||
|
||||
localTime, _, _, _ := parseLocalTime(doc.Find(".time-bar").First(), location)
|
||||
|
||||
if localTime != "16:30" {
|
||||
t.Fatalf("localTime = %q, want %q", localTime, "16:30")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferredReleaseEntriesPrefersSubForSameEpisode(t *testing.T) {
|
||||
entries := []Entry{
|
||||
{
|
||||
Title: "Tensei shitara Slime Datta Ken 4th Season",
|
||||
AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season",
|
||||
EpisodeText: "Ep 9",
|
||||
AirType: AirTypeJPN,
|
||||
LocalTime: "16:00",
|
||||
},
|
||||
{
|
||||
Title: "Tensei shitara Slime Datta Ken 4th Season",
|
||||
AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season",
|
||||
EpisodeText: "Ep 9",
|
||||
AirType: AirTypeSUB,
|
||||
LocalTime: "17:00",
|
||||
},
|
||||
{
|
||||
Title: "Tensei shitara Slime Datta Ken 4th Season",
|
||||
AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season",
|
||||
EpisodeText: "Ep 6",
|
||||
AirType: AirTypeDUB,
|
||||
LocalTime: "17:00",
|
||||
},
|
||||
}
|
||||
|
||||
got := preferredReleaseEntries(entries)
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(got) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].AirType != AirTypeSUB {
|
||||
t.Fatalf("first air type = %q, want %q", got[0].AirType, AirTypeSUB)
|
||||
}
|
||||
if got[1].AirType != AirTypeDUB {
|
||||
t.Fatalf("second air type = %q, want %q", got[1].AirType, AirTypeDUB)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,16 @@ func (c *Client) GetAnimeRecommendations(ctx context.Context, id int) ([]Recomme
|
||||
return resp.Data, nil
|
||||
}
|
||||
|
||||
func (c *Client) WarmAnimeRecommendations(id int) {
|
||||
url := fmt.Sprintf("%s/anime/%d/recommendations", c.baseURL, id)
|
||||
cacheKey := fmt.Sprintf("anime:recommendations:%d", id)
|
||||
|
||||
c.runAsyncRefresh(func(ctx context.Context) {
|
||||
var resp RecommendationsResponse
|
||||
_ = c.getWithCache(ctx, cacheKey, 24*time.Hour, url, &resp)
|
||||
})
|
||||
}
|
||||
|
||||
// GetAnimeByID returns full anime details; finished series cached 30 days, airing cached 1 day.
|
||||
func (c *Client) GetAnimeByID(ctx context.Context, id int) (Anime, error) {
|
||||
cacheKey := fmt.Sprintf("anime:%d", id)
|
||||
@@ -94,18 +104,7 @@ func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
|
||||
}
|
||||
|
||||
func (c *Client) refreshAnimeByIDAsync(id int) {
|
||||
select {
|
||||
case c.refreshSem <- struct{}{}:
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() { <-c.refreshSem }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c.runAsyncRefresh(func(ctx context.Context) {
|
||||
_, _ = c.refreshAnimeByID(ctx, id)
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,21 +5,24 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"mal/internal/config"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
netutil "mal/pkg/net"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var traceEnabled bool
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
@@ -29,6 +32,7 @@ type Client struct {
|
||||
lastReqTime time.Time // rate limiting: last request timestamp
|
||||
sf singleflight.Group
|
||||
refreshSem chan struct{}
|
||||
metrics *observability.Metrics
|
||||
|
||||
// Random anime pool for DDoS-proof truly random "Surprise Me"
|
||||
randomPool []Anime
|
||||
@@ -38,7 +42,8 @@ type Client struct {
|
||||
|
||||
const jikanSlowLogThreshold = 750 * time.Millisecond
|
||||
|
||||
func NewClient(queries *db.Queries) *Client {
|
||||
func NewClient(cfg config.Config, queries *db.Queries, metrics *observability.Metrics) *Client {
|
||||
traceEnabled = cfg.JikanTrace
|
||||
return &Client{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
@@ -51,6 +56,7 @@ func NewClient(queries *db.Queries) *Client {
|
||||
},
|
||||
baseURL: "https://api.jikan.moe/v4",
|
||||
db: queries,
|
||||
metrics: metrics,
|
||||
retrySignal: make(chan struct{}, 1),
|
||||
refreshSem: make(chan struct{}, 4),
|
||||
randomPool: make([]Anime, 0),
|
||||
@@ -140,8 +146,7 @@ func waitForRetry(ctx context.Context, delay time.Duration) error {
|
||||
}
|
||||
|
||||
func jikanTraceEnabled() bool {
|
||||
value := strings.ToLower(strings.TrimSpace(os.Getenv("MAL_JIKAN_TRACE")))
|
||||
return value == "1" || value == "true" || value == "yes"
|
||||
return traceEnabled
|
||||
}
|
||||
|
||||
func logJikanCache(cacheKey string, source string, startedAt time.Time, err error) {
|
||||
@@ -153,17 +158,25 @@ func logJikanCache(cacheKey string, source string, startedAt time.Time, err erro
|
||||
return
|
||||
}
|
||||
|
||||
errorValue := ""
|
||||
level := observability.LogLevelInfo
|
||||
if err != nil {
|
||||
errorValue = err.Error()
|
||||
level = observability.LogLevelError
|
||||
} else if source != "fresh" && source != "refresh" {
|
||||
// Stale reads are expected sometimes, but worth tracking in logs.
|
||||
level = observability.LogLevelWarn
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"jikan_cache key=%s source=%s duration_ms=%.2f error=%s",
|
||||
strconv.Quote(cacheKey),
|
||||
source,
|
||||
float64(duration.Microseconds())/1000,
|
||||
strconv.Quote(errorValue),
|
||||
observability.LogJSON(
|
||||
level,
|
||||
"jikan_cache",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{
|
||||
"cache_key": cacheKey,
|
||||
"source": source,
|
||||
"duration_ms": float64(duration.Microseconds()) / 1000,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -173,18 +186,26 @@ func logJikanUpstream(urlStr string, statusCode int, attempts int, startedAt tim
|
||||
return
|
||||
}
|
||||
|
||||
errorValue := ""
|
||||
if err != nil {
|
||||
errorValue = err.Error()
|
||||
level := observability.LogLevelInfo
|
||||
if err != nil || statusCode >= http.StatusInternalServerError {
|
||||
level = observability.LogLevelError
|
||||
} else if statusCode == http.StatusTooManyRequests || statusCode >= http.StatusBadRequest {
|
||||
level = observability.LogLevelWarn
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"jikan_upstream url=%s status=%d attempts=%d duration_ms=%.2f error=%s",
|
||||
strconv.Quote(urlStr),
|
||||
statusCode,
|
||||
attempts,
|
||||
float64(duration.Microseconds())/1000,
|
||||
strconv.Quote(errorValue),
|
||||
observability.LogJSON(
|
||||
level,
|
||||
"jikan_upstream",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{
|
||||
"url": urlStr,
|
||||
"endpoint": metricsEndpoint(urlStr),
|
||||
"status": statusCode,
|
||||
"attempts": attempts,
|
||||
"duration_ms": float64(duration.Microseconds()) / 1000,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -262,11 +283,18 @@ func (c *Client) getCache(parentCtx context.Context, key string, out any) bool {
|
||||
|
||||
data, err := c.db.GetJikanCache(ctx, key)
|
||||
if err != nil {
|
||||
c.metrics.ObserveCache("jikan", "miss")
|
||||
return false
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(data), out)
|
||||
return err == nil
|
||||
if err != nil {
|
||||
c.metrics.ObserveCache("jikan", "miss")
|
||||
return false
|
||||
}
|
||||
|
||||
c.metrics.ObserveCache("jikan", "hit")
|
||||
return true
|
||||
}
|
||||
|
||||
// getStaleCache retrieves expired-but-available cache by key.
|
||||
@@ -276,11 +304,18 @@ func (c *Client) getStaleCache(parentCtx context.Context, key string, out any) b
|
||||
|
||||
data, err := c.db.GetJikanCacheStale(ctx, key)
|
||||
if err != nil {
|
||||
c.metrics.ObserveCache("jikan_stale", "miss")
|
||||
return false
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(data), out)
|
||||
return err == nil
|
||||
if err != nil {
|
||||
c.metrics.ObserveCache("jikan_stale", "miss")
|
||||
return false
|
||||
}
|
||||
|
||||
c.metrics.ObserveCache("jikan_stale", "hit")
|
||||
return true
|
||||
}
|
||||
|
||||
// setCache stores data in cache with specified TTL.
|
||||
@@ -375,6 +410,12 @@ func (c *Client) refreshWithCacheAsync(cacheKey string, ttl time.Duration, url s
|
||||
return
|
||||
}
|
||||
|
||||
c.runAsyncRefresh(func(ctx context.Context) {
|
||||
_ = c.refreshWithCache(ctx, cacheKey, ttl, url, target)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) runAsyncRefresh(refresh func(context.Context)) {
|
||||
select {
|
||||
case c.refreshSem <- struct{}{}:
|
||||
default:
|
||||
@@ -387,7 +428,7 @@ func (c *Client) refreshWithCacheAsync(cacheKey string, ttl time.Duration, url s
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_ = c.refreshWithCache(ctx, cacheKey, ttl, url, target)
|
||||
refresh(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -425,7 +466,9 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err
|
||||
maxRetries := 5
|
||||
startedAt := time.Now()
|
||||
attempts := 0
|
||||
endpoint := metricsEndpoint(urlStr)
|
||||
logAndReturn := func(statusCode int, err error) error {
|
||||
c.metrics.ObserveJikanRequest(endpoint, statusCode, time.Since(startedAt), err)
|
||||
logJikanUpstream(urlStr, statusCode, attempts, startedAt, err)
|
||||
return err
|
||||
}
|
||||
@@ -446,6 +489,7 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err
|
||||
if err != nil {
|
||||
return logAndReturn(0, fmt.Errorf("failed to create jikan request: %w", err))
|
||||
}
|
||||
req.Header.Set("User-Agent", netutil.Generic)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -506,3 +550,36 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err
|
||||
|
||||
return logAndReturn(0, fmt.Errorf("max retries exceeded for %s", urlStr))
|
||||
}
|
||||
|
||||
func metricsEndpoint(urlStr string) string {
|
||||
trimmed := strings.TrimSpace(urlStr)
|
||||
if trimmed == "" {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
prefix := "https://api.jikan.moe/v4"
|
||||
trimmed = strings.TrimPrefix(trimmed, prefix)
|
||||
|
||||
if idx := strings.Index(trimmed, "?"); idx >= 0 {
|
||||
trimmed = trimmed[:idx]
|
||||
}
|
||||
|
||||
parts := strings.Split(trimmed, "/")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := strconv.Atoi(part); err == nil {
|
||||
out = append(out, "{id}")
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return "/"
|
||||
}
|
||||
|
||||
return "/" + strings.Join(out, "/")
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mal/internal/config"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -41,7 +43,7 @@ func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
|
||||
}
|
||||
|
||||
queries := db.New(sqlDB)
|
||||
client := NewClient(queries)
|
||||
client := NewClient(config.Config{}, queries, observability.NewMetrics())
|
||||
stale := TopAnimeResponse{Data: []Anime{{MalID: 1, Title: "stale"}}}
|
||||
staleBytes, err := json.Marshal(stale)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Package jikan provides a client for the Jikan v4 API.
|
||||
package jikan
|
||||
|
||||
import "time"
|
||||
|
||||
const shortCacheTTL = time.Hour // 1 hour - for frequently changing data
|
||||
const longCacheTTL = time.Hour * 24 // 24 hours - for stable data like genres
|
||||
const producerCacheTTL = time.Hour * 24 * 30
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package jikan
|
||||
|
||||
import (
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
import "go.uber.org/fx"
|
||||
|
||||
var Module = fx.Options(
|
||||
fx.Provide(NewClient),
|
||||
|
||||
138
integrations/jikan/producers.go
Normal file
138
integrations/jikan/producers.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package jikan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ProducerListEntry struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Titles []struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
} `json:"titles"`
|
||||
}
|
||||
|
||||
type ProducersResponse struct {
|
||||
Data []ProducerListEntry `json:"data"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
type ProducerListResult struct {
|
||||
Items []ProducerListEntry
|
||||
HasNextPage bool
|
||||
}
|
||||
|
||||
func (c *Client) GetProducers(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
|
||||
q := strings.TrimSpace(query)
|
||||
if q == "" {
|
||||
return c.fetchProducersPage(ctx, "", page, limit)
|
||||
}
|
||||
|
||||
result, err := c.fetchProducersPage(ctx, q, page, limit)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return ProducerListResult{}, err
|
||||
}
|
||||
|
||||
return c.searchProducersFromPages(ctx, q, page, limit)
|
||||
}
|
||||
|
||||
func (c *Client) fetchProducersPage(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) {
|
||||
q := strings.TrimSpace(query)
|
||||
cacheKey := fmt.Sprintf("producers:%s:%d:%d", q, page, limit)
|
||||
reqURL := fmt.Sprintf("%s/producers?page=%d&limit=%d", c.baseURL, page, limit)
|
||||
if q != "" {
|
||||
reqURL += "&q=" + url.QueryEscape(q)
|
||||
}
|
||||
|
||||
var result ProducersResponse
|
||||
if err := c.getWithCache(ctx, cacheKey, producerCacheTTL, reqURL, &result); err != nil {
|
||||
return ProducerListResult{}, err
|
||||
}
|
||||
|
||||
return ProducerListResult{
|
||||
Items: result.Data,
|
||||
HasNextPage: result.Pagination.HasNextPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) searchProducersFromPages(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) {
|
||||
const maxPagesToScan = 25
|
||||
|
||||
needle := strings.ToLower(strings.TrimSpace(query))
|
||||
startIndex := (page - 1) * limit
|
||||
endIndex := startIndex + limit
|
||||
|
||||
matches := make([]ProducerListEntry, 0, endIndex)
|
||||
scannedAll := false
|
||||
|
||||
for currentPage := 1; currentPage <= maxPagesToScan; currentPage++ {
|
||||
result, err := c.fetchProducersPage(ctx, "", currentPage, limit)
|
||||
if err != nil {
|
||||
return ProducerListResult{}, err
|
||||
}
|
||||
|
||||
for _, item := range result.Items {
|
||||
name := strings.ToLower(ProducerListEntryName(item))
|
||||
if strings.Contains(name, needle) {
|
||||
matches = append(matches, item)
|
||||
}
|
||||
}
|
||||
|
||||
if len(matches) >= endIndex {
|
||||
return ProducerListResult{
|
||||
Items: matches[startIndex:endIndex],
|
||||
HasNextPage: len(matches) > endIndex || result.HasNextPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if !result.HasNextPage {
|
||||
scannedAll = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if startIndex >= len(matches) {
|
||||
return ProducerListResult{
|
||||
Items: []ProducerListEntry{},
|
||||
HasNextPage: !scannedAll,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if endIndex > len(matches) {
|
||||
endIndex = len(matches)
|
||||
}
|
||||
|
||||
return ProducerListResult{
|
||||
Items: matches[startIndex:endIndex],
|
||||
HasNextPage: !scannedAll,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ProducerListEntryName(entry ProducerListEntry) string {
|
||||
for _, t := range entry.Titles {
|
||||
if t.Title != "" {
|
||||
return t.Title
|
||||
}
|
||||
}
|
||||
if entry.MalID > 0 {
|
||||
return strconv.Itoa(entry.MalID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mal/internal/observability"
|
||||
|
||||
"mal/integrations/watchorder"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
@@ -42,15 +43,8 @@ func relationCacheKey(id int) string {
|
||||
return fmt.Sprintf("relations:watch-order:%d", id)
|
||||
}
|
||||
|
||||
// getWatchOrder fetches watch order from chiaki, caches result for 24h.
|
||||
func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
|
||||
func (c *Client) refreshWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
|
||||
cacheKey := relationCacheKey(id)
|
||||
|
||||
var cached watchorder.WatchOrderResult
|
||||
if c.getCache(ctx, cacheKey, &cached) {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
watchOrderURL := fmt.Sprintf(chiakiWatchOrderURL, id)
|
||||
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -62,21 +56,44 @@ func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrd
|
||||
return watchorder.WatchOrderResult{}, watchorder.ErrWatchOrderNotFound
|
||||
}
|
||||
if errors.Is(err, watchorder.ErrWatchOrderMarkupNotFound) {
|
||||
log.Printf("relations: watch-order markup missing for %d (%s): %v", id, watchOrderURL, err)
|
||||
observability.Warn(
|
||||
"relations_watch_order_markup_missing",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": id,
|
||||
"url": watchOrderURL,
|
||||
},
|
||||
err,
|
||||
)
|
||||
} else if errors.As(err, &statusError) {
|
||||
log.Printf(
|
||||
"relations: watch-order http error for %d (%s): status=%d server=%q cf_ray=%q location=%q content_type=%q body=%q",
|
||||
id,
|
||||
watchOrderURL,
|
||||
statusError.StatusCode,
|
||||
statusError.Server,
|
||||
statusError.CFRay,
|
||||
statusError.Location,
|
||||
statusError.ContentType,
|
||||
statusError.BodyPreview,
|
||||
observability.Warn(
|
||||
"relations_watch_order_http_error",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": id,
|
||||
"url": watchOrderURL,
|
||||
"status": statusError.StatusCode,
|
||||
"server": statusError.Server,
|
||||
"cf_ray": statusError.CFRay,
|
||||
"location": statusError.Location,
|
||||
"content_type": statusError.ContentType,
|
||||
"body_preview": statusError.BodyPreview,
|
||||
},
|
||||
err,
|
||||
)
|
||||
} else {
|
||||
log.Printf("relations: watch-order fetch failed for %d (%s): %v", id, watchOrderURL, err)
|
||||
observability.Warn(
|
||||
"relations_watch_order_fetch_failed",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": id,
|
||||
"url": watchOrderURL,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
return watchorder.WatchOrderResult{}, err
|
||||
}
|
||||
@@ -85,6 +102,37 @@ func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrd
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) refreshWatchOrderAsync(id int) {
|
||||
c.runAsyncRefresh(func(ctx context.Context) {
|
||||
_, _ = c.refreshWatchOrder(ctx, id)
|
||||
})
|
||||
}
|
||||
|
||||
// getWatchOrder fetches watch order from chiaki, caches result for 24h.
|
||||
func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
|
||||
cacheKey := relationCacheKey(id)
|
||||
|
||||
var cached watchorder.WatchOrderResult
|
||||
if c.getCache(ctx, cacheKey, &cached) {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
if c.getStaleCache(ctx, cacheKey, &cached) {
|
||||
c.refreshWatchOrderAsync(id)
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
result, err := c.refreshWatchOrder(ctx, id)
|
||||
if err != nil {
|
||||
if c.getStaleCache(ctx, cacheKey, &cached) {
|
||||
return cached, nil
|
||||
}
|
||||
return watchorder.WatchOrderResult{}, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// currentOnlyRelation returns just the current anime when watch order lookup fails.
|
||||
func (c *Client) currentOnlyRelation(ctx context.Context, id int) ([]RelationEntry, error) {
|
||||
currentAnime, err := c.GetAnimeByID(ctx, id)
|
||||
@@ -107,7 +155,15 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
|
||||
if errors.Is(err, watchorder.ErrWatchOrderNotFound) {
|
||||
return c.currentOnlyRelation(ctx, id)
|
||||
}
|
||||
log.Printf("relations: using current-only fallback for %d: %v", id, err)
|
||||
observability.Warn(
|
||||
"relations_watch_order_fallback_current_only",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": id,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return c.currentOnlyRelation(ctx, id)
|
||||
}
|
||||
|
||||
@@ -176,9 +232,6 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
|
||||
IsCurrent: res.entry.ID == id,
|
||||
IsExtra: false,
|
||||
})
|
||||
if res.entry.ID == id {
|
||||
relations[len(relations)-1].Relation = "Current"
|
||||
}
|
||||
}
|
||||
|
||||
if !seen[id] {
|
||||
@@ -201,3 +254,9 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
|
||||
|
||||
return relations, nil
|
||||
}
|
||||
|
||||
func (c *Client) WarmFullRelations(id int) {
|
||||
c.runAsyncRefresh(func(ctx context.Context) {
|
||||
_, _ = c.GetFullRelations(ctx, id)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,23 @@ package jikan
|
||||
|
||||
import "testing"
|
||||
|
||||
func runBoolCases(t *testing.T, tests []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}, fn func(string) bool) {
|
||||
t.Helper()
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := fn(testCase.input)
|
||||
if got != testCase.want {
|
||||
t.Fatalf("expected %v, got %v", testCase.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedWatchOrderType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -16,14 +33,7 @@ func TestIsAllowedWatchOrderType(t *testing.T) {
|
||||
{name: "empty", input: "", want: false},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := isAllowedWatchOrderType(testCase.input)
|
||||
if got != testCase.want {
|
||||
t.Fatalf("expected %v, got %v", testCase.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
runBoolCases(t, tests, isAllowedWatchOrderType)
|
||||
}
|
||||
|
||||
func TestWatchOrderTypeLabel(t *testing.T) {
|
||||
@@ -58,12 +68,5 @@ func TestAllowedWatchOrderTypeFromDataset(t *testing.T) {
|
||||
{name: "label special", input: "Special", want: false},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := isAllowedWatchOrderType(testCase.input)
|
||||
if got != testCase.want {
|
||||
t.Fatalf("expected %v, got %v", testCase.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
runBoolCases(t, tests, isAllowedWatchOrderType)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SearchAdvanced performs a filtered anime search with type, status, ordering, and genre filters.
|
||||
func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (SearchResult, error) {
|
||||
// SearchAdvanced performs a filtered anime search with type, status, ordering, genre filters, and studio (producer) filters.
|
||||
func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (SearchResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o
|
||||
genresParam = strings.Join(ids, ",")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("search:%s:%s:%s:%s:%s:%s:%v:%d:%d", query, animeType, status, orderBy, sort, genresParam, sfw, page, limit)
|
||||
cacheKey := fmt.Sprintf("search:%s:%s:%s:%s:%s:%s:%d:%v:%d:%d", query, animeType, status, orderBy, sort, genresParam, studioID, sfw, page, limit)
|
||||
|
||||
var result SearchResponse
|
||||
reqURL := fmt.Sprintf("%s/anime?page=%d", c.baseURL, page)
|
||||
@@ -42,6 +42,9 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o
|
||||
if status != "" {
|
||||
reqURL += "&status=" + url.QueryEscape(status)
|
||||
}
|
||||
if studioID > 0 {
|
||||
reqURL += "&producers=" + strconv.Itoa(studioID)
|
||||
}
|
||||
if orderBy != "" {
|
||||
reqURL += "&order_by=" + url.QueryEscape(orderBy)
|
||||
}
|
||||
|
||||
@@ -15,34 +15,22 @@ type ScheduleResult struct {
|
||||
|
||||
// GetSeasonsNow returns currently airing anime for the current season.
|
||||
func (c *Client) GetSeasonsNow(ctx context.Context, page int) (TopAnimeResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
cacheKey := fmt.Sprintf("seasons_now:%d", page)
|
||||
|
||||
var result TopAnimeResponse
|
||||
reqURL := fmt.Sprintf("%s/seasons/now?page=%d", c.baseURL, page)
|
||||
|
||||
err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result)
|
||||
if err != nil {
|
||||
return TopAnimeResult{}, err
|
||||
}
|
||||
|
||||
return TopAnimeResult{
|
||||
Animes: result.Data,
|
||||
HasNextPage: result.Pagination.HasNextPage,
|
||||
}, nil
|
||||
return c.getSeasonList(ctx, page, "now")
|
||||
}
|
||||
|
||||
// GetSeasonsUpcoming returns anime scheduled to air in upcoming seasons.
|
||||
func (c *Client) GetSeasonsUpcoming(ctx context.Context, page int) (TopAnimeResult, error) {
|
||||
return c.getSeasonList(ctx, page, "upcoming")
|
||||
}
|
||||
|
||||
func (c *Client) getSeasonList(ctx context.Context, page int, season string) (TopAnimeResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
cacheKey := fmt.Sprintf("seasons_upcoming:%d", page)
|
||||
cacheKey := fmt.Sprintf("seasons_%s:%d", season, page)
|
||||
|
||||
var result TopAnimeResponse
|
||||
reqURL := fmt.Sprintf("%s/seasons/upcoming?page=%d", c.baseURL, page)
|
||||
reqURL := fmt.Sprintf("%s/seasons/%s?page=%d", c.baseURL, season, page)
|
||||
|
||||
err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package jikan
|
||||
|
||||
import ()
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ProducerResponse struct {
|
||||
Data struct {
|
||||
@@ -24,3 +27,18 @@ type ProducerResponse struct {
|
||||
} `json:"external"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (c *Client) GetProducerByID(ctx context.Context, id int) (ProducerResponse, error) {
|
||||
if id <= 0 {
|
||||
return ProducerResponse{}, fmt.Errorf("invalid producer id")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("producer:%d", id)
|
||||
reqURL := fmt.Sprintf("%s/producers/%d", c.baseURL, id)
|
||||
|
||||
var result ProducerResponse
|
||||
if err := c.getWithCache(ctx, cacheKey, producerCacheTTL, reqURL, &result); err != nil {
|
||||
return ProducerResponse{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package allanime provides an integration with the AllAnime API for episode playback.
|
||||
package allanime
|
||||
|
||||
import (
|
||||
@@ -11,9 +12,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mal/internal/domain"
|
||||
"mal/pkg/net/limits"
|
||||
"mal/pkg/net/useragent"
|
||||
"mal/pkg/net/utls"
|
||||
"mal/pkg"
|
||||
netutil "mal/pkg/net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -25,18 +25,13 @@ const (
|
||||
allAnimeBaseURL = "https://api.allanime.day"
|
||||
allAnimeReferer = "https://allmanga.to/"
|
||||
allAnimeOrigin = "https://youtu-chan.com"
|
||||
defaultUserAgent = useragent.Firefox121
|
||||
defaultUserAgent = netutil.Firefox121
|
||||
)
|
||||
|
||||
var (
|
||||
aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"}
|
||||
)
|
||||
|
||||
var allAnimeUTLSClient = &http.Client{
|
||||
Transport: &utls.UtlsRoundTripper{},
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
type searchResult struct {
|
||||
ID string
|
||||
MalID string
|
||||
@@ -51,6 +46,7 @@ type AvailableEpisodes struct {
|
||||
|
||||
type AllAnimeProvider struct {
|
||||
httpClient *http.Client
|
||||
utlsClient *http.Client
|
||||
extractor *providerExtractor
|
||||
}
|
||||
|
||||
@@ -59,6 +55,10 @@ func NewAllAnimeProvider() *AllAnimeProvider {
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
utlsClient: &http.Client{
|
||||
Transport: &netutil.UtlsRoundTripper{},
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
extractor: newProviderExtractor(),
|
||||
}
|
||||
}
|
||||
@@ -67,60 +67,75 @@ func (c *AllAnimeProvider) Name() string {
|
||||
return "AllAnime"
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
|
||||
// 1. Search for the show to get its AllAnime ID
|
||||
graphqlQuery := `query($search: SearchInput, $limit: Int, $page: Int, $translationType: VaildTranslationTypeEnumType, $countryOrigin: VaildCountryOriginEnumType) {
|
||||
shows(search: $search, limit: $limit, page: $page, translationType: $translationType, countryOrigin: $countryOrigin) {
|
||||
edges {
|
||||
_id
|
||||
malId
|
||||
name
|
||||
}
|
||||
}
|
||||
}`
|
||||
const searchQuery = `query(
|
||||
$search: SearchInput
|
||||
$translationType: VaildTranslationTypeEnumType
|
||||
$limit: Int = 40
|
||||
$page: Int = 1
|
||||
$countryOrigin: VaildCountryOriginEnumType = ALL
|
||||
) {
|
||||
shows(
|
||||
search: $search
|
||||
limit: $limit
|
||||
page: $page
|
||||
translationType: $translationType
|
||||
countryOrigin: $countryOrigin
|
||||
) {
|
||||
edges {
|
||||
_id
|
||||
malId
|
||||
name
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
variables := map[string]any{
|
||||
"search": map[string]any{
|
||||
"allowAdult": false,
|
||||
"allowUnknown": false,
|
||||
"query": query,
|
||||
},
|
||||
"limit": 40,
|
||||
"page": 1,
|
||||
"translationType": mode,
|
||||
"countryOrigin": "ALL",
|
||||
func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
|
||||
type searchData struct {
|
||||
Shows struct {
|
||||
Edges []struct {
|
||||
ID string `json:"_id"`
|
||||
MalID string `json:"malId"`
|
||||
Name string `json:"name"`
|
||||
} `json:"edges"`
|
||||
} `json:"shows"`
|
||||
}
|
||||
|
||||
result, err := c.graphqlRequest(ctx, graphqlQuery, variables)
|
||||
type searchInput struct {
|
||||
AllowAdult bool `json:"allowAdult"`
|
||||
AllowUnknown bool `json:"allowUnknown"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
type searchVariables struct {
|
||||
Search searchInput `json:"search"`
|
||||
TranslationType string `json:"translationType"`
|
||||
}
|
||||
|
||||
vars := searchVariables{
|
||||
Search: searchInput{
|
||||
AllowAdult: false,
|
||||
AllowUnknown: false,
|
||||
Query: query,
|
||||
},
|
||||
TranslationType: mode,
|
||||
}
|
||||
|
||||
data, err := graphql.Post[searchData](ctx, c.httpClient, allAnimeBaseURL+"/api", searchQuery, vars, graphql.PostOptions{
|
||||
Headers: map[string]string{
|
||||
"Referer": allAnimeReferer,
|
||||
"User-Agent": defaultUserAgent,
|
||||
},
|
||||
BodyMax: netutil.MiB2,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, ok := result["data"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid search response")
|
||||
}
|
||||
|
||||
shows, ok := data["shows"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid shows payload")
|
||||
}
|
||||
|
||||
edges, ok := shows["edges"].([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid search edges")
|
||||
}
|
||||
|
||||
out := make([]searchResult, 0, len(edges))
|
||||
for _, edge := range edges {
|
||||
item, ok := edge.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
id, _ := item["_id"].(string)
|
||||
malID, _ := item["malId"].(string)
|
||||
name, _ := item["name"].(string)
|
||||
out := make([]searchResult, 0, len(data.Shows.Edges))
|
||||
for _, edge := range data.Shows.Edges {
|
||||
id := edge.ID
|
||||
malID := edge.MalID
|
||||
name := edge.Name
|
||||
if unquoted, err := strconv.Unquote("\"" + name + "\""); err == nil {
|
||||
name = unquoted
|
||||
}
|
||||
@@ -206,7 +221,13 @@ func (c *AllAnimeProvider) GetEpisodeAvailability(ctx context.Context, animeID i
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error) {
|
||||
return c.resolveShowIDStrict(ctx, animeID, titleCandidates, "sub")
|
||||
for _, mode := range []string{"sub", "dub"} {
|
||||
showID, err := c.resolveShowIDStrict(ctx, animeID, titleCandidates, mode)
|
||||
if err == nil {
|
||||
return showID, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("allanime: no exact mal id match for %d", animeID)
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) GetEpisodeAvailabilityByProviderID(ctx context.Context, showID string) (domain.EpisodeAvailability, error) {
|
||||
@@ -233,7 +254,7 @@ func (c *AllAnimeProvider) resolveShowIDStrict(ctx context.Context, animeID int,
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("allanime: no strict mal id match for %d", animeID)
|
||||
return "", fmt.Errorf("allanime: no exact mal id match for %d in %s search", animeID, mode)
|
||||
}
|
||||
|
||||
func parseEpisodeNumbers(raw []string) []int {
|
||||
@@ -274,15 +295,9 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
|
||||
req.Header.Set("Referer", allAnimeReferer)
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
resp, respBody, err := executeAndReadResponse(c.httpClient, req, "execute graphql request", "read graphql response")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute graphql request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, limits.MiB2))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read graphql response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -329,15 +344,9 @@ func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, e
|
||||
req.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
|
||||
resp, err := allAnimeUTLSClient.Do(req)
|
||||
resp, respBody, err := executeAndReadResponse(c.utlsClient, req, "execute GET request", "read response")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute GET request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, limits.MiB2))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -450,49 +459,7 @@ func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string,
|
||||
return nil, fmt.Errorf("no source references")
|
||||
}
|
||||
|
||||
out := make([]StreamSource, 0, len(references))
|
||||
for _, ref := range references {
|
||||
target := strings.TrimSpace(ref.URL)
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
|
||||
sourceType := detectStreamType(target)
|
||||
if sourceType == "unknown" {
|
||||
sourceType = detectEmbedType(target)
|
||||
}
|
||||
|
||||
out = append(out, buildStreamSource(target, sourceType, ref.Name))
|
||||
continue
|
||||
}
|
||||
|
||||
decoded := decodeSourceURL(target)
|
||||
if decoded == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(decoded, "http://") || strings.HasPrefix(decoded, "https://") {
|
||||
sourceType := detectStreamType(decoded)
|
||||
if sourceType == "unknown" {
|
||||
sourceType = detectEmbedType(decoded)
|
||||
}
|
||||
|
||||
out = append(out, buildStreamSource(decoded, sourceType, ref.Name))
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(decoded, "/") {
|
||||
decoded = "/" + decoded
|
||||
}
|
||||
|
||||
extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, extracted...)
|
||||
}
|
||||
out := c.resolveSourceReferences(ctx, references)
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no playable sources extracted")
|
||||
@@ -517,6 +484,10 @@ func (c *AllAnimeProvider) extractSourceURLsFromData(ctx context.Context, data m
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.resolveSourceReferences(ctx, references)
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) resolveSourceReferences(ctx context.Context, references []sourceReference) []StreamSource {
|
||||
out := make([]StreamSource, 0, len(references))
|
||||
for _, ref := range references {
|
||||
target := strings.TrimSpace(ref.URL)
|
||||
@@ -564,6 +535,21 @@ func (c *AllAnimeProvider) extractSourceURLsFromData(ctx context.Context, data m
|
||||
return out
|
||||
}
|
||||
|
||||
func executeAndReadResponse(client *http.Client, req *http.Request, executeErrPrefix string, readErrPrefix string) (*http.Response, []byte, error) {
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", executeErrPrefix, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", readErrPrefix, err)
|
||||
}
|
||||
|
||||
return resp, body, nil
|
||||
}
|
||||
|
||||
func buildStreamSource(url, sourceType, provider string) StreamSource {
|
||||
return StreamSource{
|
||||
URL: url,
|
||||
|
||||
@@ -2,9 +2,10 @@ package allanime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mal/pkg/net/limits"
|
||||
netutil "mal/pkg/net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -54,7 +55,7 @@ func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, limits.MiB2)) // 2MB limit
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2)) // 2MB limit
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read provider response: %w", err)
|
||||
}
|
||||
@@ -66,25 +67,83 @@ func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath
|
||||
func (e *providerExtractor) parseProviderResponse(ctx context.Context, response string) []StreamSource {
|
||||
sources := make([]StreamSource, 0)
|
||||
providerReferer := e.referer
|
||||
|
||||
// extract per-source referer if present
|
||||
refererPattern := regexp.MustCompile(`"Referer":"([^"]+)"`)
|
||||
if match := refererPattern.FindStringSubmatch(response); len(match) >= 2 {
|
||||
providerReferer = strings.ReplaceAll(match[1], `\/`, "/")
|
||||
var root any
|
||||
if err := json.Unmarshal([]byte(response), &root); err != nil {
|
||||
return sources
|
||||
}
|
||||
|
||||
type linkItem struct {
|
||||
link string
|
||||
resolutionStr string
|
||||
}
|
||||
type hlsItem struct {
|
||||
url string
|
||||
hardsubLang string
|
||||
}
|
||||
|
||||
linkItems := make([]linkItem, 0)
|
||||
hlsItems := make([]hlsItem, 0)
|
||||
subtitles := make([]Subtitle, 0)
|
||||
|
||||
var walk func(v any)
|
||||
walk = func(v any) {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
if ref, ok := x["Referer"].(string); ok && strings.TrimSpace(ref) != "" {
|
||||
providerReferer = strings.TrimSpace(ref)
|
||||
}
|
||||
|
||||
if link, ok := x["link"].(string); ok {
|
||||
if res, ok := x["resolutionStr"].(string); ok {
|
||||
linkItems = append(linkItems, linkItem{link: link, resolutionStr: res})
|
||||
}
|
||||
}
|
||||
|
||||
if u, ok := x["url"].(string); ok {
|
||||
if lang, ok := x["hardsub_lang"].(string); ok {
|
||||
hlsItems = append(hlsItems, hlsItem{url: u, hardsubLang: lang})
|
||||
}
|
||||
}
|
||||
|
||||
if subs, ok := x["subtitles"].([]any); ok {
|
||||
for _, sub := range subs {
|
||||
obj, ok := sub.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
lang, _ := obj["lang"].(string)
|
||||
src, _ := obj["src"].(string)
|
||||
lang = strings.TrimSpace(lang)
|
||||
src = strings.TrimSpace(src)
|
||||
if lang == "" || src == "" {
|
||||
continue
|
||||
}
|
||||
subtitles = append(subtitles, Subtitle{Lang: lang, URL: src})
|
||||
}
|
||||
}
|
||||
|
||||
for _, child := range x {
|
||||
walk(child)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range x {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root)
|
||||
|
||||
if providerReferer == "" {
|
||||
providerReferer = e.referer
|
||||
}
|
||||
|
||||
// extract direct link sources (mp4/embed)
|
||||
linkPattern := regexp.MustCompile(`"link":"([^"]+)","resolutionStr":"([^"]+)"`)
|
||||
for _, match := range linkPattern.FindAllStringSubmatch(response, -1) {
|
||||
if len(match) < 3 {
|
||||
for _, item := range linkItems {
|
||||
link := strings.TrimSpace(item.link)
|
||||
if link == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
link := strings.ReplaceAll(match[1], `\/`, "/")
|
||||
quality := strings.TrimSpace(match[2])
|
||||
quality := strings.TrimSpace(item.resolutionStr)
|
||||
sourceType := detectStreamType(link)
|
||||
if sourceType == "unknown" {
|
||||
sourceType = detectEmbedType(link)
|
||||
@@ -99,14 +158,15 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
|
||||
})
|
||||
}
|
||||
|
||||
// extract HLS playlist sources
|
||||
hlsPattern := regexp.MustCompile(`"url":"([^"]+)","hardsub_lang":"en-US"`)
|
||||
for _, match := range hlsPattern.FindAllStringSubmatch(response, -1) {
|
||||
if len(match) < 2 {
|
||||
for _, item := range hlsItems {
|
||||
if strings.TrimSpace(item.url) == "" {
|
||||
continue
|
||||
}
|
||||
if item.hardsubLang != "en-US" {
|
||||
continue
|
||||
}
|
||||
|
||||
playlistURL := strings.ReplaceAll(match[1], `\/`, "/")
|
||||
playlistURL := strings.TrimSpace(item.url)
|
||||
if strings.Contains(playlistURL, "master.m3u8") {
|
||||
parsed, err := e.parseM3U8(ctx, playlistURL, providerReferer)
|
||||
if err == nil {
|
||||
@@ -124,26 +184,9 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
|
||||
})
|
||||
}
|
||||
|
||||
// extract subtitles and attach to all sources
|
||||
subtitlePattern := regexp.MustCompile(`"subtitles":\[(.*?)\]`)
|
||||
if subtitleMatch := subtitlePattern.FindStringSubmatch(response); len(subtitleMatch) >= 2 {
|
||||
subtitles := make([]Subtitle, 0)
|
||||
subtitleEntryPattern := regexp.MustCompile(`"lang":"([^"]+)".*?"src":"([^"]+)"`)
|
||||
for _, entry := range subtitleEntryPattern.FindAllStringSubmatch(subtitleMatch[1], -1) {
|
||||
if len(entry) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
subtitles = append(subtitles, Subtitle{
|
||||
Lang: strings.TrimSpace(entry[1]),
|
||||
URL: strings.ReplaceAll(entry[2], `\/`, "/"),
|
||||
})
|
||||
}
|
||||
|
||||
if len(subtitles) > 0 {
|
||||
for idx := range sources {
|
||||
sources[idx].Subtitles = subtitles
|
||||
}
|
||||
if len(subtitles) > 0 && len(sources) > 0 {
|
||||
for idx := range sources {
|
||||
sources[idx].Subtitles = append([]Subtitle(nil), subtitles...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +201,7 @@ func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, ref
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, limits.KiB512)) // 512KB limit
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.KiB512)) // 512KB limit
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package watchorder provides anime watch order data from various sources.
|
||||
package watchorder
|
||||
|
||||
import (
|
||||
@@ -5,8 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mal/pkg/net/limits"
|
||||
"mal/pkg/net/useragent"
|
||||
netutil "mal/pkg/net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -82,36 +82,12 @@ func parseRootID(url string) (int, error) {
|
||||
}
|
||||
|
||||
func addCommonHeaders(request *http.Request) {
|
||||
request.Header.Set("User-Agent", useragent.Chrome135)
|
||||
request.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
|
||||
request.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
request.Header.Set("Referer", "https://chiaki.site/")
|
||||
request.Header.Set("Cache-Control", "no-cache")
|
||||
netutil.SetBrowserHTMLHeaders(request, "https://chiaki.site/")
|
||||
}
|
||||
|
||||
func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, error) {
|
||||
client := httpClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
addCommonHeaders(request)
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
// limit body read for error context; avoid reading large error pages
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, limits.Bytes512))
|
||||
return nil, &HTTPStatusError{
|
||||
document, _, err := netutil.FetchHTMLDocument(ctx, httpClient, url, addCommonHeaders, func(response *http.Response, body []byte) error {
|
||||
return &HTTPStatusError{
|
||||
StatusCode: response.StatusCode,
|
||||
URL: url,
|
||||
Server: strings.TrimSpace(response.Header.Get("Server")),
|
||||
@@ -120,14 +96,8 @@ func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*g
|
||||
ContentType: strings.TrimSpace(response.Header.Get("Content-Type")),
|
||||
BodyPreview: strings.Join(strings.Fields(strings.TrimSpace(string(body))), " "),
|
||||
}
|
||||
}
|
||||
|
||||
document, err := goquery.NewDocumentFromReader(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse html: %w", err)
|
||||
}
|
||||
|
||||
return document, nil
|
||||
})
|
||||
return document, err
|
||||
}
|
||||
|
||||
func extractTypeLabelsByID(doc *goquery.Document) map[int]string {
|
||||
@@ -241,7 +211,7 @@ func fetchProxyText(ctx context.Context, httpClient *http.Client, url string) (s
|
||||
return "", fmt.Errorf("proxy status %d", response.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, limits.MiB2))
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, netutil.MiB2))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read proxy response: %w", err)
|
||||
}
|
||||
|
||||
@@ -141,10 +141,10 @@ Jujutsu Kaisen 0
|
||||
testClient := &http.Client{
|
||||
Timeout: time.Second,
|
||||
Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case request.URL.Host == "chiaki.site":
|
||||
switch request.URL.Host {
|
||||
case "chiaki.site":
|
||||
return mockResponse(http.StatusForbidden, map[string]string{"Content-Type": "text/html; charset=utf-8"}, "blocked"), nil
|
||||
case request.URL.Host == "r.jina.ai":
|
||||
case "r.jina.ai":
|
||||
// Proxy response is plain text/markdown.
|
||||
return mockResponse(http.StatusOK, map[string]string{"Content-Type": "text/plain; charset=utf-8"}, proxyPayload), nil
|
||||
default:
|
||||
|
||||
193
internal/anime/command_palette.go
Normal file
193
internal/anime/command_palette.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type commandPaletteItem struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Href string `json:"href"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCommandPalette(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(c.Query("q"))
|
||||
items := make([]commandPaletteItem, 0, 12)
|
||||
|
||||
if query != "" {
|
||||
items = append(items, commandPaletteItem{
|
||||
ID: "search:" + strings.ToLower(query),
|
||||
Type: "search",
|
||||
Label: fmt.Sprintf("Search anime for %q", query),
|
||||
Subtitle: "Browse",
|
||||
Href: "/browse?q=" + url.QueryEscape(query),
|
||||
Icon: "search",
|
||||
})
|
||||
|
||||
if len(query) >= 2 {
|
||||
items = append(items, h.commandPaletteAnimeResults(c, query)...)
|
||||
}
|
||||
|
||||
items = append(items, h.commandPaletteNavigationItems(query)...)
|
||||
items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...)
|
||||
items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...)
|
||||
c.JSON(http.StatusOK, items)
|
||||
return
|
||||
}
|
||||
|
||||
items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...)
|
||||
items = append(items, h.commandPaletteNavigationItems(query)...)
|
||||
items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...)
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPaletteItem {
|
||||
all := []commandPaletteItem{
|
||||
{ID: "nav:discover", Type: "navigation", Label: "Go to Discover", Subtitle: "Navigation", Href: "/discover", Icon: "compass"},
|
||||
{ID: "nav:watchlist", Type: "navigation", Label: "Go to Watchlist", Subtitle: "Navigation", Href: "/watchlist", Icon: "bookmark"},
|
||||
{ID: "nav:popular", Type: "navigation", Label: "Browse popular", Subtitle: "Browse", Href: "/browse?order_by=popularity&sort=desc", Icon: "trending"},
|
||||
{ID: "nav:airing", Type: "navigation", Label: "Currently airing", Subtitle: "Browse", Href: "/browse?status=airing&order_by=popularity&sort=desc", Icon: "play"},
|
||||
}
|
||||
if query == "" {
|
||||
return all
|
||||
}
|
||||
|
||||
filtered := make([]commandPaletteItem, 0, len(all))
|
||||
for _, item := range all {
|
||||
if commandPaletteMatches(query, item.Label, item.Subtitle) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string) []commandPaletteItem {
|
||||
searchCtx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, 0, true, 1, 5)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
animes := wrapAnimes(res.Animes)
|
||||
items := make([]commandPaletteItem, 0, len(animes))
|
||||
for _, anime := range animes {
|
||||
items = append(items, commandPaletteItem{
|
||||
ID: fmt.Sprintf("anime:%d", anime.MalID),
|
||||
Type: "anime",
|
||||
Label: anime.DisplayTitle(),
|
||||
Subtitle: strings.TrimSpace("Anime " + anime.Type),
|
||||
Href: fmt.Sprintf("/anime/%d", anime.MalID),
|
||||
Image: anime.ImageURL(),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPalettePersonalItems(c *gin.Context, userID string, query string) []commandPaletteItem {
|
||||
items := make([]commandPaletteItem, 0, 5)
|
||||
|
||||
watchlist, err := h.watchlistSvc.GetCommandPaletteWatchlist(c.Request.Context(), userID, query, 5)
|
||||
if err != nil {
|
||||
return items
|
||||
}
|
||||
|
||||
for _, entry := range watchlist {
|
||||
title := watchlistTitle(entry)
|
||||
items = append(items, commandPaletteItem{
|
||||
ID: fmt.Sprintf("watchlist:%d", entry.AnimeID),
|
||||
Type: "watchlist",
|
||||
Label: title,
|
||||
Subtitle: watchlistStatusLabel(entry.Status),
|
||||
Href: fmt.Sprintf("/anime/%d", entry.AnimeID),
|
||||
Image: entry.ImageUrl,
|
||||
})
|
||||
if len(items) >= 5 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPaletteContinueItems(c *gin.Context, userID string, query string) []commandPaletteItem {
|
||||
items := make([]commandPaletteItem, 0, 5)
|
||||
|
||||
rows, err := h.watchlistSvc.GetCommandPaletteContinueWatching(c.Request.Context(), userID, query, 5)
|
||||
if err != nil {
|
||||
return items
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
title := continueWatchingTitle(row)
|
||||
episode := ""
|
||||
href := fmt.Sprintf("/anime/%d/watch", row.AnimeID)
|
||||
if row.CurrentEpisode.Valid {
|
||||
episode = fmt.Sprintf(" episode %d", row.CurrentEpisode.Int64)
|
||||
href = fmt.Sprintf("%s?ep=%d", href, row.CurrentEpisode.Int64)
|
||||
}
|
||||
items = append(items, commandPaletteItem{
|
||||
ID: fmt.Sprintf("continue:%d", row.AnimeID),
|
||||
Type: "continue",
|
||||
Label: "Continue watching " + title,
|
||||
Subtitle: "Resume" + episode,
|
||||
Href: href,
|
||||
Image: row.ImageUrl,
|
||||
})
|
||||
if len(items) >= 5 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func commandPaletteMatches(query string, values ...string) bool {
|
||||
needle := strings.ToLower(strings.TrimSpace(query))
|
||||
for _, value := range values {
|
||||
if strings.Contains(strings.ToLower(value), needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func continueWatchingTitle(row db.GetContinueWatchingEntriesRow) string {
|
||||
return row.DisplayTitle()
|
||||
}
|
||||
|
||||
func watchlistTitle(row domain.UserWatchListRow) string {
|
||||
return row.DisplayTitle()
|
||||
}
|
||||
|
||||
func watchlistStatusLabel(status string) string {
|
||||
switch status {
|
||||
case "watching":
|
||||
return "Watching"
|
||||
case "plan_to_watch":
|
||||
return "Plan to Watch"
|
||||
default:
|
||||
return "Watchlist"
|
||||
}
|
||||
}
|
||||
835
internal/anime/handler.go
Normal file
835
internal/anime/handler.go
Normal file
@@ -0,0 +1,835 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
animeSectionTimeout = 12 * time.Second
|
||||
watchOrderTimeout = 15 * time.Second
|
||||
audioLookupTimeout = 8 * time.Second
|
||||
)
|
||||
|
||||
type AnimeHandler struct {
|
||||
svc Service
|
||||
watchlistSvc domain.WatchlistService
|
||||
episodeSvc domain.EpisodeService
|
||||
|
||||
scheduleCacheMu sync.Mutex
|
||||
scheduleCache map[string]cachedWeekSchedule
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
domain.AnimeCatalogService
|
||||
domain.AnimeDiscoverService
|
||||
domain.AnimeSearchService
|
||||
domain.AnimeDetailsService
|
||||
WarmDetailSections(id int)
|
||||
}
|
||||
|
||||
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService) *AnimeHandler {
|
||||
return &AnimeHandler{
|
||||
svc: svc,
|
||||
watchlistSvc: watchlistSvc,
|
||||
episodeSvc: episodeSvc,
|
||||
scheduleCache: map[string]cachedWeekSchedule{},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) watchlistMapForAnimes(ctx context.Context, userID string, animes []domain.Anime) map[int64]bool {
|
||||
animeIDs := make([]int64, 0, len(animes))
|
||||
for _, anime := range animes {
|
||||
if anime.MalID > 0 {
|
||||
animeIDs = append(animeIDs, int64(anime.MalID))
|
||||
}
|
||||
}
|
||||
return h.watchlistMapForIDs(ctx, userID, animeIDs)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, animeIDs []int64) map[int64]bool {
|
||||
if userID == "" || len(animeIDs) == 0 {
|
||||
return map[int64]bool{}
|
||||
}
|
||||
|
||||
watchlistMap, err := h.watchlistSvc.GetWatchlistMap(ctx, userID, animeIDs)
|
||||
if err != nil {
|
||||
return map[int64]bool{}
|
||||
}
|
||||
return watchlistMap
|
||||
}
|
||||
|
||||
func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string {
|
||||
hasKnownSub := false
|
||||
for _, episode := range episodes {
|
||||
if episode.HasDub {
|
||||
return "Dub available"
|
||||
}
|
||||
if episode.HasSub || episode.SubOnly {
|
||||
hasKnownSub = true
|
||||
}
|
||||
}
|
||||
if hasKnownSub {
|
||||
return "Subtitled only"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) animeAudioAvailability(ctx context.Context, anime domain.Anime) string {
|
||||
if h.episodeSvc == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
audioCtx, cancel := context.WithTimeout(ctx, audioLookupTimeout)
|
||||
defer cancel()
|
||||
|
||||
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(audioCtx, anime, true)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"anime_audio_availability_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return ""
|
||||
}
|
||||
if episodeList.Source != "AllAnime" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return animeAudioAvailabilityLabel(episodeList.Episodes)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) Register(r *gin.Engine) {
|
||||
r.GET("/", h.HandleCatalog)
|
||||
r.GET("/api/catalog/airing", h.HandleCatalogAiring)
|
||||
r.GET("/api/catalog/popular", h.HandleCatalogPopular)
|
||||
r.GET("/api/catalog/continue", h.HandleCatalogContinue)
|
||||
r.GET("/api/catalog/top-pick", h.HandleCatalogTopPickForYou)
|
||||
r.GET("/discover", h.HandleDiscover)
|
||||
r.GET("/discover/top-picks", h.HandleDiscoverTopPicksForYou)
|
||||
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
|
||||
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
|
||||
r.GET("/api/discover/top", h.HandleDiscoverTop)
|
||||
r.GET("/schedule", h.HandleSchedule)
|
||||
r.GET("/api/schedule", h.HandleScheduleSection)
|
||||
r.GET("/browse", h.HandleBrowse)
|
||||
r.GET("/anime/:id", h.HandleAnimeDetails)
|
||||
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
|
||||
r.GET("/api/watch-order", h.HandleHTMLWatchOrder)
|
||||
r.GET("/api/search-quick", h.HandleQuickSearch)
|
||||
r.GET("/api/command-palette", h.HandleCommandPalette)
|
||||
r.GET("/api/jikan/random/anime", h.HandleRandomAnime)
|
||||
r.GET("/api/jikan/producers", h.HandleProducers)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleProducers(c *gin.Context) {
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
|
||||
return
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit, err := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 12
|
||||
}
|
||||
if limit > 12 {
|
||||
limit = 12
|
||||
}
|
||||
|
||||
res, err := h.svc.GetProducers(c.Request.Context(), q, page, limit)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"producers_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"q": q,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
},
|
||||
err,
|
||||
)
|
||||
if strings.Contains(c.GetHeader("Accept"), "text/html") {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
||||
"_fragment": "studio_dropdown_items",
|
||||
"StudioItems": []any{},
|
||||
"HasNextPage": false,
|
||||
"Page": page,
|
||||
"NextPage": page + 1,
|
||||
"Query": q,
|
||||
"Limit": limit,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"producers_fetch_failed",
|
||||
"anime",
|
||||
"failed to load producers",
|
||||
map[string]any{"q": q, "page": page, "limit": limit},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
type item struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
items := make([]item, 0, len(res.Items))
|
||||
for _, p := range res.Items {
|
||||
name := jikan.ProducerListEntryName(p)
|
||||
if p.MalID <= 0 || name == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, item{ID: p.MalID, Name: name})
|
||||
}
|
||||
|
||||
if strings.Contains(c.GetHeader("Accept"), "text/html") {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
||||
"_fragment": "studio_dropdown_items",
|
||||
"StudioItems": items,
|
||||
"HasNextPage": res.HasNextPage,
|
||||
"Page": page,
|
||||
"NextPage": page + 1,
|
||||
"Query": q,
|
||||
"Limit": limit,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"hasNextPage": res.HasNextPage,
|
||||
"nextPage": page + 1,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
|
||||
c.HTML(http.StatusOK, "index.gohtml", gin.H{
|
||||
"CurrentPath": "/",
|
||||
"User": user,
|
||||
"WatchlistMap": map[int64]bool{},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogAiring(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Airing")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogPopular(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Popular")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Continue")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogTopPickForYou(c *gin.Context) {
|
||||
userID := server.CurrentUserID(c)
|
||||
|
||||
data, err := h.svc.GetTopPickForYou(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"top_pick_for_you_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = "TopPickForYou"
|
||||
data.Fragment = "top_pick_for_you_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "index.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
|
||||
userID := server.CurrentUserID(c)
|
||||
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
|
||||
if err != nil {
|
||||
h.abortSectionFetch(c, "catalog_section_fetch_failed", userID, section, err)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = section
|
||||
data.Fragment = "catalog_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "index.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscover(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
c.HTML(http.StatusOK, "discover.gohtml", gin.H{
|
||||
"CurrentPath": "/discover",
|
||||
"User": user,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscoverTopPicksForYou(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
userID := server.CurrentUserID(c)
|
||||
|
||||
data, err := h.svc.GetTopPicksForYou(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"top_picks_for_you_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
c.HTML(http.StatusOK, "discover.gohtml", gin.H{
|
||||
"_fragment": "",
|
||||
"CurrentPath": "/discover",
|
||||
"User": user,
|
||||
"Animes": data.Animes,
|
||||
"WatchlistMap": watchlistMap,
|
||||
"IsTopPicks": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscoverTrending(c *gin.Context) {
|
||||
h.renderDiscoverSection(c, "Trending")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscoverUpcoming(c *gin.Context) {
|
||||
h.renderDiscoverSection(c, "Upcoming")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) {
|
||||
h.renderDiscoverSection(c, "Top")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
|
||||
userID := server.CurrentUserID(c)
|
||||
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)
|
||||
if err != nil {
|
||||
h.abortSectionFetch(c, "discover_section_fetch_failed", userID, section, err)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = section
|
||||
data.Fragment = "discover_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "discover.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) abortSectionFetch(c *gin.Context, event string, userID string, section string, err error) {
|
||||
observability.Warn(
|
||||
event,
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"section": section,
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleSchedule(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
year, week := parseYearWeek(c)
|
||||
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
|
||||
"CurrentPath": "/schedule",
|
||||
"User": user,
|
||||
"ScheduleYear": year,
|
||||
"ScheduleWeek": week,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) {
|
||||
year, week := parseYearWeek(c)
|
||||
timezone := scheduleTimezone(c)
|
||||
|
||||
schedule, err := h.getCachedAnimeScheduleWeek(c.Request.Context(), year, week, timezone)
|
||||
if err != nil {
|
||||
prevYear, prevWeek := adjacentISOWeek(year, week, -1)
|
||||
nextYear, nextWeek := adjacentISOWeek(year, week, 1)
|
||||
observability.Warn(
|
||||
"animeschedule_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"year": year,
|
||||
"week": week,
|
||||
"timezone": timezone,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
|
||||
"_fragment": "schedule_section_scraped",
|
||||
"ScheduleDays": []any{},
|
||||
"ScheduleYear": year,
|
||||
"ScheduleWeek": week,
|
||||
"PrevYear": prevYear,
|
||||
"PrevWeek": prevWeek,
|
||||
"NextYear": nextYear,
|
||||
"NextWeek": nextWeek,
|
||||
"ScheduleError": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
days := buildScheduleDays(schedule, schedule.Year, schedule.Week)
|
||||
prevYear, prevWeek := adjacentISOWeek(schedule.Year, schedule.Week, -1)
|
||||
nextYear, nextWeek := adjacentISOWeek(schedule.Year, schedule.Week, 1)
|
||||
|
||||
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
|
||||
"_fragment": "schedule_section_scraped",
|
||||
"ScheduleDays": days,
|
||||
"ScheduleYear": schedule.Year,
|
||||
"ScheduleWeek": schedule.Week,
|
||||
"PrevYear": prevYear,
|
||||
"PrevWeek": prevWeek,
|
||||
"NextYear": nextYear,
|
||||
"NextWeek": nextWeek,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
||||
q := c.Query("q")
|
||||
animeType := c.Query("type")
|
||||
status := c.Query("status")
|
||||
orderBy := c.Query("order_by")
|
||||
sort := c.Query("sort")
|
||||
sfw := c.Query("sfw") != "false"
|
||||
studioID := 0
|
||||
if raw := strings.TrimSpace(c.Query("studio")); raw != "" {
|
||||
id, err := strconv.Atoi(raw)
|
||||
if err != nil || id < 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid studio id")
|
||||
return
|
||||
}
|
||||
studioID = id
|
||||
}
|
||||
|
||||
var genres []int
|
||||
for _, g := range c.QueryArray("genres") {
|
||||
id, err := strconv.Atoi(g)
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid genre id")
|
||||
return
|
||||
}
|
||||
if id > 0 {
|
||||
genres = append(genres, id)
|
||||
}
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
|
||||
return
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, studioID, sfw, page, 24)
|
||||
if err != nil {
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"browse_search_failed",
|
||||
"anime",
|
||||
"failed to load browse results",
|
||||
map[string]any{
|
||||
"q": q,
|
||||
"type": animeType,
|
||||
"status": status,
|
||||
"order_by": orderBy,
|
||||
"sort": sort,
|
||||
"studio": studioID,
|
||||
"sfw": sfw,
|
||||
"page": page,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
user := server.CurrentUser(c)
|
||||
userID := server.CurrentUserID(c)
|
||||
animes := wrapAnimes(res.Animes)
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||
|
||||
studioName := ""
|
||||
if studioID > 0 {
|
||||
name, err := h.svc.GetProducerNameByID(c.Request.Context(), studioID)
|
||||
if err == nil {
|
||||
studioName = name
|
||||
}
|
||||
}
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" && page > 1 {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
||||
"_fragment": "anime_card_scroll",
|
||||
"Animes": animes,
|
||||
"NextPage": page + 1,
|
||||
"HasNextPage": res.HasNextPage,
|
||||
"Query": q,
|
||||
"Type": animeType,
|
||||
"Status": status,
|
||||
"OrderBy": orderBy,
|
||||
"Sort": sort,
|
||||
"Genres": genres,
|
||||
"Studio": studioID,
|
||||
"StudioName": studioName,
|
||||
"SFW": sfw,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
genresList, _ := h.svc.GetGenres(c.Request.Context())
|
||||
browseData := gin.H{
|
||||
"CurrentPath": "/browse",
|
||||
"Query": q,
|
||||
"Type": animeType,
|
||||
"Status": status,
|
||||
"OrderBy": orderBy,
|
||||
"Sort": sort,
|
||||
"Genres": genres,
|
||||
"Studio": studioID,
|
||||
"StudioName": studioName,
|
||||
"SFW": sfw,
|
||||
"GenresList": genresList,
|
||||
"Animes": animes,
|
||||
"HasNextPage": res.HasNextPage,
|
||||
"NextPage": page + 1,
|
||||
"User": user,
|
||||
"WatchlistMap": watchlistMap,
|
||||
}
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
browseData["_fragment"] = "browse_content"
|
||||
c.HTML(http.StatusOK, "browse.gohtml", browseData)
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "browse.gohtml", browseData)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||
return
|
||||
}
|
||||
|
||||
section := c.Query("section")
|
||||
if section != "" && c.GetHeader("HX-Request") == "true" {
|
||||
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), animeSectionTimeout)
|
||||
defer cancel()
|
||||
|
||||
var data any
|
||||
var tplName string
|
||||
var err error
|
||||
switch section {
|
||||
case "characters":
|
||||
data, err = h.svc.GetCharacters(sectionCtx, id)
|
||||
tplName = "anime_characters"
|
||||
case "recommendations":
|
||||
data, err = h.svc.GetRecommendations(sectionCtx, id)
|
||||
tplName = "anime_recommendations"
|
||||
|
||||
case "statistics":
|
||||
data, err = h.svc.GetStatistics(sectionCtx, id)
|
||||
tplName = "anime_statistics"
|
||||
case "themes":
|
||||
data, err = h.svc.GetThemes(sectionCtx, id)
|
||||
tplName = "anime_themes"
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"anime_section_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"section": section,
|
||||
"anime_id": id,
|
||||
},
|
||||
err,
|
||||
)
|
||||
if section == "recommendations" {
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": "anime_recommendations_loading",
|
||||
"AnimeID": id,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": tplName,
|
||||
"Items": data,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
anime, err := h.svc.GetAnimeByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
h.svc.WarmDetailSections(id)
|
||||
|
||||
user := server.CurrentUser(c)
|
||||
status := ""
|
||||
var watchlistIDs []int64
|
||||
ep := 0
|
||||
var cwSeconds float64
|
||||
if user != nil {
|
||||
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), user.ID, int64(id))
|
||||
if err == nil {
|
||||
status = entry.Status
|
||||
watchlistIDs = []int64{entry.AnimeID}
|
||||
}
|
||||
|
||||
cwEntry, err := h.watchlistSvc.GetContinueWatchingEntry(c.Request.Context(), user.ID, int64(id))
|
||||
if err == nil && cwEntry.CurrentEpisode.Valid {
|
||||
ep = int(cwEntry.CurrentEpisode.Int64)
|
||||
cwSeconds = cwEntry.CurrentTimeSeconds
|
||||
}
|
||||
}
|
||||
|
||||
audioAvailability := h.animeAudioAvailability(c.Request.Context(), anime)
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"Anime": anime,
|
||||
"AudioAvailability": audioAvailability,
|
||||
"CurrentPath": fmt.Sprintf("/anime/%d", id),
|
||||
"User": user,
|
||||
"Status": status,
|
||||
"WatchlistIDs": watchlistIDs,
|
||||
"ContinueWatchingEp": ep,
|
||||
"ContinueWatchingTime": cwSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Query("animeId"))
|
||||
if err != nil || id <= 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||
return
|
||||
}
|
||||
|
||||
userID := server.CurrentUserID(c)
|
||||
|
||||
relationsCtx, cancel := context.WithTimeout(c.Request.Context(), watchOrderTimeout)
|
||||
defer cancel()
|
||||
|
||||
relations, err := h.svc.GetRelations(relationsCtx, id)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"relations_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": id,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": "watch_order_loading",
|
||||
"AnimeID": id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
relationAnimeIDs := make([]int64, 0, len(relations))
|
||||
for _, relation := range relations {
|
||||
if relation.Anime.MalID > 0 {
|
||||
relationAnimeIDs = append(relationAnimeIDs, int64(relation.Anime.MalID))
|
||||
}
|
||||
}
|
||||
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, relationAnimeIDs)
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": "watch_order",
|
||||
"Relations": relations,
|
||||
"AnimeID": id,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, 1, 5)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
|
||||
userID := server.CurrentUserID(c)
|
||||
animes := wrapAnimes(res.Animes)
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||
|
||||
type quickSearchResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Year int `json:"year"`
|
||||
Image string `json:"image"`
|
||||
InWatchlist bool `json:"in_watchlist"`
|
||||
}
|
||||
|
||||
output := make([]quickSearchResult, len(animes))
|
||||
for i, anime := range animes {
|
||||
output[i] = quickSearchResult{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
Type: anime.Type,
|
||||
Year: anime.Year,
|
||||
Image: anime.ImageURL(),
|
||||
InWatchlist: watchlistMap[int64(anime.MalID)],
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, output)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
anime, err := h.svc.GetRandomAnime(ctx)
|
||||
if err != nil {
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"random_anime_fetch_failed",
|
||||
"anime",
|
||||
"failed to fetch random anime",
|
||||
nil,
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if anime.MalID == 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadGateway, "random anime unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
inWatchlist := false
|
||||
userID := server.CurrentUserID(c)
|
||||
if userID != "" {
|
||||
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, []int64{int64(anime.MalID)})
|
||||
inWatchlist = watchlistMap[int64(anime.MalID)]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": anime,
|
||||
"in_watchlist": inWatchlist,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||
return
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
|
||||
return
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), id, page)
|
||||
if err != nil {
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"anime_reviews_fetch_failed",
|
||||
"anime",
|
||||
"failed to load reviews",
|
||||
map[string]any{"anime_id": id, "page": page},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
user := server.CurrentUser(c)
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" && page > 1 {
|
||||
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
|
||||
"_fragment": "review_cards",
|
||||
"Reviews": reviews,
|
||||
"NextPage": page + 1,
|
||||
"HasNextPage": hasNextPage,
|
||||
"AnimeID": id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
|
||||
"CurrentPath": fmt.Sprintf("/anime/%d/reviews", id),
|
||||
"Reviews": reviews,
|
||||
"NextPage": page + 1,
|
||||
"HasNextPage": hasNextPage,
|
||||
"AnimeID": id,
|
||||
"User": user,
|
||||
})
|
||||
}
|
||||
@@ -1,654 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AnimeHandler struct {
|
||||
svc domain.AnimeService
|
||||
watchlistSvc domain.WatchlistService
|
||||
}
|
||||
|
||||
func NewAnimeHandler(svc domain.AnimeService, watchlistSvc domain.WatchlistService) *AnimeHandler {
|
||||
return &AnimeHandler{
|
||||
svc: svc,
|
||||
watchlistSvc: watchlistSvc,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) watchlistMapForAnimes(ctx context.Context, userID string, animes []domain.Anime) map[int64]bool {
|
||||
animeIDs := make([]int64, 0, len(animes))
|
||||
for _, anime := range animes {
|
||||
if anime.MalID > 0 {
|
||||
animeIDs = append(animeIDs, int64(anime.MalID))
|
||||
}
|
||||
}
|
||||
return h.watchlistMapForIDs(ctx, userID, animeIDs)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, animeIDs []int64) map[int64]bool {
|
||||
if userID == "" || len(animeIDs) == 0 {
|
||||
return map[int64]bool{}
|
||||
}
|
||||
|
||||
watchlistMap, err := h.watchlistSvc.GetWatchlistMap(ctx, userID, animeIDs)
|
||||
if err != nil {
|
||||
return map[int64]bool{}
|
||||
}
|
||||
return watchlistMap
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) Register(r *gin.Engine) {
|
||||
|
||||
r.GET("/", h.HandleCatalog)
|
||||
r.GET("/api/catalog/airing", h.HandleCatalogAiring)
|
||||
r.GET("/api/catalog/popular", h.HandleCatalogPopular)
|
||||
r.GET("/api/catalog/continue", h.HandleCatalogContinue)
|
||||
r.GET("/discover", h.HandleDiscover)
|
||||
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
|
||||
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
|
||||
r.GET("/api/discover/top", h.HandleDiscoverTop)
|
||||
r.GET("/browse", h.HandleBrowse)
|
||||
r.GET("/anime/:id", h.HandleAnimeDetails)
|
||||
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
|
||||
r.GET("/api/watch-order", h.HandleHTMLWatchOrder)
|
||||
r.GET("/api/search-quick", h.HandleQuickSearch)
|
||||
r.GET("/api/command-palette", h.HandleCommandPalette)
|
||||
r.GET("/api/jikan/random/anime", h.HandleRandomAnime)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
|
||||
user, _ := c.Get("User")
|
||||
|
||||
c.HTML(http.StatusOK, "index.gohtml", gin.H{
|
||||
"CurrentPath": "/",
|
||||
"User": user,
|
||||
"WatchlistMap": map[int64]bool{},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogAiring(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Airing")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogPopular(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Popular")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Continue")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
|
||||
user, _ := c.Get("User")
|
||||
userID := ""
|
||||
if u, ok := user.(*domain.User); ok {
|
||||
userID = u.ID
|
||||
}
|
||||
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = section
|
||||
data.Fragment = "catalog_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "index.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscover(c *gin.Context) {
|
||||
user, _ := c.Get("User")
|
||||
c.HTML(http.StatusOK, "discover.gohtml", gin.H{
|
||||
"CurrentPath": "/discover",
|
||||
"User": user,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscoverTrending(c *gin.Context) {
|
||||
h.renderDiscoverSection(c, "Trending")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscoverUpcoming(c *gin.Context) {
|
||||
h.renderDiscoverSection(c, "Upcoming")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) {
|
||||
h.renderDiscoverSection(c, "Top")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
|
||||
user, _ := c.Get("User")
|
||||
userID := ""
|
||||
if u, ok := user.(*domain.User); ok {
|
||||
userID = u.ID
|
||||
}
|
||||
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = section
|
||||
data.Fragment = "discover_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "discover.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
||||
q := c.Query("q")
|
||||
animeType := c.Query("type")
|
||||
status := c.Query("status")
|
||||
orderBy := c.Query("order_by")
|
||||
sort := c.Query("sort")
|
||||
sfw := c.Query("sfw") != "false"
|
||||
|
||||
var genres []int
|
||||
for _, g := range c.QueryArray("genres") {
|
||||
id, _ := strconv.Atoi(g)
|
||||
if id > 0 {
|
||||
genres = append(genres, id)
|
||||
}
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, sfw, page, 24)
|
||||
if err != nil {
|
||||
}
|
||||
|
||||
user, _ := c.Get("User")
|
||||
userID := ""
|
||||
if u, ok := user.(*domain.User); ok {
|
||||
userID = u.ID
|
||||
}
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, res.Animes)
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" && page > 1 {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
||||
"_fragment": "anime_card_scroll",
|
||||
"Animes": res.Animes,
|
||||
"NextPage": page + 1,
|
||||
"HasNextPage": res.HasNextPage,
|
||||
"Query": q,
|
||||
"Type": animeType,
|
||||
"Status": status,
|
||||
"OrderBy": orderBy,
|
||||
"Sort": sort,
|
||||
"Genres": genres,
|
||||
"SFW": sfw,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
genresList, _ := h.svc.GetGenres(c.Request.Context())
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
||||
"_fragment": "browse_content",
|
||||
"CurrentPath": "/browse",
|
||||
"Query": q,
|
||||
"Type": animeType,
|
||||
"Status": status,
|
||||
"OrderBy": orderBy,
|
||||
"Sort": sort,
|
||||
"Genres": genres,
|
||||
"SFW": sfw,
|
||||
"GenresList": genresList,
|
||||
"Animes": res.Animes,
|
||||
"HasNextPage": res.HasNextPage,
|
||||
"NextPage": page + 1,
|
||||
"User": user,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
||||
"CurrentPath": "/browse",
|
||||
"Query": q,
|
||||
"Type": animeType,
|
||||
"Status": status,
|
||||
"OrderBy": orderBy,
|
||||
"Sort": sort,
|
||||
"Genres": genres,
|
||||
"SFW": sfw,
|
||||
"GenresList": genresList,
|
||||
"Animes": res.Animes,
|
||||
"HasNextPage": res.HasNextPage,
|
||||
"NextPage": page + 1,
|
||||
"User": user,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if id <= 0 {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
section := c.Query("section")
|
||||
if section != "" && c.GetHeader("HX-Request") == "true" {
|
||||
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), 4*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var data any
|
||||
var tplName string
|
||||
var err error
|
||||
switch section {
|
||||
case "characters":
|
||||
data, err = h.svc.GetCharacters(sectionCtx, id)
|
||||
tplName = "anime_characters"
|
||||
case "recommendations":
|
||||
data, err = h.svc.GetRecommendations(sectionCtx, id)
|
||||
tplName = "anime_recommendations"
|
||||
|
||||
case "statistics":
|
||||
data, err = h.svc.GetStatistics(sectionCtx, id)
|
||||
tplName = "anime_statistics"
|
||||
case "themes":
|
||||
data, err = h.svc.GetThemes(sectionCtx, id)
|
||||
tplName = "anime_themes"
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("failed to fetch section %s: %v", section, err)
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": tplName,
|
||||
"Items": data,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
anime, err := h.svc.GetAnimeByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := c.Get("User")
|
||||
status := ""
|
||||
var watchlistIDs []int64
|
||||
ep := 1
|
||||
var cwSeconds float64
|
||||
if u, ok := user.(*domain.User); ok {
|
||||
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), u.ID, int64(id))
|
||||
if err == nil {
|
||||
status = entry.Status
|
||||
watchlistIDs = []int64{entry.AnimeID}
|
||||
}
|
||||
|
||||
cwEntry, err := h.watchlistSvc.GetContinueWatchingEntry(c.Request.Context(), u.ID, int64(id))
|
||||
if err == nil && cwEntry.CurrentEpisode.Valid {
|
||||
ep = int(cwEntry.CurrentEpisode.Int64)
|
||||
cwSeconds = cwEntry.CurrentTimeSeconds
|
||||
}
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"Anime": anime,
|
||||
"CurrentPath": fmt.Sprintf("/anime/%d", id),
|
||||
"User": user,
|
||||
"Status": status,
|
||||
"WatchlistIDs": watchlistIDs,
|
||||
"ContinueWatchingEp": ep,
|
||||
"ContinueWatchingTime": cwSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Query("animeId"))
|
||||
if id <= 0 {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := c.Get("User")
|
||||
userID := ""
|
||||
if u, ok := user.(*domain.User); ok {
|
||||
userID = u.ID
|
||||
}
|
||||
|
||||
relationsCtx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
relations, err := h.svc.GetRelations(relationsCtx, id)
|
||||
if err != nil {
|
||||
log.Printf("failed to fetch relations for anime %d: %v", id, err)
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
relationAnimeIDs := make([]int64, 0, len(relations))
|
||||
for _, relation := range relations {
|
||||
if relation.Anime.MalID > 0 {
|
||||
relationAnimeIDs = append(relationAnimeIDs, int64(relation.Anime.MalID))
|
||||
}
|
||||
}
|
||||
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, relationAnimeIDs)
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": "watch_order",
|
||||
"Relations": relations,
|
||||
"AnimeID": id,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, true, 1, 5)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := c.Get("User")
|
||||
userID := ""
|
||||
if u, ok := user.(*domain.User); ok {
|
||||
userID = u.ID
|
||||
}
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, res.Animes)
|
||||
|
||||
type quickSearchResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Year int `json:"year"`
|
||||
Image string `json:"image"`
|
||||
InWatchlist bool `json:"in_watchlist"`
|
||||
}
|
||||
|
||||
output := make([]quickSearchResult, len(res.Animes))
|
||||
for i, anime := range res.Animes {
|
||||
output[i] = quickSearchResult{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
Type: anime.Type,
|
||||
Year: anime.Year,
|
||||
Image: anime.ImageURL(),
|
||||
InWatchlist: watchlistMap[int64(anime.MalID)],
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, output)
|
||||
}
|
||||
|
||||
type commandPaletteItem struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Href string `json:"href"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCommandPalette(c *gin.Context) {
|
||||
user, _ := c.Get("User")
|
||||
u, ok := user.(*domain.User)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(c.Query("q"))
|
||||
items := make([]commandPaletteItem, 0, 12)
|
||||
|
||||
if query != "" {
|
||||
items = append(items, commandPaletteItem{
|
||||
ID: "search:" + strings.ToLower(query),
|
||||
Type: "search",
|
||||
Label: fmt.Sprintf("Search anime for %q", query),
|
||||
Subtitle: "Browse",
|
||||
Href: "/browse?q=" + url.QueryEscape(query),
|
||||
Icon: "search",
|
||||
})
|
||||
|
||||
if len(query) >= 2 {
|
||||
items = append(items, h.commandPaletteAnimeResults(c, query)...)
|
||||
}
|
||||
|
||||
items = append(items, h.commandPaletteNavigationItems(query)...)
|
||||
items = append(items, h.commandPaletteContinueItems(c, u.ID, query)...)
|
||||
items = append(items, h.commandPalettePersonalItems(c, u.ID, query)...)
|
||||
c.JSON(http.StatusOK, items)
|
||||
return
|
||||
}
|
||||
|
||||
items = append(items, h.commandPaletteContinueItems(c, u.ID, query)...)
|
||||
items = append(items, h.commandPaletteNavigationItems(query)...)
|
||||
items = append(items, h.commandPalettePersonalItems(c, u.ID, query)...)
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPaletteItem {
|
||||
all := []commandPaletteItem{
|
||||
{ID: "nav:discover", Type: "navigation", Label: "Go to Discover", Subtitle: "Navigation", Href: "/discover", Icon: "compass"},
|
||||
{ID: "nav:watchlist", Type: "navigation", Label: "Go to Watchlist", Subtitle: "Navigation", Href: "/watchlist", Icon: "bookmark"},
|
||||
{ID: "nav:popular", Type: "navigation", Label: "Browse popular", Subtitle: "Browse", Href: "/browse?order_by=popularity&sort=desc", Icon: "trending"},
|
||||
{ID: "nav:airing", Type: "navigation", Label: "Currently airing", Subtitle: "Browse", Href: "/browse?status=airing&order_by=popularity&sort=desc", Icon: "play"},
|
||||
}
|
||||
if query == "" {
|
||||
return all
|
||||
}
|
||||
|
||||
filtered := make([]commandPaletteItem, 0, len(all))
|
||||
for _, item := range all {
|
||||
if commandPaletteMatches(query, item.Label, item.Subtitle) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string) []commandPaletteItem {
|
||||
searchCtx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, true, 1, 5)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
items := make([]commandPaletteItem, 0, len(res.Animes))
|
||||
for _, anime := range res.Animes {
|
||||
items = append(items, commandPaletteItem{
|
||||
ID: fmt.Sprintf("anime:%d", anime.MalID),
|
||||
Type: "anime",
|
||||
Label: anime.DisplayTitle(),
|
||||
Subtitle: strings.TrimSpace("Anime " + anime.Type),
|
||||
Href: fmt.Sprintf("/anime/%d", anime.MalID),
|
||||
Image: anime.ImageURL(),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPalettePersonalItems(c *gin.Context, userID string, query string) []commandPaletteItem {
|
||||
items := make([]commandPaletteItem, 0, 5)
|
||||
|
||||
watchlist, err := h.watchlistSvc.GetCommandPaletteWatchlist(c.Request.Context(), userID, query, 5)
|
||||
if err != nil {
|
||||
return items
|
||||
}
|
||||
|
||||
for _, entry := range watchlist {
|
||||
title := watchlistTitle(entry)
|
||||
items = append(items, commandPaletteItem{
|
||||
ID: fmt.Sprintf("watchlist:%d", entry.AnimeID),
|
||||
Type: "watchlist",
|
||||
Label: title,
|
||||
Subtitle: watchlistStatusLabel(entry.Status),
|
||||
Href: fmt.Sprintf("/anime/%d", entry.AnimeID),
|
||||
Image: entry.ImageUrl,
|
||||
})
|
||||
if len(items) >= 5 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPaletteContinueItems(c *gin.Context, userID string, query string) []commandPaletteItem {
|
||||
items := make([]commandPaletteItem, 0, 5)
|
||||
|
||||
rows, err := h.watchlistSvc.GetCommandPaletteContinueWatching(c.Request.Context(), userID, query, 5)
|
||||
if err != nil {
|
||||
return items
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
title := continueWatchingTitle(row)
|
||||
episode := ""
|
||||
href := fmt.Sprintf("/anime/%d/watch", row.AnimeID)
|
||||
if row.CurrentEpisode.Valid {
|
||||
episode = fmt.Sprintf(" episode %d", row.CurrentEpisode.Int64)
|
||||
href = fmt.Sprintf("%s?ep=%d", href, row.CurrentEpisode.Int64)
|
||||
}
|
||||
items = append(items, commandPaletteItem{
|
||||
ID: fmt.Sprintf("continue:%d", row.AnimeID),
|
||||
Type: "continue",
|
||||
Label: "Continue watching " + title,
|
||||
Subtitle: "Resume" + episode,
|
||||
Href: href,
|
||||
Image: row.ImageUrl,
|
||||
})
|
||||
if len(items) >= 5 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func commandPaletteMatches(query string, values ...string) bool {
|
||||
needle := strings.ToLower(strings.TrimSpace(query))
|
||||
for _, value := range values {
|
||||
if strings.Contains(strings.ToLower(value), needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func continueWatchingTitle(row db.GetContinueWatchingEntriesRow) string {
|
||||
if row.TitleEnglish.Valid && row.TitleEnglish.String != "" {
|
||||
return row.TitleEnglish.String
|
||||
}
|
||||
return row.TitleOriginal
|
||||
}
|
||||
|
||||
func watchlistTitle(row domain.UserWatchListRow) string {
|
||||
if row.TitleEnglish.Valid && row.TitleEnglish.String != "" {
|
||||
return row.TitleEnglish.String
|
||||
}
|
||||
return row.TitleOriginal
|
||||
}
|
||||
|
||||
func watchlistStatusLabel(status string) string {
|
||||
switch status {
|
||||
case "watching":
|
||||
return "Watching"
|
||||
case "plan_to_watch":
|
||||
return "Plan to Watch"
|
||||
default:
|
||||
return "Watchlist"
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
anime, err := h.svc.GetRandomAnime(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch random anime"})
|
||||
return
|
||||
}
|
||||
if anime.MalID == 0 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Random anime unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := c.Get("User")
|
||||
inWatchlist := false
|
||||
if u, ok := user.(*domain.User); ok {
|
||||
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), u.ID, []int64{int64(anime.MalID)})
|
||||
inWatchlist = watchlistMap[int64(anime.MalID)]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": anime,
|
||||
"in_watchlist": inWatchlist,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if id <= 0 {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), id, page)
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := c.Get("User")
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" && page > 1 {
|
||||
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
|
||||
"_fragment": "review_cards",
|
||||
"Reviews": reviews,
|
||||
"NextPage": page + 1,
|
||||
"HasNextPage": hasNextPage,
|
||||
"AnimeID": id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
|
||||
"CurrentPath": fmt.Sprintf("/anime/%d/reviews", id),
|
||||
"Reviews": reviews,
|
||||
"NextPage": page + 1,
|
||||
"HasNextPage": hasNextPage,
|
||||
"AnimeID": id,
|
||||
"User": user,
|
||||
})
|
||||
}
|
||||
124
internal/anime/handler_test.go
Normal file
124
internal/anime/handler_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubEpisodeService struct {
|
||||
episodes domain.CanonicalEpisodeList
|
||||
err error
|
||||
forced bool
|
||||
}
|
||||
|
||||
func (s *stubEpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.Anime, forceRefresh bool) (domain.CanonicalEpisodeList, error) {
|
||||
s.forced = forceRefresh
|
||||
if s.err != nil {
|
||||
return domain.CanonicalEpisodeList{}, s.err
|
||||
}
|
||||
return s.episodes, nil
|
||||
}
|
||||
|
||||
func (s *stubEpisodeService) RefreshTrackedDue(ctx context.Context, limit int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAnimeAudioAvailabilityLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
episodes []domain.CanonicalEpisode
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "dub availability",
|
||||
episodes: []domain.CanonicalEpisode{
|
||||
{Number: 1, HasSub: true, HasDub: true},
|
||||
},
|
||||
want: "Dub available",
|
||||
},
|
||||
{
|
||||
name: "subtitled availability",
|
||||
episodes: []domain.CanonicalEpisode{
|
||||
{Number: 1, HasSub: true, SubOnly: true},
|
||||
},
|
||||
want: "Subtitled only",
|
||||
},
|
||||
{
|
||||
name: "unknown availability",
|
||||
episodes: []domain.CanonicalEpisode{{Number: 1}},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "no episodes",
|
||||
episodes: []domain.CanonicalEpisode{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := animeAudioAvailabilityLabel(tt.episodes)
|
||||
if got != tt.want {
|
||||
t.Fatalf("animeAudioAvailabilityLabel() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnimeAudioAvailabilityRequiresAllAnimeSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "allanime source",
|
||||
source: "AllAnime",
|
||||
want: "Dub available",
|
||||
},
|
||||
{
|
||||
name: "jikan fallback source",
|
||||
source: "jikan_fallback",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "legacy source",
|
||||
source: "legacy_disabled",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "provider error",
|
||||
err: errors.New("provider unavailable"),
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
episodeSvc := &stubEpisodeService{
|
||||
episodes: domain.CanonicalEpisodeList{
|
||||
Source: tt.source,
|
||||
Episodes: []domain.CanonicalEpisode{
|
||||
{Number: 1, HasSub: true, HasDub: true},
|
||||
},
|
||||
},
|
||||
err: tt.err,
|
||||
}
|
||||
handler := NewAnimeHandler(nil, nil, episodeSvc)
|
||||
|
||||
got := handler.animeAudioAvailability(context.Background(), domain.Anime{
|
||||
Anime: jikan.Anime{MalID: 52991},
|
||||
})
|
||||
if got != tt.want {
|
||||
t.Fatalf("animeAudioAvailability() = %q, want %q", got, tt.want)
|
||||
}
|
||||
if !episodeSvc.forced {
|
||||
t.Fatal("animeAudioAvailability() did not force provider refresh")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"mal/internal/anime/handler"
|
||||
"mal/internal/anime/repository"
|
||||
"mal/internal/anime/service"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/server"
|
||||
|
||||
"go.uber.org/fx"
|
||||
@@ -11,12 +9,20 @@ import (
|
||||
|
||||
var Module = fx.Options(
|
||||
fx.Provide(
|
||||
repository.NewAnimeRepository,
|
||||
service.NewAnimeService,
|
||||
handler.NewAnimeHandler,
|
||||
NewAnimeRepository,
|
||||
fx.Annotate(
|
||||
NewAnimeService,
|
||||
fx.As(new(Service)),
|
||||
fx.As(new(domain.AnimeCatalogService)),
|
||||
fx.As(new(domain.AnimeDiscoverService)),
|
||||
fx.As(new(domain.AnimeSearchService)),
|
||||
fx.As(new(domain.AnimeDetailsService)),
|
||||
fx.As(new(domain.AnimePlaybackService)),
|
||||
),
|
||||
NewAnimeHandler,
|
||||
),
|
||||
fx.Provide(
|
||||
server.AsRouteRegister(func(h *handler.AnimeHandler) server.RouteRegister {
|
||||
server.AsRouteRegister(func(h *AnimeHandler) server.RouteRegister {
|
||||
return h
|
||||
}),
|
||||
),
|
||||
|
||||
503
internal/anime/recommendations.go
Normal file
503
internal/anime/recommendations.go
Normal file
@@ -0,0 +1,503 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"math"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
forYouMaxSeeds = 8
|
||||
forYouMaxRecommendations = 10
|
||||
forYouCandidateFetchLimit = 60
|
||||
forYouResultLimit = 18
|
||||
forYouFullResultLimit = 60
|
||||
forYouProfileSearchLimit = 8
|
||||
forYouProfileGenreSearches = 2
|
||||
forYouProfileThemeSearches = 2
|
||||
forYouCollaborativeWeight = 1.4
|
||||
forYouProfileSearchWeight = 0.8
|
||||
forYouSeedRecencyWindow = 180 * 24 * time.Hour
|
||||
forYouFreshReleaseWindow = 540 * 24 * time.Hour
|
||||
forYouGenreMatchWeight = 1.8
|
||||
forYouThemeMatchWeight = 1.0
|
||||
forYouStudioMatchWeight = 0.7
|
||||
forYouDemographicMatchWeight = 0.9
|
||||
forYouRecentDiversityWindow = 3
|
||||
forYouGenreDiversityPenalty = 1.7
|
||||
forYouThemeDiversityPenalty = 1.2
|
||||
forYouDemoDiversityPenalty = 1.0
|
||||
forYouStudioDiversityPenalty = 0.7
|
||||
)
|
||||
|
||||
type recommendationSeed struct {
|
||||
animeID int
|
||||
weight float64
|
||||
}
|
||||
|
||||
type weightedEntity struct {
|
||||
id int
|
||||
weight float64
|
||||
}
|
||||
|
||||
type profileSearchQuery struct {
|
||||
genreIDs []int
|
||||
studioID int
|
||||
weight float64
|
||||
}
|
||||
|
||||
type recommendationCandidate struct {
|
||||
anime jikan.Anime
|
||||
score float64
|
||||
genreMatches int
|
||||
themeMatches int
|
||||
studioMatches int
|
||||
demographicMatches int
|
||||
}
|
||||
|
||||
type userTasteProfile struct {
|
||||
genres map[int]float64
|
||||
themes map[int]float64
|
||||
studios map[int]float64
|
||||
demographics map[int]float64
|
||||
prefersAiring bool
|
||||
prefersRecent bool
|
||||
}
|
||||
|
||||
func buildRecommendationSeeds(
|
||||
now time.Time,
|
||||
watchlist []db.GetUserWatchListRow,
|
||||
) []recommendationSeed {
|
||||
seeds := make([]recommendationSeed, 0, min(len(watchlist), forYouMaxSeeds))
|
||||
|
||||
for _, entry := range watchlist {
|
||||
weight := recommendationEntryWeight(now, entry)
|
||||
if weight <= 0 || entry.AnimeID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
seeds = append(seeds, recommendationSeed{
|
||||
animeID: int(entry.AnimeID),
|
||||
weight: weight,
|
||||
})
|
||||
if len(seeds) >= forYouMaxSeeds {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return seeds
|
||||
}
|
||||
|
||||
func recommendationEntryWeight(now time.Time, entry db.GetUserWatchListRow) float64 {
|
||||
status := strings.TrimSpace(entry.Status)
|
||||
|
||||
var statusWeight float64
|
||||
switch status {
|
||||
case "completed":
|
||||
statusWeight = 1.0
|
||||
case "watching":
|
||||
statusWeight = 0.9
|
||||
case "plan_to_watch":
|
||||
statusWeight = 0.35
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
recencyWeight := 1.0
|
||||
if !entry.UpdatedAt.IsZero() {
|
||||
age := now.Sub(entry.UpdatedAt)
|
||||
if age > 0 {
|
||||
recencyWeight = math.Max(0.35, 1-(age.Hours()/forYouSeedRecencyWindow.Hours()))
|
||||
}
|
||||
}
|
||||
|
||||
progressWeight := 0.6
|
||||
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 {
|
||||
progressWeight = min(1.0, 0.6+(0.08*float64(entry.CurrentEpisode.Int64)))
|
||||
}
|
||||
|
||||
return statusWeight * recencyWeight * progressWeight
|
||||
}
|
||||
|
||||
func buildTasteProfile(
|
||||
now time.Time,
|
||||
seeds []recommendationSeed,
|
||||
seedAnimes []jikan.Anime,
|
||||
) userTasteProfile {
|
||||
profile := userTasteProfile{
|
||||
genres: make(map[int]float64),
|
||||
themes: make(map[int]float64),
|
||||
studios: make(map[int]float64),
|
||||
demographics: make(map[int]float64),
|
||||
}
|
||||
|
||||
var totalWeight float64
|
||||
var airingWeight float64
|
||||
var recentWeight float64
|
||||
|
||||
for i, anime := range seedAnimes {
|
||||
seedWeight := 1.0
|
||||
if i < len(seeds) && seeds[i].weight > 0 {
|
||||
seedWeight = seeds[i].weight
|
||||
}
|
||||
|
||||
addEntityWeights(profile.genres, anime.Genres, seedWeight)
|
||||
addEntityWeights(profile.themes, anime.Themes, seedWeight*0.7)
|
||||
addEntityWeights(profile.studios, anime.Studios, seedWeight*0.5)
|
||||
addEntityWeights(profile.demographics, anime.Demographics, seedWeight*0.7)
|
||||
|
||||
if anime.Airing {
|
||||
airingWeight += seedWeight
|
||||
}
|
||||
if anime.Year > 0 && now.Year()-anime.Year <= 4 {
|
||||
recentWeight += seedWeight
|
||||
}
|
||||
totalWeight += seedWeight
|
||||
}
|
||||
|
||||
if totalWeight > 0 {
|
||||
profile.prefersAiring = airingWeight/totalWeight >= 0.5
|
||||
profile.prefersRecent = recentWeight/totalWeight >= 0.5
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
func addEntityWeights(target map[int]float64, entities []jikan.NamedEntity, weight float64) {
|
||||
for _, entity := range entities {
|
||||
if entity.MalID <= 0 {
|
||||
continue
|
||||
}
|
||||
target[entity.MalID] += weight
|
||||
}
|
||||
}
|
||||
|
||||
func buildProfileSearchQueries(profile userTasteProfile) []profileSearchQuery {
|
||||
queries := make([]profileSearchQuery, 0, 6)
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.genres, forYouProfileGenreSearches) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
genreIDs: []int{entity.id},
|
||||
weight: entity.weight,
|
||||
})
|
||||
}
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.themes, forYouProfileThemeSearches) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
genreIDs: []int{entity.id},
|
||||
weight: entity.weight * 0.8,
|
||||
})
|
||||
}
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.demographics, 1) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
genreIDs: []int{entity.id},
|
||||
weight: entity.weight * 0.8,
|
||||
})
|
||||
}
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.studios, 1) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
studioID: entity.id,
|
||||
weight: entity.weight * 0.7,
|
||||
})
|
||||
}
|
||||
|
||||
return queries
|
||||
}
|
||||
|
||||
func strongestWeightedEntities(weights map[int]float64, limit int) []weightedEntity {
|
||||
if limit <= 0 || len(weights) == 0 {
|
||||
return []weightedEntity{}
|
||||
}
|
||||
|
||||
items := make([]weightedEntity, 0, len(weights))
|
||||
for id, weight := range weights {
|
||||
if id <= 0 || weight <= 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, weightedEntity{id: id, weight: weight})
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].weight == items[j].weight {
|
||||
return items[i].id < items[j].id
|
||||
}
|
||||
return items[i].weight > items[j].weight
|
||||
})
|
||||
|
||||
if len(items) > limit {
|
||||
return items[:limit]
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func profileSearchRankWeight(rank int) float64 {
|
||||
return math.Max(0.35, 1-(float64(rank)*0.08))
|
||||
}
|
||||
|
||||
func rankedCandidateRetrievalScore(collaborativeScore float64, profileSearchScore float64) float64 {
|
||||
return (math.Log1p(collaborativeScore) * forYouCollaborativeWeight) +
|
||||
(profileSearchScore * forYouProfileSearchWeight)
|
||||
}
|
||||
|
||||
func hasTasteMetadata(anime jikan.Anime) bool {
|
||||
return len(anime.Genres) > 0 ||
|
||||
len(anime.Themes) > 0 ||
|
||||
len(anime.Studios) > 0 ||
|
||||
len(anime.Demographics) > 0
|
||||
}
|
||||
|
||||
func scoreRecommendationCandidate(
|
||||
now time.Time,
|
||||
profile userTasteProfile,
|
||||
candidate jikan.Anime,
|
||||
collaborativeScore float64,
|
||||
profileSearchScore float64,
|
||||
) recommendationCandidate {
|
||||
genreMatches, genreScore := weightedEntityMatch(profile.genres, candidate.Genres)
|
||||
themeMatches, themeScore := weightedEntityMatch(profile.themes, candidate.Themes)
|
||||
studioMatches, studioScore := weightedEntityMatch(profile.studios, candidate.Studios)
|
||||
demographicMatches, demographicScore := weightedEntityMatch(profile.demographics, candidate.Demographics)
|
||||
|
||||
score := rankedCandidateRetrievalScore(collaborativeScore, profileSearchScore)
|
||||
score += genreScore * forYouGenreMatchWeight
|
||||
score += themeScore * forYouThemeMatchWeight
|
||||
score += studioScore * forYouStudioMatchWeight
|
||||
score += demographicScore * forYouDemographicMatchWeight
|
||||
|
||||
if candidate.Score > 0 {
|
||||
score += min(candidate.Score/10.0, 1.0)
|
||||
}
|
||||
if candidate.Popularity > 0 {
|
||||
score += 1.0 / math.Log(float64(candidate.Popularity)+8)
|
||||
}
|
||||
if profile.prefersAiring && candidate.Airing {
|
||||
score += 0.5
|
||||
}
|
||||
if profile.prefersRecent && candidate.Year > 0 && now.Year()-candidate.Year <= 4 {
|
||||
score += 0.45
|
||||
}
|
||||
if candidate.Year > 0 && now.Year()-candidate.Year > 15 {
|
||||
score -= 0.2
|
||||
}
|
||||
if candidate.Status == "Not yet aired" {
|
||||
score -= 0.35
|
||||
}
|
||||
if candidate.Aired.From != "" {
|
||||
if airedAt, err := time.Parse(time.RFC3339, candidate.Aired.From); err == nil {
|
||||
if now.Sub(airedAt) <= forYouFreshReleaseWindow {
|
||||
score += 0.3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return recommendationCandidate{
|
||||
anime: candidate,
|
||||
score: score,
|
||||
genreMatches: genreMatches,
|
||||
themeMatches: themeMatches,
|
||||
studioMatches: studioMatches,
|
||||
demographicMatches: demographicMatches,
|
||||
}
|
||||
}
|
||||
|
||||
func weightedEntityMatch(weights map[int]float64, entities []jikan.NamedEntity) (int, float64) {
|
||||
var (
|
||||
matches int
|
||||
score float64
|
||||
)
|
||||
|
||||
for _, entity := range entities {
|
||||
weight, ok := weights[entity.MalID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
matches++
|
||||
score += weight
|
||||
}
|
||||
|
||||
return matches, score
|
||||
}
|
||||
|
||||
func rerankRecommendationCandidates(candidates []recommendationCandidate, limit int) []domain.Anime {
|
||||
selected := make([]domain.Anime, 0, min(limit, len(candidates)))
|
||||
remaining := slices.Clone(candidates)
|
||||
seenFeatures := newDiversityFeatureCounts()
|
||||
recentFeatures := make([]diversityFeatureSet, 0, forYouRecentDiversityWindow)
|
||||
|
||||
for len(selected) < limit && len(remaining) > 0 {
|
||||
bestIndex := bestDiverseCandidateIndex(remaining, seenFeatures, recentFeatures)
|
||||
candidate := remaining[bestIndex]
|
||||
remaining = slices.Delete(remaining, bestIndex, bestIndex+1)
|
||||
|
||||
if slices.ContainsFunc(selected, func(anime domain.Anime) bool {
|
||||
return anime.MalID == candidate.anime.MalID
|
||||
}) {
|
||||
continue
|
||||
}
|
||||
|
||||
selected = append(selected, domain.Anime{Anime: candidate.anime})
|
||||
features := diversityFeatures(candidate.anime)
|
||||
seenFeatures.add(features)
|
||||
recentFeatures = append(recentFeatures, features)
|
||||
if len(recentFeatures) > forYouRecentDiversityWindow {
|
||||
recentFeatures = recentFeatures[1:]
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
type diversityFeatureSet struct {
|
||||
genres map[int]struct{}
|
||||
themes map[int]struct{}
|
||||
demographics map[int]struct{}
|
||||
studios map[int]struct{}
|
||||
}
|
||||
|
||||
type diversityFeatureCounts struct {
|
||||
genres map[int]int
|
||||
themes map[int]int
|
||||
demographics map[int]int
|
||||
studios map[int]int
|
||||
}
|
||||
|
||||
func newDiversityFeatureCounts() diversityFeatureCounts {
|
||||
return diversityFeatureCounts{
|
||||
genres: make(map[int]int),
|
||||
themes: make(map[int]int),
|
||||
demographics: make(map[int]int),
|
||||
studios: make(map[int]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (counts diversityFeatureCounts) add(features diversityFeatureSet) {
|
||||
addDiversityCounts(counts.genres, features.genres)
|
||||
addDiversityCounts(counts.themes, features.themes)
|
||||
addDiversityCounts(counts.demographics, features.demographics)
|
||||
addDiversityCounts(counts.studios, features.studios)
|
||||
}
|
||||
|
||||
func addDiversityCounts(target map[int]int, features map[int]struct{}) {
|
||||
for id := range features {
|
||||
target[id]++
|
||||
}
|
||||
}
|
||||
|
||||
func bestDiverseCandidateIndex(
|
||||
candidates []recommendationCandidate,
|
||||
seen diversityFeatureCounts,
|
||||
recent []diversityFeatureSet,
|
||||
) int {
|
||||
bestIndex := 0
|
||||
bestScore := math.Inf(-1)
|
||||
|
||||
for i, candidate := range candidates {
|
||||
score := candidate.score - diversityPenalty(diversityFeatures(candidate.anime), seen, recent)
|
||||
if score == bestScore {
|
||||
if candidate.score <= candidates[bestIndex].score {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex
|
||||
}
|
||||
|
||||
func diversityFeatures(anime jikan.Anime) diversityFeatureSet {
|
||||
return diversityFeatureSet{
|
||||
genres: entityIDSet(anime.Genres),
|
||||
themes: entityIDSet(anime.Themes),
|
||||
demographics: entityIDSet(anime.Demographics),
|
||||
studios: entityIDSet(anime.Studios),
|
||||
}
|
||||
}
|
||||
|
||||
func entityIDSet(entities []jikan.NamedEntity) map[int]struct{} {
|
||||
ids := make(map[int]struct{}, len(entities))
|
||||
for _, entity := range entities {
|
||||
if entity.MalID <= 0 {
|
||||
continue
|
||||
}
|
||||
ids[entity.MalID] = struct{}{}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func diversityPenalty(
|
||||
features diversityFeatureSet,
|
||||
seen diversityFeatureCounts,
|
||||
recent []diversityFeatureSet,
|
||||
) float64 {
|
||||
penalty := 0.0
|
||||
penalty += repeatedFeaturePenalty(features.genres, seen.genres, recentGenreCounts(recent), forYouGenreDiversityPenalty)
|
||||
penalty += repeatedFeaturePenalty(features.themes, seen.themes, recentThemeCounts(recent), forYouThemeDiversityPenalty)
|
||||
penalty += repeatedFeaturePenalty(
|
||||
features.demographics,
|
||||
seen.demographics,
|
||||
recentDemographicCounts(recent),
|
||||
forYouDemoDiversityPenalty,
|
||||
)
|
||||
penalty += repeatedFeaturePenalty(features.studios, seen.studios, recentStudioCounts(recent), forYouStudioDiversityPenalty)
|
||||
|
||||
return penalty
|
||||
}
|
||||
|
||||
func repeatedFeaturePenalty(
|
||||
features map[int]struct{},
|
||||
seen map[int]int,
|
||||
recent map[int]int,
|
||||
weight float64,
|
||||
) float64 {
|
||||
total := 0.0
|
||||
for id := range features {
|
||||
total += float64(seen[id]) * weight * 0.35
|
||||
total += float64(recent[id]) * weight
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func recentGenreCounts(recent []diversityFeatureSet) map[int]int {
|
||||
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
||||
return features.genres
|
||||
})
|
||||
}
|
||||
|
||||
func recentThemeCounts(recent []diversityFeatureSet) map[int]int {
|
||||
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
||||
return features.themes
|
||||
})
|
||||
}
|
||||
|
||||
func recentDemographicCounts(recent []diversityFeatureSet) map[int]int {
|
||||
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
||||
return features.demographics
|
||||
})
|
||||
}
|
||||
|
||||
func recentStudioCounts(recent []diversityFeatureSet) map[int]int {
|
||||
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
||||
return features.studios
|
||||
})
|
||||
}
|
||||
|
||||
func recentFeatureCounts(
|
||||
recent []diversityFeatureSet,
|
||||
selectFeatures func(diversityFeatureSet) map[int]struct{},
|
||||
) map[int]int {
|
||||
counts := make(map[int]int)
|
||||
for _, features := range recent {
|
||||
addDiversityCounts(counts, selectFeatures(features))
|
||||
}
|
||||
return counts
|
||||
}
|
||||
226
internal/anime/recommendations_test.go
Normal file
226
internal/anime/recommendations_test.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRecommendationEntryWeightPrioritizesCommittedRecentHistory(t *testing.T) {
|
||||
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
completed := recommendationEntryWeight(now, db.GetUserWatchListRow{
|
||||
Status: "completed",
|
||||
UpdatedAt: now.Add(-24 * time.Hour),
|
||||
CurrentEpisode: sql.NullInt64{Int64: 12, Valid: true},
|
||||
})
|
||||
planned := recommendationEntryWeight(now, db.GetUserWatchListRow{
|
||||
Status: "plan_to_watch",
|
||||
UpdatedAt: now.Add(-24 * time.Hour),
|
||||
})
|
||||
|
||||
if completed <= planned {
|
||||
t.Fatalf("expected completed history to outrank planned history, got completed=%f planned=%f", completed, planned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRecommendationSeedsFiltersUnsupportedStatuses(t *testing.T) {
|
||||
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
seeds := buildRecommendationSeeds(now, []db.GetUserWatchListRow{
|
||||
{AnimeID: 1, Status: "dropped", UpdatedAt: now},
|
||||
{AnimeID: 2, Status: "watching", UpdatedAt: now},
|
||||
{AnimeID: 3, Status: "completed", UpdatedAt: now},
|
||||
})
|
||||
|
||||
if len(seeds) != 2 {
|
||||
t.Fatalf("expected 2 valid seeds, got %d", len(seeds))
|
||||
}
|
||||
if seeds[0].animeID != 2 || seeds[1].animeID != 3 {
|
||||
t.Fatalf("unexpected seed ordering: %+v", seeds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreRecommendationCandidateRewardsProfileOverlap(t *testing.T) {
|
||||
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||
profile := userTasteProfile{
|
||||
genres: map[int]float64{
|
||||
1: 2.0,
|
||||
},
|
||||
themes: map[int]float64{},
|
||||
studios: map[int]float64{},
|
||||
demographics: map[int]float64{},
|
||||
}
|
||||
|
||||
matching := scoreRecommendationCandidate(now, profile, jikan.Anime{
|
||||
MalID: 10,
|
||||
Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}},
|
||||
Popularity: 100,
|
||||
Score: 8.0,
|
||||
}, 5.0, 0)
|
||||
nonMatching := scoreRecommendationCandidate(now, profile, jikan.Anime{
|
||||
MalID: 11,
|
||||
Genres: []jikan.NamedEntity{{MalID: 2, Name: "Drama"}},
|
||||
Popularity: 100,
|
||||
Score: 8.0,
|
||||
}, 5.0, 0)
|
||||
|
||||
if matching.score <= nonMatching.score {
|
||||
t.Fatalf("expected matching candidate to score higher, got matching=%f nonMatching=%f", matching.score, nonMatching.score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTasteProfileUsesSeedWeights(t *testing.T) {
|
||||
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
profile := buildTasteProfile(
|
||||
now,
|
||||
[]recommendationSeed{
|
||||
{animeID: 1, weight: 2.0},
|
||||
{animeID: 2, weight: 0.5},
|
||||
},
|
||||
[]jikan.Anime{
|
||||
{
|
||||
MalID: 1,
|
||||
Airing: true,
|
||||
Year: 2026,
|
||||
Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}},
|
||||
Themes: []jikan.NamedEntity{{MalID: 10, Name: "Team Sports"}},
|
||||
Studios: []jikan.NamedEntity{{MalID: 20, Name: "Production I.G"}},
|
||||
Demographics: []jikan.NamedEntity{{MalID: 30, Name: "Shounen"}},
|
||||
},
|
||||
{
|
||||
MalID: 2,
|
||||
Year: 2001,
|
||||
Genres: []jikan.NamedEntity{{MalID: 2, Name: "Drama"}},
|
||||
Themes: []jikan.NamedEntity{{MalID: 11, Name: "School"}},
|
||||
Studios: []jikan.NamedEntity{{MalID: 21, Name: "Madhouse"}},
|
||||
Demographics: []jikan.NamedEntity{{MalID: 31, Name: "Seinen"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if profile.genres[1] <= profile.genres[2] {
|
||||
t.Fatalf("expected stronger seed genre to carry more weight, got profile=%+v", profile.genres)
|
||||
}
|
||||
if !profile.prefersAiring {
|
||||
t.Fatal("expected weighted profile to prefer airing anime")
|
||||
}
|
||||
if !profile.prefersRecent {
|
||||
t.Fatal("expected weighted profile to prefer recent anime")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProfileSearchQueriesIncludesTasteSignals(t *testing.T) {
|
||||
profile := userTasteProfile{
|
||||
genres: map[int]float64{
|
||||
1: 2.0,
|
||||
2: 1.5,
|
||||
3: 0.2,
|
||||
},
|
||||
themes: map[int]float64{
|
||||
10: 1.4,
|
||||
},
|
||||
studios: map[int]float64{
|
||||
20: 1.0,
|
||||
},
|
||||
demographics: map[int]float64{
|
||||
30: 1.2,
|
||||
},
|
||||
}
|
||||
|
||||
queries := buildProfileSearchQueries(profile)
|
||||
|
||||
if !hasGenreSearchQuery(queries, 1) {
|
||||
t.Fatalf("expected strongest genre query, got %+v", queries)
|
||||
}
|
||||
if !hasGenreSearchQuery(queries, 10) {
|
||||
t.Fatalf("expected theme query, got %+v", queries)
|
||||
}
|
||||
if !hasGenreSearchQuery(queries, 30) {
|
||||
t.Fatalf("expected demographic query, got %+v", queries)
|
||||
}
|
||||
if !hasStudioSearchQuery(queries, 20) {
|
||||
t.Fatalf("expected studio query, got %+v", queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRerankRecommendationCandidatesSpreadsRepeatedGenres(t *testing.T) {
|
||||
const sportsGenreID = 30
|
||||
|
||||
candidates := []recommendationCandidate{
|
||||
{anime: testRecommendationAnime(1, sportsGenreID), score: 10},
|
||||
{anime: testRecommendationAnime(2, sportsGenreID), score: 9.9},
|
||||
{anime: testRecommendationAnime(3, sportsGenreID), score: 9.8},
|
||||
{anime: testRecommendationAnime(4, sportsGenreID), score: 9.7},
|
||||
{anime: testRecommendationAnime(5, sportsGenreID), score: 9.6},
|
||||
{anime: testRecommendationAnime(6, 1), score: 9.5},
|
||||
{anime: testRecommendationAnime(7, 2), score: 9.4},
|
||||
{anime: testRecommendationAnime(8, 3), score: 9.3},
|
||||
}
|
||||
|
||||
reranked := rerankRecommendationCandidates(candidates, 8)
|
||||
if len(reranked) < 5 {
|
||||
t.Fatalf("expected enough reranked candidates, got %d", len(reranked))
|
||||
}
|
||||
|
||||
for i := 0; i <= len(reranked)-5; i++ {
|
||||
if allHaveGenre(reranked[i:i+5], sportsGenreID) {
|
||||
t.Fatalf("expected reranker to avoid five sports anime in a row, got %+v", animeIDs(reranked))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testRecommendationAnime(id int, genreID int) jikan.Anime {
|
||||
return jikan.Anime{
|
||||
MalID: id,
|
||||
Genres: []jikan.NamedEntity{{MalID: genreID, Name: "Genre"}},
|
||||
}
|
||||
}
|
||||
|
||||
func allHaveGenre(animes []domain.Anime, genreID int) bool {
|
||||
for _, anime := range animes {
|
||||
hasGenre := false
|
||||
for _, genre := range anime.Genres {
|
||||
if genre.MalID == genreID {
|
||||
hasGenre = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasGenre {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func animeIDs(animes []domain.Anime) []int {
|
||||
ids := make([]int, 0, len(animes))
|
||||
for _, anime := range animes {
|
||||
ids = append(ids, anime.MalID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func hasGenreSearchQuery(queries []profileSearchQuery, genreID int) bool {
|
||||
for _, query := range queries {
|
||||
for _, id := range query.genreIDs {
|
||||
if id == genreID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasStudioSearchQuery(queries []profileSearchQuery, studioID int) bool {
|
||||
for _, query := range queries {
|
||||
if query.studioID == studioID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package repository
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
121
internal/anime/schedule.go
Normal file
121
internal/anime/schedule.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/integrations/animeschedule"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type cachedWeekSchedule struct {
|
||||
fetchedAt time.Time
|
||||
value animeschedule.WeekSchedule
|
||||
}
|
||||
|
||||
func parseYearWeek(c *gin.Context) (int, int) {
|
||||
year, _ := strconv.Atoi(c.Query("year"))
|
||||
week, _ := strconv.Atoi(c.Query("week"))
|
||||
if year <= 0 || week <= 0 {
|
||||
now := time.Now()
|
||||
y, w := now.ISOWeek()
|
||||
if year <= 0 {
|
||||
year = y
|
||||
}
|
||||
if week <= 0 {
|
||||
week = w
|
||||
}
|
||||
}
|
||||
return year, week
|
||||
}
|
||||
|
||||
func scheduleTimezone(c *gin.Context) string {
|
||||
timezone := strings.TrimSpace(c.Query("timezone"))
|
||||
if timezone == "" {
|
||||
return "UTC"
|
||||
}
|
||||
return timezone
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int, week int, timezone string) (animeschedule.WeekSchedule, error) {
|
||||
cacheKey := fmt.Sprintf("%d-%02d-%s", year, week, timezone)
|
||||
const ttl = 10 * time.Minute
|
||||
|
||||
h.scheduleCacheMu.Lock()
|
||||
cached, ok := h.scheduleCache[cacheKey]
|
||||
h.scheduleCacheMu.Unlock()
|
||||
|
||||
if ok && time.Since(cached.fetchedAt) < ttl {
|
||||
return cached.value, nil
|
||||
}
|
||||
|
||||
value, err := animeschedule.FetchWeek(ctx, nil, year, week, timezone)
|
||||
if err != nil {
|
||||
return animeschedule.WeekSchedule{}, err
|
||||
}
|
||||
|
||||
h.scheduleCacheMu.Lock()
|
||||
h.scheduleCache[cacheKey] = cachedWeekSchedule{fetchedAt: time.Now(), value: value}
|
||||
h.scheduleCacheMu.Unlock()
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
type scheduleDayView struct {
|
||||
DateLabel string
|
||||
WeekdayLabel string
|
||||
Entries []animeschedule.Entry
|
||||
}
|
||||
|
||||
func buildScheduleDays(schedule animeschedule.WeekSchedule, year int, week int) []scheduleDayView {
|
||||
start := isoWeekStartMonday(year, week)
|
||||
order := []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday, time.Saturday, time.Sunday}
|
||||
out := make([]scheduleDayView, 0, 7)
|
||||
for i, wd := range order {
|
||||
date := start.AddDate(0, 0, i)
|
||||
entries := schedule.Days[wd]
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
if !entries[i].AirsAt.IsZero() && !entries[j].AirsAt.IsZero() {
|
||||
return entries[i].AirsAt.Before(entries[j].AirsAt)
|
||||
}
|
||||
return localTimeMinutes(entries[i].LocalTime) < localTimeMinutes(entries[j].LocalTime)
|
||||
})
|
||||
out = append(out, scheduleDayView{
|
||||
DateLabel: strings.ToUpper(date.Format("02 Jan")),
|
||||
WeekdayLabel: wd.String(),
|
||||
Entries: entries,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func localTimeMinutes(localTime string) int {
|
||||
for _, layout := range []string{"15:04", "03:04 PM"} {
|
||||
t, err := time.Parse(layout, localTime)
|
||||
if err == nil {
|
||||
return t.Hour()*60 + t.Minute()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func isoWeekStartMonday(year int, week int) time.Time {
|
||||
// ISO week 1 is the week with the year's first Thursday in it.
|
||||
jan4 := time.Date(year, 1, 4, 12, 0, 0, 0, time.Local)
|
||||
// Move back to Monday
|
||||
offset := int(time.Monday - jan4.Weekday())
|
||||
if offset > 0 {
|
||||
offset -= 7
|
||||
}
|
||||
week1Monday := jan4.AddDate(0, 0, offset)
|
||||
return week1Monday.AddDate(0, 0, (week-1)*7)
|
||||
}
|
||||
|
||||
func adjacentISOWeek(year int, week int, deltaWeeks int) (int, int) {
|
||||
target := isoWeekStartMonday(year, week).AddDate(0, 0, deltaWeeks*7)
|
||||
return target.ISOWeek()
|
||||
}
|
||||
673
internal/anime/service.go
Normal file
673
internal/anime/service.go
Normal file
@@ -0,0 +1,673 @@
|
||||
// Package anime provides anime catalog, discovery, search, and details services.
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type animeService struct {
|
||||
jikan *jikan.Client
|
||||
repo domain.AnimeRepository
|
||||
}
|
||||
|
||||
func wrapAnimes(in []jikan.Anime) []domain.Anime {
|
||||
out := make([]domain.Anime, 0, len(in))
|
||||
for _, a := range in {
|
||||
out = append(out, domain.Anime{Anime: a})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) *animeService {
|
||||
return &animeService{jikan: jikan, repo: repo}
|
||||
}
|
||||
|
||||
func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (domain.CatalogSectionData, error) {
|
||||
var (
|
||||
res jikan.TopAnimeResult
|
||||
cw []db.GetContinueWatchingEntriesRow
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
switch section {
|
||||
case "Airing":
|
||||
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
|
||||
case "Popular":
|
||||
res, err = s.jikan.GetTopAnime(gCtx, 1)
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
if userID != "" && section == "Continue" {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
animes := wrapAnimes(res.Animes)
|
||||
if len(animes) > 6 {
|
||||
animes = animes[:6]
|
||||
}
|
||||
|
||||
return domain.CatalogSectionData{
|
||||
Animes: animes,
|
||||
ContinueWatching: cw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, section string) (domain.DiscoverSectionData, error) {
|
||||
var res jikan.TopAnimeResult
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
switch section {
|
||||
case "Trending":
|
||||
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
|
||||
case "Upcoming":
|
||||
res, err = s.jikan.GetSeasonsUpcoming(gCtx, 1)
|
||||
case "Top":
|
||||
res, err = s.jikan.GetTopAnime(gCtx, 1)
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return domain.DiscoverSectionData{}, err
|
||||
}
|
||||
|
||||
animes := wrapAnimes(res.Animes)
|
||||
if len(animes) > 8 {
|
||||
animes = animes[:8]
|
||||
}
|
||||
|
||||
return domain.DiscoverSectionData{
|
||||
Animes: animes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||
return s.getTopPicksForYou(ctx, userID, forYouResultLimit)
|
||||
}
|
||||
|
||||
func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||
return s.getTopPicksForYou(ctx, userID, forYouFullResultLimit)
|
||||
}
|
||||
|
||||
func (s *animeService) getTopPicksForYou(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
resultLimit int,
|
||||
) (domain.CatalogSectionData, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
seedPool := buildRecommendationSeeds(now, watchlist)
|
||||
if len(seedPool) == 0 {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
type rankedCandidate struct {
|
||||
id int
|
||||
collaborativeScore float64
|
||||
profileSearchScore float64
|
||||
anime jikan.Anime
|
||||
hasAnime bool
|
||||
}
|
||||
|
||||
watchlistAnimeIDs := make(map[int]struct{}, len(watchlist))
|
||||
for _, entry := range watchlist {
|
||||
if entry.AnimeID <= 0 {
|
||||
continue
|
||||
}
|
||||
watchlistAnimeIDs[int(entry.AnimeID)] = struct{}{}
|
||||
}
|
||||
|
||||
candidatesByID := map[int]rankedCandidate{}
|
||||
var candidatesByIDMu sync.Mutex
|
||||
upsertCandidate := func(candidate rankedCandidate) {
|
||||
if candidate.id <= 0 {
|
||||
return
|
||||
}
|
||||
if _, exists := watchlistAnimeIDs[candidate.id]; exists {
|
||||
return
|
||||
}
|
||||
|
||||
candidatesByIDMu.Lock()
|
||||
defer candidatesByIDMu.Unlock()
|
||||
|
||||
current, ok := candidatesByID[candidate.id]
|
||||
if !ok {
|
||||
candidatesByID[candidate.id] = candidate
|
||||
return
|
||||
}
|
||||
|
||||
current.collaborativeScore += candidate.collaborativeScore
|
||||
current.profileSearchScore += candidate.profileSearchScore
|
||||
if candidate.hasAnime {
|
||||
current.anime = candidate.anime
|
||||
current.hasAnime = true
|
||||
}
|
||||
candidatesByID[candidate.id] = current
|
||||
}
|
||||
|
||||
seedAnimes := make([]jikan.Anime, len(seedPool))
|
||||
var seedFetchGroup errgroup.Group
|
||||
seedFetchGroup.SetLimit(4)
|
||||
|
||||
for i, seed := range seedPool {
|
||||
seedFetchGroup.Go(func() error {
|
||||
anime, fetchErr := s.jikan.GetAnimeByID(ctx, seed.animeID)
|
||||
if fetchErr != nil {
|
||||
return fetchErr
|
||||
}
|
||||
seedAnimes[i] = anime
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := seedFetchGroup.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
profile := buildTasteProfile(now, seedPool, seedAnimes)
|
||||
|
||||
var recommendationGroup errgroup.Group
|
||||
recommendationGroup.SetLimit(4)
|
||||
|
||||
for _, seed := range seedPool {
|
||||
recommendationGroup.Go(func() error {
|
||||
recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
|
||||
if recErr != nil {
|
||||
return recErr
|
||||
}
|
||||
for i, rec := range recs {
|
||||
if i >= forYouMaxRecommendations {
|
||||
break
|
||||
}
|
||||
id := rec.Entry.MalID
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if id == seed.animeID {
|
||||
continue
|
||||
}
|
||||
upsertCandidate(rankedCandidate{
|
||||
id: id,
|
||||
collaborativeScore: float64(rec.Votes) * seed.weight,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := recommendationGroup.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
profileQueries := buildProfileSearchQueries(profile)
|
||||
var profileSearchGroup errgroup.Group
|
||||
profileSearchGroup.SetLimit(3)
|
||||
|
||||
for _, query := range profileQueries {
|
||||
profileSearchGroup.Go(func() error {
|
||||
res, searchErr := s.jikan.SearchAdvanced(
|
||||
ctx,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"score",
|
||||
"desc",
|
||||
query.genreIDs,
|
||||
query.studioID,
|
||||
true,
|
||||
1,
|
||||
forYouProfileSearchLimit,
|
||||
)
|
||||
if searchErr != nil {
|
||||
observability.Warn(
|
||||
"top_pick_profile_search_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"genres": query.genreIDs,
|
||||
"studio_id": query.studioID,
|
||||
},
|
||||
searchErr,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, anime := range res.Animes {
|
||||
if anime.MalID <= 0 {
|
||||
continue
|
||||
}
|
||||
upsertCandidate(rankedCandidate{
|
||||
id: anime.MalID,
|
||||
profileSearchScore: query.weight * profileSearchRankWeight(i),
|
||||
anime: anime,
|
||||
hasAnime: true,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := profileSearchGroup.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
if len(candidatesByID) == 0 {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
rankedIDs := make([]rankedCandidate, 0, len(candidatesByID))
|
||||
for _, item := range candidatesByID {
|
||||
rankedIDs = append(rankedIDs, item)
|
||||
}
|
||||
sort.Slice(rankedIDs, func(i, j int) bool {
|
||||
left := rankedCandidateRetrievalScore(rankedIDs[i].collaborativeScore, rankedIDs[i].profileSearchScore)
|
||||
right := rankedCandidateRetrievalScore(rankedIDs[j].collaborativeScore, rankedIDs[j].profileSearchScore)
|
||||
if left == right {
|
||||
return rankedIDs[i].id < rankedIDs[j].id
|
||||
}
|
||||
return left > right
|
||||
})
|
||||
|
||||
limit := min(len(rankedIDs), forYouCandidateFetchLimit)
|
||||
candidates := make([]recommendationCandidate, 0, limit)
|
||||
var candidatesMu sync.Mutex
|
||||
var detailGroup errgroup.Group
|
||||
detailGroup.SetLimit(6)
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
item := rankedIDs[i]
|
||||
detailGroup.Go(func() error {
|
||||
anime := item.anime
|
||||
if !item.hasAnime || !hasTasteMetadata(anime) {
|
||||
fetchedAnime, fetchErr := s.jikan.GetAnimeByID(ctx, item.id)
|
||||
if fetchErr != nil {
|
||||
observability.Warn(
|
||||
"recommendation_anime_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{"anime_id": item.id},
|
||||
fetchErr,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
anime = fetchedAnime
|
||||
}
|
||||
|
||||
candidate := scoreRecommendationCandidate(
|
||||
now,
|
||||
profile,
|
||||
anime,
|
||||
item.collaborativeScore,
|
||||
item.profileSearchScore,
|
||||
)
|
||||
candidatesMu.Lock()
|
||||
candidates = append(candidates, candidate)
|
||||
candidatesMu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := detailGroup.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].score == candidates[j].score {
|
||||
return candidates[i].anime.MalID < candidates[j].anime.MalID
|
||||
}
|
||||
return candidates[i].score > candidates[j].score
|
||||
})
|
||||
|
||||
return domain.CatalogSectionData{
|
||||
Animes: rerankRecommendationCandidates(candidates, resultLimit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetAiringSchedule(ctx context.Context, userID string) ([]domain.Anime, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return []domain.Anime{}, nil
|
||||
}
|
||||
|
||||
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]int, 0, 50)
|
||||
for _, entry := range watchlist {
|
||||
status := strings.TrimSpace(entry.Status)
|
||||
if status != "watching" && status != "plan_to_watch" {
|
||||
continue
|
||||
}
|
||||
if !entry.Airing.Valid || !entry.Airing.Bool {
|
||||
continue
|
||||
}
|
||||
if entry.AnimeID <= 0 {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, int(entry.AnimeID))
|
||||
if len(ids) >= 50 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return []domain.Anime{}, nil
|
||||
}
|
||||
|
||||
animes := make([]domain.Anime, 0, len(ids))
|
||||
var g errgroup.Group
|
||||
g.SetLimit(6)
|
||||
var mu sync.Mutex
|
||||
|
||||
for _, id := range ids {
|
||||
g.Go(func() error {
|
||||
anime, fetchErr := s.jikan.GetAnimeByID(ctx, id)
|
||||
if fetchErr != nil {
|
||||
return fetchErr
|
||||
}
|
||||
mu.Lock()
|
||||
animes = append(animes, domain.Anime{Anime: anime})
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, err
|
||||
}
|
||||
observability.Warn(
|
||||
"schedule_partial_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{"user_id": userID, "count": len(ids)},
|
||||
err,
|
||||
)
|
||||
return animes, nil
|
||||
}
|
||||
|
||||
return animes, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) {
|
||||
anime, err := s.jikan.GetAnimeByID(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Anime{}, err
|
||||
}
|
||||
return domain.Anime{Anime: anime}, nil
|
||||
}
|
||||
|
||||
func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error) {
|
||||
return s.jikan.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, studioID, sfw, page, limit)
|
||||
}
|
||||
|
||||
func (s *animeService) GetProducerNameByID(ctx context.Context, id int) (string, error) {
|
||||
res, err := s.jikan.GetProducerByID(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, t := range res.Data.Titles {
|
||||
if t.Title != "" {
|
||||
return t.Title, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetProducers(ctx context.Context, query string, page int, limit int) (jikan.ProducerListResult, error) {
|
||||
return s.jikan.GetProducers(ctx, query, page, limit)
|
||||
}
|
||||
|
||||
func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
|
||||
genres, err := s.jikan.GetAnimeGenres(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]domain.Genre, 0, len(genres))
|
||||
for _, g := range genres {
|
||||
if g.MalID <= 0 || strings.TrimSpace(g.Name) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, domain.Genre{MalID: g.MalID, Name: g.Name})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.CharacterEntry, error) {
|
||||
items, err := s.jikan.GetAnimeCharacters(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]domain.CharacterEntry, 0, len(items))
|
||||
for _, it := range items {
|
||||
var mapped domain.CharacterEntry
|
||||
mapped.Character.MalID = it.Character.MalID
|
||||
mapped.Character.URL = it.Character.URL
|
||||
mapped.Character.Name = it.Character.Name
|
||||
mapped.Character.Images.Jpg.ImageURL = it.Character.Images.Jpg.ImageURL
|
||||
mapped.Character.Images.Webp.ImageURL = it.Character.Images.Webp.ImageURL
|
||||
mapped.Character.Images.Webp.SmallImageURL = it.Character.Images.Webp.SmallImageURL
|
||||
mapped.Role = it.Role
|
||||
|
||||
if len(it.VoiceActors) > 0 {
|
||||
mapped.VoiceActors = make([]domain.CharacterVoiceActor, 0, len(it.VoiceActors))
|
||||
for _, va := range it.VoiceActors {
|
||||
var mappedVA domain.CharacterVoiceActor
|
||||
mappedVA.Language = va.Language
|
||||
mappedVA.Person.MalID = va.Person.MalID
|
||||
mappedVA.Person.URL = va.Person.URL
|
||||
mappedVA.Person.Name = va.Person.Name
|
||||
mappedVA.Person.Images.Jpg.ImageURL = va.Person.Images.Jpg.ImageURL
|
||||
mapped.VoiceActors = append(mapped.VoiceActors, mappedVA)
|
||||
}
|
||||
}
|
||||
|
||||
out = append(out, mapped)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain.RecommendationEntry, error) {
|
||||
items, err := s.jikan.GetAnimeRecommendations(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]domain.RecommendationEntry, 0, len(items))
|
||||
for _, it := range items {
|
||||
var mapped domain.RecommendationEntry
|
||||
mapped.Entry.MalID = it.Entry.MalID
|
||||
mapped.Entry.URL = it.Entry.URL
|
||||
mapped.Entry.Title = it.Entry.Title
|
||||
mapped.Entry.Images.Webp.LargeImageURL = it.Entry.Images.Webp.LargeImageURL
|
||||
mapped.URL = it.URL
|
||||
mapped.Votes = it.Votes
|
||||
out = append(out, mapped)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error) {
|
||||
return s.jikan.GetFullRelations(ctx, id)
|
||||
}
|
||||
|
||||
func (s *animeService) WarmDetailSections(id int) {
|
||||
s.jikan.WarmAnimeRecommendations(id)
|
||||
s.jikan.WarmFullRelations(id)
|
||||
}
|
||||
|
||||
func (s *animeService) GetEpisodes(ctx context.Context, id int, page int) (jikan.EpisodesResponse, error) {
|
||||
return s.jikan.GetEpisodes(ctx, id, page)
|
||||
}
|
||||
|
||||
func (s *animeService) GetStaff(ctx context.Context, id int) ([]domain.StaffEntry, error) {
|
||||
items, err := s.jikan.GetAnimeStaff(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]domain.StaffEntry, 0, len(items))
|
||||
for _, it := range items {
|
||||
var mapped domain.StaffEntry
|
||||
mapped.Person.MalID = it.Person.MalID
|
||||
mapped.Person.URL = it.Person.URL
|
||||
mapped.Person.Name = it.Person.Name
|
||||
mapped.Person.Images.Jpg.ImageURL = it.Person.Images.Jpg.ImageURL
|
||||
mapped.Positions = append([]string(nil), it.Positions...)
|
||||
out = append(out, mapped)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetStatistics(ctx context.Context, id int) (domain.Statistics, error) {
|
||||
stats, err := s.jikan.GetAnimeStatistics(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Statistics{}, err
|
||||
}
|
||||
|
||||
out := domain.Statistics{
|
||||
Watching: stats.Watching,
|
||||
Completed: stats.Completed,
|
||||
OnHold: stats.OnHold,
|
||||
Dropped: stats.Dropped,
|
||||
PlanToWatch: stats.PlanToWatch,
|
||||
Total: stats.Total,
|
||||
}
|
||||
if len(stats.Scores) > 0 {
|
||||
out.Scores = make([]domain.StatisticsScore, 0, len(stats.Scores))
|
||||
for _, s := range stats.Scores {
|
||||
out.Scores = append(out.Scores, domain.StatisticsScore{Score: s.Score, Votes: s.Votes, Percentage: s.Percentage})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetThemes(ctx context.Context, id int) (domain.ThemesData, error) {
|
||||
themes, err := s.jikan.GetAnimeThemes(ctx, id)
|
||||
if err != nil {
|
||||
return domain.ThemesData{}, err
|
||||
}
|
||||
return domain.ThemesData{
|
||||
Openings: append([]string(nil), themes.Openings...),
|
||||
Endings: append([]string(nil), themes.Endings...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetReviews(ctx context.Context, id int, page int) ([]domain.ReviewEntry, bool, error) {
|
||||
data, pag, err := s.jikan.GetAnimeReviews(ctx, id, page)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out := make([]domain.ReviewEntry, 0, len(data))
|
||||
for _, it := range data {
|
||||
mapped := domain.ReviewEntry{
|
||||
MalID: it.MalID,
|
||||
URL: it.URL,
|
||||
Type: it.Type,
|
||||
Date: it.Date,
|
||||
Review: it.Review,
|
||||
Score: it.Score,
|
||||
Tags: append([]string(nil), it.Tags...),
|
||||
IsSpoiler: it.IsSpoiler,
|
||||
IsPreliminary: it.IsPreliminary,
|
||||
EpisodesSeen: it.EpisodesSeen,
|
||||
Reactions: domain.ReviewReactions{
|
||||
Overall: it.Reactions.Overall,
|
||||
Nice: it.Reactions.Nice,
|
||||
LoveIt: it.Reactions.LoveIt,
|
||||
Funny: it.Reactions.Funny,
|
||||
Confusing: it.Reactions.Confusing,
|
||||
Informative: it.Reactions.Informative,
|
||||
WellWritten: it.Reactions.WellWritten,
|
||||
Creative: it.Reactions.Creative,
|
||||
},
|
||||
}
|
||||
mapped.User.URL = it.User.URL
|
||||
mapped.User.Username = it.User.Username
|
||||
mapped.User.Images.Jpg.ImageURL = it.User.Images.Jpg.ImageURL
|
||||
mapped.User.Images.Webp.ImageURL = it.User.Images.Webp.ImageURL
|
||||
out = append(out, mapped)
|
||||
}
|
||||
|
||||
return out, pag.HasNextPage, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error) {
|
||||
randomCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
anime, err := s.jikan.GetRandomAnime(randomCtx)
|
||||
if err == nil {
|
||||
return domain.Anime{Anime: anime}, nil
|
||||
}
|
||||
|
||||
for _, fallback := range []func(context.Context, int) (jikan.TopAnimeResult, error){
|
||||
s.jikan.GetSeasonsNow,
|
||||
s.jikan.GetTopAnime,
|
||||
s.jikan.GetSeasonsUpcoming,
|
||||
} {
|
||||
res, fallbackErr := fallback(ctx, 1)
|
||||
if fallbackErr != nil || len(res.Animes) == 0 {
|
||||
continue
|
||||
}
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
return domain.Anime{Anime: res.Animes[r.Intn(len(res.Animes))]}, nil
|
||||
}
|
||||
|
||||
return domain.Anime{}, err
|
||||
}
|
||||
|
||||
func (s *animeService) GetAllEpisodes(ctx context.Context, id int) ([]domain.EpisodeData, error) {
|
||||
episodes, err := s.jikan.GetAllEpisodes(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]domain.EpisodeData, len(episodes))
|
||||
for i, ep := range episodes {
|
||||
result[i] = domain.EpisodeData{
|
||||
MalID: ep.MalID,
|
||||
Title: ep.Title,
|
||||
IsFiller: ep.Filler,
|
||||
IsRecap: ep.Recap,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type animeService struct {
|
||||
jikan *jikan.Client
|
||||
repo domain.AnimeRepository
|
||||
}
|
||||
|
||||
func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) domain.AnimeService {
|
||||
return &animeService{jikan: jikan, repo: repo}
|
||||
}
|
||||
|
||||
func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (domain.CatalogSectionData, error) {
|
||||
var (
|
||||
res jikan.TopAnimeResult
|
||||
cw []db.GetContinueWatchingEntriesRow
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
switch section {
|
||||
case "Airing":
|
||||
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
|
||||
case "Popular":
|
||||
res, err = s.jikan.GetTopAnime(gCtx, 1)
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
if userID != "" && section == "Continue" {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
animes := res.Animes
|
||||
if len(animes) > 6 {
|
||||
animes = animes[:6]
|
||||
}
|
||||
|
||||
return domain.CatalogSectionData{
|
||||
Animes: animes,
|
||||
ContinueWatching: cw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, section string) (domain.DiscoverSectionData, error) {
|
||||
var res jikan.TopAnimeResult
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
switch section {
|
||||
case "Trending":
|
||||
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
|
||||
case "Upcoming":
|
||||
res, err = s.jikan.GetSeasonsUpcoming(gCtx, 1)
|
||||
case "Top":
|
||||
res, err = s.jikan.GetTopAnime(gCtx, 1)
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return domain.DiscoverSectionData{}, err
|
||||
}
|
||||
|
||||
animes := res.Animes
|
||||
if len(animes) > 8 {
|
||||
animes = animes[:8]
|
||||
}
|
||||
|
||||
return domain.DiscoverSectionData{
|
||||
Animes: animes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) {
|
||||
return s.jikan.GetAnimeByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error) {
|
||||
return s.jikan.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, sfw, page, limit)
|
||||
}
|
||||
|
||||
func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
|
||||
return s.jikan.GetAnimeGenres(ctx)
|
||||
}
|
||||
|
||||
func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.Character, error) {
|
||||
return s.jikan.GetAnimeCharacters(ctx, id)
|
||||
}
|
||||
|
||||
func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain.Recommendation, error) {
|
||||
return s.jikan.GetAnimeRecommendations(ctx, id)
|
||||
}
|
||||
|
||||
func (s *animeService) GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error) {
|
||||
return s.jikan.GetFullRelations(ctx, id)
|
||||
}
|
||||
|
||||
func (s *animeService) GetEpisodes(ctx context.Context, id int, page int) (jikan.EpisodesResponse, error) {
|
||||
return s.jikan.GetEpisodes(ctx, id, page)
|
||||
}
|
||||
|
||||
func (s *animeService) GetStaff(ctx context.Context, id int) ([]domain.StaffEntry, error) {
|
||||
return s.jikan.GetAnimeStaff(ctx, id)
|
||||
}
|
||||
|
||||
func (s *animeService) GetStatistics(ctx context.Context, id int) (domain.Statistics, error) {
|
||||
return s.jikan.GetAnimeStatistics(ctx, id)
|
||||
}
|
||||
|
||||
func (s *animeService) GetThemes(ctx context.Context, id int) (domain.ThemesData, error) {
|
||||
return s.jikan.GetAnimeThemes(ctx, id)
|
||||
}
|
||||
|
||||
func (s *animeService) GetReviews(ctx context.Context, id int, page int) ([]domain.ReviewEntry, bool, error) {
|
||||
data, pag, err := s.jikan.GetAnimeReviews(ctx, id, page)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return data, pag.HasNextPage, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error) {
|
||||
randomCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
anime, err := s.jikan.GetRandomAnime(randomCtx)
|
||||
if err == nil {
|
||||
return anime, nil
|
||||
}
|
||||
|
||||
for _, fallback := range []func(context.Context, int) (jikan.TopAnimeResult, error){
|
||||
s.jikan.GetSeasonsNow,
|
||||
s.jikan.GetTopAnime,
|
||||
s.jikan.GetSeasonsUpcoming,
|
||||
} {
|
||||
res, fallbackErr := fallback(ctx, 1)
|
||||
if fallbackErr != nil || len(res.Animes) == 0 {
|
||||
continue
|
||||
}
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
return res.Animes[r.Intn(len(res.Animes))], nil
|
||||
}
|
||||
|
||||
return domain.Anime{}, err
|
||||
}
|
||||
|
||||
func (s *animeService) GetAllEpisodes(ctx context.Context, id int) ([]domain.EpisodeData, error) {
|
||||
episodes, err := s.jikan.GetAllEpisodes(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]domain.EpisodeData, len(episodes))
|
||||
for i, ep := range episodes {
|
||||
result[i] = domain.EpisodeData{
|
||||
MalID: ep.MalID,
|
||||
Title: ep.Title,
|
||||
IsFiller: ep.Filler,
|
||||
IsRecap: ep.Recap,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
// Package app bootstraps and wires the application dependencies.
|
||||
package app
|
||||
|
||||
import (
|
||||
"mal/integrations/jikan"
|
||||
"mal/integrations/playback/allanime"
|
||||
"mal/internal/anime"
|
||||
"mal/internal/audit"
|
||||
"mal/internal/auth"
|
||||
"mal/internal/config"
|
||||
"mal/internal/database"
|
||||
"mal/internal/episodes"
|
||||
"mal/internal/playback"
|
||||
"mal/internal/server"
|
||||
"mal/internal/templates"
|
||||
"mal/internal/watchlist"
|
||||
"mal/templates"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/render"
|
||||
@@ -19,7 +22,9 @@ import (
|
||||
|
||||
func NewApp() *fx.App {
|
||||
return fx.New(
|
||||
config.Module,
|
||||
database.Module,
|
||||
audit.Module,
|
||||
jikan.Module,
|
||||
allanime.Module,
|
||||
episodes.Module,
|
||||
|
||||
35
internal/audit/context.go
Normal file
35
internal/audit/context.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package audit
|
||||
|
||||
import "context"
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const (
|
||||
ctxKeyIP ctxKey = "audit_ip"
|
||||
ctxKeyUserAgent ctxKey = "audit_user_agent"
|
||||
)
|
||||
|
||||
func WithRequestInfo(ctx context.Context, ip string, userAgent string) context.Context {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
next := context.WithValue(ctx, ctxKeyIP, ip)
|
||||
return context.WithValue(next, ctxKeyUserAgent, userAgent)
|
||||
}
|
||||
|
||||
func RequestInfoFromContext(ctx context.Context) (ip string, userAgent string) {
|
||||
if ctx == nil {
|
||||
return "", ""
|
||||
}
|
||||
if v := ctx.Value(ctxKeyIP); v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
ip = s
|
||||
}
|
||||
}
|
||||
if v := ctx.Value(ctxKeyUserAgent); v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
userAgent = s
|
||||
}
|
||||
}
|
||||
return ip, userAgent
|
||||
}
|
||||
29
internal/audit/middleware.go
Normal file
29
internal/audit/middleware.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ContextMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ip := clientIP(c.ClientIP())
|
||||
userAgent := strings.TrimSpace(c.GetHeader("User-Agent"))
|
||||
c.Request = c.Request.WithContext(WithRequestInfo(c.Request.Context(), ip, userAgent))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func clientIP(ip string) string {
|
||||
trimmed := strings.TrimSpace(ip)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
parsed := net.ParseIP(trimmed)
|
||||
if parsed == nil {
|
||||
return trimmed
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
9
internal/audit/module.go
Normal file
9
internal/audit/module.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
var Module = fx.Options(
|
||||
fx.Provide(NewAuditService),
|
||||
)
|
||||
73
internal/audit/service.go
Normal file
73
internal/audit/service.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Package audit provides audit logging for user actions.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type auditService struct {
|
||||
queries *db.Queries
|
||||
}
|
||||
|
||||
func NewAuditService(queries *db.Queries) domain.AuditService {
|
||||
return &auditService{queries: queries}
|
||||
}
|
||||
|
||||
func (s *auditService) Record(ctx context.Context, event domain.AuditEvent) error {
|
||||
if s == nil || s.queries == nil {
|
||||
return errors.New("audit service not configured")
|
||||
}
|
||||
action := strings.TrimSpace(event.Action)
|
||||
if action == "" {
|
||||
return errors.New("audit action missing")
|
||||
}
|
||||
|
||||
ip, userAgent := RequestInfoFromContext(ctx)
|
||||
if strings.TrimSpace(event.IP) != "" {
|
||||
ip = event.IP
|
||||
}
|
||||
if strings.TrimSpace(event.UserAgent) != "" {
|
||||
userAgent = event.UserAgent
|
||||
}
|
||||
|
||||
metadataJSON := event.MetadataJSON
|
||||
if len(metadataJSON) == 0 {
|
||||
metadataJSON = json.RawMessage("null")
|
||||
}
|
||||
|
||||
_, err := s.queries.CreateAuditLog(ctx, db.CreateAuditLogParams{
|
||||
ID: uuid.New().String(),
|
||||
UserID: sql.NullString{String: strings.TrimSpace(event.UserID), Valid: strings.TrimSpace(event.UserID) != ""},
|
||||
Action: action,
|
||||
ResourceType: sql.NullString{String: strings.TrimSpace(event.ResourceType), Valid: strings.TrimSpace(event.ResourceType) != ""},
|
||||
ResourceID: sql.NullString{String: strings.TrimSpace(event.ResourceID), Valid: strings.TrimSpace(event.ResourceID) != ""},
|
||||
Ip: sql.NullString{String: strings.TrimSpace(ip), Valid: strings.TrimSpace(ip) != ""},
|
||||
UserAgent: sql.NullString{String: strings.TrimSpace(userAgent), Valid: strings.TrimSpace(userAgent) != ""},
|
||||
MetadataJson: sql.NullString{String: string(metadataJSON), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
observability.Info(
|
||||
"audit",
|
||||
"audit",
|
||||
action,
|
||||
map[string]any{
|
||||
"user_id": event.UserID,
|
||||
"resource_type": event.ResourceType,
|
||||
"resource_id": event.ResourceID,
|
||||
},
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
82
internal/audit/service_test.go
Normal file
82
internal/audit/service_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package audit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"mal/internal/audit"
|
||||
"mal/internal/database"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
)
|
||||
|
||||
func TestRecordInsertsAuditLog(t *testing.T) {
|
||||
tmp, err := os.CreateTemp("", "mal-audit-*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
_ = tmp.Close()
|
||||
t.Cleanup(func() { _ = os.Remove(tmp.Name()) })
|
||||
|
||||
sqlDB, err := db.Open(tmp.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
if err := database.RunMigrations(sqlDB); err != nil {
|
||||
t.Fatalf("RunMigrations: %v", err)
|
||||
}
|
||||
|
||||
queries := db.New(sqlDB)
|
||||
svc := audit.NewAuditService(queries)
|
||||
|
||||
if _, err := sqlDB.Exec("INSERT INTO user (id, username, password_hash) VALUES (?, ?, ?)", "user-1", "test", "hash"); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
ctx := audit.WithRequestInfo(context.Background(), "127.0.0.1", "unit-test")
|
||||
metadata, err := json.Marshal(struct {
|
||||
Foo string `json:"foo"`
|
||||
}{Foo: "bar"})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.Record(ctx, domain.AuditEvent{
|
||||
UserID: "user-1",
|
||||
Action: "test_action",
|
||||
ResourceType: "thing",
|
||||
ResourceID: "123",
|
||||
MetadataJSON: metadata,
|
||||
}); err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
|
||||
rows, err := sqlDB.Query("SELECT action, resource_type, resource_id, ip, user_agent, metadata_json FROM audit_log WHERE user_id = ?", "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
if !rows.Next() {
|
||||
t.Fatalf("expected audit row")
|
||||
}
|
||||
|
||||
var action, resourceType, resourceID, ip, userAgent, metadataJSON string
|
||||
if err := rows.Scan(&action, &resourceType, &resourceID, &ip, &userAgent, &metadataJSON); err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
|
||||
if action != "test_action" || resourceType != "thing" || resourceID != "123" {
|
||||
t.Fatalf("unexpected row action=%q resourceType=%q resourceID=%q", action, resourceType, resourceID)
|
||||
}
|
||||
if ip != "127.0.0.1" || userAgent != "unit-test" {
|
||||
t.Fatalf("unexpected request info ip=%q userAgent=%q", ip, userAgent)
|
||||
}
|
||||
if metadataJSON == "" || metadataJSON == "null" {
|
||||
t.Fatalf("expected metadata_json, got %q", metadataJSON)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
package handler
|
||||
// Package auth provides authentication and session management.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"mal/internal/domain"
|
||||
@@ -1,4 +1,4 @@
|
||||
package middleware
|
||||
package auth
|
||||
|
||||
import (
|
||||
"mal/internal/domain"
|
||||
@@ -8,15 +8,52 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type publicRoute struct {
|
||||
method string
|
||||
path string
|
||||
prefix bool
|
||||
}
|
||||
|
||||
var publicRoutes = []publicRoute{
|
||||
// Pages.
|
||||
{method: http.MethodGet, path: "/login"},
|
||||
{method: http.MethodPost, path: "/login"},
|
||||
{method: http.MethodGet, path: "/logout"},
|
||||
|
||||
// Static assets.
|
||||
{path: "/static", prefix: true},
|
||||
{path: "/dist", prefix: true},
|
||||
|
||||
// Observability endpoints.
|
||||
{method: http.MethodGet, path: "/metrics"},
|
||||
|
||||
// Auth API.
|
||||
{method: http.MethodPost, path: "/api/auth/login"},
|
||||
}
|
||||
|
||||
func isPublicRequest(method string, path string) bool {
|
||||
for _, r := range publicRoutes {
|
||||
if r.method != "" && r.method != method {
|
||||
continue
|
||||
}
|
||||
if r.prefix {
|
||||
if strings.HasPrefix(path, r.path) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if path == r.path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// Allow access to login, logout and static assets without authentication
|
||||
if path == "/login" || path == "/logout" ||
|
||||
strings.HasPrefix(path, "/static") ||
|
||||
strings.HasPrefix(path, "/dist") ||
|
||||
path == "/api/auth/login" {
|
||||
if isPublicRequest(c.Request.Method, path) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"mal/internal/auth/handler"
|
||||
"mal/internal/auth/middleware"
|
||||
"mal/internal/auth/repository"
|
||||
"mal/internal/auth/service"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/server"
|
||||
|
||||
@@ -14,15 +10,15 @@ import (
|
||||
|
||||
var Module = fx.Options(
|
||||
fx.Provide(
|
||||
repository.NewAuthRepository,
|
||||
service.NewAuthService,
|
||||
handler.NewAuthHandler,
|
||||
NewAuthRepository,
|
||||
NewAuthService,
|
||||
NewAuthHandler,
|
||||
func(svc domain.AuthService) gin.HandlerFunc {
|
||||
return middleware.AuthMiddleware(svc)
|
||||
return AuthMiddleware(svc)
|
||||
},
|
||||
),
|
||||
fx.Provide(
|
||||
server.AsRouteRegister(func(h *handler.AuthHandler) server.RouteRegister {
|
||||
server.AsRouteRegister(func(h *AuthHandler) server.RouteRegister {
|
||||
return h
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package repository
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -27,7 +27,7 @@ func (r *authRepository) GetUserByUsername(ctx context.Context, username string)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
return &domain.User{User: u}, nil
|
||||
}
|
||||
|
||||
func (r *authRepository) GetUserByID(ctx context.Context, id string) (*domain.User, error) {
|
||||
@@ -38,7 +38,7 @@ func (r *authRepository) GetUserByID(ctx context.Context, id string) (*domain.Us
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
return &domain.User{User: u}, nil
|
||||
}
|
||||
|
||||
func (r *authRepository) CreateSession(ctx context.Context, userID string, sessionID string) (*domain.Session, error) {
|
||||
@@ -50,7 +50,7 @@ func (r *authRepository) CreateSession(ctx context.Context, userID string, sessi
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
return &domain.Session{Session: s}, nil
|
||||
}
|
||||
|
||||
func (r *authRepository) GetSession(ctx context.Context, sessionID string) (*domain.Session, error) {
|
||||
@@ -61,7 +61,7 @@ func (r *authRepository) GetSession(ctx context.Context, sessionID string) (*dom
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
return &domain.Session{Session: s}, nil
|
||||
}
|
||||
|
||||
func (r *authRepository) RefreshSession(ctx context.Context, sessionID string, expiresAt time.Time) error {
|
||||
@@ -85,7 +85,7 @@ func (r *authRepository) CreateAPIToken(ctx context.Context, userID, tokenHash,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
return &domain.APIToken{ApiToken: t}, nil
|
||||
}
|
||||
|
||||
func (r *authRepository) GetAPITokenByHash(ctx context.Context, tokenHash string) (*domain.APIToken, error) {
|
||||
@@ -96,7 +96,7 @@ func (r *authRepository) GetAPITokenByHash(ctx context.Context, tokenHash string
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
return &domain.APIToken{ApiToken: t}, nil
|
||||
}
|
||||
|
||||
func (r *authRepository) TouchAPITokenLastUsedAt(ctx context.Context, tokenID string) error {
|
||||
@@ -1,4 +1,4 @@
|
||||
package service
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mal/internal/domain"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,11 +18,12 @@ import (
|
||||
)
|
||||
|
||||
type authService struct {
|
||||
repo domain.AuthRepository
|
||||
repo domain.AuthRepository
|
||||
auditSvc domain.AuditService
|
||||
}
|
||||
|
||||
func NewAuthService(repo domain.AuthRepository) domain.AuthService {
|
||||
return &authService{repo: repo}
|
||||
func NewAuthService(repo domain.AuthRepository, auditSvc domain.AuditService) domain.AuthService {
|
||||
return &authService{repo: repo, auditSvc: auditSvc}
|
||||
}
|
||||
|
||||
func (s *authService) Login(ctx context.Context, username, password string) (*domain.Session, error) {
|
||||
@@ -58,11 +61,32 @@ func (s *authService) LoginForAPIToken(ctx context.Context, username, password,
|
||||
trimmedName = "Firefox extension"
|
||||
}
|
||||
|
||||
rawToken, tokenHash := newOpaqueToken()
|
||||
rawToken, tokenHash, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if _, err := s.repo.CreateAPIToken(ctx, user.ID, tokenHash, trimmedName); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
metadataBytes, err := json.Marshal(struct {
|
||||
Name string `json:"name"`
|
||||
}{Name: trimmedName})
|
||||
if err == nil {
|
||||
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||
UserID: user.ID,
|
||||
Action: "api_token_created",
|
||||
ResourceType: "api_token",
|
||||
MetadataJSON: metadataBytes,
|
||||
})
|
||||
} else {
|
||||
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||
UserID: user.ID,
|
||||
Action: "api_token_created",
|
||||
ResourceType: "api_token",
|
||||
})
|
||||
}
|
||||
|
||||
return rawToken, user, nil
|
||||
}
|
||||
|
||||
@@ -120,15 +144,25 @@ func (s *authService) RevokeAllAPITokensForUser(ctx context.Context, userID stri
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return errors.New("user id missing")
|
||||
}
|
||||
return s.repo.RevokeAllAPITokensForUser(ctx, userID)
|
||||
if err := s.repo.RevokeAllAPITokensForUser(ctx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||
UserID: userID,
|
||||
Action: "api_token_revoked_all",
|
||||
ResourceType: "api_token",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func newOpaqueToken() (token string, tokenHash string) {
|
||||
func newOpaqueToken() (token string, tokenHash string, err error) {
|
||||
buf := make([]byte, 32)
|
||||
_, _ = rand.Read(buf)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", "", fmt.Errorf("generate token bytes: %w", err)
|
||||
}
|
||||
token = base64.RawURLEncoding.EncodeToString(buf)
|
||||
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
tokenHash = hex.EncodeToString(sum[:])
|
||||
return token, tokenHash
|
||||
return token, tokenHash, nil
|
||||
}
|
||||
11
internal/avatar.go
Normal file
11
internal/avatar.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func DefaultAvatarURL(username string) string {
|
||||
seed := url.QueryEscape(strings.TrimSpace(username))
|
||||
return "https://api.dicebear.com/9.x/dylan/svg?seed=" + seed
|
||||
}
|
||||
85
internal/config/config.go
Normal file
85
internal/config/config.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Package config provides application configuration loading and access.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type EpisodeAvailabilityMode string
|
||||
|
||||
const (
|
||||
EpisodeAvailabilityModeAuto EpisodeAvailabilityMode = "auto"
|
||||
EpisodeAvailabilityModeLegacy EpisodeAvailabilityMode = "legacy"
|
||||
EpisodeAvailabilityModeJikan EpisodeAvailabilityMode = "jikan"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
|
||||
// GinMode maps to gin.SetMode. When empty, the server uses release mode by default.
|
||||
GinMode string
|
||||
|
||||
DatabaseFile string
|
||||
|
||||
// Allow any Origin for CORS. Intended for local dev / reverse proxy setups only.
|
||||
CORSAllowAll bool
|
||||
|
||||
EpisodeAvailabilityMode EpisodeAvailabilityMode
|
||||
|
||||
// Optional. When empty, proxy token signing is disabled.
|
||||
PlaybackProxySecret string
|
||||
|
||||
// Optional debug toggle for Jikan client tracing.
|
||||
JikanTrace bool
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Port: firstNonEmpty(strings.TrimSpace(os.Getenv("PORT")), "3000"),
|
||||
GinMode: strings.TrimSpace(os.Getenv("GIN_MODE")),
|
||||
DatabaseFile: firstNonEmpty(strings.TrimSpace(os.Getenv("DATABASE_FILE")), "mal.db"),
|
||||
CORSAllowAll: strings.TrimSpace(os.Getenv("MAL_CORS_ALLOW_ALL")) == "1",
|
||||
PlaybackProxySecret: strings.TrimSpace(os.Getenv("PLAYBACK_PROXY_SECRET")),
|
||||
JikanTrace: truthy(strings.TrimSpace(os.Getenv("MAL_JIKAN_TRACE"))),
|
||||
EpisodeAvailabilityMode: EpisodeAvailabilityModeAuto,
|
||||
}
|
||||
|
||||
if raw := strings.ToLower(strings.TrimSpace(os.Getenv("EPISODE_AVAILABILITY_MODE"))); raw != "" {
|
||||
switch EpisodeAvailabilityMode(raw) {
|
||||
case EpisodeAvailabilityModeAuto, EpisodeAvailabilityModeLegacy, EpisodeAvailabilityModeJikan:
|
||||
cfg.EpisodeAvailabilityMode = EpisodeAvailabilityMode(raw)
|
||||
default:
|
||||
return Config{}, fmt.Errorf("invalid EPISODE_AVAILABILITY_MODE: %q (expected auto|legacy|jikan)", raw)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(cfg.Port) == "" {
|
||||
return Config{}, errors.New("PORT must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(cfg.DatabaseFile) == "" {
|
||||
return Config{}, errors.New("DATABASE_FILE must not be empty")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truthy(v string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "1", "true", "yes", "y", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
7
internal/config/module.go
Normal file
7
internal/config/module.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package config
|
||||
|
||||
import "go.uber.org/fx"
|
||||
|
||||
var Module = fx.Options(
|
||||
fx.Provide(Load),
|
||||
)
|
||||
@@ -1,11 +1,13 @@
|
||||
// Package database manages database schema migrations and fixes.
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"mal/internal/config"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
"go.uber.org/fx"
|
||||
@@ -19,12 +21,11 @@ var Module = fx.Options(
|
||||
ProvideSQLDB,
|
||||
ProvideQueries,
|
||||
),
|
||||
fx.Invoke(RunMigrations),
|
||||
fx.Invoke(RunMigrationsAndFixes),
|
||||
)
|
||||
|
||||
func ProvideSQLDB() (*sql.DB, error) {
|
||||
dbPath := db.GetDBFile()
|
||||
dbConn, err := db.Open(dbPath)
|
||||
func ProvideSQLDB(cfg config.Config) (*sql.DB, error) {
|
||||
dbConn, err := db.Open(cfg.DatabaseFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
@@ -42,10 +43,16 @@ func RunMigrations(sqlDB *sql.DB) error {
|
||||
return fmt.Errorf("failed to set goose dialect: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Running database migrations...")
|
||||
observability.Info("db_migrations_start", "database", "", nil)
|
||||
if err := goose.Up(sqlDB, "migrations"); err != nil {
|
||||
return fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
func RunMigrationsAndFixes(sqlDB *sql.DB) error {
|
||||
if err := RunMigrations(sqlDB); err != nil {
|
||||
return err
|
||||
}
|
||||
return RunDataFixes(sqlDB)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
if err := RunMigrations(sqlDB); err != nil {
|
||||
|
||||
97
internal/database/fixes.go
Normal file
97
internal/database/fixes.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
dbfixes "mal/internal/database/fixes"
|
||||
"mal/internal/observability"
|
||||
)
|
||||
|
||||
func RunDataFixes(sqlDB *sql.DB) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
fixes := dbfixes.All()
|
||||
|
||||
if len(fixes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := ensureDataFixTable(ctx, sqlDB); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applied, err := loadAppliedFixes(ctx, sqlDB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, fix := range fixes {
|
||||
if applied[fix.ID] {
|
||||
continue
|
||||
}
|
||||
|
||||
observability.Info(
|
||||
"db_data_fix_start",
|
||||
"database",
|
||||
"",
|
||||
map[string]any{
|
||||
"id": fix.ID,
|
||||
},
|
||||
)
|
||||
if err := fix.Apply(ctx, sqlDB); err != nil {
|
||||
return fmt.Errorf("data fix %s failed: %w", fix.ID, err)
|
||||
}
|
||||
if err := markFixApplied(ctx, sqlDB, fix.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureDataFixTable(ctx context.Context, sqlDB *sql.DB) error {
|
||||
// Safety for cases where migrations weren't run (or in tests). This is intentionally tiny and idempotent.
|
||||
_, err := sqlDB.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS data_fixes (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure data_fixes table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadAppliedFixes(ctx context.Context, sqlDB *sql.DB) (map[string]bool, error) {
|
||||
rows, err := sqlDB.QueryContext(ctx, `SELECT id FROM data_fixes`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load applied data fixes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
applied := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("scan data fix id: %w", err)
|
||||
}
|
||||
applied[id] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate data fixes: %w", err)
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
func markFixApplied(ctx context.Context, sqlDB *sql.DB, id string) error {
|
||||
_, err := sqlDB.ExecContext(ctx, `INSERT OR IGNORE INTO data_fixes (id) VALUES (?)`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark data fix applied id=%s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package fixes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(Fix{
|
||||
ID: "20260526_episode_availability_backfill_next_refresh_at",
|
||||
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
|
||||
// Old caches could have next_refresh_at NULL (especially for airing shows with missing broadcast metadata),
|
||||
// which can result in "never refresh again" behavior on the server.
|
||||
_, err := sqlDB.ExecContext(ctx, `
|
||||
UPDATE episode_availability_cache
|
||||
SET next_refresh_at = datetime(CURRENT_TIMESTAMP, '+6 hours'),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE next_refresh_at IS NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backfill episode_availability_cache.next_refresh_at: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
46
internal/database/fixes/20260528_backfill_avatar_url.go
Normal file
46
internal/database/fixes/20260528_backfill_avatar_url.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package fixes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"mal/internal"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(Fix{
|
||||
ID: "20260528_backfill_avatar_url",
|
||||
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
|
||||
rows, err := sqlDB.QueryContext(ctx, `SELECT id, username FROM user WHERE avatar_url = ''`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
type userRow struct {
|
||||
id string
|
||||
username string
|
||||
}
|
||||
toUpdate := make([]userRow, 0, 64)
|
||||
for rows.Next() {
|
||||
var r userRow
|
||||
if err := rows.Scan(&r.id, &r.username); err != nil {
|
||||
return err
|
||||
}
|
||||
toUpdate = append(toUpdate, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, u := range toUpdate {
|
||||
avatarURL := internal.DefaultAvatarURL(u.username)
|
||||
if _, err := sqlDB.ExecContext(ctx, `UPDATE user SET avatar_url = ? WHERE id = ?`, avatarURL, u.id); err != nil {
|
||||
return fmt.Errorf("update avatar_url for user %s: %w", u.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package fixes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/config"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(Fix{
|
||||
ID: "20260608_backfill_anime_duration_seconds",
|
||||
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
|
||||
rows, err := sqlDB.QueryContext(ctx, `
|
||||
SELECT id, title_original, title_english, title_japanese, image_url, airing
|
||||
FROM anime
|
||||
WHERE duration_seconds IS NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query anime rows missing duration_seconds: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
client := jikan.NewClient(config.Config{}, db.New(sqlDB), observability.NewMetrics())
|
||||
|
||||
type animeRow struct {
|
||||
id int64
|
||||
titleOriginal string
|
||||
}
|
||||
|
||||
var toUpdate []animeRow
|
||||
for rows.Next() {
|
||||
var row animeRow
|
||||
var titleEnglish sql.NullString
|
||||
var titleJapanese sql.NullString
|
||||
var imageURL string
|
||||
var airing sql.NullBool
|
||||
if err := rows.Scan(
|
||||
&row.id,
|
||||
&row.titleOriginal,
|
||||
&titleEnglish,
|
||||
&titleJapanese,
|
||||
&imageURL,
|
||||
&airing,
|
||||
); err != nil {
|
||||
return fmt.Errorf("scan anime row missing duration_seconds: %w", err)
|
||||
}
|
||||
toUpdate = append(toUpdate, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("iterate anime rows missing duration_seconds: %w", err)
|
||||
}
|
||||
|
||||
for _, row := range toUpdate {
|
||||
anime, err := client.GetAnimeByID(ctx, int(row.id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch anime %d for duration backfill: %w", row.id, err)
|
||||
}
|
||||
|
||||
durationSeconds := anime.DurationSeconds()
|
||||
if durationSeconds <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := sqlDB.ExecContext(
|
||||
ctx,
|
||||
`UPDATE anime SET duration_seconds = ? WHERE id = ? AND duration_seconds IS NULL`,
|
||||
durationSeconds,
|
||||
row.id,
|
||||
); err != nil {
|
||||
return fmt.Errorf("update anime %d duration_seconds: %w", row.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
25
internal/database/fixes/registry.go
Normal file
25
internal/database/fixes/registry.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Package fixes implements one-off database migration fixes.
|
||||
package fixes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type Fix struct {
|
||||
ID string
|
||||
Apply func(ctx context.Context, sqlDB *sql.DB) error
|
||||
}
|
||||
|
||||
var registered []Fix
|
||||
|
||||
func Register(fix Fix) {
|
||||
registered = append(registered, fix)
|
||||
}
|
||||
|
||||
func All() []Fix {
|
||||
out := append([]Fix(nil), registered...)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
-- +goose Up
|
||||
-- +goose NO TRANSACTION
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE user_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
@@ -16,6 +19,8 @@ DROP TABLE user;
|
||||
|
||||
ALTER TABLE user_new RENAME TO user;
|
||||
|
||||
COMMIT;
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE user ADD COLUMN avatar_url TEXT NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE user SET avatar_url = 'https://api.dicebear.com/9.x/dylan/svg?seed=' || username WHERE avatar_url = '';
|
||||
-- +goose Down
|
||||
|
||||
8
internal/database/migrations/022_add_data_fixes.sql
Normal file
8
internal/database/migrations/022_add_data_fixes.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS data_fixes (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS data_fixes;
|
||||
18
internal/database/migrations/023_add_audit_log.sql
Normal file
18
internal/database/migrations/023_add_audit_log.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
user_id TEXT REFERENCES user(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT,
|
||||
resource_id TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
metadata_json TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id_occurred_at ON audit_log(user_id, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action_occurred_at ON audit_log(action, occurred_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS audit_log;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS recommendation_event (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
anime_id INTEGER,
|
||||
event_type TEXT NOT NULL,
|
||||
source TEXT,
|
||||
metadata_json TEXT,
|
||||
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(anime_id) REFERENCES anime(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recommendation_event_user_occurred_at
|
||||
ON recommendation_event(user_id, occurred_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recommendation_event_user_event_type_occurred_at
|
||||
ON recommendation_event(user_id, event_type, occurred_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recommendation_event_anime_occurred_at
|
||||
ON recommendation_event(anime_id, occurred_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recommendation_impression (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
anime_id INTEGER NOT NULL,
|
||||
rail TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
request_id TEXT,
|
||||
metadata_json TEXT,
|
||||
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(anime_id) REFERENCES anime(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recommendation_impression_user_occurred_at
|
||||
ON recommendation_impression(user_id, occurred_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recommendation_impression_request_id
|
||||
ON recommendation_impression(request_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recommendation_profile_snapshot (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
profile_json TEXT NOT NULL,
|
||||
source_window_start DATETIME,
|
||||
source_window_end DATETIME,
|
||||
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS recommendation_profile_snapshot;
|
||||
DROP INDEX IF EXISTS idx_recommendation_impression_request_id;
|
||||
DROP INDEX IF EXISTS idx_recommendation_impression_user_occurred_at;
|
||||
DROP TABLE IF EXISTS recommendation_impression;
|
||||
DROP INDEX IF EXISTS idx_recommendation_event_anime_occurred_at;
|
||||
DROP INDEX IF EXISTS idx_recommendation_event_user_event_type_occurred_at;
|
||||
DROP INDEX IF EXISTS idx_recommendation_event_user_occurred_at;
|
||||
DROP TABLE IF EXISTS recommendation_event;
|
||||
@@ -44,7 +44,7 @@ LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
items := make([]GetContinueWatchingEntriesRow, 0, int(limit))
|
||||
for rows.Next() {
|
||||
@@ -122,7 +122,7 @@ LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
items := make([]GetUserWatchListRow, 0, int(limit))
|
||||
for rows.Next() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package db provides database access via sqlc-generated queries and helper functions.
|
||||
package db
|
||||
|
||||
import "database/sql"
|
||||
@@ -18,3 +19,7 @@ func DisplayTitle(titleEnglish, titleJapanese sql.NullString, titleOriginal stri
|
||||
func (r GetUserWatchListRow) DisplayTitle() string {
|
||||
return DisplayTitle(r.TitleEnglish, r.TitleJapanese, r.TitleOriginal)
|
||||
}
|
||||
|
||||
func (r GetContinueWatchingEntriesRow) DisplayTitle() string {
|
||||
return DisplayTitle(r.TitleEnglish, r.TitleJapanese, r.TitleOriginal)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
@@ -47,6 +47,18 @@ type ApiToken struct {
|
||||
RevokedAt sql.NullTime `json:"revoked_at"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
ID string `json:"id"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
UserID sql.NullString `json:"user_id"`
|
||||
Action string `json:"action"`
|
||||
ResourceType sql.NullString `json:"resource_type"`
|
||||
ResourceID sql.NullString `json:"resource_id"`
|
||||
Ip sql.NullString `json:"ip"`
|
||||
UserAgent sql.NullString `json:"user_agent"`
|
||||
MetadataJson sql.NullString `json:"metadata_json"`
|
||||
}
|
||||
|
||||
type ContinueWatchingEntry struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
@@ -58,6 +70,11 @@ type ContinueWatchingEntry struct {
|
||||
DurationSeconds sql.NullFloat64 `json:"duration_seconds"`
|
||||
}
|
||||
|
||||
type DataFix struct {
|
||||
ID string `json:"id"`
|
||||
AppliedAt time.Time `json:"applied_at"`
|
||||
}
|
||||
|
||||
type EpisodeAvailabilityCache struct {
|
||||
AnimeID int64 `json:"anime_id"`
|
||||
Data string `json:"data"`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
type Querier interface {
|
||||
CountPendingAnimeFetchRetries(ctx context.Context) (int64, error)
|
||||
CreateAPIToken(ctx context.Context, arg CreateAPITokenParams) (ApiToken, error)
|
||||
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (AuditLog, error)
|
||||
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
|
||||
DeleteAnimeFetchRetry(ctx context.Context, animeID int64) error
|
||||
DeleteContinueWatchingEntry(ctx context.Context, arg DeleteContinueWatchingEntryParams) error
|
||||
@@ -22,8 +23,11 @@ type Querier interface {
|
||||
GetAllCachedAnime(ctx context.Context) ([]string, error)
|
||||
GetAnime(ctx context.Context, id int64) (Anime, error)
|
||||
GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error)
|
||||
GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error)
|
||||
GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error)
|
||||
GetContinueWatchingEntry(ctx context.Context, arg GetContinueWatchingEntryParams) (ContinueWatchingEntry, error)
|
||||
GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]GetContinueWatchingEntriesRow, error)
|
||||
GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]GetUserWatchListRow, error)
|
||||
GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]AnimeFetchRetry, error)
|
||||
GetEpisodeAvailabilityCache(ctx context.Context, animeID int64) (EpisodeAvailabilityCache, error)
|
||||
GetEpisodeProviderMapping(ctx context.Context, arg GetEpisodeProviderMappingParams) (EpisodeProviderMapping, error)
|
||||
@@ -35,14 +39,18 @@ type Querier interface {
|
||||
GetUser(ctx context.Context, id string) (User, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (User, error)
|
||||
GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error)
|
||||
GetUserWatchlistAnimeIDs(ctx context.Context, userID string, animeIDs []int64) ([]int64, error)
|
||||
GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error)
|
||||
GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error)
|
||||
MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error
|
||||
MarkEpisodeAvailabilityRefreshFailed(ctx context.Context, arg MarkEpisodeAvailabilityRefreshFailedParams) error
|
||||
MarkRelationsSynced(ctx context.Context, id int64) error
|
||||
RefreshSession(ctx context.Context, arg RefreshSessionParams) error
|
||||
RevokeAllAPITokensForUser(ctx context.Context, userID string) error
|
||||
SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error
|
||||
SetJikanCache(ctx context.Context, arg SetJikanCacheParams) error
|
||||
HasSkipSegmentOverrideTable(ctx context.Context) (bool, error)
|
||||
ListSkipSegmentOverrides(ctx context.Context, userID string, animeID int64, episode int64) ([]SkipSegmentOverrideRow, error)
|
||||
TouchAPITokenLastUsedAt(ctx context.Context, id string) error
|
||||
UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusParams) error
|
||||
UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error)
|
||||
@@ -50,6 +58,7 @@ type Querier interface {
|
||||
UpsertContinueWatchingEntry(ctx context.Context, arg UpsertContinueWatchingEntryParams) (ContinueWatchingEntry, error)
|
||||
UpsertEpisodeAvailabilityCache(ctx context.Context, arg UpsertEpisodeAvailabilityCacheParams) error
|
||||
UpsertEpisodeProviderMapping(ctx context.Context, arg UpsertEpisodeProviderMappingParams) error
|
||||
UpsertSkipSegmentOverride(ctx context.Context, r SkipSegmentOverrideRow) error
|
||||
UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
-- name: GetUser :one
|
||||
SELECT * FROM user WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: CreateAuditLog :one
|
||||
INSERT INTO audit_log (id, user_id, action, resource_type, resource_id, ip, user_agent, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetAuditLogsForUser :many
|
||||
SELECT *
|
||||
FROM audit_log
|
||||
WHERE user_id = ?
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT ?;
|
||||
|
||||
-- name: GetUserByUsername :one
|
||||
SELECT * FROM user WHERE username = ? LIMIT 1;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
// source: queries.sql
|
||||
|
||||
package db
|
||||
@@ -57,6 +57,49 @@ func (q *Queries) CreateAPIToken(ctx context.Context, arg CreateAPITokenParams)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createAuditLog = `-- name: CreateAuditLog :one
|
||||
INSERT INTO audit_log (id, user_id, action, resource_type, resource_id, ip, user_agent, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id, occurred_at, user_id, "action", resource_type, resource_id, ip, user_agent, metadata_json
|
||||
`
|
||||
|
||||
type CreateAuditLogParams struct {
|
||||
ID string `json:"id"`
|
||||
UserID sql.NullString `json:"user_id"`
|
||||
Action string `json:"action"`
|
||||
ResourceType sql.NullString `json:"resource_type"`
|
||||
ResourceID sql.NullString `json:"resource_id"`
|
||||
Ip sql.NullString `json:"ip"`
|
||||
UserAgent sql.NullString `json:"user_agent"`
|
||||
MetadataJson sql.NullString `json:"metadata_json"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (AuditLog, error) {
|
||||
row := q.db.QueryRowContext(ctx, createAuditLog,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
arg.Action,
|
||||
arg.ResourceType,
|
||||
arg.ResourceID,
|
||||
arg.Ip,
|
||||
arg.UserAgent,
|
||||
arg.MetadataJson,
|
||||
)
|
||||
var i AuditLog
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.OccurredAt,
|
||||
&i.UserID,
|
||||
&i.Action,
|
||||
&i.ResourceType,
|
||||
&i.ResourceID,
|
||||
&i.Ip,
|
||||
&i.UserAgent,
|
||||
&i.MetadataJson,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createSession = `-- name: CreateSession :one
|
||||
INSERT INTO session (id, user_id, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
@@ -124,22 +167,6 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const refreshSession = `-- name: RefreshSession :exec
|
||||
UPDATE session
|
||||
SET expires_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type RefreshSessionParams struct {
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) RefreshSession(ctx context.Context, arg RefreshSessionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, refreshSession, arg.ExpiresAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteWatchListEntry = `-- name: DeleteWatchListEntry :exec
|
||||
DELETE FROM watch_list_entry
|
||||
WHERE user_id = ? AND anime_id = ?
|
||||
@@ -299,6 +326,52 @@ func (q *Queries) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNe
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getAuditLogsForUser = `-- name: GetAuditLogsForUser :many
|
||||
SELECT id, occurred_at, user_id, "action", resource_type, resource_id, ip, user_agent, metadata_json
|
||||
FROM audit_log
|
||||
WHERE user_id = ?
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
type GetAuditLogsForUserParams struct {
|
||||
UserID sql.NullString `json:"user_id"`
|
||||
Limit int64 `json:"limit"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAuditLogsForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []AuditLog
|
||||
for rows.Next() {
|
||||
var i AuditLog
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.OccurredAt,
|
||||
&i.UserID,
|
||||
&i.Action,
|
||||
&i.ResourceType,
|
||||
&i.ResourceID,
|
||||
&i.Ip,
|
||||
&i.UserAgent,
|
||||
&i.MetadataJson,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getContinueWatchingEntries = `-- name: GetContinueWatchingEntries :many
|
||||
SELECT
|
||||
c.id,
|
||||
@@ -918,6 +991,22 @@ func (q *Queries) MarkRelationsSynced(ctx context.Context, id int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const refreshSession = `-- name: RefreshSession :exec
|
||||
UPDATE session
|
||||
SET expires_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type RefreshSessionParams struct {
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) RefreshSession(ctx context.Context, arg RefreshSessionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, refreshSession, arg.ExpiresAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const revokeAllAPITokensForUser = `-- name: RevokeAllAPITokensForUser :exec
|
||||
UPDATE api_token
|
||||
SET revoked_at = CURRENT_TIMESTAMP
|
||||
|
||||
@@ -3,6 +3,7 @@ package db
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
@@ -67,6 +68,9 @@ func (q *Queries) HasSkipSegmentOverrideTable(ctx context.Context) (bool, error)
|
||||
const query = `SELECT name FROM sqlite_master WHERE type='table' AND name='skip_segment_override' LIMIT 1;`
|
||||
var name sql.NullString
|
||||
if err := q.db.QueryRowContext(ctx, query).Scan(&name); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("check skip segment override table: %w", err)
|
||||
}
|
||||
return name.Valid && name.String != "", nil
|
||||
|
||||
25
internal/db/skip_segment_overrides_test.go
Normal file
25
internal/db/skip_segment_overrides_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func TestHasSkipSegmentOverrideTableReturnsFalseWhenMissing(t *testing.T) {
|
||||
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
|
||||
ok, err := New(sqlDB).HasSkipSegmentOverrideTable(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("HasSkipSegmentOverrideTable: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("HasSkipSegmentOverrideTable returned true for missing table")
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,21 @@ package db
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
// sqlite3 driver.
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// Open connects to a sqlite3 database with foreign keys enforced
|
||||
func Open(dbFile string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on", dbFile))
|
||||
// busy_timeout avoids immediate SQLITE_BUSY errors under concurrent access.
|
||||
// foreign_keys ensures FK constraints are enforced for this connection.
|
||||
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on&_busy_timeout=5000", dbFile))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open db: %w", err)
|
||||
}
|
||||
// WAL improves concurrency between readers and writers.
|
||||
_, _ = db.Exec("PRAGMA journal_mode=WAL;")
|
||||
_, _ = db.Exec("PRAGMA busy_timeout=5000;")
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// GetDBFile returns the database file path, checking DATABASE_FILE env var first
|
||||
func GetDBFile() string {
|
||||
if f := os.Getenv("DATABASE_FILE"); f != "" {
|
||||
return f
|
||||
}
|
||||
return "mal.db"
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func (q *Queries) GetUserWatchlistAnimeIDs(ctx context.Context, userID string, a
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
matches := make([]int64, 0, len(animeIDs))
|
||||
for rows.Next() {
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestGetUserWatchlistAnimeIDsFiltersRequestedIDs(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
|
||||
_, err = sqlDB.Exec(`
|
||||
CREATE TABLE watch_list_entry (
|
||||
|
||||
25
internal/dbtx/tx.go
Normal file
25
internal/dbtx/tx.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package dbtx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
func Run[T any](ctx context.Context, sqlDB *sql.DB, repo T, withTx func(*sql.Tx) T, fn func(context.Context, T) error) error {
|
||||
if sqlDB == nil {
|
||||
return fn(ctx, repo)
|
||||
}
|
||||
|
||||
tx, err := sqlDB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
txRepo := withTx(tx)
|
||||
if err := fn(ctx, txRepo); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package domain defines the core domain types and interfaces used across the application.
|
||||
package domain
|
||||
|
||||
import (
|
||||
@@ -6,24 +7,152 @@ import (
|
||||
"mal/internal/db"
|
||||
)
|
||||
|
||||
type Anime = jikan.Anime
|
||||
type TopAnimeResult = jikan.TopAnimeResult
|
||||
type Genre = jikan.Genre
|
||||
type Character = jikan.CharacterEntry
|
||||
type Recommendation = jikan.RecommendationEntry
|
||||
type StaffEntry = jikan.StaffEntry
|
||||
type Statistics = jikan.Statistics
|
||||
type ThemesData = jikan.ThemesData
|
||||
type ReviewEntry = jikan.ReviewEntry
|
||||
type Anime struct {
|
||||
jikan.Anime
|
||||
}
|
||||
|
||||
type AnimeService interface {
|
||||
type Genre struct {
|
||||
MalID int
|
||||
Name string
|
||||
}
|
||||
|
||||
type CharacterPerson struct {
|
||||
MalID int
|
||||
URL string
|
||||
Name string
|
||||
Images struct {
|
||||
Jpg struct {
|
||||
ImageURL string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type CharacterVoiceActor struct {
|
||||
Person CharacterPerson
|
||||
Language string
|
||||
}
|
||||
|
||||
type CharacterEntry struct {
|
||||
Character struct {
|
||||
MalID int
|
||||
URL string
|
||||
Name string
|
||||
Images struct {
|
||||
Jpg struct {
|
||||
ImageURL string
|
||||
}
|
||||
Webp struct {
|
||||
ImageURL string
|
||||
SmallImageURL string
|
||||
}
|
||||
}
|
||||
}
|
||||
Role string
|
||||
VoiceActors []CharacterVoiceActor
|
||||
}
|
||||
|
||||
type RecommendationEntry struct {
|
||||
Entry struct {
|
||||
MalID int
|
||||
URL string
|
||||
Title string
|
||||
Images struct {
|
||||
Webp struct {
|
||||
LargeImageURL string
|
||||
}
|
||||
}
|
||||
}
|
||||
URL string
|
||||
Votes int
|
||||
}
|
||||
|
||||
type StaffEntry struct {
|
||||
Person CharacterPerson
|
||||
Positions []string
|
||||
}
|
||||
|
||||
type StatisticsScore struct {
|
||||
Score int
|
||||
Votes int
|
||||
Percentage float64
|
||||
}
|
||||
|
||||
type Statistics struct {
|
||||
Watching int
|
||||
Completed int
|
||||
OnHold int
|
||||
Dropped int
|
||||
PlanToWatch int
|
||||
Total int
|
||||
Scores []StatisticsScore
|
||||
}
|
||||
|
||||
type ThemesData struct {
|
||||
Openings []string
|
||||
Endings []string
|
||||
}
|
||||
|
||||
type ReviewReactions struct {
|
||||
Overall int
|
||||
Nice int
|
||||
LoveIt int
|
||||
Funny int
|
||||
Confusing int
|
||||
Informative int
|
||||
WellWritten int
|
||||
Creative int
|
||||
}
|
||||
|
||||
type ReviewUser struct {
|
||||
URL string
|
||||
Username string
|
||||
Images struct {
|
||||
Jpg struct {
|
||||
ImageURL string
|
||||
}
|
||||
Webp struct {
|
||||
ImageURL string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ReviewEntry struct {
|
||||
MalID int
|
||||
URL string
|
||||
Type string
|
||||
Reactions ReviewReactions
|
||||
Date string
|
||||
Review string
|
||||
Score int
|
||||
Tags []string
|
||||
IsSpoiler bool
|
||||
IsPreliminary bool
|
||||
EpisodesSeen int
|
||||
User ReviewUser
|
||||
}
|
||||
|
||||
type AnimeCatalogService interface {
|
||||
GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error)
|
||||
GetTopPickForYou(ctx context.Context, userID string) (CatalogSectionData, error)
|
||||
GetTopPicksForYou(ctx context.Context, userID string) (CatalogSectionData, error)
|
||||
}
|
||||
|
||||
type AnimeDiscoverService interface {
|
||||
GetDiscoverSection(ctx context.Context, userID string, section string) (DiscoverSectionData, error)
|
||||
GetAnimeByID(ctx context.Context, id int) (Anime, error)
|
||||
SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error)
|
||||
GetAiringSchedule(ctx context.Context, userID string) ([]Anime, error)
|
||||
}
|
||||
|
||||
type AnimeSearchService interface {
|
||||
SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error)
|
||||
GetProducerNameByID(ctx context.Context, id int) (string, error)
|
||||
GetProducers(ctx context.Context, query string, page int, limit int) (jikan.ProducerListResult, error)
|
||||
GetGenres(ctx context.Context) ([]Genre, error)
|
||||
GetCharacters(ctx context.Context, id int) ([]Character, error)
|
||||
GetRecommendations(ctx context.Context, id int) ([]Recommendation, error)
|
||||
}
|
||||
|
||||
type AnimeDetailsService interface {
|
||||
GetAnimeByID(ctx context.Context, id int) (Anime, error)
|
||||
GetCharacters(ctx context.Context, id int) ([]CharacterEntry, error)
|
||||
GetRecommendations(ctx context.Context, id int) ([]RecommendationEntry, error)
|
||||
GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error)
|
||||
GetEpisodes(ctx context.Context, id int, page int) (jikan.EpisodesResponse, error)
|
||||
GetAllEpisodes(ctx context.Context, id int) ([]EpisodeData, error)
|
||||
@@ -34,6 +163,11 @@ type AnimeService interface {
|
||||
GetReviews(ctx context.Context, id int, page int) ([]ReviewEntry, bool, error)
|
||||
}
|
||||
|
||||
type AnimePlaybackService interface {
|
||||
GetAnimeByID(ctx context.Context, id int) (Anime, error)
|
||||
GetAllEpisodes(ctx context.Context, id int) ([]EpisodeData, error)
|
||||
}
|
||||
|
||||
type CatalogSectionData struct {
|
||||
Animes []Anime
|
||||
ContinueWatching []db.GetContinueWatchingEntriesRow
|
||||
|
||||
20
internal/domain/audit.go
Normal file
20
internal/domain/audit.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type AuditEvent struct {
|
||||
UserID string
|
||||
Action string
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
MetadataJSON json.RawMessage
|
||||
IP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type AuditService interface {
|
||||
Record(ctx context.Context, event AuditEvent) error
|
||||
}
|
||||
@@ -6,9 +6,17 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type User = db.User
|
||||
type Session = db.Session
|
||||
type APIToken = db.ApiToken
|
||||
type User struct {
|
||||
db.User
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
db.Session
|
||||
}
|
||||
|
||||
type APIToken struct {
|
||||
db.ApiToken
|
||||
}
|
||||
|
||||
const SessionLifetime = 90 * 24 * time.Hour
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ type PlaybackService interface {
|
||||
BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (WatchPageData, error)
|
||||
SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error
|
||||
CompleteAnime(ctx context.Context, userID string, animeID int64) error
|
||||
ResolveProxyToken(token string) (string, string, error)
|
||||
SignProxyToken(targetURL, referer, scope string) (string, error)
|
||||
ResolveProxyToken(token string, scope string) (string, string, error)
|
||||
UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error
|
||||
}
|
||||
|
||||
@@ -38,18 +39,15 @@ type WatchData struct {
|
||||
ModeSwitchedFrom string
|
||||
AvailableModes []string
|
||||
Segments []SkipSegment
|
||||
Airing bool
|
||||
}
|
||||
|
||||
type SubtitleItem struct {
|
||||
Lang string `json:"lang"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Referer string `json:"referer,omitempty"`
|
||||
Token string `json:"token"`
|
||||
Lang string `json:"lang"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type ModeSource struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
Referer string `json:"referer,omitempty"`
|
||||
Token string `json:"token"`
|
||||
Subtitles []SubtitleItem `json:"subtitles"`
|
||||
Qualities []string `json:"qualities,omitempty"`
|
||||
@@ -89,6 +87,7 @@ type EpisodeData struct {
|
||||
}
|
||||
|
||||
type PlaybackRepository interface {
|
||||
InTx(ctx context.Context, fn func(ctx context.Context, repo PlaybackRepository) error) error
|
||||
GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error)
|
||||
GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error)
|
||||
SaveWatchProgress(ctx context.Context, params db.SaveWatchProgressParams) error
|
||||
|
||||
@@ -21,6 +21,7 @@ type WatchlistService interface {
|
||||
}
|
||||
|
||||
type WatchlistRepository interface {
|
||||
InTx(ctx context.Context, fn func(ctx context.Context, repo WatchlistRepository) error) error
|
||||
UpsertAnime(ctx context.Context, arg db.UpsertAnimeParams) (db.Anime, error)
|
||||
GetAnime(ctx context.Context, id int64) (db.Anime, error)
|
||||
UpsertWatchListEntry(ctx context.Context, arg db.UpsertWatchListEntryParams) (db.WatchListEntry, error)
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
// Package episodes manages episode availability checking and refresh scheduling.
|
||||
package episodes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"mal/integrations/jikan"
|
||||
"mal/integrations/playback/allanime"
|
||||
"mal/internal/config"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
episodeService "mal/internal/episodes/service"
|
||||
"mal/internal/observability"
|
||||
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
func episodeAvailabilityEnabled() bool {
|
||||
value := strings.ToLower(strings.TrimSpace(os.Getenv("EPISODE_AVAILABILITY_MODE")))
|
||||
return value != "legacy" && value != "jikan"
|
||||
func episodeAvailabilityEnabled(cfg config.Config) bool {
|
||||
return cfg.EpisodeAvailabilityMode != config.EpisodeAvailabilityModeLegacy && cfg.EpisodeAvailabilityMode != config.EpisodeAvailabilityModeJikan
|
||||
}
|
||||
|
||||
var Module = fx.Options(
|
||||
fx.Provide(
|
||||
episodeAvailabilityEnabled,
|
||||
fx.Annotate(
|
||||
func(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool) domain.EpisodeService {
|
||||
return episodeService.NewEpisodeService(queries, jikanClient, providers, enabled)
|
||||
func(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, metrics *observability.Metrics) domain.EpisodeService {
|
||||
return episodeService.NewEpisodeService(queries, jikanClient, providers, enabled, metrics)
|
||||
},
|
||||
fx.ParamTags(``, ``, ``, ``),
|
||||
),
|
||||
),
|
||||
fx.Provide(func(p *allanime.AllAnimeProvider) []domain.EpisodeAvailabilityProvider {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package service provides episode availability checking logic.
|
||||
package service
|
||||
|
||||
import (
|
||||
@@ -6,18 +7,20 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
retryInterval = 15 * time.Minute
|
||||
retryWindow = 3 * time.Hour
|
||||
retryInterval = 15 * time.Minute
|
||||
retryWindow = 3 * time.Hour
|
||||
airingFallbackRefreshInterval = 6 * time.Hour
|
||||
)
|
||||
|
||||
type Clock interface {
|
||||
@@ -34,19 +37,21 @@ type EpisodeService struct {
|
||||
providers []domain.EpisodeAvailabilityProvider
|
||||
clock Clock
|
||||
enabled bool
|
||||
metrics *observability.Metrics
|
||||
}
|
||||
|
||||
func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool) domain.EpisodeService {
|
||||
return NewEpisodeServiceWithClock(queries, jikanClient, providers, enabled, realClock{})
|
||||
func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, metrics *observability.Metrics) domain.EpisodeService {
|
||||
return NewEpisodeServiceWithClock(queries, jikanClient, providers, enabled, realClock{}, metrics)
|
||||
}
|
||||
|
||||
func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock) *EpisodeService {
|
||||
func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock, metrics *observability.Metrics) *EpisodeService {
|
||||
return &EpisodeService{
|
||||
queries: queries,
|
||||
jikan: jikanClient,
|
||||
providers: providers,
|
||||
clock: clock,
|
||||
enabled: enabled,
|
||||
metrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +61,7 @@ func (s *EpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.
|
||||
}
|
||||
|
||||
if !forceRefresh {
|
||||
if cached, ok := s.getFreshCached(ctx, anime.MalID); ok {
|
||||
if cached, ok := s.getFreshCached(ctx, anime); ok {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
@@ -77,14 +82,43 @@ func (s *EpisodeService) RefreshTrackedDue(ctx context.Context, limit int) error
|
||||
return fmt.Errorf("get due tracked anime: %w", err)
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
for i, id := range ids {
|
||||
if ctx.Err() != nil {
|
||||
observability.Warn(
|
||||
"episodes_worker_tick_interrupted",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": id,
|
||||
"remaining": len(ids) - i,
|
||||
},
|
||||
ctx.Err(),
|
||||
)
|
||||
break
|
||||
}
|
||||
anime, err := s.jikan.GetAnimeByID(ctx, int(id))
|
||||
if err != nil {
|
||||
log.Printf("episodes: failed to fetch anime for refresh anime_id=%d error=%v", id, err)
|
||||
observability.Warn(
|
||||
"episodes_refresh_fetch_anime_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": id,
|
||||
},
|
||||
err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if _, err := s.refresh(ctx, anime); err != nil {
|
||||
log.Printf("episodes: refresh failed anime_id=%d error=%v", id, err)
|
||||
if _, err := s.refresh(ctx, domain.Anime{Anime: anime}); err != nil {
|
||||
observability.Warn(
|
||||
"episodes_refresh_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": id,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,22 +127,53 @@ func (s *EpisodeService) RefreshTrackedDue(ctx context.Context, limit int) error
|
||||
|
||||
func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
|
||||
now := s.clock.Now()
|
||||
log.Printf("episodes: refresh start anime_id=%d title=%q airing=%t", anime.MalID, anime.DisplayTitle(), anime.Airing)
|
||||
observability.Info(
|
||||
"episodes_refresh_start",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"title": anime.DisplayTitle(),
|
||||
"airing": anime.Airing,
|
||||
},
|
||||
)
|
||||
|
||||
jikanEpisodes, jikanErr := s.jikan.GetAllEpisodes(ctx, anime.MalID)
|
||||
if jikanErr != nil {
|
||||
log.Printf("episodes: jikan episode metadata failed anime_id=%d error=%v", anime.MalID, jikanErr)
|
||||
observability.Warn(
|
||||
"episodes_jikan_metadata_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
jikanErr,
|
||||
)
|
||||
}
|
||||
|
||||
providerAvailability, source, providerErr := s.fetchProviderAvailability(ctx, anime)
|
||||
if providerErr != nil {
|
||||
s.markFailure(ctx, anime, providerErr)
|
||||
if cached, ok := s.getCached(ctx, anime.MalID); ok {
|
||||
log.Printf("episodes: serving stale cache after provider failure anime_id=%d error=%v", anime.MalID, providerErr)
|
||||
observability.Warn(
|
||||
"episodes_provider_failed_serving_stale_cache",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
providerErr,
|
||||
)
|
||||
return cached, nil
|
||||
}
|
||||
if jikanErr == nil {
|
||||
return s.store(ctx, anime, jikanEpisodes, domain.EpisodeAvailability{}, "jikan_fallback", now, false)
|
||||
storeCtx := ctx
|
||||
if ctx.Err() != nil {
|
||||
var cancel context.CancelFunc
|
||||
storeCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
return s.store(storeCtx, anime, jikanEpisodes, domain.EpisodeAvailability{}, "jikan_fallback", now, false)
|
||||
}
|
||||
return domain.CanonicalEpisodeList{}, providerErr
|
||||
}
|
||||
@@ -121,16 +186,44 @@ func (s *EpisodeService) fetchProviderAvailability(ctx context.Context, anime do
|
||||
for _, provider := range s.providers {
|
||||
providerID, err := s.providerID(ctx, anime, provider, titles)
|
||||
if err != nil {
|
||||
log.Printf("episodes: provider id miss anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err)
|
||||
observability.Warn(
|
||||
"episodes_provider_id_miss",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"provider": provider.Name(),
|
||||
},
|
||||
err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
available, err := provider.GetEpisodeAvailabilityByProviderID(ctx, providerID)
|
||||
if err != nil {
|
||||
log.Printf("episodes: provider availability miss anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err)
|
||||
observability.Warn(
|
||||
"episodes_provider_availability_miss",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"provider": provider.Name(),
|
||||
},
|
||||
err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
log.Printf("episodes: provider availability hit anime_id=%d provider=%s sub=%d dub=%d", anime.MalID, provider.Name(), len(available.Sub), len(available.Dub))
|
||||
observability.Info(
|
||||
"episodes_provider_availability_hit",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"provider": provider.Name(),
|
||||
"sub": len(available.Sub),
|
||||
"dub": len(available.Dub),
|
||||
},
|
||||
)
|
||||
return available, provider.Name(), nil
|
||||
}
|
||||
return domain.EpisodeAvailability{}, "", fmt.Errorf("no episode availability provider matched anime_id=%d", anime.MalID)
|
||||
@@ -143,14 +236,38 @@ func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, pro
|
||||
})
|
||||
if err == nil {
|
||||
if row.FailedUntil.Valid && row.FailedUntil.Time.After(s.clock.Now()) {
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "hit")
|
||||
return "", fmt.Errorf("cached provider mapping failure active until %s: %s", row.FailedUntil.Time.Format(time.RFC3339), row.LastError)
|
||||
}
|
||||
if strings.TrimSpace(row.ProviderShowID) != "" {
|
||||
log.Printf("episodes: provider id cache hit anime_id=%d provider=%s provider_id=%s", anime.MalID, provider.Name(), row.ProviderShowID)
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "hit")
|
||||
observability.Info(
|
||||
"episodes_provider_id_cache_hit",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"provider": provider.Name(),
|
||||
"provider_id": row.ProviderShowID,
|
||||
},
|
||||
)
|
||||
return row.ProviderShowID, nil
|
||||
}
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "miss")
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
log.Printf("episodes: provider id cache read failed anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err)
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "miss")
|
||||
observability.Warn(
|
||||
"episodes_provider_id_cache_read_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"provider": provider.Name(),
|
||||
},
|
||||
err,
|
||||
)
|
||||
} else {
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "miss")
|
||||
}
|
||||
|
||||
providerID, err := provider.ResolveEpisodeProviderID(ctx, anime.MalID, titles)
|
||||
@@ -173,20 +290,51 @@ func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, pro
|
||||
LastError: "",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("episodes: provider id cache write failed anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err)
|
||||
observability.Warn(
|
||||
"episodes_provider_id_cache_write_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"provider": provider.Name(),
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
log.Printf("episodes: provider id resolved anime_id=%d provider=%s provider_id=%s", anime.MalID, provider.Name(), providerID)
|
||||
observability.Info(
|
||||
"episodes_provider_id_resolved",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"provider": provider.Name(),
|
||||
"provider_id": providerID,
|
||||
},
|
||||
)
|
||||
return providerID, nil
|
||||
}
|
||||
|
||||
func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, source string, now time.Time, providerSuccess bool) (domain.CanonicalEpisodeList, error) {
|
||||
nextRefresh := nextBroadcastAfter(anime, now)
|
||||
var nextRefreshSQL sql.NullTime
|
||||
if anime.Airing && !nextRefresh.IsZero() {
|
||||
nextRefreshSQL = sql.NullTime{Time: nextRefresh, Valid: true}
|
||||
if anime.Airing {
|
||||
// During the hours immediately following a broadcast time, providers can lag.
|
||||
// Keep retrying for a short window, even if the provider request succeeded.
|
||||
lastBroadcast := nextBroadcastBeforeOrAt(anime, now)
|
||||
if !lastBroadcast.IsZero() && now.Before(lastBroadcast.Add(retryWindow)) {
|
||||
nextRefreshSQL = sql.NullTime{Time: now.Add(retryInterval).UTC(), Valid: true}
|
||||
} else {
|
||||
next := nextBroadcastAfter(anime, now)
|
||||
if !next.IsZero() {
|
||||
nextRefreshSQL = sql.NullTime{Time: next, Valid: true}
|
||||
} else {
|
||||
// Broadcast metadata is often missing or wrong for currently airing shows.
|
||||
// Avoid "never refresh again" caches by falling back to a fixed interval.
|
||||
nextRefreshSQL = sql.NullTime{Time: now.Add(airingFallbackRefreshInterval).UTC(), Valid: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
episodes := mergeEpisodes(jikanEpisodes, availability)
|
||||
episodes := mergeEpisodes(jikanEpisodes, availability, anime.Episodes)
|
||||
payload := domain.CanonicalEpisodeList{
|
||||
AnimeID: anime.MalID,
|
||||
Episodes: episodes,
|
||||
@@ -217,11 +365,30 @@ func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpi
|
||||
LastError: "",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("episodes: cache write failed anime_id=%d source=%s error=%v", anime.MalID, source, err)
|
||||
observability.Warn(
|
||||
"episodes_cache_write_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"source": source,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
log.Printf("episodes: refresh success anime_id=%d source=%s episodes=%d next_refresh=%s", anime.MalID, source, len(episodes), payload.NextRefreshAt)
|
||||
observability.Info(
|
||||
"episodes_refresh_success",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"source": source,
|
||||
"episodes": len(episodes),
|
||||
"next_refresh": payload.NextRefreshAt,
|
||||
},
|
||||
)
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
@@ -239,7 +406,13 @@ func (s *EpisodeService) markFailure(ctx context.Context, anime domain.Anime, ca
|
||||
nextSQL = sql.NullTime{Time: next, Valid: true}
|
||||
}
|
||||
|
||||
err := s.queries.MarkEpisodeAvailabilityRefreshFailed(ctx, db.MarkEpisodeAvailabilityRefreshFailedParams{
|
||||
writeCtx := ctx
|
||||
if ctx.Err() != nil {
|
||||
var cancel context.CancelFunc
|
||||
writeCtx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
err := s.queries.MarkEpisodeAvailabilityRefreshFailed(writeCtx, db.MarkEpisodeAvailabilityRefreshFailedParams{
|
||||
LastAttemptAt: sql.NullTime{Time: now, Valid: true},
|
||||
LastError: truncate(cause.Error(), 400),
|
||||
NextRefreshAt: nextSQL,
|
||||
@@ -247,44 +420,146 @@ func (s *EpisodeService) markFailure(ctx context.Context, anime domain.Anime, ca
|
||||
AnimeID: int64(anime.MalID),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("episodes: failed to mark refresh failure anime_id=%d error=%v", anime.MalID, err)
|
||||
observability.Warn(
|
||||
"episodes_mark_failure_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
log.Printf("episodes: refresh failure recorded anime_id=%d next_retry=%s error=%v", anime.MalID, next.Format(time.RFC3339), cause)
|
||||
observability.Warn(
|
||||
"episodes_refresh_failure_recorded",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"next_retry": next.Format(time.RFC3339),
|
||||
},
|
||||
cause,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *EpisodeService) getCached(ctx context.Context, animeID int) (domain.CanonicalEpisodeList, bool) {
|
||||
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID))
|
||||
if err != nil {
|
||||
s.metrics.ObserveCache("episode_availability", "miss")
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
var payload domain.CanonicalEpisodeList
|
||||
if err := json.Unmarshal([]byte(row.Data), &payload); err != nil {
|
||||
log.Printf("episodes: invalid cached payload anime_id=%d error=%v", animeID, err)
|
||||
s.metrics.ObserveCache("episode_availability", "miss")
|
||||
observability.Warn(
|
||||
"episodes_cached_payload_invalid",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": animeID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
s.metrics.ObserveCache("episode_availability", "hit")
|
||||
return payload, true
|
||||
}
|
||||
|
||||
func (s *EpisodeService) getFreshCached(ctx context.Context, animeID int) (domain.CanonicalEpisodeList, bool) {
|
||||
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID))
|
||||
func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, bool) {
|
||||
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(anime.MalID))
|
||||
if err != nil {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(s.clock.Now()) {
|
||||
log.Printf("episodes: cached availability due for refresh anime_id=%d next_refresh=%s", animeID, row.NextRefreshAt.Time.Format(time.RFC3339))
|
||||
|
||||
now := s.clock.Now()
|
||||
if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(now) {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
observability.Info(
|
||||
"episodes_cache_due_for_refresh",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"next_refresh": row.NextRefreshAt.Time.Format(time.RFC3339),
|
||||
},
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
|
||||
if anime.Airing && row.UpdatedAt.Before(now.Add(-airingFallbackRefreshInterval)) {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
observability.Info(
|
||||
"episodes_cache_too_old_for_airing",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"updated_at": row.UpdatedAt.Format(time.RFC3339),
|
||||
},
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
|
||||
var payload domain.CanonicalEpisodeList
|
||||
if err := json.Unmarshal([]byte(row.Data), &payload); err != nil {
|
||||
log.Printf("episodes: invalid cached payload anime_id=%d error=%v", animeID, err)
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
observability.Warn(
|
||||
"episodes_cached_payload_invalid",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
log.Printf("episodes: served cached availability anime_id=%d episodes=%d next_refresh=%s", animeID, len(payload.Episodes), payload.NextRefreshAt)
|
||||
if !isCanonicalEpisodePayloadValid(payload, anime.Episodes) {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
observability.Info(
|
||||
"episodes_cached_payload_rejected",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"expected_count": anime.Episodes,
|
||||
"cached_episodes": len(payload.Episodes),
|
||||
},
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "hit")
|
||||
observability.Info(
|
||||
"episodes_cache_served",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"episodes": len(payload.Episodes),
|
||||
"next_refresh": payload.NextRefreshAt,
|
||||
},
|
||||
)
|
||||
return payload, true
|
||||
}
|
||||
|
||||
func isCanonicalEpisodePayloadValid(payload domain.CanonicalEpisodeList, expectedCount int) bool {
|
||||
if expectedCount <= 0 {
|
||||
return true
|
||||
}
|
||||
if len(payload.Episodes) > expectedCount {
|
||||
return false
|
||||
}
|
||||
for _, episode := range payload.Episodes {
|
||||
if episode.Number <= 0 || episode.Number > expectedCount {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *EpisodeService) jikanOnly(ctx context.Context, anime domain.Anime, source string) (domain.CanonicalEpisodeList, error) {
|
||||
episodes, err := s.jikan.GetAllEpisodes(ctx, anime.MalID)
|
||||
if err != nil {
|
||||
@@ -292,7 +567,7 @@ func (s *EpisodeService) jikanOnly(ctx context.Context, anime domain.Anime, sour
|
||||
}
|
||||
return domain.CanonicalEpisodeList{
|
||||
AnimeID: anime.MalID,
|
||||
Episodes: mergeEpisodes(episodes, domain.EpisodeAvailability{}),
|
||||
Episodes: mergeEpisodes(episodes, domain.EpisodeAvailability{}, anime.Episodes),
|
||||
Source: source,
|
||||
}, nil
|
||||
}
|
||||
@@ -313,7 +588,7 @@ func titleCandidates(anime domain.Anime) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability) []domain.CanonicalEpisode {
|
||||
func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, expectedCount int) []domain.CanonicalEpisode {
|
||||
type partial struct {
|
||||
title string
|
||||
filler bool
|
||||
@@ -323,18 +598,22 @@ func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAva
|
||||
}
|
||||
byNumber := map[int]partial{}
|
||||
|
||||
for _, ep := range jikanEpisodes {
|
||||
if ep.MalID <= 0 {
|
||||
for i, ep := range jikanEpisodes {
|
||||
if expectedCount > 0 && i >= expectedCount {
|
||||
break
|
||||
}
|
||||
number, ok := jikanEpisodeNumber(ep, i)
|
||||
if !ok || exceedsExpectedCount(number, expectedCount) {
|
||||
continue
|
||||
}
|
||||
item := byNumber[ep.MalID]
|
||||
item := byNumber[number]
|
||||
item.title = strings.TrimSpace(ep.Title)
|
||||
item.filler = ep.Filler
|
||||
item.recap = ep.Recap
|
||||
byNumber[ep.MalID] = item
|
||||
byNumber[number] = item
|
||||
}
|
||||
for _, n := range availability.Sub {
|
||||
if n <= 0 {
|
||||
if n <= 0 || exceedsExpectedCount(n, expectedCount) {
|
||||
continue
|
||||
}
|
||||
item := byNumber[n]
|
||||
@@ -342,7 +621,7 @@ func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAva
|
||||
byNumber[n] = item
|
||||
}
|
||||
for _, n := range availability.Dub {
|
||||
if n <= 0 {
|
||||
if n <= 0 || exceedsExpectedCount(n, expectedCount) {
|
||||
continue
|
||||
}
|
||||
item := byNumber[n]
|
||||
@@ -376,6 +655,21 @@ func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAva
|
||||
return episodes
|
||||
}
|
||||
|
||||
func jikanEpisodeNumber(ep jikan.Episode, index int) (int, bool) {
|
||||
number, err := strconv.Atoi(strings.TrimSpace(ep.Episode))
|
||||
if err == nil && number > 0 {
|
||||
return number, true
|
||||
}
|
||||
if index < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return index + 1, true
|
||||
}
|
||||
|
||||
func exceedsExpectedCount(number int, expectedCount int) bool {
|
||||
return expectedCount > 0 && number > expectedCount
|
||||
}
|
||||
|
||||
func nextRetryTime(anime domain.Anime, now time.Time) time.Time {
|
||||
broadcast := nextBroadcastBeforeOrAt(anime, now)
|
||||
if broadcast.IsZero() || now.After(broadcast.Add(retryWindow)) {
|
||||
@@ -403,13 +697,31 @@ func nextBroadcastAfter(anime domain.Anime, after time.Time) time.Time {
|
||||
if loaded, err := time.LoadLocation(tz); err == nil {
|
||||
loc = loaded
|
||||
} else {
|
||||
log.Printf("episodes: failed to parse broadcast timezone anime_id=%d timezone=%q error=%v", anime.MalID, tz, err)
|
||||
observability.Warn(
|
||||
"episodes_broadcast_timezone_parse_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"timezone": tz,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
hour, minute, ok := parseBroadcastTime(anime.Broadcast.Time)
|
||||
if !ok {
|
||||
log.Printf("episodes: failed to parse broadcast time anime_id=%d time=%q", anime.MalID, anime.Broadcast.Time)
|
||||
observability.Warn(
|
||||
"episodes_broadcast_time_parse_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"time": anime.Broadcast.Time,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ import (
|
||||
|
||||
func TestMergeEpisodesUsesUnionAndSynthesizesProviderOnlyEntries(t *testing.T) {
|
||||
episodes := mergeEpisodes([]jikan.Episode{
|
||||
{MalID: 1, Title: "Start"},
|
||||
{MalID: 2, Title: "Second", Filler: true},
|
||||
{MalID: 5, Title: "Future", Recap: true},
|
||||
{MalID: 101, Episode: "1", Title: "Start"},
|
||||
{MalID: 102, Episode: "2", Title: "Second", Filler: true},
|
||||
{MalID: 105, Episode: "5", Title: "Future", Recap: true},
|
||||
}, domain.EpisodeAvailability{
|
||||
Sub: []int{1, 2, 3, 6},
|
||||
Dub: []int{1, 2, 3},
|
||||
})
|
||||
}, 0)
|
||||
|
||||
if len(episodes) != 5 {
|
||||
t.Fatalf("len(episodes) = %d, want 5", len(episodes))
|
||||
@@ -28,8 +28,66 @@ func TestMergeEpisodesUsesUnionAndSynthesizesProviderOnlyEntries(t *testing.T) {
|
||||
assertEpisode(t, episodes[4], 6, "Episode 6", true, false, true, false, false)
|
||||
}
|
||||
|
||||
func TestMergeEpisodesIgnoresInvalidJikanEpisodeNumbers(t *testing.T) {
|
||||
episodes := mergeEpisodes([]jikan.Episode{
|
||||
{MalID: 201, Episode: "", Title: "Missing"},
|
||||
{MalID: 202, Episode: "Preview", Title: "Preview"},
|
||||
{MalID: 203, Episode: "0", Title: "Zero"},
|
||||
}, domain.EpisodeAvailability{}, 0)
|
||||
|
||||
if len(episodes) != 3 {
|
||||
t.Fatalf("len(episodes) = %d, want 3", len(episodes))
|
||||
}
|
||||
|
||||
assertEpisode(t, episodes[0], 1, "Missing", false, false, false, false, false)
|
||||
assertEpisode(t, episodes[1], 2, "Preview", false, false, false, false, false)
|
||||
assertEpisode(t, episodes[2], 3, "Zero", false, false, false, false, false)
|
||||
}
|
||||
|
||||
func TestMergeEpisodesCapsMalformedJikanListsToDeclaredEpisodeCount(t *testing.T) {
|
||||
episodes := mergeEpisodes([]jikan.Episode{
|
||||
{MalID: 301, Episode: "", Title: "Rimuru's Busy Life"},
|
||||
{MalID: 302, Episode: "", Title: "Trade with the Animal Kingdom"},
|
||||
{MalID: 303, Episode: "", Title: "Paradise, Once More"},
|
||||
{MalID: 304, Episode: "", Title: "The Scheming Kingdom of Falmuth"},
|
||||
{MalID: 305, Episode: "", Title: "Prelude to the Disaster"},
|
||||
{MalID: 306, Episode: "", Title: "The Beauty Makes Her Move"},
|
||||
{MalID: 307, Episode: "", Title: "Despair"},
|
||||
{MalID: 308, Episode: "", Title: "Hope"},
|
||||
{MalID: 309, Episode: "", Title: "Putting Everything on the Line"},
|
||||
{MalID: 310, Episode: "", Title: "Megiddo"},
|
||||
{MalID: 311, Episode: "", Title: "Birth of a Demon Lord"},
|
||||
{MalID: 312, Episode: "", Title: "The One Unleashed"},
|
||||
{MalID: 313, Episode: "", Title: "The Visitors"},
|
||||
}, domain.EpisodeAvailability{
|
||||
Sub: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13},
|
||||
Dub: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13},
|
||||
}, 12)
|
||||
|
||||
if len(episodes) != 12 {
|
||||
t.Fatalf("len(episodes) = %d, want 12", len(episodes))
|
||||
}
|
||||
|
||||
assertEpisode(t, episodes[0], 1, "Rimuru's Busy Life", true, true, false, false, false)
|
||||
assertEpisode(t, episodes[11], 12, "The One Unleashed", true, true, false, false, false)
|
||||
}
|
||||
|
||||
func TestIsCanonicalEpisodePayloadValidRejectsOverflowingCachedPayload(t *testing.T) {
|
||||
payload := domain.CanonicalEpisodeList{
|
||||
Episodes: []domain.CanonicalEpisode{
|
||||
{Number: 1, Title: "Episode 1"},
|
||||
{Number: 2, Title: "Episode 2"},
|
||||
{Number: 13, Title: "Episode 13"},
|
||||
},
|
||||
}
|
||||
|
||||
if isCanonicalEpisodePayloadValid(payload, 12) {
|
||||
t.Fatal("expected cached payload to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) {
|
||||
anime := domain.Anime{MalID: 1}
|
||||
anime := domain.Anime{Anime: jikan.Anime{MalID: 1}}
|
||||
anime.Broadcast.Day = "Saturdays"
|
||||
anime.Broadcast.Time = "23:00"
|
||||
anime.Broadcast.Timezone = "Asia/Tokyo"
|
||||
@@ -44,7 +102,7 @@ func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNextRetryTimeWithinAndAfterRetryWindow(t *testing.T) {
|
||||
anime := domain.Anime{MalID: 1}
|
||||
anime := domain.Anime{Anime: jikan.Anime{MalID: 1}}
|
||||
anime.Broadcast.Day = "Saturdays"
|
||||
anime.Broadcast.Time = "12:00"
|
||||
anime.Broadcast.Timezone = "UTC"
|
||||
|
||||
@@ -2,8 +2,8 @@ package episodes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
@@ -11,25 +11,44 @@ import (
|
||||
|
||||
const workerInterval = time.Minute
|
||||
|
||||
func RegisterWorker(lc fx.Lifecycle, svc domain.EpisodeService) {
|
||||
func RegisterWorker(lc fx.Lifecycle, svc domain.EpisodeService, metrics *observability.Metrics) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(context.Context) error {
|
||||
OnStart: func(startCtx context.Context) error {
|
||||
// Tie worker lifetime to fx lifecycle start context cancellation.
|
||||
go func() {
|
||||
log.Println("episodes: availability worker started")
|
||||
<-startCtx.Done()
|
||||
cancel()
|
||||
}()
|
||||
go func() {
|
||||
observability.Info("episodes_worker_start", "episodes", "", nil)
|
||||
ticker := time.NewTicker(workerInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
if err := svc.RefreshTrackedDue(ctx, 25); err != nil {
|
||||
log.Printf("episodes: availability worker tick failed error=%v", err)
|
||||
tickCtx, tickCancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
err := svc.RefreshTrackedDue(tickCtx, 25)
|
||||
tickCancel()
|
||||
if err != nil {
|
||||
metrics.ObserveWorkerTick("episodes_availability", err)
|
||||
observability.Warn(
|
||||
"episodes_worker_tick_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"worker": "episodes_availability",
|
||||
},
|
||||
err,
|
||||
)
|
||||
} else {
|
||||
metrics.ObserveWorkerTick("episodes_availability", nil)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-ctx.Done():
|
||||
log.Println("episodes: availability worker stopped")
|
||||
observability.Info("episodes_worker_stop", "episodes", "", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
15
internal/observability/helpers.go
Normal file
15
internal/observability/helpers.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package observability
|
||||
|
||||
// Small helpers to keep logging consistent and low-friction across the codebase.
|
||||
|
||||
func Info(event string, component string, message string, fields map[string]any) {
|
||||
LogJSON(LogLevelInfo, event, component, message, fields, nil)
|
||||
}
|
||||
|
||||
func Warn(event string, component string, message string, fields map[string]any, err error) {
|
||||
LogJSON(LogLevelWarn, event, component, message, fields, err)
|
||||
}
|
||||
|
||||
func Error(event string, component string, message string, fields map[string]any, err error) {
|
||||
LogJSON(LogLevelError, event, component, message, fields, err)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user