Merge branch 'main' of github.com:/mkelvers/mal into dev
This commit is contained in:
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$
|
||||||
32
CONFLICTS.md
Normal file
32
CONFLICTS.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Conflicts / Remaining Issues
|
||||||
|
|
||||||
|
1. **God interface (`AnimeService`)**
|
||||||
|
- `internal/domain/anime.go` still defines a large `AnimeService` interface (catalog + discover + search + details + staff/stats/reviews).
|
||||||
|
- Needs to be split into smaller interfaces (ISP) and rewired through handlers/services.
|
||||||
|
|
||||||
|
2. **Domain layer still leaks external models**
|
||||||
|
- While `domain.User` and `domain.Anime` are now real types, many other domain types are still direct aliases to integration/DB types (e.g. `Genre`, `Recommendation`, etc. in `internal/domain/anime.go`).
|
||||||
|
- Goal is a stable domain model that does not break if Jikan/DB structs change.
|
||||||
|
|
||||||
|
3. **No real DB transactions for multi-write operations**
|
||||||
|
- Multi-step writes (e.g. playback completion / watchlist updates) still do not run inside a database transaction.
|
||||||
|
- Errors are no longer swallowed in several places, but atomicity is still not guaranteed.
|
||||||
|
|
||||||
|
4. **DiceBear URL duplication**
|
||||||
|
- Default avatar URL logic is duplicated in `cmd/user/main.go` and `internal/database/migrations/016_add_avatar_url.sql`.
|
||||||
|
- Needs centralization (or migration updated to match single source of truth).
|
||||||
|
|
||||||
|
5. **AllAnime package-level shared HTTP client**
|
||||||
|
- `integrations/playback/allanime/client.go` still has a package-level mutable `http.Client` (`allAnimeUTLSClient`).
|
||||||
|
- Should be instance-owned or injected to avoid cross-test/env coupling.
|
||||||
|
|
||||||
|
6. **Regex-based parsing of upstream JSON-ish responses**
|
||||||
|
- `integrations/playback/allanime/extractor.go` still parses provider responses using regex.
|
||||||
|
- Should be replaced with real JSON decoding (or a more robust parser) where possible.
|
||||||
|
|
||||||
|
7. **Template duplication / drift risk**
|
||||||
|
- `templates/watchlist.gohtml` and `templates/watchlist_partial.gohtml` are still separate with overlapping markup.
|
||||||
|
- Inline JS was removed, but the duplication itself remains and can still drift.
|
||||||
|
|
||||||
|
8. **Remaining handler consistency**
|
||||||
|
- Some modules still have duplicated user extraction patterns and could be unified (e.g. `currentUser()` helper usage beyond playback).
|
||||||
18
Dockerfile
18
Dockerfile
@@ -5,14 +5,22 @@ WORKDIR /app
|
|||||||
# Enable CGO for sqlite3
|
# Enable CGO for sqlite3
|
||||||
ENV CGO_ENABLED=1
|
ENV CGO_ENABLED=1
|
||||||
|
|
||||||
# Install sqlc for code generation
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
unzip \
|
||||||
|
gcc \
|
||||||
|
libc6-dev \
|
||||||
|
libsqlite3-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install build dependencies for bun + assets
|
# Install bun (for building frontend assets)
|
||||||
RUN apt-get update && apt-get install -y ca-certificates sqlite3 curl unzip && rm -rf /var/lib/apt/lists/*
|
|
||||||
RUN curl -fsSL https://bun.sh/install | bash
|
RUN curl -fsSL https://bun.sh/install | bash
|
||||||
ENV PATH="/root/.bun/bin:${PATH}"
|
ENV PATH="/root/.bun/bin:${PATH}"
|
||||||
|
|
||||||
|
# Install sqlc for code generation
|
||||||
|
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0
|
||||||
|
|
||||||
ENV GOPROXY=direct
|
ENV GOPROXY=direct
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
@@ -50,7 +58,7 @@ COPY --from=builder /app/templates ./templates
|
|||||||
COPY --from=builder /app/static ./static
|
COPY --from=builder /app/static ./static
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/internal/database/migrations ./migrations
|
COPY --from=builder /app/internal/database/migrations ./migrations
|
||||||
COPY docker/entrypoint.sh ./entrypoint.sh
|
COPY entrypoint.sh ./entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|||||||
119
README.md
119
README.md
@@ -1,136 +1,53 @@
|
|||||||
# MyAnimeList
|
# MyAnimeList
|
||||||
|
|
||||||
<table align="center">
|
<p align="center">
|
||||||
<tr>
|
<picture>
|
||||||
<td>
|
<source media="(prefers-color-scheme: dark)" srcset="/static/assets/readme-logo-dark.svg" />
|
||||||
<picture>
|
<img src="/static/assets/readme-logo-light.svg" alt="MyAnimeList logo" width="120" />
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="/static/assets/readme-logo-dark.svg" />
|
</picture>
|
||||||
<img src="/static/assets/readme-logo-light.svg" alt="MyAnimeList logo" width="140" />
|
</p>
|
||||||
</picture>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<strong>MyAnimeList</strong><br />
|
|
||||||
My personal anime tracker, built because nothing else felt right.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Go" src="https://img.shields.io/badge/go-1.25-00ADD8?style=flat-square&logo=go" />
|
<img alt="Go" src="https://img.shields.io/badge/go-1.25-00ADD8?style=flat-square&logo=go" />
|
||||||
<img alt="SQLite" src="https://img.shields.io/badge/database-sqlite-003B57?style=flat-square&logo=sqlite" />
|
<img alt="SQLite" src="https://img.shields.io/badge/database-sqlite-003B57?style=flat-square&logo=sqlite" />
|
||||||
<img alt="Tailwind" src="https://img.shields.io/badge/tailwind-4-06B6D4?style=flat-square&logo=tailwindcss" />
|
<img alt="Tailwind" src="https://img.shields.io/badge/tailwind-4-06D6D4?style=flat-square&logo=tailwindcss" />
|
||||||
<img alt="HTMX" src="https://img.shields.io/badge/htmx-partial--updates-3366CC?style=flat-square" />
|
<img alt="HTMX" src="https://img.shields.io/badge/htmx-partial--updates-3366CC?style=flat-square" />
|
||||||
</p>
|
</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.
|
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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Repository structure
|
## Repository structure
|
||||||
|
|
||||||
The codebase follows standard Go project layout conventions.
|
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
| ----------------- | ------------------------------------------------ |
|
| ----------------- | ------------------------------------------------ |
|
||||||
| `api/*` | Feature routes: anime, auth, playback, watchlist |
|
| `api/*` | Feature routes: anime, auth, playback, watchlist |
|
||||||
| `cmd/server` | Application entrypoint and CLI commands |
|
| `cmd/server` | Application entrypoint and CLI commands |
|
||||||
|
| `cmd/user` | User management CLI (create, update, delete) |
|
||||||
| `integrations/*` | External API clients and scraping |
|
| `integrations/*` | External API clients and scraping |
|
||||||
| `internal/*` | Core services: db, middleware, server, worker |
|
| `internal/*` | Core services: db, middleware, server, worker |
|
||||||
| `pkg/middleware` | Generic HTTP middleware |
|
| `pkg/middleware` | Generic HTTP middleware |
|
||||||
| `templates/*` | Server-rendered HTML templates |
|
| `templates/*` | Server-rendered HTML templates |
|
||||||
| `migrations` | Schema evolution |
|
| `migrations` | Schema evolution (20 migrations) |
|
||||||
| `static` / `dist` | Frontend assets |
|
| `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.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/mkelvers/mal.git && cd mal
|
just dev
|
||||||
openssl rand -base32 32
|
|
||||||
PLAYBACK_PROXY_SECRET="your-32-char-secret" go run ./cmd/server
|
|
||||||
go run ./cmd/user <username> <password>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The app runs at `http://localhost:3000`.
|
## Contributing
|
||||||
|
|
||||||
### Tasks
|
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.
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Migrations run automatically on startup.
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
Keep secrets out of version control, do not publish real credentials in documentation or screenshots, and report security issues privately before public disclosure.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is released under the MIT License. See `LICENSE` for details.
|
MIT. See `LICENSE`.
|
||||||
|
|||||||
13
bun.lock
13
bun.lock
@@ -4,12 +4,9 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "myanimelist-ui",
|
"name": "myanimelist-ui",
|
||||||
"dependencies": {
|
|
||||||
"dompurify": "^3.4.1",
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/cli": "^4.2.4",
|
"@tailwindcss/cli": "^4.2.4",
|
||||||
"@toolwind/anchors": "^1.0.10",
|
"@types/node": "^24.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
||||||
"@typescript-eslint/parser": "^8.59.2",
|
"@typescript-eslint/parser": "^8.59.2",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
@@ -118,15 +115,13 @@
|
|||||||
|
|
||||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="],
|
"@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/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
"@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/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=="],
|
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="],
|
"@typescript-eslint/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=="],
|
||||||
|
|
||||||
@@ -166,8 +161,6 @@
|
|||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"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=="],
|
"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=="],
|
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||||
@@ -340,6 +333,8 @@
|
|||||||
|
|
||||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package main runs the MAL web server.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,32 +1,50 @@
|
|||||||
|
// Package main provides small CLI utilities for local admin tasks.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"mal/internal/config"
|
||||||
|
"mal/internal/database"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
|
"mal/internal/observability"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
dbConn, err := db.Open(db.GetDBFile())
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
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() }()
|
defer func() { _ = dbConn.Close() }()
|
||||||
|
|
||||||
if len(os.Args) == 2 && os.Args[1] == "update-avatar" {
|
if len(os.Args) == 2 {
|
||||||
updateAvatars(dbConn)
|
switch os.Args[1] {
|
||||||
return
|
case "update-avatar":
|
||||||
|
updateAvatars(dbConn)
|
||||||
|
return
|
||||||
|
case "run-fixes":
|
||||||
|
runFixes(dbConn)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(os.Args) != 3 {
|
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")
|
observability.Warn("cli_usage", "cmd/user", "invalid arguments", map[string]any{"argc": len(os.Args)}, nil)
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "Usage: go run cmd/user/main.go <username> <password>\n go run cmd/user/main.go update-avatar\n go run cmd/user/main.go run-fixes")
|
||||||
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
username := os.Args[1]
|
username := os.Args[1]
|
||||||
@@ -35,7 +53,8 @@ func main() {
|
|||||||
var existingID string
|
var existingID string
|
||||||
err = dbConn.QueryRow("SELECT id FROM user WHERE username = ?", username).Scan(&existingID)
|
err = dbConn.QueryRow("SELECT id FROM user WHERE username = ?", username).Scan(&existingID)
|
||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil && err != sql.ErrNoRows {
|
||||||
log.Fatalf("database error: %v", err)
|
observability.Error("cli_user_lookup_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -51,12 +70,14 @@ func main() {
|
|||||||
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to hash password: %v", err)
|
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = dbConn.Exec("UPDATE user SET password_hash = ? WHERE id = ?", string(hash), existingID)
|
_, err = dbConn.Exec("UPDATE user SET password_hash = ? WHERE id = ?", string(hash), existingID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to update user: %v", err)
|
observability.Error("cli_user_password_update_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Password for '%s' updated successfully!\n", username)
|
fmt.Printf("Password for '%s' updated successfully!\n", username)
|
||||||
@@ -65,14 +86,16 @@ func main() {
|
|||||||
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to hash password: %v", err)
|
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
id := uuid.New().String()
|
id := uuid.New().String()
|
||||||
avatarURL := fmt.Sprintf("https://api.dicebear.com/9.x/dylan/svg?seed=%s", username)
|
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)
|
_, err = dbConn.Exec("INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)", id, username, string(hash), avatarURL)
|
||||||
if err != nil {
|
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)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("User '%s' was created successfully!\n", username)
|
fmt.Printf("User '%s' was created successfully!\n", username)
|
||||||
@@ -81,7 +104,8 @@ func main() {
|
|||||||
func updateAvatars(dbConn *sql.DB) {
|
func updateAvatars(dbConn *sql.DB) {
|
||||||
rows, err := dbConn.Query("SELECT id, username FROM user")
|
rows, err := dbConn.Query("SELECT id, username FROM user")
|
||||||
if err != nil {
|
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() }()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
@@ -89,20 +113,55 @@ func updateAvatars(dbConn *sql.DB) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id, username string
|
var id, username string
|
||||||
if err := rows.Scan(&id, &username); err != nil {
|
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 := fmt.Sprintf("https://api.dicebear.com/9.x/dylan/svg?seed=%s", username)
|
||||||
_, err := dbConn.Exec("UPDATE user SET avatar_url = ? WHERE id = ?", avatarURL, id)
|
_, err := dbConn.Exec("UPDATE user SET avatar_url = ? WHERE id = ?", avatarURL, id)
|
||||||
if err != nil {
|
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++
|
count++
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
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)
|
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,3 +9,4 @@ if [ ! -x /app/main_server ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
exec /app/main_server
|
exec /app/main_server
|
||||||
|
|
||||||
@@ -2,27 +2,38 @@ import tseslint from '@typescript-eslint/eslint-plugin';
|
|||||||
import tsParser from '@typescript-eslint/parser';
|
import tsParser from '@typescript-eslint/parser';
|
||||||
import prettier from 'eslint-plugin-prettier';
|
import prettier from 'eslint-plugin-prettier';
|
||||||
import eslintConfigPrettier from 'eslint-config-prettier';
|
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const tsconfigRootDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
ignores: ['dist/**', 'node_modules/**', 'server', '*.js'],
|
ignores: ['dist/**', 'node_modules/**', 'server', '*.js'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.ts'],
|
files: ['static/**/*.ts'],
|
||||||
plugins: {
|
plugins: {
|
||||||
'@typescript-eslint': tseslint,
|
'@typescript-eslint': tseslint,
|
||||||
prettier,
|
prettier,
|
||||||
},
|
},
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: tsParser,
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.json'],
|
||||||
|
tsconfigRootDir,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...eslintConfigPrettier.rules,
|
...eslintConfigPrettier.rules,
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
...tseslint.configs.recommended.rules,
|
||||||
|
...tseslint.configs.stylistic.rules,
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'warn',
|
'error',
|
||||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||||
],
|
],
|
||||||
|
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
||||||
'prettier/prettier': 'error',
|
'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();
|
|
||||||
@@ -5,21 +5,24 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mal/internal/config"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
|
"mal/internal/observability"
|
||||||
|
"mal/pkg/net/useragent"
|
||||||
|
|
||||||
"golang.org/x/sync/singleflight"
|
"golang.org/x/sync/singleflight"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var traceEnabled bool
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
baseURL string
|
baseURL string
|
||||||
@@ -29,6 +32,7 @@ type Client struct {
|
|||||||
lastReqTime time.Time // rate limiting: last request timestamp
|
lastReqTime time.Time // rate limiting: last request timestamp
|
||||||
sf singleflight.Group
|
sf singleflight.Group
|
||||||
refreshSem chan struct{}
|
refreshSem chan struct{}
|
||||||
|
metrics *observability.Metrics
|
||||||
|
|
||||||
// Random anime pool for DDoS-proof truly random "Surprise Me"
|
// Random anime pool for DDoS-proof truly random "Surprise Me"
|
||||||
randomPool []Anime
|
randomPool []Anime
|
||||||
@@ -38,7 +42,8 @@ type Client struct {
|
|||||||
|
|
||||||
const jikanSlowLogThreshold = 750 * time.Millisecond
|
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{
|
return &Client{
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
@@ -51,6 +56,7 @@ func NewClient(queries *db.Queries) *Client {
|
|||||||
},
|
},
|
||||||
baseURL: "https://api.jikan.moe/v4",
|
baseURL: "https://api.jikan.moe/v4",
|
||||||
db: queries,
|
db: queries,
|
||||||
|
metrics: metrics,
|
||||||
retrySignal: make(chan struct{}, 1),
|
retrySignal: make(chan struct{}, 1),
|
||||||
refreshSem: make(chan struct{}, 4),
|
refreshSem: make(chan struct{}, 4),
|
||||||
randomPool: make([]Anime, 0),
|
randomPool: make([]Anime, 0),
|
||||||
@@ -140,8 +146,7 @@ func waitForRetry(ctx context.Context, delay time.Duration) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func jikanTraceEnabled() bool {
|
func jikanTraceEnabled() bool {
|
||||||
value := strings.ToLower(strings.TrimSpace(os.Getenv("MAL_JIKAN_TRACE")))
|
return traceEnabled
|
||||||
return value == "1" || value == "true" || value == "yes"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func logJikanCache(cacheKey string, source string, startedAt time.Time, err error) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
errorValue := ""
|
level := observability.LogLevelInfo
|
||||||
if err != nil {
|
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(
|
observability.LogJSON(
|
||||||
"jikan_cache key=%s source=%s duration_ms=%.2f error=%s",
|
level,
|
||||||
strconv.Quote(cacheKey),
|
"jikan_cache",
|
||||||
source,
|
"jikan",
|
||||||
float64(duration.Microseconds())/1000,
|
"",
|
||||||
strconv.Quote(errorValue),
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
errorValue := ""
|
level := observability.LogLevelInfo
|
||||||
if err != nil {
|
if err != nil || statusCode >= http.StatusInternalServerError {
|
||||||
errorValue = err.Error()
|
level = observability.LogLevelError
|
||||||
|
} else if statusCode == http.StatusTooManyRequests || statusCode >= http.StatusBadRequest {
|
||||||
|
level = observability.LogLevelWarn
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf(
|
observability.LogJSON(
|
||||||
"jikan_upstream url=%s status=%d attempts=%d duration_ms=%.2f error=%s",
|
level,
|
||||||
strconv.Quote(urlStr),
|
"jikan_upstream",
|
||||||
statusCode,
|
"jikan",
|
||||||
attempts,
|
"",
|
||||||
float64(duration.Microseconds())/1000,
|
map[string]any{
|
||||||
strconv.Quote(errorValue),
|
"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)
|
data, err := c.db.GetJikanCache(ctx, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
c.metrics.ObserveCache("jikan", "miss")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal([]byte(data), out)
|
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.
|
// 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)
|
data, err := c.db.GetJikanCacheStale(ctx, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
c.metrics.ObserveCache("jikan_stale", "miss")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal([]byte(data), out)
|
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.
|
// setCache stores data in cache with specified TTL.
|
||||||
@@ -425,7 +460,9 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err
|
|||||||
maxRetries := 5
|
maxRetries := 5
|
||||||
startedAt := time.Now()
|
startedAt := time.Now()
|
||||||
attempts := 0
|
attempts := 0
|
||||||
|
endpoint := metricsEndpoint(urlStr)
|
||||||
logAndReturn := func(statusCode int, err error) error {
|
logAndReturn := func(statusCode int, err error) error {
|
||||||
|
c.metrics.ObserveJikanRequest(endpoint, statusCode, time.Since(startedAt), err)
|
||||||
logJikanUpstream(urlStr, statusCode, attempts, startedAt, err)
|
logJikanUpstream(urlStr, statusCode, attempts, startedAt, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -446,6 +483,7 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return logAndReturn(0, fmt.Errorf("failed to create jikan request: %w", err))
|
return logAndReturn(0, fmt.Errorf("failed to create jikan request: %w", err))
|
||||||
}
|
}
|
||||||
|
req.Header.Set("User-Agent", useragent.Generic)
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -506,3 +544,36 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err
|
|||||||
|
|
||||||
return logAndReturn(0, fmt.Errorf("max retries exceeded for %s", urlStr))
|
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"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"mal/internal/config"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
|
"mal/internal/observability"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -41,7 +43,7 @@ func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
queries := db.New(sqlDB)
|
queries := db.New(sqlDB)
|
||||||
client := NewClient(queries)
|
client := NewClient(config.Config{}, queries, observability.NewMetrics())
|
||||||
stale := TopAnimeResponse{Data: []Anime{{MalID: 1, Title: "stale"}}}
|
stale := TopAnimeResponse{Data: []Anime{{MalID: 1, Title: "stale"}}}
|
||||||
staleBytes, err := json.Marshal(stale)
|
staleBytes, err := json.Marshal(stale)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ import "time"
|
|||||||
|
|
||||||
const shortCacheTTL = time.Hour // 1 hour - for frequently changing data
|
const shortCacheTTL = time.Hour // 1 hour - for frequently changing data
|
||||||
const longCacheTTL = time.Hour * 24 // 24 hours - for stable data like genres
|
const longCacheTTL = time.Hour * 24 // 24 hours - for stable data like genres
|
||||||
|
const producerCacheTTL = time.Hour * 24 * 30
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package jikan
|
package jikan
|
||||||
|
|
||||||
import (
|
import "go.uber.org/fx"
|
||||||
"go.uber.org/fx"
|
|
||||||
)
|
|
||||||
|
|
||||||
var Module = fx.Options(
|
var Module = fx.Options(
|
||||||
fx.Provide(NewClient),
|
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"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mal/internal/observability"
|
||||||
|
|
||||||
"mal/integrations/watchorder"
|
"mal/integrations/watchorder"
|
||||||
|
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
@@ -62,21 +63,44 @@ func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrd
|
|||||||
return watchorder.WatchOrderResult{}, watchorder.ErrWatchOrderNotFound
|
return watchorder.WatchOrderResult{}, watchorder.ErrWatchOrderNotFound
|
||||||
}
|
}
|
||||||
if errors.Is(err, watchorder.ErrWatchOrderMarkupNotFound) {
|
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) {
|
} else if errors.As(err, &statusError) {
|
||||||
log.Printf(
|
observability.Warn(
|
||||||
"relations: watch-order http error for %d (%s): status=%d server=%q cf_ray=%q location=%q content_type=%q body=%q",
|
"relations_watch_order_http_error",
|
||||||
id,
|
"jikan",
|
||||||
watchOrderURL,
|
"",
|
||||||
statusError.StatusCode,
|
map[string]any{
|
||||||
statusError.Server,
|
"anime_id": id,
|
||||||
statusError.CFRay,
|
"url": watchOrderURL,
|
||||||
statusError.Location,
|
"status": statusError.StatusCode,
|
||||||
statusError.ContentType,
|
"server": statusError.Server,
|
||||||
statusError.BodyPreview,
|
"cf_ray": statusError.CFRay,
|
||||||
|
"location": statusError.Location,
|
||||||
|
"content_type": statusError.ContentType,
|
||||||
|
"body_preview": statusError.BodyPreview,
|
||||||
|
},
|
||||||
|
err,
|
||||||
)
|
)
|
||||||
} else {
|
} 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
|
return watchorder.WatchOrderResult{}, err
|
||||||
}
|
}
|
||||||
@@ -107,7 +131,15 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
|
|||||||
if errors.Is(err, watchorder.ErrWatchOrderNotFound) {
|
if errors.Is(err, watchorder.ErrWatchOrderNotFound) {
|
||||||
return c.currentOnlyRelation(ctx, id)
|
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)
|
return c.currentOnlyRelation(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,9 +208,6 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
|
|||||||
IsCurrent: res.entry.ID == id,
|
IsCurrent: res.entry.ID == id,
|
||||||
IsExtra: false,
|
IsExtra: false,
|
||||||
})
|
})
|
||||||
if res.entry.ID == id {
|
|
||||||
relations[len(relations)-1].Relation = "Current"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !seen[id] {
|
if !seen[id] {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SearchAdvanced performs a filtered anime search with type, status, ordering, and genre filters.
|
// 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, sfw bool, page, limit int) (SearchResult, error) {
|
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 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o
|
|||||||
genresParam = strings.Join(ids, ",")
|
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
|
var result SearchResponse
|
||||||
reqURL := fmt.Sprintf("%s/anime?page=%d", c.baseURL, page)
|
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 != "" {
|
if status != "" {
|
||||||
reqURL += "&status=" + url.QueryEscape(status)
|
reqURL += "&status=" + url.QueryEscape(status)
|
||||||
}
|
}
|
||||||
|
if studioID > 0 {
|
||||||
|
reqURL += "&producers=" + strconv.Itoa(studioID)
|
||||||
|
}
|
||||||
if orderBy != "" {
|
if orderBy != "" {
|
||||||
reqURL += "&order_by=" + url.QueryEscape(orderBy)
|
reqURL += "&order_by=" + url.QueryEscape(orderBy)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package jikan
|
package jikan
|
||||||
|
|
||||||
import ()
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
type ProducerResponse struct {
|
type ProducerResponse struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
@@ -24,3 +27,18 @@ type ProducerResponse struct {
|
|||||||
} `json:"external"`
|
} `json:"external"`
|
||||||
} `json:"data"`
|
} `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
|
||||||
|
}
|
||||||
|
|||||||
@@ -141,10 +141,10 @@ Jujutsu Kaisen 0
|
|||||||
testClient := &http.Client{
|
testClient := &http.Client{
|
||||||
Timeout: time.Second,
|
Timeout: time.Second,
|
||||||
Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||||
switch {
|
switch request.URL.Host {
|
||||||
case request.URL.Host == "chiaki.site":
|
case "chiaki.site":
|
||||||
return mockResponse(http.StatusForbidden, map[string]string{"Content-Type": "text/html; charset=utf-8"}, "blocked"), nil
|
return mockResponse(http.StatusForbidden, map[string]string{"Content-Type": "text/html; charset=utf-8"}, "blocked"), nil
|
||||||
case request.URL.Host == "r.jina.ai":
|
case "r.jina.ai":
|
||||||
// Proxy response is plain text/markdown.
|
// Proxy response is plain text/markdown.
|
||||||
return mockResponse(http.StatusOK, map[string]string{"Content-Type": "text/plain; charset=utf-8"}, proxyPayload), nil
|
return mockResponse(http.StatusOK, map[string]string{"Content-Type": "text/plain; charset=utf-8"}, proxyPayload), nil
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"mal/integrations/jikan"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
|
"mal/internal/observability"
|
||||||
|
"mal/internal/server"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -20,6 +22,14 @@ type AnimeHandler struct {
|
|||||||
watchlistSvc domain.WatchlistService
|
watchlistSvc domain.WatchlistService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 NewAnimeHandler(svc domain.AnimeService, watchlistSvc domain.WatchlistService) *AnimeHandler {
|
func NewAnimeHandler(svc domain.AnimeService, watchlistSvc domain.WatchlistService) *AnimeHandler {
|
||||||
return &AnimeHandler{
|
return &AnimeHandler{
|
||||||
svc: svc,
|
svc: svc,
|
||||||
@@ -59,6 +69,9 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
|
|||||||
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
|
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
|
||||||
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
|
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
|
||||||
r.GET("/api/discover/top", h.HandleDiscoverTop)
|
r.GET("/api/discover/top", h.HandleDiscoverTop)
|
||||||
|
r.GET("/api/discover/for-you", h.HandleDiscoverForYou)
|
||||||
|
r.GET("/schedule", h.HandleSchedule)
|
||||||
|
r.GET("/api/schedule", h.HandleScheduleSection)
|
||||||
r.GET("/browse", h.HandleBrowse)
|
r.GET("/browse", h.HandleBrowse)
|
||||||
r.GET("/anime/:id", h.HandleAnimeDetails)
|
r.GET("/anime/:id", h.HandleAnimeDetails)
|
||||||
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
|
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
|
||||||
@@ -66,6 +79,101 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
|
|||||||
r.GET("/api/search-quick", h.HandleQuickSearch)
|
r.GET("/api/search-quick", h.HandleQuickSearch)
|
||||||
r.GET("/api/command-palette", h.HandleCommandPalette)
|
r.GET("/api/command-palette", h.HandleCommandPalette)
|
||||||
r.GET("/api/jikan/random/anime", h.HandleRandomAnime)
|
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) {
|
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
|
||||||
@@ -98,6 +206,17 @@ func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
|
|||||||
}
|
}
|
||||||
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
|
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"catalog_section_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"section": section,
|
||||||
|
"user_id": userID,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +248,36 @@ func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) {
|
|||||||
h.renderDiscoverSection(c, "Top")
|
h.renderDiscoverSection(c, "Top")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleDiscoverForYou(c *gin.Context) {
|
||||||
|
user, _ := c.Get("User")
|
||||||
|
userID := ""
|
||||||
|
if u, ok := user.(*domain.User); ok {
|
||||||
|
userID = u.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.svc.GetDiscoverForYou(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"discover_for_you_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"user_id": userID,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||||
|
|
||||||
|
data.Section = "ForYou"
|
||||||
|
data.Fragment = "discover_row"
|
||||||
|
data.WatchlistMap = watchlistMap
|
||||||
|
c.HTML(http.StatusOK, "discover.gohtml", data)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
|
func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
|
||||||
user, _ := c.Get("User")
|
user, _ := c.Get("User")
|
||||||
userID := ""
|
userID := ""
|
||||||
@@ -137,6 +286,17 @@ func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
|
|||||||
}
|
}
|
||||||
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)
|
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"discover_section_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"section": section,
|
||||||
|
"user_id": userID,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +308,45 @@ func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
|
|||||||
c.HTML(http.StatusOK, "discover.gohtml", data)
|
c.HTML(http.StatusOK, "discover.gohtml", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleSchedule(c *gin.Context) {
|
||||||
|
user, _ := c.Get("User")
|
||||||
|
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
|
||||||
|
"CurrentPath": "/schedule",
|
||||||
|
"User": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) {
|
||||||
|
user, _ := c.Get("User")
|
||||||
|
userID := ""
|
||||||
|
if u, ok := user.(*domain.User); ok {
|
||||||
|
userID = u.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
animes, err := h.svc.GetAiringSchedule(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"schedule_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"user_id": userID,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
|
||||||
|
"_fragment": "schedule_section",
|
||||||
|
"Animes": animes,
|
||||||
|
"WatchlistMap": watchlistMap,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
||||||
q := c.Query("q")
|
q := c.Query("q")
|
||||||
animeType := c.Query("type")
|
animeType := c.Query("type")
|
||||||
@@ -155,22 +354,58 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
|||||||
orderBy := c.Query("order_by")
|
orderBy := c.Query("order_by")
|
||||||
sort := c.Query("sort")
|
sort := c.Query("sort")
|
||||||
sfw := c.Query("sfw") != "false"
|
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
|
var genres []int
|
||||||
for _, g := range c.QueryArray("genres") {
|
for _, g := range c.QueryArray("genres") {
|
||||||
id, _ := strconv.Atoi(g)
|
id, err := strconv.Atoi(g)
|
||||||
|
if err != nil {
|
||||||
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid genre id")
|
||||||
|
return
|
||||||
|
}
|
||||||
if id > 0 {
|
if id > 0 {
|
||||||
genres = append(genres, id)
|
genres = append(genres, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if err != nil {
|
||||||
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
|
||||||
|
return
|
||||||
|
}
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, sfw, page, 24)
|
res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, studioID, sfw, page, 24)
|
||||||
if err != nil {
|
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, _ := c.Get("User")
|
user, _ := c.Get("User")
|
||||||
@@ -178,12 +413,21 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
|||||||
if u, ok := user.(*domain.User); ok {
|
if u, ok := user.(*domain.User); ok {
|
||||||
userID = u.ID
|
userID = u.ID
|
||||||
}
|
}
|
||||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, res.Animes)
|
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 {
|
if c.GetHeader("HX-Request") == "true" && page > 1 {
|
||||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
||||||
"_fragment": "anime_card_scroll",
|
"_fragment": "anime_card_scroll",
|
||||||
"Animes": res.Animes,
|
"Animes": animes,
|
||||||
"NextPage": page + 1,
|
"NextPage": page + 1,
|
||||||
"HasNextPage": res.HasNextPage,
|
"HasNextPage": res.HasNextPage,
|
||||||
"Query": q,
|
"Query": q,
|
||||||
@@ -192,6 +436,8 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
|||||||
"OrderBy": orderBy,
|
"OrderBy": orderBy,
|
||||||
"Sort": sort,
|
"Sort": sort,
|
||||||
"Genres": genres,
|
"Genres": genres,
|
||||||
|
"Studio": studioID,
|
||||||
|
"StudioName": studioName,
|
||||||
"SFW": sfw,
|
"SFW": sfw,
|
||||||
"WatchlistMap": watchlistMap,
|
"WatchlistMap": watchlistMap,
|
||||||
})
|
})
|
||||||
@@ -210,9 +456,11 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
|||||||
"OrderBy": orderBy,
|
"OrderBy": orderBy,
|
||||||
"Sort": sort,
|
"Sort": sort,
|
||||||
"Genres": genres,
|
"Genres": genres,
|
||||||
|
"Studio": studioID,
|
||||||
|
"StudioName": studioName,
|
||||||
"SFW": sfw,
|
"SFW": sfw,
|
||||||
"GenresList": genresList,
|
"GenresList": genresList,
|
||||||
"Animes": res.Animes,
|
"Animes": animes,
|
||||||
"HasNextPage": res.HasNextPage,
|
"HasNextPage": res.HasNextPage,
|
||||||
"NextPage": page + 1,
|
"NextPage": page + 1,
|
||||||
"User": user,
|
"User": user,
|
||||||
@@ -229,9 +477,11 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
|||||||
"OrderBy": orderBy,
|
"OrderBy": orderBy,
|
||||||
"Sort": sort,
|
"Sort": sort,
|
||||||
"Genres": genres,
|
"Genres": genres,
|
||||||
|
"Studio": studioID,
|
||||||
|
"StudioName": studioName,
|
||||||
"SFW": sfw,
|
"SFW": sfw,
|
||||||
"GenresList": genresList,
|
"GenresList": genresList,
|
||||||
"Animes": res.Animes,
|
"Animes": animes,
|
||||||
"HasNextPage": res.HasNextPage,
|
"HasNextPage": res.HasNextPage,
|
||||||
"NextPage": page + 1,
|
"NextPage": page + 1,
|
||||||
"User": user,
|
"User": user,
|
||||||
@@ -240,9 +490,9 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
||||||
id, _ := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if id <= 0 {
|
if err != nil || id <= 0 {
|
||||||
c.Status(http.StatusNotFound)
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +521,16 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("failed to fetch section %s: %v", section, err)
|
observability.Warn(
|
||||||
|
"anime_section_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"section": section,
|
||||||
|
"anime_id": id,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -292,7 +551,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
|||||||
user, _ := c.Get("User")
|
user, _ := c.Get("User")
|
||||||
status := ""
|
status := ""
|
||||||
var watchlistIDs []int64
|
var watchlistIDs []int64
|
||||||
ep := 1
|
ep := 0
|
||||||
var cwSeconds float64
|
var cwSeconds float64
|
||||||
if u, ok := user.(*domain.User); ok {
|
if u, ok := user.(*domain.User); ok {
|
||||||
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), u.ID, int64(id))
|
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), u.ID, int64(id))
|
||||||
@@ -320,9 +579,9 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
|
func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
|
||||||
id, _ := strconv.Atoi(c.Query("animeId"))
|
id, err := strconv.Atoi(c.Query("animeId"))
|
||||||
if id <= 0 {
|
if err != nil || id <= 0 {
|
||||||
c.Status(http.StatusBadRequest)
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +596,15 @@ func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
|
|||||||
|
|
||||||
relations, err := h.svc.GetRelations(relationsCtx, id)
|
relations, err := h.svc.GetRelations(relationsCtx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("failed to fetch relations for anime %d: %v", id, err)
|
observability.Warn(
|
||||||
|
"relations_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"anime_id": id,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -365,7 +632,7 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, true, 1, 5)
|
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, 1, 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, []any{})
|
c.JSON(http.StatusOK, []any{})
|
||||||
return
|
return
|
||||||
@@ -376,7 +643,8 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
|
|||||||
if u, ok := user.(*domain.User); ok {
|
if u, ok := user.(*domain.User); ok {
|
||||||
userID = u.ID
|
userID = u.ID
|
||||||
}
|
}
|
||||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, res.Animes)
|
animes := wrapAnimes(res.Animes)
|
||||||
|
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||||
|
|
||||||
type quickSearchResult struct {
|
type quickSearchResult struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
@@ -387,8 +655,8 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
|
|||||||
InWatchlist bool `json:"in_watchlist"`
|
InWatchlist bool `json:"in_watchlist"`
|
||||||
}
|
}
|
||||||
|
|
||||||
output := make([]quickSearchResult, len(res.Animes))
|
output := make([]quickSearchResult, len(animes))
|
||||||
for i, anime := range res.Animes {
|
for i, anime := range animes {
|
||||||
output[i] = quickSearchResult{
|
output[i] = quickSearchResult{
|
||||||
ID: anime.MalID,
|
ID: anime.MalID,
|
||||||
Title: anime.DisplayTitle(),
|
Title: anime.DisplayTitle(),
|
||||||
@@ -473,13 +741,14 @@ func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string)
|
|||||||
searchCtx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
|
searchCtx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, true, 1, 5)
|
res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, 0, true, 1, 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]commandPaletteItem, 0, len(res.Animes))
|
animes := wrapAnimes(res.Animes)
|
||||||
for _, anime := range res.Animes {
|
items := make([]commandPaletteItem, 0, len(animes))
|
||||||
|
for _, anime := range animes {
|
||||||
items = append(items, commandPaletteItem{
|
items = append(items, commandPaletteItem{
|
||||||
ID: fmt.Sprintf("anime:%d", anime.MalID),
|
ID: fmt.Sprintf("anime:%d", anime.MalID),
|
||||||
Type: "anime",
|
Type: "anime",
|
||||||
@@ -591,11 +860,19 @@ func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
|
|||||||
|
|
||||||
anime, err := h.svc.GetRandomAnime(ctx)
|
anime, err := h.svc.GetRandomAnime(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch random anime"})
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"random_anime_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"failed to fetch random anime",
|
||||||
|
nil,
|
||||||
|
err,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if anime.MalID == 0 {
|
if anime.MalID == 0 {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Random anime unavailable"})
|
server.RespondHTMLOrJSONError(c, http.StatusBadGateway, "random anime unavailable")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,20 +890,32 @@ func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) {
|
func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) {
|
||||||
id, _ := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if id <= 0 {
|
if err != nil || id <= 0 {
|
||||||
c.Status(http.StatusNotFound)
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if err != nil {
|
||||||
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
|
||||||
|
return
|
||||||
|
}
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), id, page)
|
reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), id, page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Status(http.StatusInternalServerError)
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"anime_reviews_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"failed to load reviews",
|
||||||
|
map[string]any{"anime_id": id, "page": page},
|
||||||
|
err,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
|
"mal/internal/observability"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
@@ -16,6 +21,14 @@ type animeService struct {
|
|||||||
repo domain.AnimeRepository
|
repo domain.AnimeRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func wrapAnimes(in []jikan.Anime) []domain.Anime {
|
||||||
|
out := make([]domain.Anime, 0, len(in))
|
||||||
|
for _, a := range in {
|
||||||
|
out = append(out, domain.Anime{Anime: a})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) domain.AnimeService {
|
func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) domain.AnimeService {
|
||||||
return &animeService{jikan: jikan, repo: repo}
|
return &animeService{jikan: jikan, repo: repo}
|
||||||
}
|
}
|
||||||
@@ -51,7 +64,7 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
|
|||||||
return domain.CatalogSectionData{}, err
|
return domain.CatalogSectionData{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
animes := res.Animes
|
animes := wrapAnimes(res.Animes)
|
||||||
if len(animes) > 6 {
|
if len(animes) > 6 {
|
||||||
animes = animes[:6]
|
animes = animes[:6]
|
||||||
}
|
}
|
||||||
@@ -84,7 +97,7 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se
|
|||||||
return domain.DiscoverSectionData{}, err
|
return domain.DiscoverSectionData{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
animes := res.Animes
|
animes := wrapAnimes(res.Animes)
|
||||||
if len(animes) > 8 {
|
if len(animes) > 8 {
|
||||||
animes = animes[:8]
|
animes = animes[:8]
|
||||||
}
|
}
|
||||||
@@ -94,12 +107,204 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) {
|
func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (domain.DiscoverSectionData, error) {
|
||||||
return s.jikan.GetAnimeByID(ctx, id)
|
if strings.TrimSpace(userID) == "" {
|
||||||
|
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.DiscoverSectionData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
seedIDs := make([]int, 0, 5)
|
||||||
|
for _, entry := range watchlist {
|
||||||
|
status := strings.TrimSpace(entry.Status)
|
||||||
|
if status != "watching" && status != "completed" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.AnimeID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seedIDs = append(seedIDs, int(entry.AnimeID))
|
||||||
|
if len(seedIDs) >= 5 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(seedIDs) == 0 {
|
||||||
|
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ranked struct {
|
||||||
|
id int
|
||||||
|
votes int
|
||||||
|
}
|
||||||
|
|
||||||
|
recommended := map[int]ranked{}
|
||||||
|
var g errgroup.Group
|
||||||
|
g.SetLimit(4)
|
||||||
|
|
||||||
|
for _, seedID := range seedIDs {
|
||||||
|
g.Go(func() error {
|
||||||
|
recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seedID)
|
||||||
|
if recErr != nil {
|
||||||
|
return recErr
|
||||||
|
}
|
||||||
|
for _, rec := range recs {
|
||||||
|
id := rec.Entry.MalID
|
||||||
|
if id <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id == seedID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current, ok := recommended[id]
|
||||||
|
if !ok {
|
||||||
|
recommended[id] = ranked{id: id, votes: rec.Votes}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current.votes += rec.Votes
|
||||||
|
recommended[id] = current
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Wait(); err != nil {
|
||||||
|
return domain.DiscoverSectionData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recommended) == 0 {
|
||||||
|
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rankedIDs := make([]ranked, 0, len(recommended))
|
||||||
|
for _, item := range recommended {
|
||||||
|
rankedIDs = append(rankedIDs, item)
|
||||||
|
}
|
||||||
|
sort.Slice(rankedIDs, func(i, j int) bool {
|
||||||
|
if rankedIDs[i].votes == rankedIDs[j].votes {
|
||||||
|
return rankedIDs[i].id < rankedIDs[j].id
|
||||||
|
}
|
||||||
|
return rankedIDs[i].votes > rankedIDs[j].votes
|
||||||
|
})
|
||||||
|
|
||||||
|
limit := min(len(rankedIDs), 12)
|
||||||
|
|
||||||
|
animes := make([]domain.Anime, 0, limit)
|
||||||
|
for i := range limit {
|
||||||
|
anime, fetchErr := s.jikan.GetAnimeByID(ctx, rankedIDs[i].id)
|
||||||
|
if fetchErr != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"recommendation_anime_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{"anime_id": rankedIDs[i].id},
|
||||||
|
fetchErr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
animes = append(animes, domain.Anime{Anime: anime})
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.DiscoverSectionData{Animes: animes}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error) {
|
func (s *animeService) GetAiringSchedule(ctx context.Context, userID string) ([]domain.Anime, error) {
|
||||||
return s.jikan.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, sfw, page, limit)
|
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) {
|
func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
|
||||||
@@ -148,7 +353,7 @@ func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error)
|
|||||||
|
|
||||||
anime, err := s.jikan.GetRandomAnime(randomCtx)
|
anime, err := s.jikan.GetRandomAnime(randomCtx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return anime, nil
|
return domain.Anime{Anime: anime}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, fallback := range []func(context.Context, int) (jikan.TopAnimeResult, error){
|
for _, fallback := range []func(context.Context, int) (jikan.TopAnimeResult, error){
|
||||||
@@ -161,7 +366,7 @@ func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
return res.Animes[r.Intn(len(res.Animes))], nil
|
return domain.Anime{Anime: res.Animes[r.Intn(len(res.Animes))]}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return domain.Anime{}, err
|
return domain.Anime{}, err
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import (
|
|||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
"mal/integrations/playback/allanime"
|
"mal/integrations/playback/allanime"
|
||||||
"mal/internal/anime"
|
"mal/internal/anime"
|
||||||
|
"mal/internal/audit"
|
||||||
"mal/internal/auth"
|
"mal/internal/auth"
|
||||||
|
"mal/internal/config"
|
||||||
"mal/internal/database"
|
"mal/internal/database"
|
||||||
"mal/internal/episodes"
|
"mal/internal/episodes"
|
||||||
"mal/internal/playback"
|
"mal/internal/playback"
|
||||||
"mal/internal/server"
|
"mal/internal/server"
|
||||||
"mal/internal/templates"
|
|
||||||
"mal/internal/watchlist"
|
"mal/internal/watchlist"
|
||||||
|
"mal/templates"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gin-gonic/gin/render"
|
"github.com/gin-gonic/gin/render"
|
||||||
@@ -19,7 +21,9 @@ import (
|
|||||||
|
|
||||||
func NewApp() *fx.App {
|
func NewApp() *fx.App {
|
||||||
return fx.New(
|
return fx.New(
|
||||||
|
config.Module,
|
||||||
database.Module,
|
database.Module,
|
||||||
|
audit.Module,
|
||||||
jikan.Module,
|
jikan.Module,
|
||||||
allanime.Module,
|
allanime.Module,
|
||||||
episodes.Module,
|
episodes.Module,
|
||||||
|
|||||||
30
internal/audit/middleware.go
Normal file
30
internal/audit/middleware.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"mal/internal/auditctx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ContextMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
ip := clientIP(c.ClientIP())
|
||||||
|
userAgent := strings.TrimSpace(c.GetHeader("User-Agent"))
|
||||||
|
c.Request = c.Request.WithContext(auditctx.WithRequestInfo(c.Request.Context(), ip, userAgent))
|
||||||
|
c.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()
|
||||||
|
}
|
||||||
11
internal/audit/module.go
Normal file
11
internal/audit/module.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mal/internal/audit/service"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Module = fx.Options(
|
||||||
|
fx.Provide(service.NewAuditService),
|
||||||
|
)
|
||||||
73
internal/audit/service/service.go
Normal file
73
internal/audit/service/service.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"mal/internal/auditctx"
|
||||||
|
"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 := auditctx.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
|
||||||
|
}
|
||||||
83
internal/audit/service/service_test.go
Normal file
83
internal/audit/service/service_test.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package service_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mal/internal/audit/service"
|
||||||
|
"mal/internal/auditctx"
|
||||||
|
"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 := service.NewAuditService(queries)
|
||||||
|
|
||||||
|
if _, err := sqlDB.Exec("INSERT INTO user (id, username, password_hash) VALUES (?, ?, ?)", "user-1", "test", "hash"); err != nil {
|
||||||
|
t.Fatalf("insert user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := auditctx.WithRequestInfo(context.Background(), "127.0.0.1", "unit-test")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
35
internal/auditctx/context.go
Normal file
35
internal/auditctx/context.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package auditctx
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -8,15 +8,52 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"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 {
|
func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
|
|
||||||
// Allow access to login, logout and static assets without authentication
|
if isPublicRequest(c.Request.Method, path) {
|
||||||
if path == "/login" || path == "/logout" ||
|
|
||||||
strings.HasPrefix(path, "/static") ||
|
|
||||||
strings.HasPrefix(path, "/dist") ||
|
|
||||||
path == "/api/auth/login" {
|
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func (r *authRepository) GetUserByUsername(ctx context.Context, username string)
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &u, nil
|
return &domain.User{User: u}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *authRepository) GetUserByID(ctx context.Context, id string) (*domain.User, error) {
|
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 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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &s, nil
|
return &domain.Session{Session: s}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *authRepository) GetSession(ctx context.Context, sessionID string) (*domain.Session, error) {
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &s, nil
|
return &domain.Session{Session: s}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *authRepository) RefreshSession(ctx context.Context, sessionID string, expiresAt time.Time) error {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &t, nil
|
return &domain.APIToken{ApiToken: t}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *authRepository) GetAPITokenByHash(ctx context.Context, tokenHash string) (*domain.APIToken, error) {
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &t, nil
|
return &domain.APIToken{ApiToken: t}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *authRepository) TouchAPITokenLastUsedAt(ctx context.Context, tokenID string) error {
|
func (r *authRepository) TouchAPITokenLastUsedAt(ctx context.Context, tokenID string) error {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,11 +18,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type authService struct {
|
type authService struct {
|
||||||
repo domain.AuthRepository
|
repo domain.AuthRepository
|
||||||
|
auditSvc domain.AuditService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthService(repo domain.AuthRepository) domain.AuthService {
|
func NewAuthService(repo domain.AuthRepository, auditSvc domain.AuditService) domain.AuthService {
|
||||||
return &authService{repo: repo}
|
return &authService{repo: repo, auditSvc: auditSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *authService) Login(ctx context.Context, username, password string) (*domain.Session, error) {
|
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"
|
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 {
|
if _, err := s.repo.CreateAPIToken(ctx, user.ID, tokenHash, trimmedName); err != nil {
|
||||||
return "", nil, err
|
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
|
return rawToken, user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,15 +144,25 @@ func (s *authService) RevokeAllAPITokensForUser(ctx context.Context, userID stri
|
|||||||
if strings.TrimSpace(userID) == "" {
|
if strings.TrimSpace(userID) == "" {
|
||||||
return errors.New("user id missing")
|
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)
|
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)
|
token = base64.RawURLEncoding.EncodeToString(buf)
|
||||||
|
|
||||||
sum := sha256.Sum256([]byte(token))
|
sum := sha256.Sum256([]byte(token))
|
||||||
tokenHash = hex.EncodeToString(sum[:])
|
tokenHash = hex.EncodeToString(sum[:])
|
||||||
return token, tokenHash
|
return token, tokenHash, nil
|
||||||
}
|
}
|
||||||
|
|||||||
84
internal/config/config.go
Normal file
84
internal/config/config.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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),
|
||||||
|
)
|
||||||
@@ -4,8 +4,9 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"mal/internal/config"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
|
"mal/internal/observability"
|
||||||
|
|
||||||
"github.com/pressly/goose/v3"
|
"github.com/pressly/goose/v3"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -19,12 +20,11 @@ var Module = fx.Options(
|
|||||||
ProvideSQLDB,
|
ProvideSQLDB,
|
||||||
ProvideQueries,
|
ProvideQueries,
|
||||||
),
|
),
|
||||||
fx.Invoke(RunMigrations),
|
fx.Invoke(RunMigrationsAndFixes),
|
||||||
)
|
)
|
||||||
|
|
||||||
func ProvideSQLDB() (*sql.DB, error) {
|
func ProvideSQLDB(cfg config.Config) (*sql.DB, error) {
|
||||||
dbPath := db.GetDBFile()
|
dbConn, err := db.Open(cfg.DatabaseFile)
|
||||||
dbConn, err := db.Open(dbPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
}
|
}
|
||||||
@@ -42,10 +42,16 @@ func RunMigrations(sqlDB *sql.DB) error {
|
|||||||
return fmt.Errorf("failed to set goose dialect: %w", err)
|
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 {
|
if err := goose.Up(sqlDB, "migrations"); err != nil {
|
||||||
return fmt.Errorf("failed to run migrations: %w", err)
|
return fmt.Errorf("failed to run migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("open sqlite: %v", err)
|
t.Fatalf("open sqlite: %v", err)
|
||||||
}
|
}
|
||||||
defer sqlDB.Close()
|
defer func() { _ = sqlDB.Close() }()
|
||||||
sqlDB.SetMaxOpenConns(1)
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
if err := RunMigrations(sqlDB); err != nil {
|
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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
24
internal/database/fixes/registry.go
Normal file
24
internal/database/fixes/registry.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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 Up
|
||||||
|
-- +goose NO TRANSACTION
|
||||||
PRAGMA foreign_keys = OFF;
|
PRAGMA foreign_keys = OFF;
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
CREATE TABLE user_new (
|
CREATE TABLE user_new (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
@@ -16,6 +19,8 @@ DROP TABLE user;
|
|||||||
|
|
||||||
ALTER TABLE user_new RENAME TO user;
|
ALTER TABLE user_new RENAME TO user;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
-- +goose Down
|
-- +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;
|
||||||
@@ -44,7 +44,7 @@ LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
items := make([]GetContinueWatchingEntriesRow, 0, int(limit))
|
items := make([]GetContinueWatchingEntriesRow, 0, int(limit))
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
@@ -122,7 +122,7 @@ LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
items := make([]GetUserWatchListRow, 0, int(limit))
|
items := make([]GetUserWatchListRow, 0, int(limit))
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package db
|
package db
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package db
|
package db
|
||||||
|
|
||||||
@@ -47,6 +47,18 @@ type ApiToken struct {
|
|||||||
RevokedAt sql.NullTime `json:"revoked_at"`
|
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 {
|
type ContinueWatchingEntry struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
@@ -58,6 +70,11 @@ type ContinueWatchingEntry struct {
|
|||||||
DurationSeconds sql.NullFloat64 `json:"duration_seconds"`
|
DurationSeconds sql.NullFloat64 `json:"duration_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DataFix struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
AppliedAt time.Time `json:"applied_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type EpisodeAvailabilityCache struct {
|
type EpisodeAvailabilityCache struct {
|
||||||
AnimeID int64 `json:"anime_id"`
|
AnimeID int64 `json:"anime_id"`
|
||||||
Data string `json:"data"`
|
Data string `json:"data"`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package db
|
package db
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
type Querier interface {
|
type Querier interface {
|
||||||
CountPendingAnimeFetchRetries(ctx context.Context) (int64, error)
|
CountPendingAnimeFetchRetries(ctx context.Context) (int64, error)
|
||||||
CreateAPIToken(ctx context.Context, arg CreateAPITokenParams) (ApiToken, 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)
|
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
|
||||||
DeleteAnimeFetchRetry(ctx context.Context, animeID int64) error
|
DeleteAnimeFetchRetry(ctx context.Context, animeID int64) error
|
||||||
DeleteContinueWatchingEntry(ctx context.Context, arg DeleteContinueWatchingEntryParams) error
|
DeleteContinueWatchingEntry(ctx context.Context, arg DeleteContinueWatchingEntryParams) error
|
||||||
@@ -22,8 +23,11 @@ type Querier interface {
|
|||||||
GetAllCachedAnime(ctx context.Context) ([]string, error)
|
GetAllCachedAnime(ctx context.Context) ([]string, error)
|
||||||
GetAnime(ctx context.Context, id int64) (Anime, error)
|
GetAnime(ctx context.Context, id int64) (Anime, error)
|
||||||
GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error)
|
GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error)
|
||||||
|
GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error)
|
||||||
GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error)
|
GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error)
|
||||||
GetContinueWatchingEntry(ctx context.Context, arg GetContinueWatchingEntryParams) (ContinueWatchingEntry, 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)
|
GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]AnimeFetchRetry, error)
|
||||||
GetEpisodeAvailabilityCache(ctx context.Context, animeID int64) (EpisodeAvailabilityCache, error)
|
GetEpisodeAvailabilityCache(ctx context.Context, animeID int64) (EpisodeAvailabilityCache, error)
|
||||||
GetEpisodeProviderMapping(ctx context.Context, arg GetEpisodeProviderMappingParams) (EpisodeProviderMapping, error)
|
GetEpisodeProviderMapping(ctx context.Context, arg GetEpisodeProviderMappingParams) (EpisodeProviderMapping, error)
|
||||||
@@ -35,14 +39,18 @@ type Querier interface {
|
|||||||
GetUser(ctx context.Context, id string) (User, error)
|
GetUser(ctx context.Context, id string) (User, error)
|
||||||
GetUserByUsername(ctx context.Context, username string) (User, error)
|
GetUserByUsername(ctx context.Context, username string) (User, error)
|
||||||
GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, 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)
|
GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error)
|
||||||
GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error)
|
GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error)
|
||||||
MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error
|
MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error
|
||||||
MarkEpisodeAvailabilityRefreshFailed(ctx context.Context, arg MarkEpisodeAvailabilityRefreshFailedParams) error
|
MarkEpisodeAvailabilityRefreshFailed(ctx context.Context, arg MarkEpisodeAvailabilityRefreshFailedParams) error
|
||||||
MarkRelationsSynced(ctx context.Context, id int64) error
|
MarkRelationsSynced(ctx context.Context, id int64) error
|
||||||
|
RefreshSession(ctx context.Context, arg RefreshSessionParams) error
|
||||||
RevokeAllAPITokensForUser(ctx context.Context, userID string) error
|
RevokeAllAPITokensForUser(ctx context.Context, userID string) error
|
||||||
SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error
|
SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error
|
||||||
SetJikanCache(ctx context.Context, arg SetJikanCacheParams) 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
|
TouchAPITokenLastUsedAt(ctx context.Context, id string) error
|
||||||
UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusParams) error
|
UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusParams) error
|
||||||
UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error)
|
UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error)
|
||||||
@@ -50,6 +58,7 @@ type Querier interface {
|
|||||||
UpsertContinueWatchingEntry(ctx context.Context, arg UpsertContinueWatchingEntryParams) (ContinueWatchingEntry, error)
|
UpsertContinueWatchingEntry(ctx context.Context, arg UpsertContinueWatchingEntryParams) (ContinueWatchingEntry, error)
|
||||||
UpsertEpisodeAvailabilityCache(ctx context.Context, arg UpsertEpisodeAvailabilityCacheParams) error
|
UpsertEpisodeAvailabilityCache(ctx context.Context, arg UpsertEpisodeAvailabilityCacheParams) error
|
||||||
UpsertEpisodeProviderMapping(ctx context.Context, arg UpsertEpisodeProviderMappingParams) error
|
UpsertEpisodeProviderMapping(ctx context.Context, arg UpsertEpisodeProviderMappingParams) error
|
||||||
|
UpsertSkipSegmentOverride(ctx context.Context, r SkipSegmentOverrideRow) error
|
||||||
UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error)
|
UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
-- name: GetUser :one
|
-- name: GetUser :one
|
||||||
SELECT * FROM user WHERE id = ? LIMIT 1;
|
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
|
-- name: GetUserByUsername :one
|
||||||
SELECT * FROM user WHERE username = ? LIMIT 1;
|
SELECT * FROM user WHERE username = ? LIMIT 1;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
// source: queries.sql
|
// source: queries.sql
|
||||||
|
|
||||||
package db
|
package db
|
||||||
@@ -57,6 +57,49 @@ func (q *Queries) CreateAPIToken(ctx context.Context, arg CreateAPITokenParams)
|
|||||||
return i, err
|
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
|
const createSession = `-- name: CreateSession :one
|
||||||
INSERT INTO session (id, user_id, expires_at)
|
INSERT INTO session (id, user_id, expires_at)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
@@ -124,22 +167,6 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error {
|
|||||||
return err
|
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
|
const deleteWatchListEntry = `-- name: DeleteWatchListEntry :exec
|
||||||
DELETE FROM watch_list_entry
|
DELETE FROM watch_list_entry
|
||||||
WHERE user_id = ? AND anime_id = ?
|
WHERE user_id = ? AND anime_id = ?
|
||||||
@@ -299,6 +326,52 @@ func (q *Queries) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNe
|
|||||||
return items, nil
|
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
|
const getContinueWatchingEntries = `-- name: GetContinueWatchingEntries :many
|
||||||
SELECT
|
SELECT
|
||||||
c.id,
|
c.id,
|
||||||
@@ -918,6 +991,22 @@ func (q *Queries) MarkRelationsSynced(ctx context.Context, id int64) error {
|
|||||||
return err
|
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
|
const revokeAllAPITokensForUser = `-- name: RevokeAllAPITokensForUser :exec
|
||||||
UPDATE api_token
|
UPDATE api_token
|
||||||
SET revoked_at = CURRENT_TIMESTAMP
|
SET revoked_at = CURRENT_TIMESTAMP
|
||||||
|
|||||||
@@ -3,24 +3,21 @@ package db
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
|
|
||||||
|
// sqlite3 driver.
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Open connects to a sqlite3 database with foreign keys enforced
|
// Open connects to a sqlite3 database with foreign keys enforced
|
||||||
func Open(dbFile string) (*sql.DB, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open db: %w", err)
|
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
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
matches := make([]int64, 0, len(animeIDs))
|
matches := make([]int64, 0, len(animeIDs))
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestGetUserWatchlistAnimeIDsFiltersRequestedIDs(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open sqlite: %v", err)
|
t.Fatalf("open sqlite: %v", err)
|
||||||
}
|
}
|
||||||
defer sqlDB.Close()
|
defer func() { _ = sqlDB.Close() }()
|
||||||
|
|
||||||
_, err = sqlDB.Exec(`
|
_, err = sqlDB.Exec(`
|
||||||
CREATE TABLE watch_list_entry (
|
CREATE TABLE watch_list_entry (
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Anime = jikan.Anime
|
type Anime struct {
|
||||||
|
jikan.Anime
|
||||||
|
}
|
||||||
type TopAnimeResult = jikan.TopAnimeResult
|
type TopAnimeResult = jikan.TopAnimeResult
|
||||||
type Genre = jikan.Genre
|
type Genre = jikan.Genre
|
||||||
type Character = jikan.CharacterEntry
|
type Character = jikan.CharacterEntry
|
||||||
@@ -19,8 +21,12 @@ type ReviewEntry = jikan.ReviewEntry
|
|||||||
type AnimeService interface {
|
type AnimeService interface {
|
||||||
GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error)
|
GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error)
|
||||||
GetDiscoverSection(ctx context.Context, userID string, section string) (DiscoverSectionData, error)
|
GetDiscoverSection(ctx context.Context, userID string, section string) (DiscoverSectionData, error)
|
||||||
|
GetDiscoverForYou(ctx context.Context, userID string) (DiscoverSectionData, error)
|
||||||
|
GetAiringSchedule(ctx context.Context, userID string) ([]Anime, error)
|
||||||
GetAnimeByID(ctx context.Context, id int) (Anime, error)
|
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)
|
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)
|
GetGenres(ctx context.Context) ([]Genre, error)
|
||||||
GetCharacters(ctx context.Context, id int) ([]Character, error)
|
GetCharacters(ctx context.Context, id int) ([]Character, error)
|
||||||
GetRecommendations(ctx context.Context, id int) ([]Recommendation, error)
|
GetRecommendations(ctx context.Context, id int) ([]Recommendation, error)
|
||||||
|
|||||||
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"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User = db.User
|
type User struct {
|
||||||
type Session = db.Session
|
db.User
|
||||||
type APIToken = db.ApiToken
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
db.Session
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIToken struct {
|
||||||
|
db.ApiToken
|
||||||
|
}
|
||||||
|
|
||||||
const SessionLifetime = 90 * 24 * time.Hour
|
const SessionLifetime = 90 * 24 * time.Hour
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,28 @@
|
|||||||
package episodes
|
package episodes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
"mal/integrations/playback/allanime"
|
"mal/integrations/playback/allanime"
|
||||||
|
"mal/internal/config"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
episodeService "mal/internal/episodes/service"
|
episodeService "mal/internal/episodes/service"
|
||||||
|
"mal/internal/observability"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func episodeAvailabilityEnabled() bool {
|
func episodeAvailabilityEnabled(cfg config.Config) bool {
|
||||||
value := strings.ToLower(strings.TrimSpace(os.Getenv("EPISODE_AVAILABILITY_MODE")))
|
return cfg.EpisodeAvailabilityMode != config.EpisodeAvailabilityModeLegacy && cfg.EpisodeAvailabilityMode != config.EpisodeAvailabilityModeJikan
|
||||||
return value != "legacy" && value != "jikan"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var Module = fx.Options(
|
var Module = fx.Options(
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
episodeAvailabilityEnabled,
|
episodeAvailabilityEnabled,
|
||||||
fx.Annotate(
|
fx.Annotate(
|
||||||
func(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool) domain.EpisodeService {
|
func(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, metrics *observability.Metrics) domain.EpisodeService {
|
||||||
return episodeService.NewEpisodeService(queries, jikanClient, providers, enabled)
|
return episodeService.NewEpisodeService(queries, jikanClient, providers, enabled, metrics)
|
||||||
},
|
},
|
||||||
fx.ParamTags(``, ``, ``, ``),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
fx.Provide(func(p *allanime.AllAnimeProvider) []domain.EpisodeAvailabilityProvider {
|
fx.Provide(func(p *allanime.AllAnimeProvider) []domain.EpisodeAvailabilityProvider {
|
||||||
|
|||||||
@@ -6,18 +6,19 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
|
"mal/internal/observability"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
retryInterval = 15 * time.Minute
|
retryInterval = 15 * time.Minute
|
||||||
retryWindow = 3 * time.Hour
|
retryWindow = 3 * time.Hour
|
||||||
|
airingFallbackRefreshInterval = 6 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
type Clock interface {
|
type Clock interface {
|
||||||
@@ -34,19 +35,21 @@ type EpisodeService struct {
|
|||||||
providers []domain.EpisodeAvailabilityProvider
|
providers []domain.EpisodeAvailabilityProvider
|
||||||
clock Clock
|
clock Clock
|
||||||
enabled bool
|
enabled bool
|
||||||
|
metrics *observability.Metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool) domain.EpisodeService {
|
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{})
|
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{
|
return &EpisodeService{
|
||||||
queries: queries,
|
queries: queries,
|
||||||
jikan: jikanClient,
|
jikan: jikanClient,
|
||||||
providers: providers,
|
providers: providers,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
|
metrics: metrics,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +59,7 @@ func (s *EpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !forceRefresh {
|
if !forceRefresh {
|
||||||
if cached, ok := s.getFreshCached(ctx, anime.MalID); ok {
|
if cached, ok := s.getFreshCached(ctx, anime); ok {
|
||||||
return cached, nil
|
return cached, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,11 +83,27 @@ func (s *EpisodeService) RefreshTrackedDue(ctx context.Context, limit int) error
|
|||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
anime, err := s.jikan.GetAnimeByID(ctx, int(id))
|
anime, err := s.jikan.GetAnimeByID(ctx, int(id))
|
||||||
if err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
if _, err := s.refresh(ctx, anime); err != nil {
|
if _, err := s.refresh(ctx, domain.Anime{Anime: anime}); err != nil {
|
||||||
log.Printf("episodes: refresh failed anime_id=%d error=%v", id, err)
|
observability.Warn(
|
||||||
|
"episodes_refresh_failed",
|
||||||
|
"episodes",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"anime_id": id,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,18 +112,43 @@ func (s *EpisodeService) RefreshTrackedDue(ctx context.Context, limit int) error
|
|||||||
|
|
||||||
func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
|
func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
|
||||||
now := s.clock.Now()
|
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)
|
jikanEpisodes, jikanErr := s.jikan.GetAllEpisodes(ctx, anime.MalID)
|
||||||
if jikanErr != nil {
|
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)
|
providerAvailability, source, providerErr := s.fetchProviderAvailability(ctx, anime)
|
||||||
if providerErr != nil {
|
if providerErr != nil {
|
||||||
s.markFailure(ctx, anime, providerErr)
|
s.markFailure(ctx, anime, providerErr)
|
||||||
if cached, ok := s.getCached(ctx, anime.MalID); ok {
|
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
|
return cached, nil
|
||||||
}
|
}
|
||||||
if jikanErr == nil {
|
if jikanErr == nil {
|
||||||
@@ -121,16 +165,44 @@ func (s *EpisodeService) fetchProviderAvailability(ctx context.Context, anime do
|
|||||||
for _, provider := range s.providers {
|
for _, provider := range s.providers {
|
||||||
providerID, err := s.providerID(ctx, anime, provider, titles)
|
providerID, err := s.providerID(ctx, anime, provider, titles)
|
||||||
if err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
available, err := provider.GetEpisodeAvailabilityByProviderID(ctx, providerID)
|
available, err := provider.GetEpisodeAvailabilityByProviderID(ctx, providerID)
|
||||||
if err != nil {
|
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
|
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 available, provider.Name(), nil
|
||||||
}
|
}
|
||||||
return domain.EpisodeAvailability{}, "", fmt.Errorf("no episode availability provider matched anime_id=%d", anime.MalID)
|
return domain.EpisodeAvailability{}, "", fmt.Errorf("no episode availability provider matched anime_id=%d", anime.MalID)
|
||||||
@@ -143,14 +215,38 @@ func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, pro
|
|||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if row.FailedUntil.Valid && row.FailedUntil.Time.After(s.clock.Now()) {
|
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)
|
return "", fmt.Errorf("cached provider mapping failure active until %s: %s", row.FailedUntil.Time.Format(time.RFC3339), row.LastError)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(row.ProviderShowID) != "" {
|
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
|
return row.ProviderShowID, nil
|
||||||
}
|
}
|
||||||
|
s.metrics.ObserveCache("episode_provider_mapping", "miss")
|
||||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
} 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)
|
providerID, err := provider.ResolveEpisodeProviderID(ctx, anime.MalID, titles)
|
||||||
@@ -173,17 +269,48 @@ func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, pro
|
|||||||
LastError: "",
|
LastError: "",
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
|
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) {
|
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
|
var nextRefreshSQL sql.NullTime
|
||||||
if anime.Airing && !nextRefresh.IsZero() {
|
if anime.Airing {
|
||||||
nextRefreshSQL = sql.NullTime{Time: nextRefresh, Valid: true}
|
// 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)
|
||||||
@@ -217,11 +344,30 @@ func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpi
|
|||||||
LastError: "",
|
LastError: "",
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
|
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
|
return payload, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,41 +393,114 @@ func (s *EpisodeService) markFailure(ctx context.Context, anime domain.Anime, ca
|
|||||||
AnimeID: int64(anime.MalID),
|
AnimeID: int64(anime.MalID),
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
|
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) {
|
func (s *EpisodeService) getCached(ctx context.Context, animeID int) (domain.CanonicalEpisodeList, bool) {
|
||||||
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID))
|
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.metrics.ObserveCache("episode_availability", "miss")
|
||||||
return domain.CanonicalEpisodeList{}, false
|
return domain.CanonicalEpisodeList{}, false
|
||||||
}
|
}
|
||||||
var payload domain.CanonicalEpisodeList
|
var payload domain.CanonicalEpisodeList
|
||||||
if err := json.Unmarshal([]byte(row.Data), &payload); err != nil {
|
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
|
return domain.CanonicalEpisodeList{}, false
|
||||||
}
|
}
|
||||||
|
s.metrics.ObserveCache("episode_availability", "hit")
|
||||||
return payload, true
|
return payload, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *EpisodeService) getFreshCached(ctx context.Context, animeID int) (domain.CanonicalEpisodeList, bool) {
|
func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, bool) {
|
||||||
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID))
|
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(anime.MalID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||||
return domain.CanonicalEpisodeList{}, false
|
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
|
return domain.CanonicalEpisodeList{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload domain.CanonicalEpisodeList
|
var payload domain.CanonicalEpisodeList
|
||||||
if err := json.Unmarshal([]byte(row.Data), &payload); err != nil {
|
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
|
return domain.CanonicalEpisodeList{}, false
|
||||||
}
|
}
|
||||||
log.Printf("episodes: served cached availability anime_id=%d episodes=%d next_refresh=%s", animeID, len(payload.Episodes), payload.NextRefreshAt)
|
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
|
return payload, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,13 +622,31 @@ func nextBroadcastAfter(anime domain.Anime, after time.Time) time.Time {
|
|||||||
if loaded, err := time.LoadLocation(tz); err == nil {
|
if loaded, err := time.LoadLocation(tz); err == nil {
|
||||||
loc = loaded
|
loc = loaded
|
||||||
} else {
|
} 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)
|
hour, minute, ok := parseBroadcastTime(anime.Broadcast.Time)
|
||||||
if !ok {
|
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{}
|
return time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestMergeEpisodesUsesUnionAndSynthesizesProviderOnlyEntries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) {
|
func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) {
|
||||||
anime := domain.Anime{MalID: 1}
|
anime := domain.Anime{Anime: jikan.Anime{MalID: 1}}
|
||||||
anime.Broadcast.Day = "Saturdays"
|
anime.Broadcast.Day = "Saturdays"
|
||||||
anime.Broadcast.Time = "23:00"
|
anime.Broadcast.Time = "23:00"
|
||||||
anime.Broadcast.Timezone = "Asia/Tokyo"
|
anime.Broadcast.Timezone = "Asia/Tokyo"
|
||||||
@@ -44,7 +44,7 @@ func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNextRetryTimeWithinAndAfterRetryWindow(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.Day = "Saturdays"
|
||||||
anime.Broadcast.Time = "12:00"
|
anime.Broadcast.Time = "12:00"
|
||||||
anime.Broadcast.Timezone = "UTC"
|
anime.Broadcast.Timezone = "UTC"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package episodes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
|
"mal/internal/observability"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -11,25 +11,44 @@ import (
|
|||||||
|
|
||||||
const workerInterval = time.Minute
|
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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
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() {
|
go func() {
|
||||||
log.Println("episodes: availability worker started")
|
<-startCtx.Done()
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
observability.Info("episodes_worker_start", "episodes", "", nil)
|
||||||
ticker := time.NewTicker(workerInterval)
|
ticker := time.NewTicker(workerInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
if err := svc.RefreshTrackedDue(ctx, 25); err != nil {
|
tickCtx, tickCancel := context.WithTimeout(ctx, 45*time.Second)
|
||||||
log.Printf("episodes: availability worker tick failed error=%v", err)
|
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 {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
log.Println("episodes: availability worker stopped")
|
observability.Info("episodes_worker_stop", "episodes", "", nil)
|
||||||
return
|
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)
|
||||||
|
}
|
||||||
58
internal/observability/log.go
Normal file
58
internal/observability/log.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogLevel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LogLevelInfo LogLevel = "info"
|
||||||
|
LogLevelWarn LogLevel = "warn"
|
||||||
|
LogLevelError LogLevel = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogEvent struct {
|
||||||
|
TS string `json:"ts"`
|
||||||
|
Level LogLevel `json:"level"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Fields map[string]any `json:"fields,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Component string `json:"component,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogJSON(level LogLevel, event string, component string, message string, fields map[string]any, err error) {
|
||||||
|
errorValue := ""
|
||||||
|
if err != nil {
|
||||||
|
errorValue = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := LogEvent{
|
||||||
|
TS: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
|
Level: level,
|
||||||
|
Event: event,
|
||||||
|
Message: message,
|
||||||
|
Fields: fields,
|
||||||
|
Error: errorValue,
|
||||||
|
Component: component,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort. If encoding fails, fall back to a minimal line.
|
||||||
|
bytes, marshalErr := json.Marshal(entry)
|
||||||
|
if marshalErr != nil {
|
||||||
|
// Keep output JSON-only even on failures by constructing a minimal entry.
|
||||||
|
// Marshal individual strings to ensure proper escaping.
|
||||||
|
tsBytes, _ := json.Marshal(time.Now().UTC().Format(time.RFC3339Nano))
|
||||||
|
levelBytes, _ := json.Marshal(level)
|
||||||
|
eventBytes, _ := json.Marshal("log_marshal_failed")
|
||||||
|
componentBytes, _ := json.Marshal(component)
|
||||||
|
errBytes, _ := json.Marshal(marshalErr.Error())
|
||||||
|
log.Printf(`{"ts":%s,"level":%s,"event":%s,"component":%s,"error":%s}`, tsBytes, levelBytes, eventBytes, componentBytes, errBytes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Print(string(bytes))
|
||||||
|
}
|
||||||
297
internal/observability/metrics.go
Normal file
297
internal/observability/metrics.go
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultDurationBuckets = []float64{
|
||||||
|
0.005,
|
||||||
|
0.01,
|
||||||
|
0.025,
|
||||||
|
0.05,
|
||||||
|
0.1,
|
||||||
|
0.25,
|
||||||
|
0.5,
|
||||||
|
1,
|
||||||
|
2.5,
|
||||||
|
5,
|
||||||
|
10,
|
||||||
|
}
|
||||||
|
|
||||||
|
type counterSample struct {
|
||||||
|
labels map[string]string
|
||||||
|
value uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type histogramSample struct {
|
||||||
|
labels map[string]string
|
||||||
|
buckets []uint64
|
||||||
|
count uint64
|
||||||
|
sum float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type counterVec struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
labelNames []string
|
||||||
|
samples map[string]*counterSample
|
||||||
|
}
|
||||||
|
|
||||||
|
type histogramVec struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
labelNames []string
|
||||||
|
bounds []float64
|
||||||
|
samples map[string]*histogramSample
|
||||||
|
}
|
||||||
|
|
||||||
|
type Metrics struct {
|
||||||
|
httpRequests *counterVec
|
||||||
|
httpRequestLatency *histogramVec
|
||||||
|
jikanRequests *counterVec
|
||||||
|
jikanRequestErrors *counterVec
|
||||||
|
jikanLatency *histogramVec
|
||||||
|
workerTicks *counterVec
|
||||||
|
cacheOperations *counterVec
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMetrics() *Metrics {
|
||||||
|
return &Metrics{
|
||||||
|
httpRequests: newCounterVec("method", "route", "status"),
|
||||||
|
httpRequestLatency: newHistogramVec(defaultDurationBuckets, "method", "route", "status"),
|
||||||
|
jikanRequests: newCounterVec("endpoint", "status"),
|
||||||
|
jikanRequestErrors: newCounterVec("endpoint", "status"),
|
||||||
|
jikanLatency: newHistogramVec(defaultDurationBuckets, "endpoint", "status"),
|
||||||
|
workerTicks: newCounterVec("worker", "result"),
|
||||||
|
cacheOperations: newCounterVec("cache", "result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Metrics) Handler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
m.writePrometheus(w)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Metrics) ObserveHTTPRequest(method string, route string, status int, duration time.Duration) {
|
||||||
|
statusLabel := strconv.Itoa(status)
|
||||||
|
m.httpRequests.Inc(method, route, statusLabel)
|
||||||
|
m.httpRequestLatency.Observe(duration.Seconds(), method, route, statusLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Metrics) ObserveJikanRequest(endpoint string, status int, duration time.Duration, err error) {
|
||||||
|
statusLabel := strconv.Itoa(status)
|
||||||
|
m.jikanRequests.Inc(endpoint, statusLabel)
|
||||||
|
m.jikanLatency.Observe(duration.Seconds(), endpoint, statusLabel)
|
||||||
|
if err != nil || status >= http.StatusBadRequest {
|
||||||
|
m.jikanRequestErrors.Inc(endpoint, statusLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Metrics) ObserveWorkerTick(worker string, err error) {
|
||||||
|
if err != nil {
|
||||||
|
m.workerTicks.Inc(worker, "failure")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.workerTicks.Inc(worker, "success")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Metrics) ObserveCache(cache string, result string) {
|
||||||
|
m.cacheOperations.Inc(cache, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Metrics) writePrometheus(w http.ResponseWriter) {
|
||||||
|
writeCounterMetric(w, "mal_http_requests_total", "Total HTTP requests by method, route, and status.", m.httpRequests.snapshot())
|
||||||
|
writeHistogramMetric(w, "mal_http_request_duration_seconds", "HTTP request latency in seconds.", m.httpRequestLatency.snapshot(), m.httpRequestLatency.bounds)
|
||||||
|
writeCounterMetric(w, "mal_jikan_upstream_requests_total", "Total upstream Jikan requests by endpoint and status.", m.jikanRequests.snapshot())
|
||||||
|
writeCounterMetric(w, "mal_jikan_upstream_errors_total", "Total upstream Jikan errors by endpoint and status.", m.jikanRequestErrors.snapshot())
|
||||||
|
writeHistogramMetric(w, "mal_jikan_upstream_request_duration_seconds", "Upstream Jikan request latency in seconds.", m.jikanLatency.snapshot(), m.jikanLatency.bounds)
|
||||||
|
writeCounterMetric(w, "mal_worker_ticks_total", "Total background worker ticks by worker and result.", m.workerTicks.snapshot())
|
||||||
|
writeCounterMetric(w, "mal_cache_operations_total", "Total cache hits and misses by cache name.", m.cacheOperations.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCounterVec(labelNames ...string) *counterVec {
|
||||||
|
return &counterVec{
|
||||||
|
labelNames: append([]string(nil), labelNames...),
|
||||||
|
samples: make(map[string]*counterSample),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *counterVec) Inc(labelValues ...string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
key, labels := buildLabelKey(c.labelNames, labelValues)
|
||||||
|
if labels == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sample, ok := c.samples[key]
|
||||||
|
if !ok {
|
||||||
|
sample = &counterSample{labels: labels}
|
||||||
|
c.samples[key] = sample
|
||||||
|
}
|
||||||
|
sample.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *counterVec) snapshot() []counterSample {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
keys := sortedCounterSampleKeys(c.samples)
|
||||||
|
out := make([]counterSample, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
sample := c.samples[key]
|
||||||
|
out = append(out, counterSample{
|
||||||
|
labels: copyLabels(sample.labels),
|
||||||
|
value: sample.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHistogramVec(bounds []float64, labelNames ...string) *histogramVec {
|
||||||
|
return &histogramVec{
|
||||||
|
labelNames: append([]string(nil), labelNames...),
|
||||||
|
bounds: append([]float64(nil), bounds...),
|
||||||
|
samples: make(map[string]*histogramSample),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *histogramVec) Observe(value float64, labelValues ...string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
key, labels := buildLabelKey(h.labelNames, labelValues)
|
||||||
|
if labels == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sample, ok := h.samples[key]
|
||||||
|
if !ok {
|
||||||
|
sample = &histogramSample{
|
||||||
|
labels: labels,
|
||||||
|
buckets: make([]uint64, len(h.bounds)),
|
||||||
|
}
|
||||||
|
h.samples[key] = sample
|
||||||
|
}
|
||||||
|
|
||||||
|
sample.count++
|
||||||
|
sample.sum += value
|
||||||
|
for idx, bound := range h.bounds {
|
||||||
|
if value <= bound {
|
||||||
|
sample.buckets[idx]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *histogramVec) snapshot() []histogramSample {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
keys := sortedHistogramSampleKeys(h.samples)
|
||||||
|
out := make([]histogramSample, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
sample := h.samples[key]
|
||||||
|
buckets := make([]uint64, len(sample.buckets))
|
||||||
|
copy(buckets, sample.buckets)
|
||||||
|
out = append(out, histogramSample{
|
||||||
|
labels: copyLabels(sample.labels),
|
||||||
|
buckets: buckets,
|
||||||
|
count: sample.count,
|
||||||
|
sum: sample.sum,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildLabelKey(labelNames []string, labelValues []string) (string, map[string]string) {
|
||||||
|
if len(labelNames) != len(labelValues) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := make(map[string]string, len(labelNames))
|
||||||
|
parts := make([]string, 0, len(labelNames)*2)
|
||||||
|
for idx, name := range labelNames {
|
||||||
|
value := labelValues[idx]
|
||||||
|
labels[name] = value
|
||||||
|
parts = append(parts, name, value)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\xff"), labels
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyLabels(labels map[string]string) map[string]string {
|
||||||
|
out := make(map[string]string, len(labels))
|
||||||
|
maps.Copy(out, labels)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedCounterSampleKeys(samples map[string]*counterSample) []string {
|
||||||
|
keys := make([]string, 0, len(samples))
|
||||||
|
for key := range samples {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedHistogramSampleKeys(samples map[string]*histogramSample) []string {
|
||||||
|
keys := make([]string, 0, len(samples))
|
||||||
|
for key := range samples {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCounterMetric(w http.ResponseWriter, name string, help string, samples []counterSample) {
|
||||||
|
_, _ = fmt.Fprintf(w, "# HELP %s %s\n", name, help)
|
||||||
|
_, _ = fmt.Fprintf(w, "# TYPE %s counter\n", name)
|
||||||
|
for _, sample := range samples {
|
||||||
|
_, _ = fmt.Fprintf(w, "%s%s %d\n", name, formatLabels(sample.labels), sample.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHistogramMetric(w http.ResponseWriter, name string, help string, samples []histogramSample, bounds []float64) {
|
||||||
|
_, _ = fmt.Fprintf(w, "# HELP %s %s\n", name, help)
|
||||||
|
_, _ = fmt.Fprintf(w, "# TYPE %s histogram\n", name)
|
||||||
|
for _, sample := range samples {
|
||||||
|
for idx, bound := range bounds {
|
||||||
|
labels := copyLabels(sample.labels)
|
||||||
|
labels["le"] = formatFloat(bound)
|
||||||
|
_, _ = fmt.Fprintf(w, "%s_bucket%s %d\n", name, formatLabels(labels), sample.buckets[idx])
|
||||||
|
}
|
||||||
|
labels := copyLabels(sample.labels)
|
||||||
|
labels["le"] = "+Inf"
|
||||||
|
_, _ = fmt.Fprintf(w, "%s_bucket%s %d\n", name, formatLabels(labels), sample.count)
|
||||||
|
_, _ = fmt.Fprintf(w, "%s_sum%s %s\n", name, formatLabels(sample.labels), formatFloat(sample.sum))
|
||||||
|
_, _ = fmt.Fprintf(w, "%s_count%s %d\n", name, formatLabels(sample.labels), sample.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatLabels(labels map[string]string) string {
|
||||||
|
if len(labels) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(labels))
|
||||||
|
for key := range labels {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
parts = append(parts, fmt.Sprintf(`%s=%q`, key, labels[key]))
|
||||||
|
}
|
||||||
|
return "{" + strings.Join(parts, ",") + "}"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFloat(value float64) string {
|
||||||
|
return strconv.FormatFloat(value, 'f', -1, 64)
|
||||||
|
}
|
||||||
47
internal/observability/metrics_test.go
Normal file
47
internal/observability/metrics_test.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMetricsHandlerRendersPrometheusFamilies(t *testing.T) {
|
||||||
|
metrics := NewMetrics()
|
||||||
|
metrics.ObserveHTTPRequest(http.MethodGet, "/anime/:id", http.StatusOK, 125*time.Millisecond)
|
||||||
|
metrics.ObserveJikanRequest("/anime/{id}", http.StatusTooManyRequests, 800*time.Millisecond, assertErr{})
|
||||||
|
metrics.ObserveWorkerTick("episodes_availability", nil)
|
||||||
|
metrics.ObserveCache("jikan", "hit")
|
||||||
|
metrics.ObserveCache("episode_availability", "miss")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
metrics.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
body, err := io.ReadAll(rec.Result().Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := string(body)
|
||||||
|
assertContains(t, text, `mal_http_requests_total{method="GET",route="/anime/:id",status="200"} 1`)
|
||||||
|
assertContains(t, text, `mal_http_request_duration_seconds_count{method="GET",route="/anime/:id",status="200"} 1`)
|
||||||
|
assertContains(t, text, `mal_jikan_upstream_requests_total{endpoint="/anime/{id}",status="429"} 1`)
|
||||||
|
assertContains(t, text, `mal_jikan_upstream_errors_total{endpoint="/anime/{id}",status="429"} 1`)
|
||||||
|
assertContains(t, text, `mal_worker_ticks_total{result="success",worker="episodes_availability"} 1`)
|
||||||
|
assertContains(t, text, `mal_cache_operations_total{cache="episode_availability",result="miss"} 1`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type assertErr struct{}
|
||||||
|
|
||||||
|
func (assertErr) Error() string { return "boom" }
|
||||||
|
|
||||||
|
func assertContains(t *testing.T, text string, want string) {
|
||||||
|
t.Helper()
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("missing metric line %q in:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
|
"mal/internal/server"
|
||||||
"mal/pkg/net/limits"
|
"mal/pkg/net/limits"
|
||||||
"mal/pkg/net/proxytransport"
|
"mal/pkg/net/proxytransport"
|
||||||
"mal/pkg/net/useragent"
|
"mal/pkg/net/useragent"
|
||||||
@@ -86,13 +87,13 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
|
|||||||
func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
|
func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
|
||||||
animeID, err := strconv.Atoi(c.Param("animeId"))
|
animeID, err := strconv.Atoi(c.Param("animeId"))
|
||||||
if err != nil || animeID <= 0 {
|
if err != nil || animeID <= 0 {
|
||||||
c.Status(http.StatusBadRequest)
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
episode := c.Param("episode")
|
episode := c.Param("episode")
|
||||||
if episode == "" {
|
if episode == "" {
|
||||||
c.Status(http.StatusBadRequest)
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "missing episode")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +107,15 @@ func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
|
|||||||
|
|
||||||
data, err := h.svc.BuildWatchData(c.Request.Context(), animeID, []string{}, episode, mode, userID)
|
data, err := h.svc.BuildWatchData(c.Request.Context(), animeID, []string{}, episode, mode, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Status(http.StatusInternalServerError)
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"watch_episode_data_build_failed",
|
||||||
|
"playback",
|
||||||
|
"failed to load episode data",
|
||||||
|
map[string]any{"anime_id": animeID, "episode": episode, "mode": mode, "user_id": userID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +157,7 @@ func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
// Avoid spamming 500s for anonymous playback; progress is user-scoped.
|
// Avoid spamming 500s for anonymous playback; progress is user-scoped.
|
||||||
c.Status(http.StatusUnauthorized)
|
server.RespondHTMLOrJSONError(c, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,13 +168,21 @@ func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.Status(http.StatusBadRequest)
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.svc.SaveProgress(c.Request.Context(), userID, req.MalID, req.Episode, req.TimeSeconds)
|
err := h.svc.SaveProgress(c.Request.Context(), userID, req.MalID, req.Episode, req.TimeSeconds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Status(http.StatusInternalServerError)
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"watch_progress_save_failed",
|
||||||
|
"playback",
|
||||||
|
"failed to save progress",
|
||||||
|
map[string]any{"mal_id": req.MalID, "episode": req.Episode, "user_id": userID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,13 +202,21 @@ func (h *PlaybackHandler) HandleWatchComplete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.Status(http.StatusBadRequest)
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.svc.CompleteAnime(c.Request.Context(), userID, req.MalID)
|
err := h.svc.CompleteAnime(c.Request.Context(), userID, req.MalID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Status(http.StatusInternalServerError)
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"watch_complete_failed",
|
||||||
|
"playback",
|
||||||
|
"failed to complete anime",
|
||||||
|
map[string]any{"mal_id": req.MalID, "user_id": userID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +263,8 @@ func (h *PlaybackHandler) HandleEpisodeThumbnails(c *gin.Context) {
|
|||||||
|
|
||||||
allEpisodes, err := h.animeSvc.GetAllEpisodes(c.Request.Context(), id)
|
allEpisodes, err := h.animeSvc.GetAllEpisodes(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
anime, _ := h.animeSvc.GetAnimeByID(c.Request.Context(), id)
|
anime, _ := h.animeSvc.GetAnimeByID(c.Request.Context(), id)
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ func (c *subtitleCache) Get(key string, now time.Time) (data []byte, contentType
|
|||||||
if el == nil {
|
if el == nil {
|
||||||
return nil, "", false
|
return nil, "", false
|
||||||
}
|
}
|
||||||
entry := el.Value.(subtitleCacheEntry)
|
entry, ok := el.Value.(subtitleCacheEntry)
|
||||||
|
if !ok {
|
||||||
|
c.removeElement(el)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
if !entry.expiresAt.IsZero() && now.After(entry.expiresAt) {
|
if !entry.expiresAt.IsZero() && now.After(entry.expiresAt) {
|
||||||
c.removeElement(el)
|
c.removeElement(el)
|
||||||
return nil, "", false
|
return nil, "", false
|
||||||
@@ -61,7 +65,11 @@ func (c *subtitleCache) Set(key string, data []byte, contentType string, now tim
|
|||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
if el := c.entries[key]; el != nil {
|
if el := c.entries[key]; el != nil {
|
||||||
entry := el.Value.(subtitleCacheEntry)
|
entry, ok := el.Value.(subtitleCacheEntry)
|
||||||
|
if !ok {
|
||||||
|
c.removeElement(el)
|
||||||
|
return
|
||||||
|
}
|
||||||
entry.data = data
|
entry.data = data
|
||||||
entry.contentType = contentType
|
entry.contentType = contentType
|
||||||
entry.expiresAt = now.Add(c.ttl)
|
entry.expiresAt = now.Add(c.ttl)
|
||||||
@@ -89,7 +97,11 @@ func (c *subtitleCache) Set(key string, data []byte, contentType string, now tim
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *subtitleCache) removeElement(el *list.Element) {
|
func (c *subtitleCache) removeElement(el *list.Element) {
|
||||||
entry := el.Value.(subtitleCacheEntry)
|
entry, ok := el.Value.(subtitleCacheEntry)
|
||||||
|
if !ok {
|
||||||
|
c.lru.Remove(el)
|
||||||
|
return
|
||||||
|
}
|
||||||
delete(c.entries, entry.key)
|
delete(c.entries, entry.key)
|
||||||
c.lru.Remove(el)
|
c.lru.Remove(el)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
package playback
|
package playback
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
|
|
||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
"mal/integrations/playback/allanime"
|
"mal/integrations/playback/allanime"
|
||||||
|
"mal/internal/config"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"mal/internal/playback/handler"
|
"mal/internal/playback/handler"
|
||||||
"mal/internal/playback/repository"
|
"mal/internal/playback/repository"
|
||||||
@@ -14,18 +13,17 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func provideProxyTokenKey() string {
|
func provideProxyTokenKey(cfg config.Config) service.ProxyTokenKey {
|
||||||
return os.Getenv("PLAYBACK_PROXY_SECRET")
|
return service.ProxyTokenKey(cfg.PlaybackProxySecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
var Module = fx.Options(
|
var Module = fx.Options(
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
repository.NewPlaybackRepository,
|
repository.NewPlaybackRepository,
|
||||||
fx.Annotate(
|
fx.Annotate(
|
||||||
func(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client, episodeSvc domain.EpisodeService, proxyTokenKey string) domain.PlaybackService {
|
func(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client, episodeSvc domain.EpisodeService, auditSvc domain.AuditService, proxyTokenKey service.ProxyTokenKey) domain.PlaybackService {
|
||||||
return service.NewPlaybackService(repo, providers, jikan, episodeSvc, proxyTokenKey)
|
return service.NewPlaybackService(repo, providers, jikan, episodeSvc, auditSvc, proxyTokenKey)
|
||||||
},
|
},
|
||||||
fx.ParamTags(``, ``, ``, ``, ``),
|
|
||||||
),
|
),
|
||||||
func(svc domain.PlaybackService, animeSvc domain.AnimeService) *handler.PlaybackHandler {
|
func(svc domain.PlaybackService, animeSvc domain.AnimeService) *handler.PlaybackHandler {
|
||||||
return handler.NewPlaybackHandler(svc, animeSvc)
|
return handler.NewPlaybackHandler(svc, animeSvc)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
|
"mal/internal/observability"
|
||||||
"mal/pkg/net/limits"
|
"mal/pkg/net/limits"
|
||||||
"mal/pkg/net/useragent"
|
"mal/pkg/net/useragent"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -31,8 +32,11 @@ type playbackService struct {
|
|||||||
episodes domain.EpisodeService
|
episodes domain.EpisodeService
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
proxyTokenKey string
|
proxyTokenKey string
|
||||||
|
auditSvc domain.AuditService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProxyTokenKey string
|
||||||
|
|
||||||
type proxyTokenPayload struct {
|
type proxyTokenPayload struct {
|
||||||
TargetURL string `json:"u"`
|
TargetURL string `json:"u"`
|
||||||
Referer string `json:"r,omitempty"`
|
Referer string `json:"r,omitempty"`
|
||||||
@@ -40,8 +44,16 @@ type proxyTokenPayload struct {
|
|||||||
ExpiresAt int64 `json:"exp"`
|
ExpiresAt int64 `json:"exp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client, episodes domain.EpisodeService, proxyTokenKey string) domain.PlaybackService {
|
func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client, episodes domain.EpisodeService, auditSvc domain.AuditService, proxyTokenKey ProxyTokenKey) domain.PlaybackService {
|
||||||
return &playbackService{repo: repo, providers: providers, jikan: jikan, episodes: episodes, httpClient: &http.Client{Timeout: 10 * time.Second}, proxyTokenKey: proxyTokenKey}
|
return &playbackService{
|
||||||
|
repo: repo,
|
||||||
|
providers: providers,
|
||||||
|
jikan: jikan,
|
||||||
|
episodes: episodes,
|
||||||
|
auditSvc: auditSvc,
|
||||||
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
proxyTokenKey: string(proxyTokenKey),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *playbackService) SignProxyToken(targetURL, referer, scope string) (string, error) {
|
func (s *playbackService) SignProxyToken(targetURL, referer, scope string) (string, error) {
|
||||||
@@ -59,7 +71,9 @@ func (s *playbackService) SignProxyToken(targetURL, referer, scope string) (stri
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
mac := hmac.New(sha256.New, []byte(s.proxyTokenKey))
|
mac := hmac.New(sha256.New, []byte(s.proxyTokenKey))
|
||||||
mac.Write(body)
|
if _, err := mac.Write(body); err != nil {
|
||||||
|
return "", fmt.Errorf("sign proxy token: %w", err)
|
||||||
|
}
|
||||||
signature := mac.Sum(nil)
|
signature := mac.Sum(nil)
|
||||||
encodedBody := base64.RawURLEncoding.EncodeToString(body)
|
encodedBody := base64.RawURLEncoding.EncodeToString(body)
|
||||||
encodedSignature := base64.RawURLEncoding.EncodeToString(signature)
|
encodedSignature := base64.RawURLEncoding.EncodeToString(signature)
|
||||||
@@ -78,11 +92,16 @@ func (s *playbackService) VerifyProxyToken(token string) (proxyTokenPayload, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return proxyTokenPayload{}, err
|
return proxyTokenPayload{}, err
|
||||||
}
|
}
|
||||||
|
decodedSig, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return proxyTokenPayload{}, fmt.Errorf("invalid signature encoding: %w", err)
|
||||||
|
}
|
||||||
mac := hmac.New(sha256.New, []byte(s.proxyTokenKey))
|
mac := hmac.New(sha256.New, []byte(s.proxyTokenKey))
|
||||||
mac.Write(body)
|
if _, err := mac.Write(body); err != nil {
|
||||||
signature := mac.Sum(nil)
|
return proxyTokenPayload{}, fmt.Errorf("verify proxy token: %w", err)
|
||||||
encodedSig := base64.RawURLEncoding.EncodeToString(signature)
|
}
|
||||||
if encodedSig != parts[1] {
|
expectedSig := mac.Sum(nil)
|
||||||
|
if !hmac.Equal(expectedSig, decodedSig) {
|
||||||
return proxyTokenPayload{}, fmt.Errorf("invalid signature")
|
return proxyTokenPayload{}, fmt.Errorf("invalid signature")
|
||||||
}
|
}
|
||||||
var payload proxyTokenPayload
|
var payload proxyTokenPayload
|
||||||
@@ -124,7 +143,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
canonicalEpisodes, err := s.episodes.GetCanonicalEpisodes(ctx, anime, false)
|
canonicalEpisodes, err := s.episodes.GetCanonicalEpisodes(ctx, domain.Anime{Anime: anime}, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.WatchPageData{}, fmt.Errorf("failed to fetch episodes: %w", err)
|
return domain.WatchPageData{}, fmt.Errorf("failed to fetch episodes: %w", err)
|
||||||
}
|
}
|
||||||
@@ -272,7 +291,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
|
|||||||
|
|
||||||
return domain.WatchPageData{
|
return domain.WatchPageData{
|
||||||
WatchData: watchData,
|
WatchData: watchData,
|
||||||
Anime: anime,
|
Anime: domain.Anime{Anime: anime},
|
||||||
Episodes: canonicalEpisodes.Episodes,
|
Episodes: canonicalEpisodes.Episodes,
|
||||||
CurrentEpID: episode,
|
CurrentEpID: episode,
|
||||||
WatchlistStatus: watchlistStatus,
|
WatchlistStatus: watchlistStatus,
|
||||||
@@ -300,16 +319,35 @@ func (s *playbackService) CompleteAnime(ctx context.Context, userID string, anim
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{
|
if err := s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
AnimeID: animeID,
|
AnimeID: animeID,
|
||||||
})
|
}); err != nil {
|
||||||
return s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{
|
return err
|
||||||
|
}
|
||||||
|
if err := s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
AnimeID: animeID,
|
AnimeID: animeID,
|
||||||
CurrentEpisode: sql.NullInt64{Valid: false},
|
CurrentEpisode: sql.NullInt64{Valid: false},
|
||||||
CurrentTimeSeconds: 0,
|
CurrentTimeSeconds: 0,
|
||||||
})
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||||
|
UserID: userID,
|
||||||
|
Action: "watch_completed",
|
||||||
|
ResourceType: "anime",
|
||||||
|
ResourceID: strconv.FormatInt(animeID, 10),
|
||||||
|
}); err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"audit_record_failed",
|
||||||
|
"playback",
|
||||||
|
"",
|
||||||
|
map[string]any{"user_id": userID, "anime_id": animeID, "action": "watch_completed"},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *playbackService) SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error {
|
func (s *playbackService) SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error {
|
||||||
@@ -321,7 +359,31 @@ func (s *playbackService) SaveProgress(ctx context.Context, userID string, anime
|
|||||||
CurrentTimeSeconds: timeSeconds,
|
CurrentTimeSeconds: timeSeconds,
|
||||||
DurationSeconds: sql.NullFloat64{Valid: false},
|
DurationSeconds: sql.NullFloat64{Valid: false},
|
||||||
})
|
})
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataBytes, marshalErr := json.Marshal(struct {
|
||||||
|
Episode int `json:"episode"`
|
||||||
|
TimeSeconds float64 `json:"time_seconds"`
|
||||||
|
}{Episode: episode, TimeSeconds: timeSeconds})
|
||||||
|
if marshalErr == nil {
|
||||||
|
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||||
|
UserID: userID,
|
||||||
|
Action: "watch_progress_saved",
|
||||||
|
ResourceType: "anime",
|
||||||
|
ResourceID: strconv.FormatInt(animeID, 10),
|
||||||
|
MetadataJSON: metadataBytes,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||||
|
UserID: userID,
|
||||||
|
Action: "watch_progress_saved",
|
||||||
|
ResourceType: "anime",
|
||||||
|
ResourceID: strconv.FormatInt(animeID, 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *playbackService) UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error {
|
func (s *playbackService) UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error {
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"mal/internal/config"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CORSMiddleware() gin.HandlerFunc {
|
func CORSMiddleware() gin.HandlerFunc {
|
||||||
allowAll := os.Getenv("MAL_CORS_ALLOW_ALL") == "1"
|
return CORSMiddlewareWithConfig(config.Config{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CORSMiddlewareWithConfig(cfg config.Config) gin.HandlerFunc {
|
||||||
|
allowAll := cfg.CORSAllowAll
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
origin := c.GetHeader("Origin")
|
origin := c.GetHeader("Origin")
|
||||||
if origin != "" && (allowAll || isAllowedOrigin(origin)) {
|
if origin != "" && (allowAll || isAllowedOrigin(origin)) {
|
||||||
@@ -32,9 +35,6 @@ func CORSMiddleware() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isAllowedOrigin(origin string) bool {
|
func isAllowedOrigin(origin string) bool {
|
||||||
if strings.HasPrefix(origin, "moz-extension://") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "https://localhost:") {
|
if strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "https://localhost:") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"mal/internal/observability"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RequestLogger() gin.HandlerFunc {
|
func RequestLogger(metrics *observability.Metrics) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
@@ -21,17 +20,34 @@ func RequestLogger() gin.HandlerFunc {
|
|||||||
route = path
|
route = path
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf(
|
duration := time.Since(start)
|
||||||
"http_request method=%s route=%s path=%s query=%s status=%d duration_ms=%.2f bytes=%d client_ip=%s errors=%s",
|
metrics.ObserveHTTPRequest(c.Request.Method, route, c.Writer.Status(), duration)
|
||||||
c.Request.Method,
|
|
||||||
strconv.Quote(route),
|
level := observability.LogLevelInfo
|
||||||
strconv.Quote(path),
|
status := c.Writer.Status()
|
||||||
strconv.Quote(query),
|
if status >= 500 {
|
||||||
c.Writer.Status(),
|
level = observability.LogLevelError
|
||||||
float64(time.Since(start).Microseconds())/1000,
|
} else if status >= 400 {
|
||||||
c.Writer.Size(),
|
level = observability.LogLevelWarn
|
||||||
strconv.Quote(c.ClientIP()),
|
}
|
||||||
strconv.Quote(c.Errors.ByType(gin.ErrorTypePrivate).String()),
|
|
||||||
|
observability.LogJSON(
|
||||||
|
level,
|
||||||
|
"http_request",
|
||||||
|
"http",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"method": c.Request.Method,
|
||||||
|
"route": route,
|
||||||
|
"path": path,
|
||||||
|
"query": query,
|
||||||
|
"status": status,
|
||||||
|
"duration_ms": float64(duration.Microseconds()) / 1000,
|
||||||
|
"bytes": c.Writer.Size(),
|
||||||
|
"client_ip": c.ClientIP(),
|
||||||
|
"errors": c.Errors.ByType(gin.ErrorTypePrivate).String(),
|
||||||
|
},
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
internal/server/respond.go
Normal file
42
internal/server/respond.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mal/internal/observability"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func RespondHTMLOrJSONError(c *gin.Context, status int, message string) {
|
||||||
|
if acceptsHTML(c) {
|
||||||
|
c.String(status, message)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(status, ErrorResponse{Error: message})
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RespondError(c *gin.Context, status int, event string, component string, message string, fields map[string]any, err error) {
|
||||||
|
level := observability.LogLevelWarn
|
||||||
|
if status >= http.StatusInternalServerError {
|
||||||
|
level = observability.LogLevelError
|
||||||
|
}
|
||||||
|
observability.LogJSON(level, event, component, "", fields, err)
|
||||||
|
RespondHTMLOrJSONError(c, status, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func acceptsHTML(c *gin.Context) bool {
|
||||||
|
if strings.Contains(c.GetHeader("Accept"), "text/html") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(c.GetHeader("HX-Request")), "true") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"mal/internal/audit"
|
||||||
|
"mal/internal/config"
|
||||||
|
"mal/internal/observability"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -13,43 +14,59 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var Module = fx.Options(
|
var Module = fx.Options(
|
||||||
|
fx.Provide(observability.NewMetrics),
|
||||||
fx.Provide(ProvideRouter),
|
fx.Provide(ProvideRouter),
|
||||||
fx.Invoke(RunServer),
|
fx.Invoke(RunServer),
|
||||||
)
|
)
|
||||||
|
|
||||||
func ProvideRouter(htmlRender render.HTMLRender) *gin.Engine {
|
func ProvideRouter(cfg config.Config, htmlRender render.HTMLRender, metrics *observability.Metrics) *gin.Engine {
|
||||||
if os.Getenv("GIN_MODE") == "" {
|
if cfg.GinMode == "" {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
} else {
|
||||||
|
gin.SetMode(cfg.GinMode)
|
||||||
}
|
}
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(CORSMiddleware(), RequestLogger(), gin.Recovery())
|
r.Use(CORSMiddlewareWithConfig(cfg), audit.ContextMiddleware(), RequestLogger(metrics), gin.Recovery())
|
||||||
r.Static("/static", "./static")
|
r.Static("/static", "./static")
|
||||||
r.Static("/dist", "./dist")
|
r.Static("/dist", "./dist")
|
||||||
|
r.GET("/metrics", gin.WrapH(metrics.Handler()))
|
||||||
r.HTMLRender = htmlRender
|
r.HTMLRender = htmlRender
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func RunServer(lifecycle fx.Lifecycle, r *gin.Engine) {
|
func RunServer(cfg config.Config, lifecycle fx.Lifecycle, r *gin.Engine) {
|
||||||
port := os.Getenv("PORT")
|
port := cfg.Port
|
||||||
if port == "" {
|
|
||||||
port = "3000"
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := newHTTPServer(":"+port, r)
|
srv := newHTTPServer(":"+port, r)
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
OnStart: func(context.Context) error {
|
OnStart: func(context.Context) error {
|
||||||
log.Printf("Starting server on http://localhost:%s", port)
|
observability.Info(
|
||||||
|
"server_start",
|
||||||
|
"server",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"port": port,
|
||||||
|
},
|
||||||
|
)
|
||||||
go func() {
|
go func() {
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
// Avoid exiting the process from a goroutine; let the process supervisor handle restarts.
|
// Avoid exiting the process from a goroutine; let the process supervisor handle restarts.
|
||||||
log.Printf("server listen error: %s", err)
|
observability.Error(
|
||||||
|
"server_listen_error",
|
||||||
|
"server",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"port": port,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
OnStop: func(ctx context.Context) error {
|
OnStop: func(ctx context.Context) error {
|
||||||
log.Println("Shutting down server...")
|
observability.Info("server_stop", "server", "", nil)
|
||||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return srv.Shutdown(ctx)
|
return srv.Shutdown(ctx)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"mal/internal/observability"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -42,7 +43,7 @@ func TestRequestLoggerUsesMatchedRoute(t *testing.T) {
|
|||||||
defer log.SetOutput(previousOutput)
|
defer log.SetOutput(previousOutput)
|
||||||
|
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(RequestLogger())
|
router.Use(RequestLogger(observability.NewMetrics()))
|
||||||
router.GET("/anime/:id", func(c *gin.Context) {
|
router.GET("/anime/:id", func(c *gin.Context) {
|
||||||
c.String(http.StatusOK, "ok")
|
c.String(http.StatusOK, "ok")
|
||||||
})
|
})
|
||||||
@@ -57,10 +58,13 @@ func TestRequestLoggerUsesMatchedRoute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logLine := string(output)
|
logLine := string(output)
|
||||||
if !strings.Contains(logLine, `route="/anime/:id"`) {
|
if !strings.Contains(logLine, `"event":"http_request"`) {
|
||||||
|
t.Fatalf("log line missing event: %s", logLine)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logLine, `"route":"/anime/:id"`) {
|
||||||
t.Fatalf("log line missing route: %s", logLine)
|
t.Fatalf("log line missing route: %s", logLine)
|
||||||
}
|
}
|
||||||
if !strings.Contains(logLine, `status=200`) {
|
if !strings.Contains(logLine, `"status":200`) {
|
||||||
t.Fatalf("log line missing status: %s", logLine)
|
t.Fatalf("log line missing status: %s", logLine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
package templates
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"html/template"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/gin-gonic/gin/render"
|
|
||||||
"go.uber.org/fx"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FS is the interface for template filesystem, to be provided by the main app or a mock.
|
|
||||||
type FS interface {
|
|
||||||
ReadFile(name string) ([]byte, error)
|
|
||||||
ReadDir(name string) ([]os.DirEntry, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// We will use embed.FS but wrapped in an interface if needed, or just use it directly.
|
|
||||||
// For now let's assume we pass the root embed.FS to the constructor.
|
|
||||||
|
|
||||||
type Renderer struct {
|
|
||||||
templates map[string]*template.Template
|
|
||||||
}
|
|
||||||
|
|
||||||
var Module = fx.Options(
|
|
||||||
fx.Provide(ProvideRenderer),
|
|
||||||
)
|
|
||||||
|
|
||||||
func ProvideRenderer() (*Renderer, error) {
|
|
||||||
// In the final version, this will use an embedded FS.
|
|
||||||
// For now, let's keep it working with the local filesystem but as an fx service.
|
|
||||||
r := &Renderer{
|
|
||||||
templates: make(map[string]*template.Template),
|
|
||||||
}
|
|
||||||
|
|
||||||
funcs := template.FuncMap{
|
|
||||||
"dict": func(values ...any) map[string]any {
|
|
||||||
m := make(map[string]any)
|
|
||||||
for i := 0; i < len(values)-1; i += 2 {
|
|
||||||
key, ok := values[i].(string)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
m[key] = values[i+1]
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
},
|
|
||||||
"json": func(v any) template.HTMLAttr {
|
|
||||||
b, _ := json.Marshal(v)
|
|
||||||
return template.HTMLAttr(b)
|
|
||||||
},
|
|
||||||
"genresParams": func(genres []int) string {
|
|
||||||
if len(genres) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var s strings.Builder
|
|
||||||
for _, g := range genres {
|
|
||||||
s.WriteString("genres=" + fmt.Sprintf("%d", g) + "&")
|
|
||||||
}
|
|
||||||
return s.String()[:len(s.String())-1]
|
|
||||||
},
|
|
||||||
"hasGenre": func(id int, genres []int) bool {
|
|
||||||
return slices.Contains(genres, id)
|
|
||||||
},
|
|
||||||
"add": func(a, b int) int {
|
|
||||||
return a + b
|
|
||||||
},
|
|
||||||
"sub": func(a, b int) int {
|
|
||||||
return a - b
|
|
||||||
},
|
|
||||||
"mul": func(a, b float64) float64 {
|
|
||||||
return a * b
|
|
||||||
},
|
|
||||||
"imul": func(a, b int) int {
|
|
||||||
return a * b
|
|
||||||
},
|
|
||||||
"div": func(a, b float64) float64 {
|
|
||||||
if b == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return a / b
|
|
||||||
},
|
|
||||||
"ceilDiv": func(a, b int) int {
|
|
||||||
if b == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return (a + b - 1) / b
|
|
||||||
},
|
|
||||||
"toFloat": func(a int) float64 {
|
|
||||||
return float64(a)
|
|
||||||
},
|
|
||||||
"seq": func(v any) []int {
|
|
||||||
var count int
|
|
||||||
switch n := v.(type) {
|
|
||||||
case int:
|
|
||||||
count = n
|
|
||||||
case int64:
|
|
||||||
count = int(n)
|
|
||||||
default:
|
|
||||||
count = 0
|
|
||||||
}
|
|
||||||
res := make([]int, count)
|
|
||||||
for i := 0; i < count; i++ {
|
|
||||||
res[i] = i
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
},
|
|
||||||
"min": func(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
},
|
|
||||||
"int": func(v any) int {
|
|
||||||
switch n := v.(type) {
|
|
||||||
case int:
|
|
||||||
return n
|
|
||||||
case int64:
|
|
||||||
return int(n)
|
|
||||||
case float64:
|
|
||||||
return int(n)
|
|
||||||
case string:
|
|
||||||
i, _ := strconv.Atoi(n)
|
|
||||||
return i
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"percent": func(current, total float64) float64 {
|
|
||||||
if total == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return (current / total) * 100
|
|
||||||
},
|
|
||||||
"formatDate": func(dateStr string) string {
|
|
||||||
t, err := time.Parse(time.RFC3339, dateStr)
|
|
||||||
if err != nil {
|
|
||||||
t, err = time.Parse("2006-01-02T15:04:05+00:00", dateStr)
|
|
||||||
if err != nil {
|
|
||||||
return dateStr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return t.Format("Jan 2, 2006")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pages, err := filepath.Glob(filepath.Join(".", "templates", "*.gohtml"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
subpages, err := filepath.Glob(filepath.Join(".", "templates", "anime", "*.gohtml"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
allPages := append(pages, subpages...)
|
|
||||||
|
|
||||||
components, err := filepath.Glob(filepath.Join(".", "templates", "components", "*.gohtml"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
basePath := filepath.Join(".", "templates", "base.gohtml")
|
|
||||||
|
|
||||||
for _, page := range allPages {
|
|
||||||
name := filepath.Base(page)
|
|
||||||
if name == "base.gohtml" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpl := template.New("base.gohtml").Funcs(funcs)
|
|
||||||
tmpl = template.Must(tmpl.ParseFiles(basePath))
|
|
||||||
if len(components) > 0 {
|
|
||||||
tmpl = template.Must(tmpl.ParseFiles(components...))
|
|
||||||
}
|
|
||||||
tmpl = template.Must(tmpl.ParseFiles(page))
|
|
||||||
|
|
||||||
r.templates[name] = tmpl
|
|
||||||
}
|
|
||||||
|
|
||||||
return r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Renderer) Instance(name string, data any) render.Render {
|
|
||||||
return HTMLRender{
|
|
||||||
Renderer: r,
|
|
||||||
Name: name,
|
|
||||||
Data: data,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type HTMLRender struct {
|
|
||||||
Renderer *Renderer
|
|
||||||
Name string
|
|
||||||
Data any
|
|
||||||
}
|
|
||||||
|
|
||||||
type templateFragmentData interface {
|
|
||||||
TemplateFragment() string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h HTMLRender) Render(w http.ResponseWriter) error {
|
|
||||||
tmpl, ok := h.Renderer.templates[h.Name]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("template %s not found", h.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
var block any
|
|
||||||
|
|
||||||
// Handle both map[string]any and gin.H (which is map[string]any but might
|
|
||||||
// behave differently depending on the Go version/compiler in type assertions)
|
|
||||||
if dataMap, ok := h.Data.(map[string]any); ok {
|
|
||||||
block = dataMap["_fragment"]
|
|
||||||
} else if ginH, ok := h.Data.(gin.H); ok {
|
|
||||||
block = ginH["_fragment"]
|
|
||||||
} else if fragmentData, ok := h.Data.(templateFragmentData); ok {
|
|
||||||
block = fragmentData.TemplateFragment()
|
|
||||||
}
|
|
||||||
|
|
||||||
if blockStr, ok := block.(string); ok && blockStr != "" {
|
|
||||||
return tmpl.ExecuteTemplate(w, blockStr, h.Data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tmpl.ExecuteTemplate(w, "base.gohtml", h.Data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h HTMLRender) WriteContentType(w http.ResponseWriter) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecuteFragment is for HTMX partials
|
|
||||||
func (r *Renderer) ExecuteFragment(w io.Writer, name string, block string, data any) error {
|
|
||||||
tmpl, ok := r.templates[name]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("template %s not found", name)
|
|
||||||
}
|
|
||||||
return tmpl.ExecuteTemplate(w, block, data)
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
|
"mal/internal/server"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -35,13 +36,21 @@ func (h *WatchlistHandler) HandleUpdateWatchlist(c *gin.Context) {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil || body.AnimeID <= 0 || body.Status == "" {
|
if err := c.ShouldBindJSON(&body); err != nil || body.AnimeID <= 0 || body.Status == "" {
|
||||||
c.Status(http.StatusBadRequest)
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.svc.UpdateEntry(c.Request.Context(), userID, body.AnimeID, body.Status)
|
err := h.svc.UpdateEntry(c.Request.Context(), userID, body.AnimeID, body.Status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Status(http.StatusInternalServerError)
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"watchlist_update_failed",
|
||||||
|
"watchlist",
|
||||||
|
"failed to update watchlist entry",
|
||||||
|
map[string]any{"user_id": userID, "anime_id": body.AnimeID, "status": body.Status},
|
||||||
|
err,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,16 +64,24 @@ func (h *WatchlistHandler) HandleDeleteWatchlist(c *gin.Context) {
|
|||||||
userID = u.ID
|
userID = u.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
animeID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
animeID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
|
||||||
if animeID <= 0 {
|
if err != nil || animeID <= 0 {
|
||||||
c.Status(http.StatusBadRequest)
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.svc.RemoveEntry(c.Request.Context(), userID, animeID)
|
err = h.svc.RemoveEntry(c.Request.Context(), userID, animeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Status(http.StatusInternalServerError)
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"watchlist_remove_failed",
|
||||||
|
"watchlist",
|
||||||
|
"failed to remove watchlist entry",
|
||||||
|
map[string]any{"user_id": userID, "anime_id": animeID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,16 +95,24 @@ func (h *WatchlistHandler) HandleDeleteContinueWatching(c *gin.Context) {
|
|||||||
userID = u.ID
|
userID = u.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
animeID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
animeID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
|
||||||
if animeID <= 0 {
|
if err != nil || animeID <= 0 {
|
||||||
c.Status(http.StatusBadRequest)
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.svc.DeleteContinueWatching(c.Request.Context(), userID, animeID)
|
err = h.svc.DeleteContinueWatching(c.Request.Context(), userID, animeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Status(http.StatusInternalServerError)
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"continue_watching_delete_failed",
|
||||||
|
"watchlist",
|
||||||
|
"failed to delete continue watching entry",
|
||||||
|
map[string]any{"user_id": userID, "anime_id": animeID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +128,15 @@ func (h *WatchlistHandler) HandleGetWatchlist(c *gin.Context) {
|
|||||||
|
|
||||||
entries, err := h.svc.GetWatchlist(c.Request.Context(), userID)
|
entries, err := h.svc.GetWatchlist(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Status(http.StatusInternalServerError)
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"watchlist_load_failed",
|
||||||
|
"watchlist",
|
||||||
|
"failed to load watchlist",
|
||||||
|
map[string]any{"user_id": userID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,15 +23,18 @@ func (s *watchlistService) UpdateEntry(ctx context.Context, userID string, anime
|
|||||||
_, err := s.repo.GetAnime(ctx, animeID)
|
_, err := s.repo.GetAnime(ctx, animeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
anime, err := s.jikan.GetAnimeByID(ctx, int(animeID))
|
anime, err := s.jikan.GetAnimeByID(ctx, int(animeID))
|
||||||
if err == nil {
|
if err != nil {
|
||||||
_, _ = s.repo.UpsertAnime(ctx, db.UpsertAnimeParams{
|
return err
|
||||||
ID: int64(anime.MalID),
|
}
|
||||||
TitleOriginal: anime.Title,
|
if _, err := s.repo.UpsertAnime(ctx, db.UpsertAnimeParams{
|
||||||
TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""},
|
ID: int64(anime.MalID),
|
||||||
TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""},
|
TitleOriginal: anime.Title,
|
||||||
ImageUrl: anime.ImageURL(),
|
TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""},
|
||||||
Airing: sql.NullBool{Bool: anime.Airing, Valid: true},
|
TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""},
|
||||||
})
|
ImageUrl: anime.ImageURL(),
|
||||||
|
Airing: sql.NullBool{Bool: anime.Airing, Valid: true},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,10 +99,12 @@ func (s *watchlistService) GetContinueWatchingEntry(ctx context.Context, userID
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *watchlistService) DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error {
|
func (s *watchlistService) DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error {
|
||||||
_ = s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{
|
if err := s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
AnimeID: animeID,
|
AnimeID: animeID,
|
||||||
})
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{
|
return s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
AnimeID: animeID,
|
AnimeID: animeID,
|
||||||
|
|||||||
16
justfile
16
justfile
@@ -1,13 +1,17 @@
|
|||||||
set shell := ["bash", "-c"]
|
set shell := ["bash", "-c"]
|
||||||
set dotenv-load := true
|
set dotenv-load := true
|
||||||
|
|
||||||
export GOCACHE := justfile_directory() + "/.cache/go-build"
|
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
go fmt ./...
|
go fmt ./...
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
go fmt ./... && go vet ./...
|
bun run lint:go
|
||||||
|
|
||||||
|
lint-ts:
|
||||||
|
bun run lint:ts
|
||||||
|
|
||||||
|
lint-go:
|
||||||
|
bun run lint:go
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
@@ -37,3 +41,9 @@ dev: build
|
|||||||
clean:
|
clean:
|
||||||
rm -rf dist/*
|
rm -rf dist/*
|
||||||
rm -f server
|
rm -f server
|
||||||
|
|
||||||
|
new-data-fix name:
|
||||||
|
bun scripts/new-data-fix.ts {{name}}
|
||||||
|
|
||||||
|
run-fixes:
|
||||||
|
go run ./cmd/user run-fixes
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
'commands':
|
'commands':
|
||||||
{
|
{
|
||||||
'go-fmt': { 'run': 'go fmt ./...' },
|
'go-fmt': { 'run': 'go fmt ./...' },
|
||||||
'go-vet': { 'run': 'go vet ./...' },
|
'go-lint': { 'run': 'bun run lint:go' },
|
||||||
'go-test': { 'run': 'go test ./...' },
|
'go-test': { 'run': 'go test ./...' },
|
||||||
'ts-typecheck': { 'run': 'bunx tsc -p tsconfig.json --noEmit' },
|
'ts-typecheck': { 'run': 'bunx tsc -p tsconfig.json --noEmit' },
|
||||||
'build-assets': { 'run': 'bun run build:assets' },
|
'build-assets': { 'run': 'bun run build:assets' },
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -1,19 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "myanimelist-ui",
|
"name": "mal",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:css": "bunx @tailwindcss/cli -i ./static/assets/style.css -o ./dist/tailwind.css",
|
"build:css": "bunx @tailwindcss/cli -i ./static/assets/style.css -o ./dist/tailwind.css",
|
||||||
"watch:css": "bunx @tailwindcss/cli -i ./static/assets/style.css -o ./dist/tailwind.css --watch",
|
"watch:css": "bunx @tailwindcss/cli -i ./static/assets/style.css -o ./dist/tailwind.css --watch",
|
||||||
"build:ts": "bun build ./static/player/main.ts --outdir ./dist/static/player --target browser --splitting && bun build ./static/*.ts --outdir ./dist --target browser",
|
"build:ts": "bun build ./static/player/main.ts --outdir ./dist/static/player --target browser --splitting && bun build ./static/*.ts --outdir ./dist/static --target browser --root ./static --entry-naming \"[name].js\"",
|
||||||
"typecheck": "bunx tsc -p tsconfig.json --noEmit",
|
"typecheck": "bunx tsc -p tsconfig.json --noEmit",
|
||||||
"build:assets": "bun run build:css && bun run build:ts",
|
"build:assets": "bun run build:css && bun run build:ts",
|
||||||
"format": "bunx prettier . --write",
|
"format": "bunx prettier . --write",
|
||||||
"lint": "bunx eslint . --fix"
|
"lint": "bun run lint:ts && bun run lint:go",
|
||||||
|
"lint:ts": "bunx eslint . --max-warnings 0",
|
||||||
|
"lint:ts:fix": "bunx eslint . --fix",
|
||||||
|
"lint:go": "golangci-lint run ./..."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/cli": "^4.2.4",
|
"@tailwindcss/cli": "^4.2.4",
|
||||||
"@toolwind/anchors": "^1.0.10",
|
"@types/node": "^24.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
||||||
"@typescript-eslint/parser": "^8.59.2",
|
"@typescript-eslint/parser": "^8.59.2",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
@@ -25,7 +28,5 @@
|
|||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {}
|
||||||
"dompurify": "^3.4.1"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
73
scripts/new-data-fix.ts
Normal file
73
scripts/new-data-fix.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { mkdir, writeFile, access } from 'node:fs/promises';
|
||||||
|
import { constants as fsConstants } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
function toSlug(raw: string): string {
|
||||||
|
const trimmed = raw.trim().toLowerCase();
|
||||||
|
const slug = trimmed.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatYYYYMMDD(date: Date): string {
|
||||||
|
const year = String(date.getFullYear());
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}${month}${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(filePath, fsConstants.F_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const rawName = process.argv[2] ?? '';
|
||||||
|
const slug = toSlug(rawName);
|
||||||
|
if (slug.length === 0) {
|
||||||
|
throw new Error('usage: bun scripts/new-data-fix.ts <name>');
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `${formatYYYYMMDD(new Date())}_${slug}`;
|
||||||
|
const dir = path.join(process.cwd(), 'internal', 'database', 'fixes');
|
||||||
|
const filePath = path.join(dir, `${id}.go`);
|
||||||
|
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
if (await fileExists(filePath)) {
|
||||||
|
throw new Error(`data fix already exists: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = `package fixes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(Fix{
|
||||||
|
ID: "${id}",
|
||||||
|
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
|
||||||
|
// TODO: implement fix
|
||||||
|
// _, err := sqlDB.ExecContext(ctx, \`UPDATE ...\`)
|
||||||
|
// if err != nil { return fmt.Errorf("...: %w", err) }
|
||||||
|
return fmt.Errorf("unimplemented data fix: ${id}")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
await writeFile(filePath, contents, { encoding: 'utf8' });
|
||||||
|
process.stdout.write(`${filePath}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -1,5 +1,28 @@
|
|||||||
import { parseClassList } from './utils';
|
import { parseClassList } from './utils';
|
||||||
|
|
||||||
|
const initSynopsisToggle = (): void => {
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const target = e.target;
|
||||||
|
if (!(target instanceof Element)) return;
|
||||||
|
|
||||||
|
const btn = target.closest<HTMLButtonElement>('[data-synopsis-toggle]');
|
||||||
|
if (!btn) return;
|
||||||
|
const container = document.getElementById('synopsis-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const isClamped = container.classList.contains('line-clamp-6');
|
||||||
|
if (isClamped) {
|
||||||
|
container.classList.remove('line-clamp-6');
|
||||||
|
btn.textContent = 'Show less';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.classList.add('line-clamp-6');
|
||||||
|
btn.textContent = 'Read more';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
initSynopsisToggle();
|
||||||
|
|
||||||
const setDropdownMenuState = (menu: HTMLElement, isOpen: boolean): void => {
|
const setDropdownMenuState = (menu: HTMLElement, isOpen: boolean): void => {
|
||||||
// data attributes store the class lists to add/remove
|
// data attributes store the class lists to add/remove
|
||||||
const openClasses = parseClassList(menu.getAttribute('data-dropdown-open-classes'));
|
const openClasses = parseClassList(menu.getAttribute('data-dropdown-open-classes'));
|
||||||
|
|||||||
@@ -1,42 +1,34 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&display=swap');
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,600&display=swap');
|
|
||||||
@import '@toolwind/anchors';
|
|
||||||
|
|
||||||
@source "../../templates/**/*.gohtml";
|
@source "../../templates/**/*.gohtml";
|
||||||
@source "../**/*.ts";
|
@source "../**/*.ts";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-background: light-dark(#f7f6f3, #0b0c10);
|
--color-background: light-dark(#ffffff, #0b0c10);
|
||||||
--color-background-sidebar: light-dark(#fbfbfa, #0f1115);
|
--color-background-sidebar: light-dark(#f7f7f7, #0f1115);
|
||||||
--color-background-header: light-dark(#fbfbfa, #0f1115);
|
--color-background-header: light-dark(#fbfbfb, #0f1115);
|
||||||
--color-background-surface: light-dark(#ffffff, #17181c);
|
--color-background-surface: light-dark(#ffffff, #17181c);
|
||||||
--color-background-button: light-dark(#ffffff, #131417);
|
--color-background-button: light-dark(#f5f5f5, #131417);
|
||||||
--color-background-button-hover: light-dark(#f7f6f3, #1c1d22);
|
--color-background-button-hover: light-dark(#ececec, #1c1d22);
|
||||||
|
|
||||||
--color-foreground-muted: light-dark(#787774, #a1a1aa);
|
|
||||||
--color-foreground: light-dark(#111111, #f3f4f6);
|
--color-foreground: light-dark(#111111, #f3f4f6);
|
||||||
|
--color-accent: #00b3c4;
|
||||||
--color-accent: #1f6c9f;
|
--color-surface-hover: light-dark(rgba(0, 0, 0, 0.04), rgba(255, 255, 255, 0.05));
|
||||||
--color-border: light-dark(rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.1));
|
|
||||||
--color-surface-hover: light-dark(rgba(0, 0, 0, 0.03), rgba(255, 255, 255, 0.05));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
|
|
||||||
--bg: var(--color-background);
|
--bg: var(--color-background);
|
||||||
--panel: light-dark(#f5f5f4, #181818);
|
--panel: light-dark(#f7f7f7, #181818);
|
||||||
--panel-soft: light-dark(#e7e5e4, #202020);
|
--panel-soft: light-dark(#ececec, #202020);
|
||||||
--header: light-dark(#ffffff, #101010);
|
--header: light-dark(#ffffff, #101010);
|
||||||
--text: light-dark(#1c1917, #e7e5e4);
|
--text: light-dark(#111111, #e7e5e4);
|
||||||
--text-muted: light-dark(#57534e, #a8a29e);
|
--text-muted: light-dark(#666666, #a8a29e);
|
||||||
--text-faint: light-dark(#a8a29e, #78716c);
|
--text-faint: light-dark(#9a9a9a, #78716c);
|
||||||
--accent: var(--color-accent);
|
--accent: var(--color-accent);
|
||||||
--danger: #dc2626;
|
--danger: #dc2626;
|
||||||
--surface-thumb: light-dark(#e7e5e4, #44403c);
|
--surface-thumb: light-dark(#cccccc, #44403c);
|
||||||
--surface-tab-hover: light-dark(#e7e5e4, #202020);
|
--surface-tab-hover: light-dark(#e4e4e4, #202020);
|
||||||
--surface-tab-active: light-dark(#1c1917, #fafaf9);
|
--surface-tab-active: light-dark(#1e1b17, #fafaf9);
|
||||||
--text-tab-active: light-dark(#fafaf9, #0c0a09);
|
--text-tab-active: light-dark(#fafaf9, #0c0a09);
|
||||||
--surface-select: light-dark(#ffffff, #181818);
|
--surface-select: light-dark(#ffffff, #181818);
|
||||||
--text-on-accent: light-dark(#fafaf9, #0c0a09);
|
--text-on-accent: light-dark(#fafaf9, #0c0a09);
|
||||||
@@ -44,6 +36,9 @@
|
|||||||
--shadow-subtle: light-dark(0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18));
|
--shadow-subtle: light-dark(0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18));
|
||||||
--shadow-card: light-dark(0 2px 8px rgba(0, 0, 0, 0.04), 0 2px 10px rgba(0, 0, 0, 0.28));
|
--shadow-card: light-dark(0 2px 8px rgba(0, 0, 0, 0.04), 0 2px 10px rgba(0, 0, 0, 0.28));
|
||||||
--shadow-card-hover: light-dark(0 6px 18px rgba(0, 0, 0, 0.06), 0 6px 20px rgba(0, 0, 0, 0.34));
|
--shadow-card-hover: light-dark(0 6px 18px rgba(0, 0, 0, 0.06), 0 6px 20px rgba(0, 0, 0, 0.34));
|
||||||
|
--scrollbar-track: light-dark(rgba(0, 0, 0, 0.04), rgba(255, 255, 255, 0.05));
|
||||||
|
--scrollbar-thumb: light-dark(rgba(0, 0, 0, 0.16), rgba(255, 255, 255, 0.2));
|
||||||
|
--scrollbar-thumb-hover: light-dark(rgba(0, 0, 0, 0.26), rgba(255, 255, 255, 0.3));
|
||||||
--space-1: 0.25rem;
|
--space-1: 0.25rem;
|
||||||
--space-2: 0.5rem;
|
--space-2: 0.5rem;
|
||||||
--space-3: 0.75rem;
|
--space-3: 0.75rem;
|
||||||
@@ -58,106 +53,18 @@
|
|||||||
--radius: 0px;
|
--radius: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='light'] {
|
|
||||||
color-scheme: light;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme='dark'] {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
[data-watchlist-toggle] .watchlist-icon,
|
||||||
.shadow-soft {
|
[data-watchlist-toggle] .watchlist-icon path {
|
||||||
box-shadow: var(--shadow-card);
|
fill: none;
|
||||||
}
|
|
||||||
|
|
||||||
.shadow-soft-hover:hover {
|
|
||||||
box-shadow: var(--shadow-card-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-hairline {
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading-serif {
|
|
||||||
font-family: var(--font-serif);
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
line-height: 1.15;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mono {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Default to square corners; opt back in selectively (e.g. inputs). */
|
[data-watchlist-toggle][data-watchlist-state='in'] .watchlist-icon,
|
||||||
:where(input, textarea, select) {
|
[data-watchlist-toggle][data-watchlist-state='in'] .watchlist-icon path {
|
||||||
border-radius: 6px !important;
|
fill: currentColor;
|
||||||
}
|
|
||||||
|
|
||||||
:where(.rounded-keep) {
|
|
||||||
border-radius: 6px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-hide {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
|
||||||
display: block;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-hide::-webkit-scrollbar-track {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-hide::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-hide::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
button.in-watchlist .watchlist-icon {
|
|
||||||
fill: currentColor !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-hide {
|
|
||||||
-ms-overflow-style: auto;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.show-controls [data-video-overlay] {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-video-player].fullscreen:not(.show-controls) [data-video-overlay] {
|
|
||||||
opacity: 0 !important;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-video-player].fullscreen:not(.show-controls) {
|
|
||||||
cursor: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-video-player].fullscreen:not(.show-controls) video {
|
|
||||||
cursor: none;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,3 +41,56 @@ const initDiscoverTabs = (): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
initDiscoverTabs();
|
initDiscoverTabs();
|
||||||
|
|
||||||
|
const initSurpriseMe = (): void => {
|
||||||
|
let isFetchingRandom = false;
|
||||||
|
|
||||||
|
const onClick = async (): Promise<void> => {
|
||||||
|
if (isFetchingRandom) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('surprise-btn') as HTMLButtonElement | null;
|
||||||
|
if (!btn) return;
|
||||||
|
isFetchingRandom = true;
|
||||||
|
|
||||||
|
const spinner = document.getElementById('surprise-spinner');
|
||||||
|
const text = document.getElementById('surprise-text');
|
||||||
|
const icon = document.getElementById('surprise-icon');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
spinner?.classList.remove('hidden');
|
||||||
|
icon?.classList.add('hidden');
|
||||||
|
if (text) text.textContent = 'Picking…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/jikan/random/anime?t=${Date.now()}`, { cache: 'no-store' });
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch random anime');
|
||||||
|
const json = (await res.json()) as unknown;
|
||||||
|
const data = (json as { data?: unknown }).data as { mal_id?: unknown } | undefined;
|
||||||
|
const malId = typeof data?.mal_id === 'number' ? data.mal_id : 0;
|
||||||
|
if (malId > 0) {
|
||||||
|
window.location.href = `/anime/${malId}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('Random anime missing mal_id');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('Could not pick a random anime right now. Please try again.');
|
||||||
|
} finally {
|
||||||
|
isFetchingRandom = false;
|
||||||
|
btn.disabled = false;
|
||||||
|
spinner?.classList.add('hidden');
|
||||||
|
icon?.classList.remove('hidden');
|
||||||
|
if (text) text.textContent = 'Surprise Me';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const target = e.target;
|
||||||
|
if (!(target instanceof Element)) return;
|
||||||
|
const surprise = target.closest('[data-surprise-me]');
|
||||||
|
if (!surprise) return;
|
||||||
|
void onClick();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
initSurpriseMe();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class UIDropdown extends HTMLElement {
|
class UIDropdown extends HTMLElement {
|
||||||
isOpen: boolean = false;
|
isOpen = false;
|
||||||
contentEl: HTMLElement | null = null;
|
contentEl: HTMLElement | null = null;
|
||||||
isClosing: boolean = false; // debounce flag
|
isClosing = false; // debounce flag
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -64,3 +64,25 @@ class UIDropdown extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('ui-dropdown', UIDropdown);
|
customElements.define('ui-dropdown', UIDropdown);
|
||||||
|
|
||||||
|
const initStudioDropdown = (): void => {
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const target = e.target;
|
||||||
|
if (!(target instanceof Element)) return;
|
||||||
|
|
||||||
|
const btn = target.closest<HTMLButtonElement>('button[data-studio-select]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const input = document.getElementById('studio-input') as HTMLInputElement | null;
|
||||||
|
const form = document.getElementById('browse-search-form') as HTMLFormElement | null;
|
||||||
|
if (!input || !form) return;
|
||||||
|
|
||||||
|
input.value = btn.dataset.studioSelect ?? '';
|
||||||
|
form.requestSubmit();
|
||||||
|
|
||||||
|
const dropdown = btn.closest('ui-dropdown') as { close?: () => void } | null;
|
||||||
|
dropdown?.close?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
initStudioDropdown();
|
||||||
|
|||||||
71
static/htmx.ts
Normal file
71
static/htmx.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
export {};
|
||||||
|
|
||||||
|
type ToastFn = (opts: { message: string; duration?: number }) => void;
|
||||||
|
|
||||||
|
const getToast = (): ToastFn | null => {
|
||||||
|
const anyWindow = window as unknown as { showToast?: ToastFn };
|
||||||
|
return typeof anyWindow.showToast === 'function' ? anyWindow.showToast : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toast = (message: string): void => {
|
||||||
|
getToast()?.({ message });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setBusy = (el: Element | null, busy: boolean): void => {
|
||||||
|
if (!(el instanceof HTMLElement)) return;
|
||||||
|
el.toggleAttribute('aria-busy', busy);
|
||||||
|
el.dataset.htmxLoading = busy ? 'true' : 'false';
|
||||||
|
|
||||||
|
if (el instanceof HTMLButtonElement) {
|
||||||
|
el.disabled = busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (busy) {
|
||||||
|
el.dataset.htmxBusy = 'true';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete el.dataset.htmxBusy;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTriggerFromHtmxEvent = (event: Event): Element | null => {
|
||||||
|
const detail = event as unknown as { detail?: { elt?: Element } };
|
||||||
|
return detail.detail?.elt ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReady = (fn: () => void): void => {
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', fn, { once: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
|
||||||
|
onReady(() => {
|
||||||
|
document.addEventListener('htmx:beforeRequest', event => {
|
||||||
|
setBusy(getTriggerFromHtmxEvent(event), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('htmx:afterRequest', event => {
|
||||||
|
setBusy(getTriggerFromHtmxEvent(event), false);
|
||||||
|
|
||||||
|
const remaining = document.querySelectorAll('.continue-watching-item').length;
|
||||||
|
if (remaining !== 0) return;
|
||||||
|
|
||||||
|
const section = document.getElementById('continue-watching-section');
|
||||||
|
section?.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('htmx:responseError', () => {
|
||||||
|
toast('Something went wrong');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('htmx:sendError', () => {
|
||||||
|
toast('Network error');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('htmx:timeout', () => {
|
||||||
|
toast('Request timed out');
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
static/images/background.png
Normal file
BIN
static/images/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 MiB |
@@ -1,4 +1,6 @@
|
|||||||
import { state } from './state';
|
import { state } from './state';
|
||||||
|
import { saveProgress } from './progress';
|
||||||
|
import { safeLocalStorage } from './storage';
|
||||||
|
|
||||||
export const formatTime = (seconds: number): string => {
|
export const formatTime = (seconds: number): string => {
|
||||||
if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
|
if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
|
||||||
@@ -77,6 +79,35 @@ export const syncVolumeUI = (): void => {
|
|||||||
updateMuteIcons(state.video.muted || state.video.volume === 0);
|
updateMuteIcons(state.video.muted || state.video.volume === 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const VOLUME_STORAGE_KEY = 'player-volume';
|
||||||
|
|
||||||
|
const parseStoredVolume = (raw: string | null): number | null => {
|
||||||
|
if (!raw) return null;
|
||||||
|
const v = Number.parseFloat(raw);
|
||||||
|
if (!Number.isFinite(v)) return null;
|
||||||
|
if (v < 0 || v > 1) return null;
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyStoredVolume = (): void => {
|
||||||
|
const stored = parseStoredVolume(safeLocalStorage.getItem(VOLUME_STORAGE_KEY));
|
||||||
|
if (stored === null) return;
|
||||||
|
|
||||||
|
state.video.volume = stored;
|
||||||
|
state.video.muted = stored === 0;
|
||||||
|
if (stored > 0) state.lastKnownVolume = stored;
|
||||||
|
};
|
||||||
|
|
||||||
|
let volumeSaveTimer: number | undefined;
|
||||||
|
const schedulePersistVolume = (): void => {
|
||||||
|
window.clearTimeout(volumeSaveTimer);
|
||||||
|
volumeSaveTimer = window.setTimeout(() => {
|
||||||
|
if (!Number.isFinite(state.video.volume)) return;
|
||||||
|
const clamped = Math.max(0, Math.min(1, state.video.volume));
|
||||||
|
safeLocalStorage.setItem(VOLUME_STORAGE_KEY, clamped.toFixed(3));
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
|
||||||
interface Controls {
|
interface Controls {
|
||||||
playPause: HTMLButtonElement | null;
|
playPause: HTMLButtonElement | null;
|
||||||
muteBtn: HTMLButtonElement | null;
|
muteBtn: HTMLButtonElement | null;
|
||||||
@@ -137,6 +168,8 @@ const updateMuteIcons = (isMuted: boolean): void => {
|
|||||||
* Sets up video event listeners for icon sync.
|
* Sets up video event listeners for icon sync.
|
||||||
*/
|
*/
|
||||||
export const setupControls = (): void => {
|
export const setupControls = (): void => {
|
||||||
|
applyStoredVolume();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
playPause,
|
playPause,
|
||||||
muteBtn,
|
muteBtn,
|
||||||
@@ -203,8 +236,12 @@ export const setupControls = (): void => {
|
|||||||
state.video.addEventListener('pause', () => {
|
state.video.addEventListener('pause', () => {
|
||||||
updatePlayPauseIcons(false);
|
updatePlayPauseIcons(false);
|
||||||
showControls();
|
showControls();
|
||||||
|
void saveProgress();
|
||||||
|
});
|
||||||
|
state.video.addEventListener('volumechange', () => {
|
||||||
|
syncVolumeUI();
|
||||||
|
schedulePersistVolume();
|
||||||
});
|
});
|
||||||
state.video.addEventListener('volumechange', syncVolumeUI);
|
|
||||||
|
|
||||||
// mouse move in container shows controls
|
// mouse move in container shows controls
|
||||||
state.container.addEventListener('mousemove', showControls);
|
state.container.addEventListener('mousemove', showControls);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { state } from '../state';
|
import { state } from '../state';
|
||||||
import { SkipSegment } from '../types';
|
import type { SkipSegment } from '../types';
|
||||||
import { resolveActiveSegments, renderSegments } from '../skip/segments';
|
import { resolveActiveSegments, renderSegments } from '../skip/segments';
|
||||||
import { updateSubtitleOptions } from '../subtitles';
|
import { updateSubtitleOptions } from '../subtitles';
|
||||||
import { updateQualityOptions } from '../quality';
|
import { updateQualityOptions } from '../quality';
|
||||||
import { updateModeButtons } from '../mode';
|
import { updateModeButtons } from '../mode';
|
||||||
import { updateOverlay, isAutoplayEnabled, switchEpisodeRange } from './ui';
|
import { updateOverlay, isAutoplayEnabled, switchEpisodeRange } from './ui';
|
||||||
import { markEpisodeTransition } from '../progress';
|
import { markEpisodeTransition } from '../progress';
|
||||||
|
import { safeLocalStorage } from '../storage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles video end: either marks complete or loads next episode.
|
* Handles video end: either marks complete or loads next episode.
|
||||||
@@ -71,10 +72,12 @@ export const goToNextEpisode = async (): Promise<void> => {
|
|||||||
state.container.dataset.startTimeSeconds = String(state.startTimeSeconds);
|
state.container.dataset.startTimeSeconds = String(state.startTimeSeconds);
|
||||||
|
|
||||||
// load new video (keep preferences)
|
// load new video (keep preferences)
|
||||||
const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best';
|
const preferredQuality = safeLocalStorage.getItem('mal:preferred-quality') || 'best';
|
||||||
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`;
|
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`;
|
||||||
state.video.load();
|
state.video.load();
|
||||||
if (!state.video.paused) state.video.play().catch(() => {});
|
if (!state.video.paused) {
|
||||||
|
state.video.play().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
state.pendingSeekTime = null;
|
state.pendingSeekTime = null;
|
||||||
state.completionSent = false;
|
state.completionSent = false;
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { state } from '../state';
|
|||||||
* Injects images into episode cards, replaces placeholder.
|
* Injects images into episode cards, replaces placeholder.
|
||||||
*/
|
*/
|
||||||
export const setupThumbnails = (): void => {
|
export const setupThumbnails = (): void => {
|
||||||
|
const episodeList = state.episodeList;
|
||||||
|
if (!episodeList) return;
|
||||||
|
|
||||||
fetch(`/api/watch/thumbnails/${state.malID}`)
|
fetch(`/api/watch/thumbnails/${state.malID}`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then((data: Array<{ mal_id: number; url: string; title?: string }>) => {
|
.then((data: { mal_id: number; url: string; title?: string }[]) => {
|
||||||
if (!state.episodeList) return;
|
|
||||||
data.forEach(item => {
|
data.forEach(item => {
|
||||||
const card = state.episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`);
|
const card = episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`);
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
|
|
||||||
// inject thumbnail image
|
// inject thumbnail image
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { state } from '../state';
|
import { state } from '../state';
|
||||||
|
import { qs } from '../../q';
|
||||||
|
import { safeLocalStorage } from '../storage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Syncs autoplay checkbox with localStorage on init.
|
* Syncs autoplay checkbox with localStorage on init.
|
||||||
@@ -7,11 +9,11 @@ import { state } from '../state';
|
|||||||
export const setupAutoplayButton = (): void => {
|
export const setupAutoplayButton = (): void => {
|
||||||
const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
|
const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.checked = localStorage.getItem('mal:autoplay-enabled') !== 'false';
|
btn.checked = safeLocalStorage.getItem('mal:autoplay-enabled') !== 'false';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isAutoplayEnabled = (): boolean =>
|
export const isAutoplayEnabled = (): boolean =>
|
||||||
localStorage.getItem('mal:autoplay-enabled') !== 'false';
|
safeLocalStorage.getItem('mal:autoplay-enabled') !== 'false';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates video overlay text (shown briefly on episode change).
|
* Updates video overlay text (shown briefly on episode change).
|
||||||
@@ -19,7 +21,8 @@ export const isAutoplayEnabled = (): boolean =>
|
|||||||
export const updateOverlay = (episode: string, title: string): void => {
|
export const updateOverlay = (episode: string, title: string): void => {
|
||||||
if (!state.videoOverlay) return;
|
if (!state.videoOverlay) return;
|
||||||
const p = state.videoOverlay.querySelector('p');
|
const p = state.videoOverlay.querySelector('p');
|
||||||
p && (p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`);
|
if (!p) return;
|
||||||
|
p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// helper: get all episode elements from grid and list
|
// helper: get all episode elements from grid and list
|
||||||
@@ -57,7 +60,7 @@ export const updateEpisodeHighlight = (num: number): void => {
|
|||||||
* Updates dropdown label and hides/shows episode cards.
|
* Updates dropdown label and hides/shows episode cards.
|
||||||
*/
|
*/
|
||||||
export const switchEpisodeRange = (idx: number): void => {
|
export const switchEpisodeRange = (idx: number): void => {
|
||||||
const dropdown = state.container.querySelector('[data-episode-dropdown]') as HTMLElement | null;
|
const dropdown = qs<HTMLElement>('[data-episode-dropdown]');
|
||||||
if (!dropdown) return;
|
if (!dropdown) return;
|
||||||
const btns = Array.from(dropdown.querySelectorAll('.episode-range-btn')) as HTMLButtonElement[];
|
const btns = Array.from(dropdown.querySelectorAll('.episode-range-btn')) as HTMLButtonElement[];
|
||||||
const target = btns[idx];
|
const target = btns[idx];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
seekBy,
|
seekBy,
|
||||||
setVolume,
|
setVolume,
|
||||||
} from './controls';
|
} from './controls';
|
||||||
|
import { saveProgress } from './progress';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up keyboard shortcuts for player control.
|
* Sets up keyboard shortcuts for player control.
|
||||||
@@ -26,6 +27,7 @@ export const setupKeyboard = (): void => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
togglePlayPause();
|
togglePlayPause();
|
||||||
showControls();
|
showControls();
|
||||||
|
void saveProgress();
|
||||||
break;
|
break;
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
case 'KeyJ':
|
case 'KeyJ':
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { resolveActiveSegments, renderSegments } from './skip/segments';
|
|||||||
import { setupSegmentEditor } from './skip/editor';
|
import { setupSegmentEditor } from './skip/editor';
|
||||||
import { setupThumbnails } from './episodes/thumbnails';
|
import { setupThumbnails } from './episodes/thumbnails';
|
||||||
import { markEpisodeTransition, setupProgress } from './progress';
|
import { markEpisodeTransition, setupProgress } from './progress';
|
||||||
|
import { safeLocalStorage } from './storage';
|
||||||
import {
|
import {
|
||||||
absoluteTimeFromDisplay,
|
absoluteTimeFromDisplay,
|
||||||
absoluteTimeFromRatio,
|
absoluteTimeFromRatio,
|
||||||
@@ -20,21 +21,38 @@ import {
|
|||||||
} from './timeline';
|
} from './timeline';
|
||||||
import { formatTime } from './controls';
|
import { formatTime } from './controls';
|
||||||
|
|
||||||
let initialized = false; // prevent double init on htmx swaps
|
let currentContainer: HTMLElement | null = null;
|
||||||
|
let cleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
type ClosableDropdown = HTMLElement & { close: () => void };
|
||||||
|
const isClosableDropdown = (el: Element | null): el is ClosableDropdown => {
|
||||||
|
if (!el) return false;
|
||||||
|
if (!(el instanceof HTMLElement)) return false;
|
||||||
|
const maybe = el as Partial<{ close: unknown }>;
|
||||||
|
return typeof maybe.close === 'function';
|
||||||
|
};
|
||||||
|
|
||||||
const hidePreviewPopover = (): void => {
|
const hidePreviewPopover = (): void => {
|
||||||
if (!state.previewPopover) return;
|
if (!state.previewPopover) return;
|
||||||
|
state.previewPopover.classList.add('hidden');
|
||||||
state.previewPopover.classList.add('opacity-0');
|
state.previewPopover.classList.add('opacity-0');
|
||||||
state.previewPopover.classList.remove('opacity-100');
|
state.previewPopover.classList.remove('opacity-100');
|
||||||
state.previewPopover.style.left = '0px';
|
state.previewPopover.style.left = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const showPreviewPopover = (): void => {
|
const showPreviewPopover = (): void => {
|
||||||
if (!state.previewPopover) return;
|
if (!state.previewPopover) return;
|
||||||
|
state.previewPopover.classList.remove('hidden');
|
||||||
state.previewPopover.classList.remove('opacity-0');
|
state.previewPopover.classList.remove('opacity-0');
|
||||||
state.previewPopover.classList.add('opacity-100');
|
state.previewPopover.classList.add('opacity-100');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const teardownPlayer = (): void => {
|
||||||
|
cleanup?.();
|
||||||
|
cleanup = null;
|
||||||
|
currentContainer = null;
|
||||||
|
};
|
||||||
|
|
||||||
// updates time preview on progress bar hover
|
// updates time preview on progress bar hover
|
||||||
const updatePreviewUI = (ratio: number): void => {
|
const updatePreviewUI = (ratio: number): void => {
|
||||||
const progressWrap = state.container.querySelector('[data-progress-wrap]') as HTMLElement | null;
|
const progressWrap = state.container.querySelector('[data-progress-wrap]') as HTMLElement | null;
|
||||||
@@ -65,20 +83,25 @@ const updatePreviewUI = (ratio: number): void => {
|
|||||||
|
|
||||||
const initPlayer = (): void => {
|
const initPlayer = (): void => {
|
||||||
const container = document.querySelector('[data-video-player]') as HTMLElement | null;
|
const container = document.querySelector('[data-video-player]') as HTMLElement | null;
|
||||||
if (!container || initialized) return;
|
if (!container) return;
|
||||||
|
if (container === currentContainer) return;
|
||||||
|
teardownPlayer();
|
||||||
|
|
||||||
if (!initState(container)) {
|
if (!initState(container)) {
|
||||||
console.error('Video player markup is missing required controls.');
|
console.error('Video player markup is missing required controls.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
initialized = true;
|
currentContainer = container;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const signal = abortController.signal;
|
||||||
|
cleanup = () => abortController.abort();
|
||||||
|
|
||||||
const loading = container.querySelector('[data-loading]') as HTMLElement | null;
|
const loading = container.querySelector('[data-loading]') as HTMLElement | null;
|
||||||
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null;
|
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null;
|
||||||
|
|
||||||
// build video src from mode, token, and saved quality preference
|
// build video src from mode, token, and saved quality preference
|
||||||
// Only set if not already provided by the inline script during HTML parsing
|
// Only set if not already provided by the inline script during HTML parsing
|
||||||
const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best';
|
const preferredQuality = safeLocalStorage.getItem('mal:preferred-quality') || 'best';
|
||||||
const streamToken = state.modeSources[state.currentMode]?.token;
|
const streamToken = state.modeSources[state.currentMode]?.token;
|
||||||
if (!state.video.src && streamToken) {
|
if (!state.video.src && streamToken) {
|
||||||
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`;
|
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`;
|
||||||
@@ -106,7 +129,9 @@ const initPlayer = (): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onLoadedMetadata = (): void => {
|
const onLoadedMetadata = (): void => {
|
||||||
loading && (loading.style.display = 'none');
|
if (loading) {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
}
|
||||||
invalidateBounds();
|
invalidateBounds();
|
||||||
|
|
||||||
resolveActiveSegments();
|
resolveActiveSegments();
|
||||||
@@ -126,137 +151,193 @@ const initPlayer = (): void => {
|
|||||||
state.transitionEpisode = null;
|
state.transitionEpisode = null;
|
||||||
}
|
}
|
||||||
// autoplay if not already playing (inline script may have already called play())
|
// autoplay if not already playing (inline script may have already called play())
|
||||||
if (state.shouldAutoPlay || state.video.paused) state.video.play().catch(() => {});
|
if (state.shouldAutoPlay || state.video.paused) {
|
||||||
|
state.video.play().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
updateTimeline(state.video.currentTime);
|
updateTimeline(state.video.currentTime);
|
||||||
updateSkipButton(state.video.currentTime);
|
updateSkipButton(state.video.currentTime);
|
||||||
};
|
};
|
||||||
|
|
||||||
state.video.addEventListener('loadedmetadata', onLoadedMetadata);
|
state.video.addEventListener('loadedmetadata', onLoadedMetadata, { signal });
|
||||||
// inline script runs during HTML parsing before initPlayer; if metadata
|
// inline script runs during HTML parsing before initPlayer; if metadata
|
||||||
// already loaded, fire the handler immediately
|
// already loaded, fire the handler immediately
|
||||||
if (state.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
if (state.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||||
onLoadedMetadata();
|
onLoadedMetadata();
|
||||||
}
|
}
|
||||||
|
|
||||||
state.video.addEventListener('waiting', () => {
|
state.video.addEventListener(
|
||||||
loading && (loading.style.display = 'flex');
|
'waiting',
|
||||||
});
|
() => {
|
||||||
state.video.addEventListener('playing', () => {
|
if (loading) {
|
||||||
loading && (loading.style.display = 'none');
|
loading.style.display = 'flex';
|
||||||
});
|
}
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
state.video.addEventListener(
|
||||||
|
'playing',
|
||||||
|
() => {
|
||||||
|
if (loading) {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
// update progress bar during buffering
|
// update progress bar during buffering
|
||||||
state.video.addEventListener('progress', () => {
|
state.video.addEventListener(
|
||||||
updateTimeline(state.video.currentTime);
|
'progress',
|
||||||
});
|
() => {
|
||||||
|
updateTimeline(state.video.currentTime);
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
|
||||||
// main loop: update progress, subtitles, skip buttons
|
// main loop: update progress, subtitles, skip buttons
|
||||||
state.video.addEventListener('timeupdate', () => {
|
state.video.addEventListener(
|
||||||
updateTimeline(state.video.currentTime);
|
'timeupdate',
|
||||||
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime));
|
() => {
|
||||||
updateSkipButton(state.video.currentTime);
|
updateTimeline(state.video.currentTime);
|
||||||
});
|
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime));
|
||||||
|
updateSkipButton(state.video.currentTime);
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
|
||||||
state.video.addEventListener('ended', () => {
|
state.video.addEventListener(
|
||||||
goToNextEpisode();
|
'ended',
|
||||||
});
|
() => {
|
||||||
|
goToNextEpisode();
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
|
||||||
// click/drag to seek (pointer events are more consistent across fullscreen/mobile)
|
// click/drag to seek (pointer events are more consistent across fullscreen/mobile)
|
||||||
progressWrap?.addEventListener('pointerdown', e => {
|
progressWrap?.addEventListener(
|
||||||
// ignore right/middle click
|
'pointerdown',
|
||||||
if ('button' in e && e.button !== 0) return;
|
e => {
|
||||||
state.isScrubbing = true;
|
// ignore right/middle click
|
||||||
try {
|
if ('button' in e && e.button !== 0) return;
|
||||||
(e.currentTarget as HTMLElement).setPointerCapture((e as PointerEvent).pointerId);
|
state.isScrubbing = true;
|
||||||
} catch {}
|
try {
|
||||||
const rect = progressWrap.getBoundingClientRect();
|
(e.currentTarget as HTMLElement).setPointerCapture((e as PointerEvent).pointerId);
|
||||||
state.video.currentTime = absoluteTimeFromRatio(
|
} catch {}
|
||||||
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
const rect = progressWrap.getBoundingClientRect();
|
||||||
);
|
state.video.currentTime = absoluteTimeFromRatio(
|
||||||
updateTimeline(state.video.currentTime);
|
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||||
updateSkipButton(state.video.currentTime);
|
);
|
||||||
showControls();
|
updateTimeline(state.video.currentTime);
|
||||||
});
|
updateSkipButton(state.video.currentTime);
|
||||||
|
showControls();
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
|
||||||
// hover to preview time
|
// hover to preview time
|
||||||
progressWrap?.addEventListener('pointermove', e => {
|
progressWrap?.addEventListener(
|
||||||
const rect = progressWrap.getBoundingClientRect();
|
'pointermove',
|
||||||
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
e => {
|
||||||
});
|
const rect = progressWrap.getBoundingClientRect();
|
||||||
|
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
|
||||||
progressWrap?.addEventListener('pointerleave', hidePreviewPopover);
|
progressWrap?.addEventListener('pointerleave', hidePreviewPopover, { signal });
|
||||||
progressWrap?.addEventListener('pointerup', () => {
|
progressWrap?.addEventListener(
|
||||||
// ensure we finish the seek even if no window mousemove fired
|
'pointerup',
|
||||||
if (!progressWrap) return;
|
() => {
|
||||||
state.isScrubbing = false;
|
// ensure we finish the seek even if no window mousemove fired
|
||||||
});
|
if (!progressWrap) return;
|
||||||
|
state.isScrubbing = false;
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
|
||||||
// dragging outside progress bar while scrubbing
|
// dragging outside progress bar while scrubbing
|
||||||
window.addEventListener('pointermove', e => {
|
window.addEventListener(
|
||||||
if (!state.isScrubbing || !progressWrap) return;
|
'pointermove',
|
||||||
const rect = progressWrap.getBoundingClientRect();
|
e => {
|
||||||
state.video.currentTime = absoluteTimeFromRatio(
|
if (!state.isScrubbing || !progressWrap) return;
|
||||||
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
const rect = progressWrap.getBoundingClientRect();
|
||||||
);
|
state.video.currentTime = absoluteTimeFromRatio(
|
||||||
updateTimeline(state.video.currentTime);
|
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||||
updateSkipButton(state.video.currentTime);
|
);
|
||||||
});
|
updateTimeline(state.video.currentTime);
|
||||||
|
updateSkipButton(state.video.currentTime);
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
|
||||||
// track next-episode links outside the player so they start fresh after finishing an episode
|
// track next-episode links outside the player so they start fresh after finishing an episode
|
||||||
document.addEventListener('click', e => {
|
document.addEventListener(
|
||||||
const target = e.target;
|
'click',
|
||||||
if (!(target instanceof Element)) return;
|
e => {
|
||||||
const anchor = target.closest('a[href]');
|
const target = e.target;
|
||||||
if (!(anchor instanceof HTMLAnchorElement)) return;
|
if (!(target instanceof Element)) return;
|
||||||
const url = new URL(anchor.href, location.origin);
|
const anchor = target.closest('a[href]');
|
||||||
if (url.origin !== location.origin) return;
|
if (!(anchor instanceof HTMLAnchorElement)) return;
|
||||||
const parts = url.pathname.split('/').filter(Boolean);
|
const url = new URL(anchor.href, location.origin);
|
||||||
if (parts[0] !== 'anime' || parts[2] !== 'watch') return;
|
if (url.origin !== location.origin) return;
|
||||||
if (Number.parseInt(parts[1], 10) !== state.malID) return;
|
const parts = url.pathname.split('/').filter(Boolean);
|
||||||
const nextEpisode = Number.parseInt(url.searchParams.get('ep') ?? '1', 10);
|
if (parts[0] !== 'anime' || parts[2] !== 'watch') return;
|
||||||
const currentEpisode = Number.parseInt(state.currentEpisode, 10);
|
if (Number.parseInt(parts[1], 10) !== state.malID) return;
|
||||||
if (nextEpisode === currentEpisode + 1) markEpisodeTransition(nextEpisode);
|
const nextEpisode = Number.parseInt(url.searchParams.get('ep') ?? '1', 10);
|
||||||
});
|
const currentEpisode = Number.parseInt(state.currentEpisode, 10);
|
||||||
|
if (nextEpisode === currentEpisode + 1) markEpisodeTransition(nextEpisode);
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
|
||||||
state.video.addEventListener('click', showControls);
|
state.video.addEventListener('click', showControls, { signal });
|
||||||
|
|
||||||
const searchInput = document.querySelector('[data-episode-search]') as HTMLInputElement | null;
|
const searchInput = document.querySelector('[data-episode-search]') as HTMLInputElement | null;
|
||||||
const dropdown = container.querySelector('[data-episode-dropdown]') as HTMLElement | null;
|
const dropdown = document.querySelector('[data-episode-dropdown]') as HTMLElement | null;
|
||||||
let searchDebounce: number | undefined;
|
let searchDebounce: number | undefined;
|
||||||
|
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.addEventListener('input', () => {
|
searchInput.addEventListener(
|
||||||
clearTimeout(searchDebounce);
|
'input',
|
||||||
// debounce to avoid excessive range switches while typing
|
() => {
|
||||||
searchDebounce = window.setTimeout(() => {
|
clearTimeout(searchDebounce);
|
||||||
const val = searchInput.value.replace(/\D/g, '');
|
// debounce to avoid excessive range switches while typing
|
||||||
if (!val) {
|
searchDebounce = window.setTimeout(() => {
|
||||||
// clear: jump to current episode range
|
const val = searchInput.value.replace(/\D/g, '');
|
||||||
const cur = Number.parseInt(state.currentEpisode, 10);
|
if (!val) {
|
||||||
switchEpisodeRange(Math.floor((cur - 1) / 100));
|
// clear: jump to current episode range
|
||||||
updateEpisodeHighlight(cur);
|
const cur = Number.parseInt(state.currentEpisode, 10);
|
||||||
return;
|
switchEpisodeRange(Math.floor((cur - 1) / 100));
|
||||||
}
|
updateEpisodeHighlight(cur);
|
||||||
const ep = Number.parseInt(val, 10);
|
return;
|
||||||
if (!ep || ep <= 0) return;
|
}
|
||||||
const maxEp = state.totalEpisodes > 0 ? state.totalEpisodes : 500;
|
const ep = Number.parseInt(val, 10);
|
||||||
const clamped = Math.min(ep, maxEp);
|
if (!ep || ep <= 0) return;
|
||||||
searchInput.value = String(clamped);
|
const maxEp = state.totalEpisodes > 0 ? state.totalEpisodes : 500;
|
||||||
if (state.episodeGrid) {
|
const clamped = Math.min(ep, maxEp);
|
||||||
switchEpisodeRange(Math.floor((clamped - 1) / 100));
|
searchInput.value = String(clamped);
|
||||||
updateEpisodeHighlight(clamped);
|
if (state.episodeGrid) {
|
||||||
}
|
switchEpisodeRange(Math.floor((clamped - 1) / 100));
|
||||||
}, 300);
|
updateEpisodeHighlight(clamped);
|
||||||
});
|
}
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// range buttons (100s of episodes)
|
// range buttons (100s of episodes)
|
||||||
if (dropdown) {
|
if (dropdown) {
|
||||||
dropdown.querySelectorAll('.episode-range-btn').forEach(btn => {
|
dropdown.querySelectorAll('.episode-range-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener(
|
||||||
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? '0', 10);
|
'click',
|
||||||
switchEpisodeRange(idx);
|
() => {
|
||||||
});
|
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? '0', 10);
|
||||||
|
switchEpisodeRange(idx);
|
||||||
|
const dd = btn.closest('ui-dropdown');
|
||||||
|
if (isClosableDropdown(dd)) dd.close();
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,3 +354,10 @@ document.body.addEventListener('htmx:afterSwap', (e: Event) => {
|
|||||||
const target = (e as CustomEvent).detail?.target as HTMLElement | null;
|
const target = (e as CustomEvent).detail?.target as HTMLElement | null;
|
||||||
if (target?.querySelector('[data-video-player]')) initPlayer();
|
if (target?.querySelector('[data-video-player]')) initPlayer();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener('htmx:beforeSwap', (e: Event) => {
|
||||||
|
const target = (e as CustomEvent).detail?.target as HTMLElement | null;
|
||||||
|
if (target && currentContainer && target.contains(currentContainer)) {
|
||||||
|
teardownPlayer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { displayTimeFromAbsolute } from './timeline';
|
|||||||
import { showControls } from './controls';
|
import { showControls } from './controls';
|
||||||
import { updateSubtitleOptions } from './subtitles';
|
import { updateSubtitleOptions } from './subtitles';
|
||||||
import { updateQualityOptions } from './quality';
|
import { updateQualityOptions } from './quality';
|
||||||
|
import { safeLocalStorage } from './storage';
|
||||||
|
|
||||||
// builds stream URL with mode, token, and optional quality param
|
// builds stream URL with mode, token, and optional quality param
|
||||||
const streamUrlForMode = (mode: string, quality?: string): string => {
|
const streamUrlForMode = (mode: string, quality?: string): string => {
|
||||||
@@ -21,7 +22,9 @@ const loadVideo = (url: string): void => {
|
|||||||
state.video.src = url;
|
state.video.src = url;
|
||||||
state.video.load();
|
state.video.load();
|
||||||
state.pendingSeekTime = prevTime; // restored in loadedmetadata handler
|
state.pendingSeekTime = prevTime; // restored in loadedmetadata handler
|
||||||
if (wasPlaying) state.video.play().catch(() => {});
|
if (wasPlaying) {
|
||||||
|
state.video.play().catch(() => undefined);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,8 +34,11 @@ const loadVideo = (url: string): void => {
|
|||||||
export const switchMode = (mode: string): void => {
|
export const switchMode = (mode: string): void => {
|
||||||
if (!state.availableModes.includes(mode) || mode === state.currentMode) return;
|
if (!state.availableModes.includes(mode) || mode === state.currentMode) return;
|
||||||
state.currentMode = mode;
|
state.currentMode = mode;
|
||||||
localStorage.setItem('player-audio-mode', mode);
|
safeLocalStorage.setItem('player-audio-mode', mode);
|
||||||
loadVideo(streamUrlForMode(mode, state.container.querySelector('[data-quality-select]')?.value));
|
const qualitySelect = state.container.querySelector(
|
||||||
|
'[data-quality-select]'
|
||||||
|
) as HTMLSelectElement | null;
|
||||||
|
loadVideo(streamUrlForMode(mode, qualitySelect?.value));
|
||||||
updateSubtitleOptions();
|
updateSubtitleOptions();
|
||||||
updateQualityOptions();
|
updateQualityOptions();
|
||||||
updateModeButtons();
|
updateModeButtons();
|
||||||
@@ -48,16 +54,20 @@ export const updateModeButtons = (): void => {
|
|||||||
const m = state.currentMode;
|
const m = state.currentMode;
|
||||||
|
|
||||||
dub?.classList.toggle('text-accent', m === 'dub');
|
dub?.classList.toggle('text-accent', m === 'dub');
|
||||||
dub?.classList.toggle('text-white', m !== 'dub');
|
dub?.classList.toggle('text-foreground', m !== 'dub');
|
||||||
dub?.classList.toggle('opacity-50', !state.availableModes.includes('dub'));
|
dub?.classList.toggle('opacity-50', !state.availableModes.includes('dub'));
|
||||||
dub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('dub'));
|
dub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('dub'));
|
||||||
dub && (dub.disabled = !state.availableModes.includes('dub'));
|
if (dub) {
|
||||||
|
dub.disabled = !state.availableModes.includes('dub');
|
||||||
|
}
|
||||||
|
|
||||||
sub?.classList.toggle('text-accent', m === 'sub');
|
sub?.classList.toggle('text-accent', m === 'sub');
|
||||||
sub?.classList.toggle('text-white', m !== 'sub');
|
sub?.classList.toggle('text-foreground', m !== 'sub');
|
||||||
sub?.classList.toggle('opacity-50', !state.availableModes.includes('sub'));
|
sub?.classList.toggle('opacity-50', !state.availableModes.includes('sub'));
|
||||||
sub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('sub'));
|
sub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('sub'));
|
||||||
sub && (sub.disabled = !state.availableModes.includes('sub'));
|
if (sub) {
|
||||||
|
sub.disabled = !state.availableModes.includes('sub');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,7 +92,7 @@ export const setupMode = (): void => {
|
|||||||
|
|
||||||
const autoplayBtn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
|
const autoplayBtn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
|
||||||
autoplayBtn?.addEventListener('change', e => {
|
autoplayBtn?.addEventListener('change', e => {
|
||||||
localStorage.setItem(
|
safeLocalStorage.setItem(
|
||||||
'mal:autoplay-enabled',
|
'mal:autoplay-enabled',
|
||||||
(e.target as HTMLInputElement).checked ? 'true' : 'false'
|
(e.target as HTMLInputElement).checked ? 'true' : 'false'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,35 +16,49 @@ const sendBeacon = (payload: string) => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let saveProgressInFlight: Promise<void> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves current progress to backend.
|
* Saves current progress to backend.
|
||||||
* Debounced: skips if within 5s of last save for same episode.
|
* Debounced: skips if within 5s of last save for same episode.
|
||||||
*/
|
*/
|
||||||
export const saveProgress = async (): Promise<void> => {
|
export const saveProgress = async (): Promise<void> => {
|
||||||
if (state.transitionEpisode !== null || !state.malID || state.video.currentTime < 1) return;
|
if (saveProgressInFlight) return saveProgressInFlight;
|
||||||
// progress is user-scoped; avoid spamming 401s for anonymous sessions
|
|
||||||
if (!document.cookie.includes('mal_session=')) return;
|
|
||||||
const episode = Number.parseInt(state.currentEpisode, 10);
|
|
||||||
if (!episode) return;
|
|
||||||
|
|
||||||
const safeTime = displayTimeFromAbsolute(state.video.currentTime);
|
const request = (async (): Promise<void> => {
|
||||||
// skip if recently saved
|
if (state.transitionEpisode !== null || !state.malID || state.video.currentTime < 1) return;
|
||||||
if (
|
const episode = Number.parseInt(state.currentEpisode, 10);
|
||||||
state.lastSavedProgress.episode === state.currentEpisode &&
|
if (!episode) return;
|
||||||
Math.abs(state.lastSavedProgress.seconds - safeTime) < 5
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const payload = buildPayload(episode, safeTime);
|
const safeTime = displayTimeFromAbsolute(state.video.currentTime);
|
||||||
|
// skip if recently saved
|
||||||
|
if (
|
||||||
|
state.lastSavedProgress.episode === state.currentEpisode &&
|
||||||
|
Math.abs(state.lastSavedProgress.seconds - safeTime) < 5
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildPayload(episode, safeTime);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/watch-progress', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
state.lastSavedProgress = { episode: state.currentEpisode, seconds: safeTime };
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
|
||||||
|
saveProgressInFlight = request;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/watch-progress', {
|
await request;
|
||||||
method: 'POST',
|
} finally {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (saveProgressInFlight === request) {
|
||||||
body: payload,
|
saveProgressInFlight = null;
|
||||||
});
|
}
|
||||||
if (!res.ok) return;
|
}
|
||||||
state.lastSavedProgress = { episode: state.currentEpisode, seconds: safeTime };
|
|
||||||
} catch {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// schedules periodic save every 30s during playback
|
// schedules periodic save every 30s during playback
|
||||||
@@ -62,8 +76,6 @@ const scheduleProgressSave = (): void => {
|
|||||||
*/
|
*/
|
||||||
export const markEpisodeTransition = (episodeNumber: number): void => {
|
export const markEpisodeTransition = (episodeNumber: number): void => {
|
||||||
if (!state.malID || !episodeNumber) return;
|
if (!state.malID || !episodeNumber) return;
|
||||||
// progress is user-scoped; avoid sending beacons for anonymous sessions
|
|
||||||
if (!document.cookie.includes('mal_session=')) return;
|
|
||||||
if (state.progressSaveTimer !== undefined) {
|
if (state.progressSaveTimer !== undefined) {
|
||||||
window.clearTimeout(state.progressSaveTimer);
|
window.clearTimeout(state.progressSaveTimer);
|
||||||
state.progressSaveTimer = undefined;
|
state.progressSaveTimer = undefined;
|
||||||
@@ -77,7 +89,7 @@ export const markEpisodeTransition = (episodeNumber: number): void => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
body: payload,
|
body: payload,
|
||||||
}).catch(() => {});
|
}).catch(() => undefined);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,7 +118,6 @@ export const setupProgress = (): void => {
|
|||||||
// save on page close
|
// save on page close
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
if (state.transitionEpisode !== null || state.completionSent || !state.malID) return;
|
if (state.transitionEpisode !== null || state.completionSent || !state.malID) return;
|
||||||
if (!document.cookie.includes('mal_session=')) return;
|
|
||||||
const episode = Number.parseInt(state.currentEpisode, 10);
|
const episode = Number.parseInt(state.currentEpisode, 10);
|
||||||
if (!episode) return;
|
if (!episode) return;
|
||||||
sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime)));
|
sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime)));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { state } from './state';
|
import { state } from './state';
|
||||||
import { displayTimeFromAbsolute } from './timeline';
|
import { displayTimeFromAbsolute } from './timeline';
|
||||||
|
import { safeLocalStorage } from './storage';
|
||||||
|
|
||||||
// same as mode.ts - could be extracted to shared util
|
// same as mode.ts - could be extracted to shared util
|
||||||
const streamUrlForMode = (mode: string, quality?: string): string => {
|
const streamUrlForMode = (mode: string, quality?: string): string => {
|
||||||
@@ -17,7 +18,9 @@ const loadVideo = (url: string): void => {
|
|||||||
state.video.src = url;
|
state.video.src = url;
|
||||||
state.video.load();
|
state.video.load();
|
||||||
state.pendingSeekTime = prevTime;
|
state.pendingSeekTime = prevTime;
|
||||||
if (wasPlaying) state.video.play().catch(() => {});
|
if (wasPlaying) {
|
||||||
|
state.video.play().catch(() => undefined);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +30,7 @@ const loadVideo = (url: string): void => {
|
|||||||
export const switchQuality = (quality: string): void => {
|
export const switchQuality = (quality: string): void => {
|
||||||
const url = streamUrlForMode(state.currentMode, quality);
|
const url = streamUrlForMode(state.currentMode, quality);
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
localStorage.setItem('mal:preferred-quality', quality);
|
safeLocalStorage.setItem('mal:preferred-quality', quality);
|
||||||
loadVideo(url);
|
loadVideo(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,7 +57,7 @@ export const updateQualityOptions = (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// restore saved preference
|
// restore saved preference
|
||||||
const preferred = localStorage.getItem('mal:preferred-quality') || 'best';
|
const preferred = safeLocalStorage.getItem('mal:preferred-quality') || 'best';
|
||||||
select.value = qualities.includes(preferred) ? preferred : 'best';
|
select.value = qualities.includes(preferred) ? preferred : 'best';
|
||||||
|
|
||||||
// hide if no quality options
|
// hide if no quality options
|
||||||
|
|||||||
@@ -28,9 +28,17 @@ export const setupSegmentEditor = (): void => {
|
|||||||
const typeOptions = Array.from(
|
const typeOptions = Array.from(
|
||||||
panel.querySelectorAll('[data-segment-type-option]')
|
panel.querySelectorAll('[data-segment-type-option]')
|
||||||
) as HTMLButtonElement[];
|
) as HTMLButtonElement[];
|
||||||
|
const focusableSelector = [
|
||||||
|
'button:not([disabled])',
|
||||||
|
'input:not([disabled])',
|
||||||
|
'select:not([disabled])',
|
||||||
|
'textarea:not([disabled])',
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
let startTime: number | null = null;
|
let startTime: number | null = null;
|
||||||
let endTime: number | null = null;
|
let endTime: number | null = null;
|
||||||
|
let lastActiveElement: HTMLElement | null = null;
|
||||||
|
|
||||||
const setError = (msg: string | null): void => {
|
const setError = (msg: string | null): void => {
|
||||||
if (!errorBox) return;
|
if (!errorBox) return;
|
||||||
@@ -49,13 +57,22 @@ export const setupSegmentEditor = (): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const open = (): void => {
|
const open = (): void => {
|
||||||
|
lastActiveElement =
|
||||||
|
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
panel.classList.remove('hidden');
|
panel.classList.remove('hidden');
|
||||||
|
panel.classList.add('flex');
|
||||||
|
panel.setAttribute('aria-hidden', 'false');
|
||||||
setError(null);
|
setError(null);
|
||||||
showControls();
|
showControls();
|
||||||
|
const firstFocusable = panel.querySelector(focusableSelector) as HTMLElement | null;
|
||||||
|
firstFocusable?.focus();
|
||||||
};
|
};
|
||||||
const close = (): void => {
|
const close = (): void => {
|
||||||
panel.classList.add('hidden');
|
panel.classList.add('hidden');
|
||||||
|
panel.classList.remove('flex');
|
||||||
|
panel.setAttribute('aria-hidden', 'true');
|
||||||
setError(null);
|
setError(null);
|
||||||
|
lastActiveElement?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
toggleBtn.addEventListener('click', () => {
|
toggleBtn.addEventListener('click', () => {
|
||||||
@@ -64,12 +81,49 @@ export const setupSegmentEditor = (): void => {
|
|||||||
});
|
});
|
||||||
closeBtn?.addEventListener('click', close);
|
closeBtn?.addEventListener('click', close);
|
||||||
|
|
||||||
// close when clicking outside the segment capture UI
|
document.addEventListener('keydown', e => {
|
||||||
|
if (panel.classList.contains('hidden')) return;
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key !== 'Tab') return;
|
||||||
|
const focusables = Array.from(panel.querySelectorAll(focusableSelector)).filter(
|
||||||
|
el =>
|
||||||
|
el instanceof HTMLElement && !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')
|
||||||
|
) as HTMLElement[];
|
||||||
|
if (focusables.length === 0) return;
|
||||||
|
|
||||||
|
const first = focusables[0];
|
||||||
|
const last = focusables[focusables.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (!(active instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
if (e.shiftKey && active === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!e.shiftKey && active === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// close when clicking the backdrop outside the modal content
|
||||||
document.addEventListener('pointerdown', e => {
|
document.addEventListener('pointerdown', e => {
|
||||||
if (panel.classList.contains('hidden')) return;
|
if (panel.classList.contains('hidden')) return;
|
||||||
const target = e.target as Node | null;
|
const target = e.target as Node | null;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
if (root.contains(target)) return;
|
if (
|
||||||
|
(e.target as HTMLElement | null)?.closest('[data-segment-editor] [data-segment-editor-close]')
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const content = panel.firstElementChild;
|
||||||
|
if (content && content.contains(target)) return;
|
||||||
close();
|
close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { state } from '../state';
|
import { state } from '../state';
|
||||||
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from '../timeline';
|
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from '../timeline';
|
||||||
import { showControls } from '../controls';
|
import { showControls } from '../controls';
|
||||||
|
import { saveProgress } from '../progress';
|
||||||
|
import { safeLocalStorage } from '../storage';
|
||||||
|
|
||||||
// button label based on segment type
|
// button label based on segment type
|
||||||
const skipLabel = (type: string): string => (type === 'ed' ? 'Skip outro' : 'Skip intro');
|
const skipLabel = (type: string): string => (type === 'ed' ? 'Skip outro' : 'Skip intro');
|
||||||
@@ -26,9 +28,10 @@ export const updateSkipButton = (currentTime: number): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// auto-skip: jump to end if enabled
|
// auto-skip: jump to end if enabled
|
||||||
const autoSkip = localStorage.getItem('mal:autoskip-enabled') === 'true';
|
const autoSkip = safeLocalStorage.getItem('mal:autoskip-enabled') === 'true';
|
||||||
if (autoSkip && displayTime >= segment.start && displayTime < segment.end) {
|
if (autoSkip && displayTime >= segment.start && displayTime < segment.end) {
|
||||||
state.video.currentTime = absoluteTimeFromDisplay(segment.end + 0.01);
|
state.video.currentTime = absoluteTimeFromDisplay(segment.end + 0.01);
|
||||||
|
void saveProgress();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +49,8 @@ export const updateSkipButton = (currentTime: number): void => {
|
|||||||
*/
|
*/
|
||||||
export const updateAutoSkipButton = (): void => {
|
export const updateAutoSkipButton = (): void => {
|
||||||
const btn = document.querySelector('[data-autoskip]') as HTMLInputElement | null;
|
const btn = document.querySelector('[data-autoskip]') as HTMLInputElement | null;
|
||||||
btn && (btn.checked = localStorage.getItem('mal:autoskip-enabled') === 'true');
|
if (!btn) return;
|
||||||
|
btn.checked = safeLocalStorage.getItem('mal:autoskip-enabled') === 'true';
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,7 +60,7 @@ export const setupSkip = (): void => {
|
|||||||
document.addEventListener('change', e => {
|
document.addEventListener('change', e => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.hasAttribute('data-autoskip')) {
|
if (target.hasAttribute('data-autoskip')) {
|
||||||
localStorage.setItem(
|
safeLocalStorage.setItem(
|
||||||
'mal:autoskip-enabled',
|
'mal:autoskip-enabled',
|
||||||
(target as HTMLInputElement).checked ? 'true' : 'false'
|
(target as HTMLInputElement).checked ? 'true' : 'false'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,8 +66,9 @@ export const renderSegments = (): void => {
|
|||||||
const bar = document.createElement('div');
|
const bar = document.createElement('div');
|
||||||
// use distinct colors so segments are readable over buffered/progress fills
|
// use distinct colors so segments are readable over buffered/progress fills
|
||||||
bar.className = 'absolute top-0 h-full opacity-95';
|
bar.className = 'absolute top-0 h-full opacity-95';
|
||||||
// single color for OP/ED, rendered above buffered/progress fills
|
// distinct colors for OP/ED, rendered above buffered/progress fills
|
||||||
bar.classList.add('bg-amber-300/90');
|
const t = (s.type || '').toLowerCase();
|
||||||
|
bar.style.backgroundColor = t === 'ed' ? '#60a5fa' : '#f5c542';
|
||||||
bar.style.left = `${(s.start / bounds) * 100}%`;
|
bar.style.left = `${(s.start / bounds) * 100}%`;
|
||||||
bar.style.width = `${((s.end - s.start) / bounds) * 100}%`;
|
bar.style.width = `${((s.end - s.start) / bounds) * 100}%`;
|
||||||
track.appendChild(bar);
|
track.appendChild(bar);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user