Compare commits

..

1 Commits

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

View File

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

View File

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

View File

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

119
README.md
View File

@@ -1,53 +1,136 @@
# MyAnimeList
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/static/assets/readme-logo-dark.svg" />
<img src="/static/assets/readme-logo-light.svg" alt="MyAnimeList logo" width="120" />
</picture>
</p>
<table align="center">
<tr>
<td>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/static/assets/readme-logo-dark.svg" />
<img src="/static/assets/readme-logo-light.svg" alt="MyAnimeList logo" width="140" />
</picture>
</td>
<td>
<strong>MyAnimeList</strong><br />
My personal anime tracker, built because nothing else felt right.
</td>
</tr>
</table>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/go-1.25-00ADD8?style=flat-square&logo=go" />
<img alt="SQLite" src="https://img.shields.io/badge/database-sqlite-003B57?style=flat-square&logo=sqlite" />
<img alt="Tailwind" src="https://img.shields.io/badge/tailwind-4-06D6D4?style=flat-square&logo=tailwindcss" />
<img alt="Tailwind" src="https://img.shields.io/badge/tailwind-4-06B6D4?style=flat-square&logo=tailwindcss" />
<img alt="HTMX" src="https://img.shields.io/badge/htmx-partial--updates-3366CC?style=flat-square" />
</p>
---
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.
## Why this project exists
It is a self-hosted Go server that streams anime through a proxy layer, catalogs metadata, and tracks your progress.
I built this for myself.
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.
I was frustrated with the UI and UX of every tracker I tried. Even when something looked decent, it still felt awkward to use day-to-day, or it was missing pieces I considered essential. I wanted one place that matched how I actually watch anime: search fast, get context fast, update status fast, and move on.
So this project is personal first and public second. I put it on GitHub because I like shipping in the open, not because it was originally designed as a general-purpose product for everyone.
Technically, I also wanted to prove that a small, server-rendered Go app could stay reliable even when upstream anime APIs are inconsistent. A lot of this code exists because real APIs rate-limit, timeout, and occasionally fail at the worst possible moment.
## What the application offers
For my own workflow, MyAnimeList combines catalog browsing, seasonal discovery, quick search, detail pages with recommendations and relations, watchlist management, continue-watching, and in-app playback in one server-rendered interface.
The interface is minimal and functional, featuring a dark theme and quick access to tracking tools.
## Technical approach
The application is written in Go and rendered on the server with `html/template`, with SQLite as the primary datastore and `sqlc` for typed query generation. Styling uses Tailwind CSS v4. HTMX and small TypeScript modules handle incremental interactions, which keeps the interface responsive without moving the entire product into a heavy client-side architecture.
The external anime data source is Jikan (`https://api.jikan.moe/v4`). Because reliability is a first-class concern, the client layer includes request pacing, bounded retries, backoff behavior, stale-cache fallback, and a persisted retry queue for failed fetches. Playback proxying uses uTLS to bypass Cloudflare protections.
Upstream APIs can fail transiently with `429` and `5xx` responses, so the app favors graceful degradation over hard failure. Cached values are used when fresh requests fail, retryable failures are persisted and replayed in a background worker, and relation synchronization is incremental so one bad fetch does not block the rest of the graph.
## Repository structure
The codebase follows standard Go project layout conventions.
| Path | Purpose |
| ----------------- | ------------------------------------------------ |
| `api/*` | Feature routes: anime, auth, playback, watchlist |
| `cmd/server` | Application entrypoint and CLI commands |
| `cmd/user` | User management CLI (create, update, delete) |
| `integrations/*` | External API clients and scraping |
| `internal/*` | Core services: db, middleware, server, worker |
| `pkg/middleware` | Generic HTTP middleware |
| `templates/*` | Server-rendered HTML templates |
| `migrations` | Schema evolution (20 migrations) |
| `migrations` | Schema evolution |
| `static` / `dist` | Frontend assets |
## Running locally
## Getting started
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.
Requires Go `1.25+`, Bun, and [just](https://github.com/casey/just) (`brew install just`).
```bash
just dev
git clone https://github.com/mkelvers/mal.git && cd mal
openssl rand -base32 32
PLAYBACK_PROXY_SECRET="your-32-char-secret" go run ./cmd/server
go run ./cmd/user <username> <password>
```
## Contributing
The app runs at `http://localhost:3000`.
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.
### Tasks
The justfile automates common tasks:
```bash
just fmt # format go code
just lint # go fmt && go vet
just test # run go tests
just build # build go binary + frontend
just check # lint, test, typecheck, build
just dev # build and run
just install-hooks # install pre-push hooks
```
### Docker
```bash
docker build -t mal .
docker run --rm -p 3000:3000 -e PLAYBACK_PROXY_SECRET="$(openssl rand -base32 32)" mal
# persistent data
docker run --rm -p 3000:3000 \
-e DATABASE_FILE=/app/data/mal.db \
-e PLAYBACK_PROXY_SECRET="your-secret" \
-v "$(pwd)/data:/app/data" \
mal
docker exec mal ./cmd/user <username> <password>
```
## Configuration
| Variable | Default | Description |
| ----------------------- | ------------------- | ----------------------------------------------------------- |
| `PORT` | `3000` | HTTP listen port |
| `DATABASE_FILE` | `mal.db` | SQLite database file path |
| `ENV` | _(empty)_ | Set to `production` to enable secure session cookies |
| `MIGRATIONS_DIR` | _(auto-discovered)_ | Optional explicit path to migration files |
| `PLAYBACK_PROXY_SECRET` | _(required)_ | HMAC secret for signed playback proxy tokens (min 32 chars) |
| `MAL_JIKAN_TRACE` | `false` | Log all Jikan cache/upstream timings when enabled |
## Testing
Run locally with `just check` or manually:
```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
MIT. See `LICENSE`.
This project is released under the MIT License. See `LICENSE` for details.

View File

@@ -4,9 +4,12 @@
"workspaces": {
"": {
"name": "myanimelist-ui",
"dependencies": {
"dompurify": "^3.4.1",
},
"devDependencies": {
"@tailwindcss/cli": "^4.2.4",
"@types/node": "^24.0.0",
"@toolwind/anchors": "^1.0.10",
"@typescript-eslint/eslint-plugin": "^8.59.2",
"@typescript-eslint/parser": "^8.59.2",
"eslint": "^10.3.0",
@@ -115,13 +118,15 @@
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="],
"@toolwind/anchors": ["@toolwind/anchors@1.0.10", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0" } }, "sha512-F3J/lxGGPUy+GIpT49NmYMF1X7l0d7UzdDASni29il2ro5sT4cYfPBFHBAfOM0lpgKOr/HnqINlomngt8BcvnA=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="],
@@ -161,6 +166,8 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dompurify": ["dompurify@3.4.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw=="],
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
@@ -333,8 +340,6 @@
"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=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],

View File

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

View File

@@ -1,50 +1,32 @@
// Package main provides small CLI utilities for local admin tasks.
package main
import (
"bufio"
"database/sql"
"fmt"
"log"
"os"
"strings"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"mal/internal/config"
"mal/internal/database"
"mal/internal/db"
"mal/internal/observability"
)
func main() {
cfg, err := config.Load()
dbConn, err := db.Open(db.GetDBFile())
if err != nil {
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)
log.Fatalf("failed to open db: %v", err)
}
defer func() { _ = dbConn.Close() }()
if len(os.Args) == 2 {
switch os.Args[1] {
case "update-avatar":
updateAvatars(dbConn)
return
case "run-fixes":
runFixes(dbConn)
return
}
if len(os.Args) == 2 && os.Args[1] == "update-avatar" {
updateAvatars(dbConn)
return
}
if len(os.Args) != 3 {
observability.Warn("cli_usage", "cmd/user", "invalid arguments", map[string]any{"argc": len(os.Args)}, nil)
_, _ = fmt.Fprintln(os.Stderr, "Usage: go run cmd/user/main.go <username> <password>\n go run cmd/user/main.go update-avatar\n go run cmd/user/main.go run-fixes")
os.Exit(2)
log.Fatalf("Usage: go run cmd/user/main.go <username> <password>\n go run cmd/user/main.go update-avatar")
}
username := os.Args[1]
@@ -53,8 +35,7 @@ func main() {
var existingID string
err = dbConn.QueryRow("SELECT id FROM user WHERE username = ?", username).Scan(&existingID)
if err != nil && err != sql.ErrNoRows {
observability.Error("cli_user_lookup_failed", "cmd/user", "", map[string]any{"username": username}, err)
os.Exit(1)
log.Fatalf("database error: %v", err)
}
if err == nil {
@@ -70,14 +51,12 @@ func main() {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
os.Exit(1)
log.Fatalf("failed to hash password: %v", err)
}
_, err = dbConn.Exec("UPDATE user SET password_hash = ? WHERE id = ?", string(hash), existingID)
if err != nil {
observability.Error("cli_user_password_update_failed", "cmd/user", "", map[string]any{"username": username}, err)
os.Exit(1)
log.Fatalf("failed to update user: %v", err)
}
fmt.Printf("Password for '%s' updated successfully!\n", username)
@@ -86,16 +65,14 @@ func main() {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
os.Exit(1)
log.Fatalf("failed to hash password: %v", err)
}
id := uuid.New().String()
avatarURL := fmt.Sprintf("https://api.dicebear.com/9.x/dylan/svg?seed=%s", username)
_, err = dbConn.Exec("INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)", id, username, string(hash), avatarURL)
if err != nil {
observability.Error("cli_user_create_failed", "cmd/user", "", map[string]any{"username": username}, err)
os.Exit(1)
log.Fatalf("failed to create user: %v", err)
}
fmt.Printf("User '%s' was created successfully!\n", username)
@@ -104,8 +81,7 @@ func main() {
func updateAvatars(dbConn *sql.DB) {
rows, err := dbConn.Query("SELECT id, username FROM user")
if err != nil {
observability.Error("cli_users_list_failed", "cmd/user", "", nil, err)
os.Exit(1)
log.Fatalf("failed to fetch users: %v", err)
}
defer func() { _ = rows.Close() }()
@@ -113,55 +89,20 @@ func updateAvatars(dbConn *sql.DB) {
for rows.Next() {
var id, username string
if err := rows.Scan(&id, &username); err != nil {
observability.Error("cli_user_scan_failed", "cmd/user", "", nil, err)
os.Exit(1)
log.Fatalf("failed to scan user: %v", err)
}
avatarURL := fmt.Sprintf("https://api.dicebear.com/9.x/dylan/svg?seed=%s", username)
_, err := dbConn.Exec("UPDATE user SET avatar_url = ? WHERE id = ?", avatarURL, id)
if err != nil {
observability.Error("cli_user_avatar_update_failed", "cmd/user", "", map[string]any{"username": username}, err)
os.Exit(1)
log.Fatalf("failed to update avatar for %s: %v", username, err)
}
count++
}
if err := rows.Err(); err != nil {
observability.Error("cli_users_iter_failed", "cmd/user", "", nil, err)
os.Exit(1)
log.Fatalf("iteration error: %v", err)
}
fmt.Printf("Updated avatars for %d user(s)\n", count)
}
func runFixes(dbConn *sql.DB) {
if err := database.RunMigrationsAndFixes(dbConn); err != nil {
observability.Error("cli_run_migrations_and_fixes_failed", "cmd/user", "", nil, err)
os.Exit(1)
}
rows, err := dbConn.Query("SELECT id, applied_at FROM data_fixes ORDER BY id ASC")
if err != nil {
observability.Error("cli_data_fixes_list_failed", "cmd/user", "", nil, err)
os.Exit(1)
}
defer func() { _ = rows.Close() }()
count := 0
for rows.Next() {
var id string
var appliedAt string
if err := rows.Scan(&id, &appliedAt); err != nil {
observability.Error("cli_data_fix_scan_failed", "cmd/user", "", nil, err)
os.Exit(1)
}
fmt.Printf("%s applied_at=%s\n", id, appliedAt)
count++
}
if err := rows.Err(); err != nil {
observability.Error("cli_data_fixes_iter_failed", "cmd/user", "", nil, err)
os.Exit(1)
}
fmt.Printf("Applied fixes: %d\n", count)
}

View File

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 685 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,4 +4,3 @@ import "time"
const shortCacheTTL = time.Hour // 1 hour - for frequently changing data
const longCacheTTL = time.Hour * 24 // 24 hours - for stable data like genres
const producerCacheTTL = time.Hour * 24 * 30

View File

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

View File

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

View File

@@ -4,12 +4,11 @@ import (
"context"
"errors"
"fmt"
"log"
"sort"
"strings"
"time"
"mal/internal/observability"
"mal/integrations/watchorder"
"golang.org/x/sync/errgroup"
@@ -63,44 +62,21 @@ func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrd
return watchorder.WatchOrderResult{}, watchorder.ErrWatchOrderNotFound
}
if errors.Is(err, watchorder.ErrWatchOrderMarkupNotFound) {
observability.Warn(
"relations_watch_order_markup_missing",
"jikan",
"",
map[string]any{
"anime_id": id,
"url": watchOrderURL,
},
err,
)
log.Printf("relations: watch-order markup missing for %d (%s): %v", id, watchOrderURL, err)
} else if errors.As(err, &statusError) {
observability.Warn(
"relations_watch_order_http_error",
"jikan",
"",
map[string]any{
"anime_id": id,
"url": watchOrderURL,
"status": statusError.StatusCode,
"server": statusError.Server,
"cf_ray": statusError.CFRay,
"location": statusError.Location,
"content_type": statusError.ContentType,
"body_preview": statusError.BodyPreview,
},
err,
log.Printf(
"relations: watch-order http error for %d (%s): status=%d server=%q cf_ray=%q location=%q content_type=%q body=%q",
id,
watchOrderURL,
statusError.StatusCode,
statusError.Server,
statusError.CFRay,
statusError.Location,
statusError.ContentType,
statusError.BodyPreview,
)
} else {
observability.Warn(
"relations_watch_order_fetch_failed",
"jikan",
"",
map[string]any{
"anime_id": id,
"url": watchOrderURL,
},
err,
)
log.Printf("relations: watch-order fetch failed for %d (%s): %v", id, watchOrderURL, err)
}
return watchorder.WatchOrderResult{}, err
}
@@ -131,15 +107,7 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
if errors.Is(err, watchorder.ErrWatchOrderNotFound) {
return c.currentOnlyRelation(ctx, id)
}
observability.Warn(
"relations_watch_order_fallback_current_only",
"jikan",
"",
map[string]any{
"anime_id": id,
},
err,
)
log.Printf("relations: using current-only fallback for %d: %v", id, err)
return c.currentOnlyRelation(ctx, id)
}
@@ -208,6 +176,9 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
IsCurrent: res.entry.ID == id,
IsExtra: false,
})
if res.entry.ID == id {
relations[len(relations)-1].Relation = "Current"
}
}
if !seen[id] {

View File

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

View File

@@ -1,9 +1,6 @@
package jikan
import (
"context"
"fmt"
)
import ()
type ProducerResponse struct {
Data struct {
@@ -27,18 +24,3 @@ type ProducerResponse struct {
} `json:"external"`
} `json:"data"`
}
func (c *Client) GetProducerByID(ctx context.Context, id int) (ProducerResponse, error) {
if id <= 0 {
return ProducerResponse{}, fmt.Errorf("invalid producer id")
}
cacheKey := fmt.Sprintf("producer:%d", id)
reqURL := fmt.Sprintf("%s/producers/%d", c.baseURL, id)
var result ProducerResponse
if err := c.getWithCache(ctx, cacheKey, producerCacheTTL, reqURL, &result); err != nil {
return ProducerResponse{}, err
}
return result, nil
}

View File

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

View File

@@ -3,11 +3,9 @@ package handler
import (
"context"
"fmt"
"mal/integrations/jikan"
"log"
"mal/internal/db"
"mal/internal/domain"
"mal/internal/observability"
"mal/internal/server"
"net/http"
"net/url"
"strconv"
@@ -22,14 +20,6 @@ type AnimeHandler struct {
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 {
return &AnimeHandler{
svc: svc,
@@ -69,9 +59,6 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
r.GET("/api/discover/top", h.HandleDiscoverTop)
r.GET("/api/discover/for-you", h.HandleDiscoverForYou)
r.GET("/schedule", h.HandleSchedule)
r.GET("/api/schedule", h.HandleScheduleSection)
r.GET("/browse", h.HandleBrowse)
r.GET("/anime/:id", h.HandleAnimeDetails)
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
@@ -79,101 +66,6 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
r.GET("/api/search-quick", h.HandleQuickSearch)
r.GET("/api/command-palette", h.HandleCommandPalette)
r.GET("/api/jikan/random/anime", h.HandleRandomAnime)
r.GET("/api/jikan/producers", h.HandleProducers)
}
func (h *AnimeHandler) HandleProducers(c *gin.Context) {
q := strings.TrimSpace(c.Query("q"))
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
return
}
if page < 1 {
page = 1
}
limit, err := strconv.Atoi(c.DefaultQuery("limit", "50"))
if err != nil {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid limit")
return
}
if limit < 1 {
limit = 12
}
if limit > 12 {
limit = 12
}
res, err := h.svc.GetProducers(c.Request.Context(), q, page, limit)
if err != nil {
observability.Warn(
"producers_fetch_failed",
"anime",
"",
map[string]any{
"q": q,
"page": page,
"limit": limit,
},
err,
)
if strings.Contains(c.GetHeader("Accept"), "text/html") {
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
"_fragment": "studio_dropdown_items",
"StudioItems": []any{},
"HasNextPage": false,
"Page": page,
"NextPage": page + 1,
"Query": q,
"Limit": limit,
})
return
}
server.RespondError(
c,
http.StatusInternalServerError,
"producers_fetch_failed",
"anime",
"failed to load producers",
map[string]any{"q": q, "page": page, "limit": limit},
err,
)
return
}
type item struct {
ID int `json:"id"`
Name string `json:"name"`
}
items := make([]item, 0, len(res.Items))
for _, p := range res.Items {
name := jikan.ProducerListEntryName(p)
if p.MalID <= 0 || name == "" {
continue
}
items = append(items, item{ID: p.MalID, Name: name})
}
if strings.Contains(c.GetHeader("Accept"), "text/html") {
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
"_fragment": "studio_dropdown_items",
"StudioItems": items,
"HasNextPage": res.HasNextPage,
"Page": page,
"NextPage": page + 1,
"Query": q,
"Limit": limit,
})
return
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"hasNextPage": res.HasNextPage,
"nextPage": page + 1,
})
}
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
@@ -206,17 +98,6 @@ func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
}
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
if err != nil {
observability.Warn(
"catalog_section_fetch_failed",
"anime",
"",
map[string]any{
"section": section,
"user_id": userID,
},
err,
)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
@@ -248,36 +129,6 @@ func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) {
h.renderDiscoverSection(c, "Top")
}
func (h *AnimeHandler) HandleDiscoverForYou(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
data, err := h.svc.GetDiscoverForYou(c.Request.Context(), userID)
if err != nil {
observability.Warn(
"discover_for_you_fetch_failed",
"anime",
"",
map[string]any{
"user_id": userID,
},
err,
)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
data.Section = "ForYou"
data.Fragment = "discover_row"
data.WatchlistMap = watchlistMap
c.HTML(http.StatusOK, "discover.gohtml", data)
}
func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
user, _ := c.Get("User")
userID := ""
@@ -286,17 +137,6 @@ func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
}
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)
if err != nil {
observability.Warn(
"discover_section_fetch_failed",
"anime",
"",
map[string]any{
"section": section,
"user_id": userID,
},
err,
)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
@@ -308,45 +148,6 @@ func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
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) {
q := c.Query("q")
animeType := c.Query("type")
@@ -354,58 +155,22 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
orderBy := c.Query("order_by")
sort := c.Query("sort")
sfw := c.Query("sfw") != "false"
studioID := 0
if raw := strings.TrimSpace(c.Query("studio")); raw != "" {
id, err := strconv.Atoi(raw)
if err != nil || id < 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid studio id")
return
}
studioID = id
}
var genres []int
for _, g := range c.QueryArray("genres") {
id, err := strconv.Atoi(g)
if err != nil {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid genre id")
return
}
id, _ := strconv.Atoi(g)
if id > 0 {
genres = append(genres, id)
}
}
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, studioID, sfw, page, 24)
res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, sfw, page, 24)
if err != nil {
server.RespondError(
c,
http.StatusInternalServerError,
"browse_search_failed",
"anime",
"failed to load browse results",
map[string]any{
"q": q,
"type": animeType,
"status": status,
"order_by": orderBy,
"sort": sort,
"studio": studioID,
"sfw": sfw,
"page": page,
},
err,
)
return
}
user, _ := c.Get("User")
@@ -413,21 +178,12 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
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
}
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, res.Animes)
if c.GetHeader("HX-Request") == "true" && page > 1 {
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
"_fragment": "anime_card_scroll",
"Animes": animes,
"Animes": res.Animes,
"NextPage": page + 1,
"HasNextPage": res.HasNextPage,
"Query": q,
@@ -436,8 +192,6 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
"OrderBy": orderBy,
"Sort": sort,
"Genres": genres,
"Studio": studioID,
"StudioName": studioName,
"SFW": sfw,
"WatchlistMap": watchlistMap,
})
@@ -456,11 +210,9 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
"OrderBy": orderBy,
"Sort": sort,
"Genres": genres,
"Studio": studioID,
"StudioName": studioName,
"SFW": sfw,
"GenresList": genresList,
"Animes": animes,
"Animes": res.Animes,
"HasNextPage": res.HasNextPage,
"NextPage": page + 1,
"User": user,
@@ -477,11 +229,9 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
"OrderBy": orderBy,
"Sort": sort,
"Genres": genres,
"Studio": studioID,
"StudioName": studioName,
"SFW": sfw,
"GenresList": genresList,
"Animes": animes,
"Animes": res.Animes,
"HasNextPage": res.HasNextPage,
"NextPage": page + 1,
"User": user,
@@ -490,9 +240,9 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
}
func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
c.Status(http.StatusNotFound)
return
}
@@ -521,16 +271,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
}
if err != nil {
observability.Warn(
"anime_section_fetch_failed",
"anime",
"",
map[string]any{
"section": section,
"anime_id": id,
},
err,
)
log.Printf("failed to fetch section %s: %v", section, err)
c.Status(http.StatusNoContent)
return
}
@@ -551,7 +292,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
user, _ := c.Get("User")
status := ""
var watchlistIDs []int64
ep := 0
ep := 1
var cwSeconds float64
if u, ok := user.(*domain.User); ok {
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), u.ID, int64(id))
@@ -579,9 +320,9 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
}
func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
id, err := strconv.Atoi(c.Query("animeId"))
if err != nil || id <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
id, _ := strconv.Atoi(c.Query("animeId"))
if id <= 0 {
c.Status(http.StatusBadRequest)
return
}
@@ -596,15 +337,7 @@ func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
relations, err := h.svc.GetRelations(relationsCtx, id)
if err != nil {
observability.Warn(
"relations_fetch_failed",
"anime",
"",
map[string]any{
"anime_id": id,
},
err,
)
log.Printf("failed to fetch relations for anime %d: %v", id, err)
c.Status(http.StatusNoContent)
return
}
@@ -632,7 +365,7 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
return
}
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, 1, 5)
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, true, 1, 5)
if err != nil {
c.JSON(http.StatusOK, []any{})
return
@@ -643,8 +376,7 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
animes := wrapAnimes(res.Animes)
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, res.Animes)
type quickSearchResult struct {
ID int `json:"id"`
@@ -655,8 +387,8 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
InWatchlist bool `json:"in_watchlist"`
}
output := make([]quickSearchResult, len(animes))
for i, anime := range animes {
output := make([]quickSearchResult, len(res.Animes))
for i, anime := range res.Animes {
output[i] = quickSearchResult{
ID: anime.MalID,
Title: anime.DisplayTitle(),
@@ -741,14 +473,13 @@ func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string)
searchCtx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
defer cancel()
res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, 0, true, 1, 5)
res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, true, 1, 5)
if err != nil {
return nil
}
animes := wrapAnimes(res.Animes)
items := make([]commandPaletteItem, 0, len(animes))
for _, anime := range animes {
items := make([]commandPaletteItem, 0, len(res.Animes))
for _, anime := range res.Animes {
items = append(items, commandPaletteItem{
ID: fmt.Sprintf("anime:%d", anime.MalID),
Type: "anime",
@@ -860,19 +591,11 @@ func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
anime, err := h.svc.GetRandomAnime(ctx)
if err != nil {
server.RespondError(
c,
http.StatusInternalServerError,
"random_anime_fetch_failed",
"anime",
"failed to fetch random anime",
nil,
err,
)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch random anime"})
return
}
if anime.MalID == 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadGateway, "random anime unavailable")
c.JSON(http.StatusBadGateway, gin.H{"error": "Random anime unavailable"})
return
}
@@ -890,32 +613,20 @@ func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
}
func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
c.Status(http.StatusNotFound)
return
}
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), id, page)
if err != nil {
server.RespondError(
c,
http.StatusInternalServerError,
"anime_reviews_fetch_failed",
"anime",
"failed to load reviews",
map[string]any{"anime_id": id, "page": page},
err,
)
c.Status(http.StatusInternalServerError)
return
}

View File

@@ -2,15 +2,10 @@ package service
import (
"context"
"errors"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"mal/internal/observability"
"math/rand"
"sort"
"strings"
"sync"
"time"
"golang.org/x/sync/errgroup"
@@ -21,14 +16,6 @@ type animeService struct {
repo domain.AnimeRepository
}
func wrapAnimes(in []jikan.Anime) []domain.Anime {
out := make([]domain.Anime, 0, len(in))
for _, a := range in {
out = append(out, domain.Anime{Anime: a})
}
return out
}
func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) domain.AnimeService {
return &animeService{jikan: jikan, repo: repo}
}
@@ -64,7 +51,7 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
return domain.CatalogSectionData{}, err
}
animes := wrapAnimes(res.Animes)
animes := res.Animes
if len(animes) > 6 {
animes = animes[:6]
}
@@ -97,7 +84,7 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se
return domain.DiscoverSectionData{}, err
}
animes := wrapAnimes(res.Animes)
animes := res.Animes
if len(animes) > 8 {
animes = animes[:8]
}
@@ -107,204 +94,12 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se
}, nil
}
func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (domain.DiscoverSectionData, error) {
if strings.TrimSpace(userID) == "" {
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
}
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
if err != nil {
return domain.DiscoverSectionData{}, err
}
seedIDs := make([]int, 0, 5)
for _, entry := range watchlist {
status := strings.TrimSpace(entry.Status)
if status != "watching" && status != "completed" {
continue
}
if entry.AnimeID <= 0 {
continue
}
seedIDs = append(seedIDs, int(entry.AnimeID))
if len(seedIDs) >= 5 {
break
}
}
if len(seedIDs) == 0 {
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
}
type ranked struct {
id int
votes int
}
recommended := map[int]ranked{}
var g errgroup.Group
g.SetLimit(4)
for _, seedID := range seedIDs {
g.Go(func() error {
recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seedID)
if recErr != nil {
return recErr
}
for _, rec := range recs {
id := rec.Entry.MalID
if id <= 0 {
continue
}
if id == seedID {
continue
}
current, ok := recommended[id]
if !ok {
recommended[id] = ranked{id: id, votes: rec.Votes}
continue
}
current.votes += rec.Votes
recommended[id] = current
}
return nil
})
}
if err := g.Wait(); err != nil {
return domain.DiscoverSectionData{}, err
}
if len(recommended) == 0 {
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
}
rankedIDs := make([]ranked, 0, len(recommended))
for _, item := range recommended {
rankedIDs = append(rankedIDs, item)
}
sort.Slice(rankedIDs, func(i, j int) bool {
if rankedIDs[i].votes == rankedIDs[j].votes {
return rankedIDs[i].id < rankedIDs[j].id
}
return rankedIDs[i].votes > rankedIDs[j].votes
})
limit := min(len(rankedIDs), 12)
animes := make([]domain.Anime, 0, limit)
for i := range limit {
anime, fetchErr := s.jikan.GetAnimeByID(ctx, rankedIDs[i].id)
if fetchErr != nil {
observability.Warn(
"recommendation_anime_fetch_failed",
"anime",
"",
map[string]any{"anime_id": rankedIDs[i].id},
fetchErr,
)
continue
}
animes = append(animes, domain.Anime{Anime: anime})
}
return domain.DiscoverSectionData{Animes: animes}, nil
}
func (s *animeService) GetAiringSchedule(ctx context.Context, userID string) ([]domain.Anime, error) {
if strings.TrimSpace(userID) == "" {
return []domain.Anime{}, nil
}
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
if err != nil {
return nil, err
}
ids := make([]int, 0, 50)
for _, entry := range watchlist {
status := strings.TrimSpace(entry.Status)
if status != "watching" && status != "plan_to_watch" {
continue
}
if !entry.Airing.Valid || !entry.Airing.Bool {
continue
}
if entry.AnimeID <= 0 {
continue
}
ids = append(ids, int(entry.AnimeID))
if len(ids) >= 50 {
break
}
}
if len(ids) == 0 {
return []domain.Anime{}, nil
}
animes := make([]domain.Anime, 0, len(ids))
var g errgroup.Group
g.SetLimit(6)
var mu sync.Mutex
for _, id := range ids {
g.Go(func() error {
anime, fetchErr := s.jikan.GetAnimeByID(ctx, id)
if fetchErr != nil {
return fetchErr
}
mu.Lock()
animes = append(animes, domain.Anime{Anime: anime})
mu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
observability.Warn(
"schedule_partial_fetch_failed",
"anime",
"",
map[string]any{"user_id": userID, "count": len(ids)},
err,
)
return animes, nil
}
return animes, nil
}
func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) {
anime, err := s.jikan.GetAnimeByID(ctx, id)
if err != nil {
return domain.Anime{}, err
}
return domain.Anime{Anime: anime}, nil
return s.jikan.GetAnimeByID(ctx, id)
}
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) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error) {
return s.jikan.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, sfw, page, limit)
}
func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
@@ -353,7 +148,7 @@ func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error)
anime, err := s.jikan.GetRandomAnime(randomCtx)
if err == nil {
return domain.Anime{Anime: anime}, nil
return anime, nil
}
for _, fallback := range []func(context.Context, int) (jikan.TopAnimeResult, error){
@@ -366,7 +161,7 @@ func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error)
continue
}
r := rand.New(rand.NewSource(time.Now().UnixNano()))
return domain.Anime{Anime: res.Animes[r.Intn(len(res.Animes))]}, nil
return res.Animes[r.Intn(len(res.Animes))], nil
}
return domain.Anime{}, err

View File

@@ -4,15 +4,13 @@ import (
"mal/integrations/jikan"
"mal/integrations/playback/allanime"
"mal/internal/anime"
"mal/internal/audit"
"mal/internal/auth"
"mal/internal/config"
"mal/internal/database"
"mal/internal/episodes"
"mal/internal/playback"
"mal/internal/server"
"mal/internal/templates"
"mal/internal/watchlist"
"mal/templates"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/render"
@@ -21,9 +19,7 @@ import (
func NewApp() *fx.App {
return fx.New(
config.Module,
database.Module,
audit.Module,
jikan.Module,
allanime.Module,
episodes.Module,

View File

@@ -1,30 +0,0 @@
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()
}

View File

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

View File

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

View File

@@ -1,83 +0,0 @@
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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,27 +0,0 @@
package fixes
import (
"context"
"database/sql"
"fmt"
)
func init() {
Register(Fix{
ID: "20260526_episode_availability_backfill_next_refresh_at",
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
// Old caches could have next_refresh_at NULL (especially for airing shows with missing broadcast metadata),
// which can result in "never refresh again" behavior on the server.
_, err := sqlDB.ExecContext(ctx, `
UPDATE episode_availability_cache
SET next_refresh_at = datetime(CURRENT_TIMESTAMP, '+6 hours'),
updated_at = CURRENT_TIMESTAMP
WHERE next_refresh_at IS NULL;
`)
if err != nil {
return fmt.Errorf("backfill episode_availability_cache.next_refresh_at: %w", err)
}
return nil
},
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,9 +6,7 @@ import (
"mal/internal/db"
)
type Anime struct {
jikan.Anime
}
type Anime = jikan.Anime
type TopAnimeResult = jikan.TopAnimeResult
type Genre = jikan.Genre
type Character = jikan.CharacterEntry
@@ -21,12 +19,8 @@ type ReviewEntry = jikan.ReviewEntry
type AnimeService interface {
GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, 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)
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)
SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error)
GetGenres(ctx context.Context) ([]Genre, error)
GetCharacters(ctx context.Context, id int) ([]Character, error)
GetRecommendations(ctx context.Context, id int) ([]Recommendation, error)

View File

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

View File

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

View File

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

View File

@@ -6,19 +6,18 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"mal/internal/observability"
"sort"
"strings"
"time"
)
const (
retryInterval = 15 * time.Minute
retryWindow = 3 * time.Hour
airingFallbackRefreshInterval = 6 * time.Hour
retryInterval = 15 * time.Minute
retryWindow = 3 * time.Hour
)
type Clock interface {
@@ -35,21 +34,19 @@ type EpisodeService struct {
providers []domain.EpisodeAvailabilityProvider
clock Clock
enabled bool
metrics *observability.Metrics
}
func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, metrics *observability.Metrics) domain.EpisodeService {
return NewEpisodeServiceWithClock(queries, jikanClient, providers, enabled, realClock{}, metrics)
func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool) domain.EpisodeService {
return NewEpisodeServiceWithClock(queries, jikanClient, providers, enabled, realClock{})
}
func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock, metrics *observability.Metrics) *EpisodeService {
func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock) *EpisodeService {
return &EpisodeService{
queries: queries,
jikan: jikanClient,
providers: providers,
clock: clock,
enabled: enabled,
metrics: metrics,
}
}
@@ -59,7 +56,7 @@ func (s *EpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.
}
if !forceRefresh {
if cached, ok := s.getFreshCached(ctx, anime); ok {
if cached, ok := s.getFreshCached(ctx, anime.MalID); ok {
return cached, nil
}
}
@@ -83,27 +80,11 @@ func (s *EpisodeService) RefreshTrackedDue(ctx context.Context, limit int) error
for _, id := range ids {
anime, err := s.jikan.GetAnimeByID(ctx, int(id))
if err != nil {
observability.Warn(
"episodes_refresh_fetch_anime_failed",
"episodes",
"",
map[string]any{
"anime_id": id,
},
err,
)
log.Printf("episodes: failed to fetch anime for refresh anime_id=%d error=%v", id, err)
continue
}
if _, err := s.refresh(ctx, domain.Anime{Anime: anime}); err != nil {
observability.Warn(
"episodes_refresh_failed",
"episodes",
"",
map[string]any{
"anime_id": id,
},
err,
)
if _, err := s.refresh(ctx, anime); err != nil {
log.Printf("episodes: refresh failed anime_id=%d error=%v", id, err)
}
}
@@ -112,43 +93,18 @@ func (s *EpisodeService) RefreshTrackedDue(ctx context.Context, limit int) error
func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
now := s.clock.Now()
observability.Info(
"episodes_refresh_start",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"title": anime.DisplayTitle(),
"airing": anime.Airing,
},
)
log.Printf("episodes: refresh start anime_id=%d title=%q airing=%t", anime.MalID, anime.DisplayTitle(), anime.Airing)
jikanEpisodes, jikanErr := s.jikan.GetAllEpisodes(ctx, anime.MalID)
if jikanErr != nil {
observability.Warn(
"episodes_jikan_metadata_failed",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
},
jikanErr,
)
log.Printf("episodes: jikan episode metadata failed anime_id=%d error=%v", anime.MalID, jikanErr)
}
providerAvailability, source, providerErr := s.fetchProviderAvailability(ctx, anime)
if providerErr != nil {
s.markFailure(ctx, anime, providerErr)
if cached, ok := s.getCached(ctx, anime.MalID); ok {
observability.Warn(
"episodes_provider_failed_serving_stale_cache",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
},
providerErr,
)
log.Printf("episodes: serving stale cache after provider failure anime_id=%d error=%v", anime.MalID, providerErr)
return cached, nil
}
if jikanErr == nil {
@@ -165,44 +121,16 @@ func (s *EpisodeService) fetchProviderAvailability(ctx context.Context, anime do
for _, provider := range s.providers {
providerID, err := s.providerID(ctx, anime, provider, titles)
if err != nil {
observability.Warn(
"episodes_provider_id_miss",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"provider": provider.Name(),
},
err,
)
log.Printf("episodes: provider id miss anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err)
continue
}
available, err := provider.GetEpisodeAvailabilityByProviderID(ctx, providerID)
if err != nil {
observability.Warn(
"episodes_provider_availability_miss",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"provider": provider.Name(),
},
err,
)
log.Printf("episodes: provider availability miss anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err)
continue
}
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),
},
)
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))
return available, provider.Name(), nil
}
return domain.EpisodeAvailability{}, "", fmt.Errorf("no episode availability provider matched anime_id=%d", anime.MalID)
@@ -215,38 +143,14 @@ func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, pro
})
if err == nil {
if row.FailedUntil.Valid && row.FailedUntil.Time.After(s.clock.Now()) {
s.metrics.ObserveCache("episode_provider_mapping", "hit")
return "", fmt.Errorf("cached provider mapping failure active until %s: %s", row.FailedUntil.Time.Format(time.RFC3339), row.LastError)
}
if strings.TrimSpace(row.ProviderShowID) != "" {
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,
},
)
log.Printf("episodes: provider id cache hit anime_id=%d provider=%s provider_id=%s", anime.MalID, provider.Name(), row.ProviderShowID)
return row.ProviderShowID, nil
}
s.metrics.ObserveCache("episode_provider_mapping", "miss")
} else if !errors.Is(err, sql.ErrNoRows) {
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")
log.Printf("episodes: provider id cache read failed anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err)
}
providerID, err := provider.ResolveEpisodeProviderID(ctx, anime.MalID, titles)
@@ -269,48 +173,17 @@ func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, pro
LastError: "",
})
if err != nil {
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 cache write failed anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err)
}
observability.Info(
"episodes_provider_id_resolved",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"provider": provider.Name(),
"provider_id": providerID,
},
)
log.Printf("episodes: provider id resolved anime_id=%d provider=%s provider_id=%s", anime.MalID, provider.Name(), providerID)
return providerID, nil
}
func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, source string, now time.Time, providerSuccess bool) (domain.CanonicalEpisodeList, error) {
nextRefresh := nextBroadcastAfter(anime, now)
var nextRefreshSQL sql.NullTime
if anime.Airing {
// 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}
}
}
if anime.Airing && !nextRefresh.IsZero() {
nextRefreshSQL = sql.NullTime{Time: nextRefresh, Valid: true}
}
episodes := mergeEpisodes(jikanEpisodes, availability)
@@ -344,30 +217,11 @@ func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpi
LastError: "",
})
if err != nil {
observability.Warn(
"episodes_cache_write_failed",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"source": source,
},
err,
)
log.Printf("episodes: cache write failed anime_id=%d source=%s error=%v", anime.MalID, source, err)
return payload, nil
}
observability.Info(
"episodes_refresh_success",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"source": source,
"episodes": len(episodes),
"next_refresh": payload.NextRefreshAt,
},
)
log.Printf("episodes: refresh success anime_id=%d source=%s episodes=%d next_refresh=%s", anime.MalID, source, len(episodes), payload.NextRefreshAt)
return payload, nil
}
@@ -393,114 +247,41 @@ func (s *EpisodeService) markFailure(ctx context.Context, anime domain.Anime, ca
AnimeID: int64(anime.MalID),
})
if err != nil {
observability.Warn(
"episodes_mark_failure_failed",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
},
err,
)
log.Printf("episodes: failed to mark refresh failure anime_id=%d error=%v", anime.MalID, err)
return
}
observability.Warn(
"episodes_refresh_failure_recorded",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"next_retry": next.Format(time.RFC3339),
},
cause,
)
log.Printf("episodes: refresh failure recorded anime_id=%d next_retry=%s error=%v", anime.MalID, next.Format(time.RFC3339), cause)
}
func (s *EpisodeService) getCached(ctx context.Context, animeID int) (domain.CanonicalEpisodeList, bool) {
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID))
if err != nil {
s.metrics.ObserveCache("episode_availability", "miss")
return domain.CanonicalEpisodeList{}, false
}
var payload domain.CanonicalEpisodeList
if err := json.Unmarshal([]byte(row.Data), &payload); err != nil {
s.metrics.ObserveCache("episode_availability", "miss")
observability.Warn(
"episodes_cached_payload_invalid",
"episodes",
"",
map[string]any{
"anime_id": animeID,
},
err,
)
log.Printf("episodes: invalid cached payload anime_id=%d error=%v", animeID, err)
return domain.CanonicalEpisodeList{}, false
}
s.metrics.ObserveCache("episode_availability", "hit")
return payload, true
}
func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, bool) {
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(anime.MalID))
func (s *EpisodeService) getFreshCached(ctx context.Context, animeID int) (domain.CanonicalEpisodeList, bool) {
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID))
if err != nil {
s.metrics.ObserveCache("episode_availability_fresh", "miss")
return domain.CanonicalEpisodeList{}, false
}
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),
},
)
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))
return domain.CanonicalEpisodeList{}, false
}
var payload domain.CanonicalEpisodeList
if err := json.Unmarshal([]byte(row.Data), &payload); err != nil {
s.metrics.ObserveCache("episode_availability_fresh", "miss")
observability.Warn(
"episodes_cached_payload_invalid",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
},
err,
)
log.Printf("episodes: invalid cached payload anime_id=%d error=%v", animeID, err)
return domain.CanonicalEpisodeList{}, false
}
s.metrics.ObserveCache("episode_availability_fresh", "hit")
observability.Info(
"episodes_cache_served",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"episodes": len(payload.Episodes),
"next_refresh": payload.NextRefreshAt,
},
)
log.Printf("episodes: served cached availability anime_id=%d episodes=%d next_refresh=%s", animeID, len(payload.Episodes), payload.NextRefreshAt)
return payload, true
}
@@ -622,31 +403,13 @@ func nextBroadcastAfter(anime domain.Anime, after time.Time) time.Time {
if loaded, err := time.LoadLocation(tz); err == nil {
loc = loaded
} else {
observability.Warn(
"episodes_broadcast_timezone_parse_failed",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"timezone": tz,
},
err,
)
log.Printf("episodes: failed to parse broadcast timezone anime_id=%d timezone=%q error=%v", anime.MalID, tz, err)
}
}
hour, minute, ok := parseBroadcastTime(anime.Broadcast.Time)
if !ok {
observability.Warn(
"episodes_broadcast_time_parse_failed",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"time": anime.Broadcast.Time,
},
nil,
)
log.Printf("episodes: failed to parse broadcast time anime_id=%d time=%q", anime.MalID, anime.Broadcast.Time)
return time.Time{}
}

View File

@@ -29,7 +29,7 @@ func TestMergeEpisodesUsesUnionAndSynthesizesProviderOnlyEntries(t *testing.T) {
}
func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) {
anime := domain.Anime{Anime: jikan.Anime{MalID: 1}}
anime := domain.Anime{MalID: 1}
anime.Broadcast.Day = "Saturdays"
anime.Broadcast.Time = "23:00"
anime.Broadcast.Timezone = "Asia/Tokyo"
@@ -44,7 +44,7 @@ func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) {
}
func TestNextRetryTimeWithinAndAfterRetryWindow(t *testing.T) {
anime := domain.Anime{Anime: jikan.Anime{MalID: 1}}
anime := domain.Anime{MalID: 1}
anime.Broadcast.Day = "Saturdays"
anime.Broadcast.Time = "12:00"
anime.Broadcast.Timezone = "UTC"

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,47 +0,0 @@
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)
}
}

View File

@@ -4,7 +4,6 @@ import (
"fmt"
"io"
"mal/internal/domain"
"mal/internal/server"
"mal/pkg/net/limits"
"mal/pkg/net/proxytransport"
"mal/pkg/net/useragent"
@@ -87,13 +86,13 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
animeID, err := strconv.Atoi(c.Param("animeId"))
if err != nil || animeID <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
c.Status(http.StatusBadRequest)
return
}
episode := c.Param("episode")
if episode == "" {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "missing episode")
c.Status(http.StatusBadRequest)
return
}
@@ -107,15 +106,7 @@ func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
data, err := h.svc.BuildWatchData(c.Request.Context(), animeID, []string{}, episode, mode, userID)
if err != nil {
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,
)
c.Status(http.StatusInternalServerError)
return
}
@@ -157,7 +148,7 @@ func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
}
if userID == "" {
// Avoid spamming 500s for anonymous playback; progress is user-scoped.
server.RespondHTMLOrJSONError(c, http.StatusUnauthorized, "unauthorized")
c.Status(http.StatusUnauthorized)
return
}
@@ -168,21 +159,13 @@ func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid request body")
c.Status(http.StatusBadRequest)
return
}
err := h.svc.SaveProgress(c.Request.Context(), userID, req.MalID, req.Episode, req.TimeSeconds)
if err != nil {
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,
)
c.Status(http.StatusInternalServerError)
return
}
@@ -202,21 +185,13 @@ func (h *PlaybackHandler) HandleWatchComplete(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid request body")
c.Status(http.StatusBadRequest)
return
}
err := h.svc.CompleteAnime(c.Request.Context(), userID, req.MalID)
if err != nil {
server.RespondError(
c,
http.StatusInternalServerError,
"watch_complete_failed",
"playback",
"failed to complete anime",
map[string]any{"mal_id": req.MalID, "user_id": userID},
err,
)
c.Status(http.StatusInternalServerError)
return
}
@@ -263,8 +238,6 @@ func (h *PlaybackHandler) HandleEpisodeThumbnails(c *gin.Context) {
allEpisodes, err := h.animeSvc.GetAllEpisodes(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
anime, _ := h.animeSvc.GetAnimeByID(c.Request.Context(), id)

View File

@@ -47,11 +47,7 @@ func (c *subtitleCache) Get(key string, now time.Time) (data []byte, contentType
if el == nil {
return nil, "", false
}
entry, ok := el.Value.(subtitleCacheEntry)
if !ok {
c.removeElement(el)
return nil, "", false
}
entry := el.Value.(subtitleCacheEntry)
if !entry.expiresAt.IsZero() && now.After(entry.expiresAt) {
c.removeElement(el)
return nil, "", false
@@ -65,11 +61,7 @@ func (c *subtitleCache) Set(key string, data []byte, contentType string, now tim
defer c.mu.Unlock()
if el := c.entries[key]; el != nil {
entry, ok := el.Value.(subtitleCacheEntry)
if !ok {
c.removeElement(el)
return
}
entry := el.Value.(subtitleCacheEntry)
entry.data = data
entry.contentType = contentType
entry.expiresAt = now.Add(c.ttl)
@@ -97,11 +89,7 @@ func (c *subtitleCache) Set(key string, data []byte, contentType string, now tim
}
func (c *subtitleCache) removeElement(el *list.Element) {
entry, ok := el.Value.(subtitleCacheEntry)
if !ok {
c.lru.Remove(el)
return
}
entry := el.Value.(subtitleCacheEntry)
delete(c.entries, entry.key)
c.lru.Remove(el)
}

View File

@@ -1,9 +1,10 @@
package playback
import (
"os"
"mal/integrations/jikan"
"mal/integrations/playback/allanime"
"mal/internal/config"
"mal/internal/domain"
"mal/internal/playback/handler"
"mal/internal/playback/repository"
@@ -13,17 +14,18 @@ import (
"go.uber.org/fx"
)
func provideProxyTokenKey(cfg config.Config) service.ProxyTokenKey {
return service.ProxyTokenKey(cfg.PlaybackProxySecret)
func provideProxyTokenKey() string {
return os.Getenv("PLAYBACK_PROXY_SECRET")
}
var Module = fx.Options(
fx.Provide(
repository.NewPlaybackRepository,
fx.Annotate(
func(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client, episodeSvc domain.EpisodeService, auditSvc domain.AuditService, proxyTokenKey service.ProxyTokenKey) domain.PlaybackService {
return service.NewPlaybackService(repo, providers, jikan, episodeSvc, auditSvc, proxyTokenKey)
func(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client, episodeSvc domain.EpisodeService, proxyTokenKey string) domain.PlaybackService {
return service.NewPlaybackService(repo, providers, jikan, episodeSvc, proxyTokenKey)
},
fx.ParamTags(``, ``, ``, ``, ``),
),
func(svc domain.PlaybackService, animeSvc domain.AnimeService) *handler.PlaybackHandler {
return handler.NewPlaybackHandler(svc, animeSvc)

View File

@@ -12,7 +12,6 @@ import (
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"mal/internal/observability"
"mal/pkg/net/limits"
"mal/pkg/net/useragent"
"net/http"
@@ -32,11 +31,8 @@ type playbackService struct {
episodes domain.EpisodeService
httpClient *http.Client
proxyTokenKey string
auditSvc domain.AuditService
}
type ProxyTokenKey string
type proxyTokenPayload struct {
TargetURL string `json:"u"`
Referer string `json:"r,omitempty"`
@@ -44,16 +40,8 @@ type proxyTokenPayload struct {
ExpiresAt int64 `json:"exp"`
}
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,
auditSvc: auditSvc,
httpClient: &http.Client{Timeout: 10 * time.Second},
proxyTokenKey: string(proxyTokenKey),
}
func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client, episodes domain.EpisodeService, proxyTokenKey string) domain.PlaybackService {
return &playbackService{repo: repo, providers: providers, jikan: jikan, episodes: episodes, httpClient: &http.Client{Timeout: 10 * time.Second}, proxyTokenKey: proxyTokenKey}
}
func (s *playbackService) SignProxyToken(targetURL, referer, scope string) (string, error) {
@@ -71,9 +59,7 @@ func (s *playbackService) SignProxyToken(targetURL, referer, scope string) (stri
return "", err
}
mac := hmac.New(sha256.New, []byte(s.proxyTokenKey))
if _, err := mac.Write(body); err != nil {
return "", fmt.Errorf("sign proxy token: %w", err)
}
mac.Write(body)
signature := mac.Sum(nil)
encodedBody := base64.RawURLEncoding.EncodeToString(body)
encodedSignature := base64.RawURLEncoding.EncodeToString(signature)
@@ -92,16 +78,11 @@ func (s *playbackService) VerifyProxyToken(token string) (proxyTokenPayload, err
if err != nil {
return proxyTokenPayload{}, err
}
decodedSig, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return proxyTokenPayload{}, fmt.Errorf("invalid signature encoding: %w", err)
}
mac := hmac.New(sha256.New, []byte(s.proxyTokenKey))
if _, err := mac.Write(body); err != nil {
return proxyTokenPayload{}, fmt.Errorf("verify proxy token: %w", err)
}
expectedSig := mac.Sum(nil)
if !hmac.Equal(expectedSig, decodedSig) {
mac.Write(body)
signature := mac.Sum(nil)
encodedSig := base64.RawURLEncoding.EncodeToString(signature)
if encodedSig != parts[1] {
return proxyTokenPayload{}, fmt.Errorf("invalid signature")
}
var payload proxyTokenPayload
@@ -143,7 +124,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
}
}
canonicalEpisodes, err := s.episodes.GetCanonicalEpisodes(ctx, domain.Anime{Anime: anime}, false)
canonicalEpisodes, err := s.episodes.GetCanonicalEpisodes(ctx, anime, false)
if err != nil {
return domain.WatchPageData{}, fmt.Errorf("failed to fetch episodes: %w", err)
}
@@ -291,7 +272,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
return domain.WatchPageData{
WatchData: watchData,
Anime: domain.Anime{Anime: anime},
Anime: anime,
Episodes: canonicalEpisodes.Episodes,
CurrentEpID: episode,
WatchlistStatus: watchlistStatus,
@@ -319,35 +300,16 @@ func (s *playbackService) CompleteAnime(ctx context.Context, userID string, anim
}
}
if err := s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{
_ = s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{
UserID: userID,
AnimeID: animeID,
}); err != nil {
return err
}
if err := s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{
})
return s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{
UserID: userID,
AnimeID: animeID,
CurrentEpisode: sql.NullInt64{Valid: false},
CurrentTimeSeconds: 0,
}); err != nil {
return err
}
if err := s.auditSvc.Record(ctx, domain.AuditEvent{
UserID: userID,
Action: "watch_completed",
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 {
@@ -359,31 +321,7 @@ func (s *playbackService) SaveProgress(ctx context.Context, userID string, anime
CurrentTimeSeconds: timeSeconds,
DurationSeconds: sql.NullFloat64{Valid: false},
})
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
return err
}
func (s *playbackService) UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error {

View File

@@ -1,19 +1,16 @@
package server
import (
"mal/internal/config"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
)
func CORSMiddleware() gin.HandlerFunc {
return CORSMiddlewareWithConfig(config.Config{})
}
allowAll := os.Getenv("MAL_CORS_ALLOW_ALL") == "1"
func CORSMiddlewareWithConfig(cfg config.Config) gin.HandlerFunc {
allowAll := cfg.CORSAllowAll
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" && (allowAll || isAllowedOrigin(origin)) {
@@ -35,6 +32,9 @@ func CORSMiddlewareWithConfig(cfg config.Config) gin.HandlerFunc {
}
func isAllowedOrigin(origin string) bool {
if strings.HasPrefix(origin, "moz-extension://") {
return true
}
if strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "https://localhost:") {
return true
}

View File

@@ -1,13 +1,14 @@
package server
import (
"mal/internal/observability"
"log"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
func RequestLogger(metrics *observability.Metrics) gin.HandlerFunc {
func RequestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
@@ -20,34 +21,17 @@ func RequestLogger(metrics *observability.Metrics) gin.HandlerFunc {
route = path
}
duration := time.Since(start)
metrics.ObserveHTTPRequest(c.Request.Method, route, c.Writer.Status(), duration)
level := observability.LogLevelInfo
status := c.Writer.Status()
if status >= 500 {
level = observability.LogLevelError
} else if status >= 400 {
level = observability.LogLevelWarn
}
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,
log.Printf(
"http_request method=%s route=%s path=%s query=%s status=%d duration_ms=%.2f bytes=%d client_ip=%s errors=%s",
c.Request.Method,
strconv.Quote(route),
strconv.Quote(path),
strconv.Quote(query),
c.Writer.Status(),
float64(time.Since(start).Microseconds())/1000,
c.Writer.Size(),
strconv.Quote(c.ClientIP()),
strconv.Quote(c.Errors.ByType(gin.ErrorTypePrivate).String()),
)
}
}

View File

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

View File

@@ -2,10 +2,9 @@ package server
import (
"context"
"mal/internal/audit"
"mal/internal/config"
"mal/internal/observability"
"log"
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
@@ -14,59 +13,43 @@ import (
)
var Module = fx.Options(
fx.Provide(observability.NewMetrics),
fx.Provide(ProvideRouter),
fx.Invoke(RunServer),
)
func ProvideRouter(cfg config.Config, htmlRender render.HTMLRender, metrics *observability.Metrics) *gin.Engine {
if cfg.GinMode == "" {
func ProvideRouter(htmlRender render.HTMLRender) *gin.Engine {
if os.Getenv("GIN_MODE") == "" {
gin.SetMode(gin.ReleaseMode)
} else {
gin.SetMode(cfg.GinMode)
}
r := gin.New()
r.Use(CORSMiddlewareWithConfig(cfg), audit.ContextMiddleware(), RequestLogger(metrics), gin.Recovery())
r.Use(CORSMiddleware(), RequestLogger(), gin.Recovery())
r.Static("/static", "./static")
r.Static("/dist", "./dist")
r.GET("/metrics", gin.WrapH(metrics.Handler()))
r.HTMLRender = htmlRender
return r
}
func RunServer(cfg config.Config, lifecycle fx.Lifecycle, r *gin.Engine) {
port := cfg.Port
func RunServer(lifecycle fx.Lifecycle, r *gin.Engine) {
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
srv := newHTTPServer(":"+port, r)
lifecycle.Append(fx.Hook{
OnStart: func(context.Context) error {
observability.Info(
"server_start",
"server",
"",
map[string]any{
"port": port,
},
)
log.Printf("Starting server on http://localhost:%s", port)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
// Avoid exiting the process from a goroutine; let the process supervisor handle restarts.
observability.Error(
"server_listen_error",
"server",
"",
map[string]any{
"port": port,
},
err,
)
log.Printf("server listen error: %s", err)
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
observability.Info("server_stop", "server", "", nil)
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
return srv.Shutdown(ctx)

View File

@@ -4,7 +4,6 @@ import (
"bytes"
"io"
"log"
"mal/internal/observability"
"net/http"
"net/http/httptest"
"strings"
@@ -43,7 +42,7 @@ func TestRequestLoggerUsesMatchedRoute(t *testing.T) {
defer log.SetOutput(previousOutput)
router := gin.New()
router.Use(RequestLogger(observability.NewMetrics()))
router.Use(RequestLogger())
router.GET("/anime/:id", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
@@ -58,13 +57,10 @@ func TestRequestLoggerUsesMatchedRoute(t *testing.T) {
}
logLine := string(output)
if !strings.Contains(logLine, `"event":"http_request"`) {
t.Fatalf("log line missing event: %s", logLine)
}
if !strings.Contains(logLine, `"route":"/anime/:id"`) {
if !strings.Contains(logLine, `route="/anime/:id"`) {
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)
}
}

View File

@@ -0,0 +1,248 @@
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)
}

View File

@@ -2,7 +2,6 @@ package handler
import (
"mal/internal/domain"
"mal/internal/server"
"net/http"
"strconv"
@@ -36,21 +35,13 @@ func (h *WatchlistHandler) HandleUpdateWatchlist(c *gin.Context) {
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.AnimeID <= 0 || body.Status == "" {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid request body")
c.Status(http.StatusBadRequest)
return
}
err := h.svc.UpdateEntry(c.Request.Context(), userID, body.AnimeID, body.Status)
if err != nil {
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,
)
c.Status(http.StatusInternalServerError)
return
}
@@ -64,24 +55,16 @@ func (h *WatchlistHandler) HandleDeleteWatchlist(c *gin.Context) {
userID = u.ID
}
animeID, err := strconv.ParseInt(c.Param("id"), 10, 64)
animeID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || animeID <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
if animeID <= 0 {
c.Status(http.StatusBadRequest)
return
}
err = h.svc.RemoveEntry(c.Request.Context(), userID, animeID)
err := h.svc.RemoveEntry(c.Request.Context(), userID, animeID)
if err != nil {
server.RespondError(
c,
http.StatusInternalServerError,
"watchlist_remove_failed",
"watchlist",
"failed to remove watchlist entry",
map[string]any{"user_id": userID, "anime_id": animeID},
err,
)
c.Status(http.StatusInternalServerError)
return
}
@@ -95,24 +78,16 @@ func (h *WatchlistHandler) HandleDeleteContinueWatching(c *gin.Context) {
userID = u.ID
}
animeID, err := strconv.ParseInt(c.Param("id"), 10, 64)
animeID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || animeID <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
if animeID <= 0 {
c.Status(http.StatusBadRequest)
return
}
err = h.svc.DeleteContinueWatching(c.Request.Context(), userID, animeID)
err := h.svc.DeleteContinueWatching(c.Request.Context(), userID, animeID)
if err != nil {
server.RespondError(
c,
http.StatusInternalServerError,
"continue_watching_delete_failed",
"watchlist",
"failed to delete continue watching entry",
map[string]any{"user_id": userID, "anime_id": animeID},
err,
)
c.Status(http.StatusInternalServerError)
return
}
@@ -128,15 +103,7 @@ func (h *WatchlistHandler) HandleGetWatchlist(c *gin.Context) {
entries, err := h.svc.GetWatchlist(c.Request.Context(), userID)
if err != nil {
server.RespondError(
c,
http.StatusInternalServerError,
"watchlist_load_failed",
"watchlist",
"failed to load watchlist",
map[string]any{"user_id": userID},
err,
)
c.Status(http.StatusInternalServerError)
return
}

View File

@@ -23,18 +23,15 @@ func (s *watchlistService) UpdateEntry(ctx context.Context, userID string, anime
_, err := s.repo.GetAnime(ctx, animeID)
if err != nil {
anime, err := s.jikan.GetAnimeByID(ctx, int(animeID))
if err != nil {
return err
}
if _, err := s.repo.UpsertAnime(ctx, db.UpsertAnimeParams{
ID: int64(anime.MalID),
TitleOriginal: anime.Title,
TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""},
TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""},
ImageUrl: anime.ImageURL(),
Airing: sql.NullBool{Bool: anime.Airing, Valid: true},
}); err != nil {
return err
if err == nil {
_, _ = s.repo.UpsertAnime(ctx, db.UpsertAnimeParams{
ID: int64(anime.MalID),
TitleOriginal: anime.Title,
TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""},
TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""},
ImageUrl: anime.ImageURL(),
Airing: sql.NullBool{Bool: anime.Airing, Valid: true},
})
}
}
@@ -99,12 +96,10 @@ func (s *watchlistService) GetContinueWatchingEntry(ctx context.Context, userID
}
func (s *watchlistService) DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error {
if err := s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{
_ = s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{
UserID: userID,
AnimeID: animeID,
}); err != nil {
return err
}
})
return s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{
UserID: userID,
AnimeID: animeID,

View File

@@ -1,17 +1,13 @@
set shell := ["bash", "-c"]
set dotenv-load := true
export GOCACHE := justfile_directory() + "/.cache/go-build"
fmt:
go fmt ./...
lint:
bun run lint:go
lint-ts:
bun run lint:ts
lint-go:
bun run lint:go
go fmt ./... && go vet ./...
test:
go test ./...
@@ -41,9 +37,3 @@ dev: build
clean:
rm -rf dist/*
rm -f server
new-data-fix name:
bun scripts/new-data-fix.ts {{name}}
run-fixes:
go run ./cmd/user run-fixes

View File

@@ -13,7 +13,7 @@
'commands':
{
'go-fmt': { 'run': 'go fmt ./...' },
'go-lint': { 'run': 'bun run lint:go' },
'go-vet': { 'run': 'go vet ./...' },
'go-test': { 'run': 'go test ./...' },
'ts-typecheck': { 'run': 'bunx tsc -p tsconfig.json --noEmit' },
'build-assets': { 'run': 'bun run build:assets' },

View File

@@ -1,22 +1,19 @@
{
"name": "mal",
"name": "myanimelist-ui",
"private": true,
"type": "module",
"scripts": {
"build:css": "bunx @tailwindcss/cli -i ./static/assets/style.css -o ./dist/tailwind.css",
"watch:css": "bunx @tailwindcss/cli -i ./static/assets/style.css -o ./dist/tailwind.css --watch",
"build:ts": "bun build ./static/player/main.ts --outdir ./dist/static/player --target browser --splitting && bun build ./static/*.ts --outdir ./dist/static --target browser --root ./static --entry-naming \"[name].js\"",
"build:ts": "bun build ./static/player/main.ts --outdir ./dist/static/player --target browser --splitting && bun build ./static/*.ts --outdir ./dist --target browser",
"typecheck": "bunx tsc -p tsconfig.json --noEmit",
"build:assets": "bun run build:css && bun run build:ts",
"format": "bunx prettier . --write",
"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 ./..."
"lint": "bunx eslint . --fix"
},
"devDependencies": {
"@tailwindcss/cli": "^4.2.4",
"@types/node": "^24.0.0",
"@toolwind/anchors": "^1.0.10",
"@typescript-eslint/eslint-plugin": "^8.59.2",
"@typescript-eslint/parser": "^8.59.2",
"eslint": "^10.3.0",
@@ -28,5 +25,7 @@
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3"
},
"dependencies": {}
"dependencies": {
"dompurify": "^3.4.1"
}
}

View File

@@ -1,73 +0,0 @@
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;
});

View File

@@ -1,28 +1,5 @@
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 => {
// data attributes store the class lists to add/remove
const openClasses = parseClassList(menu.getAttribute('data-dropdown-open-classes'));

View File

@@ -1,34 +1,42 @@
@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 "../**/*.ts";
@theme {
--color-background: light-dark(#ffffff, #0b0c10);
--color-background-sidebar: light-dark(#f7f7f7, #0f1115);
--color-background-header: light-dark(#fbfbfb, #0f1115);
--color-background: light-dark(#f7f6f3, #0b0c10);
--color-background-sidebar: light-dark(#fbfbfa, #0f1115);
--color-background-header: light-dark(#fbfbfa, #0f1115);
--color-background-surface: light-dark(#ffffff, #17181c);
--color-background-button: light-dark(#f5f5f5, #131417);
--color-background-button-hover: light-dark(#ececec, #1c1d22);
--color-background-button: light-dark(#ffffff, #131417);
--color-background-button-hover: light-dark(#f7f6f3, #1c1d22);
--color-foreground-muted: light-dark(#787774, #a1a1aa);
--color-foreground: light-dark(#111111, #f3f4f6);
--color-accent: #00b3c4;
--color-surface-hover: light-dark(rgba(0, 0, 0, 0.04), rgba(255, 255, 255, 0.05));
--color-accent: #1f6c9f;
--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 {
color-scheme: light dark;
--bg: var(--color-background);
--panel: light-dark(#f7f7f7, #181818);
--panel-soft: light-dark(#ececec, #202020);
--panel: light-dark(#f5f5f4, #181818);
--panel-soft: light-dark(#e7e5e4, #202020);
--header: light-dark(#ffffff, #101010);
--text: light-dark(#111111, #e7e5e4);
--text-muted: light-dark(#666666, #a8a29e);
--text-faint: light-dark(#9a9a9a, #78716c);
--text: light-dark(#1c1917, #e7e5e4);
--text-muted: light-dark(#57534e, #a8a29e);
--text-faint: light-dark(#a8a29e, #78716c);
--accent: var(--color-accent);
--danger: #dc2626;
--surface-thumb: light-dark(#cccccc, #44403c);
--surface-tab-hover: light-dark(#e4e4e4, #202020);
--surface-tab-active: light-dark(#1e1b17, #fafaf9);
--surface-thumb: light-dark(#e7e5e4, #44403c);
--surface-tab-hover: light-dark(#e7e5e4, #202020);
--surface-tab-active: light-dark(#1c1917, #fafaf9);
--text-tab-active: light-dark(#fafaf9, #0c0a09);
--surface-select: light-dark(#ffffff, #181818);
--text-on-accent: light-dark(#fafaf9, #0c0a09);
@@ -36,9 +44,6 @@
--shadow-subtle: light-dark(0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18));
--shadow-card: light-dark(0 2px 8px rgba(0, 0, 0, 0.04), 0 2px 10px rgba(0, 0, 0, 0.28));
--shadow-card-hover: light-dark(0 6px 18px rgba(0, 0, 0, 0.06), 0 6px 20px rgba(0, 0, 0, 0.34));
--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-2: 0.5rem;
--space-3: 0.75rem;
@@ -53,18 +58,106 @@
--radius: 0px;
}
[data-theme='light'] {
color-scheme: light;
}
[data-theme='dark'] {
color-scheme: dark;
}
html,
body {
background-color: var(--color-background);
color: var(--text);
}
[data-watchlist-toggle] .watchlist-icon,
[data-watchlist-toggle] .watchlist-icon path {
fill: none;
@layer utilities {
.shadow-soft {
box-shadow: var(--shadow-card);
}
.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);
}
}
[data-watchlist-toggle][data-watchlist-state='in'] .watchlist-icon,
[data-watchlist-toggle][data-watchlist-state='in'] .watchlist-icon path {
fill: currentColor;
/* Default to square corners; opt back in selectively (e.g. inputs). */
:where(input, textarea, select) {
border-radius: 6px !important;
}
: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;
}

View File

@@ -41,56 +41,3 @@ const initDiscoverTabs = (): void => {
};
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();

View File

@@ -1,7 +1,7 @@
class UIDropdown extends HTMLElement {
isOpen = false;
isOpen: boolean = false;
contentEl: HTMLElement | null = null;
isClosing = false; // debounce flag
isClosing: boolean = false; // debounce flag
constructor() {
super();
@@ -64,25 +64,3 @@ class UIDropdown extends HTMLElement {
}
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();

View File

@@ -1,71 +0,0 @@
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');
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 MiB

View File

@@ -1,6 +1,4 @@
import { state } from './state';
import { saveProgress } from './progress';
import { safeLocalStorage } from './storage';
export const formatTime = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
@@ -79,35 +77,6 @@ export const syncVolumeUI = (): void => {
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 {
playPause: HTMLButtonElement | null;
muteBtn: HTMLButtonElement | null;
@@ -168,8 +137,6 @@ const updateMuteIcons = (isMuted: boolean): void => {
* Sets up video event listeners for icon sync.
*/
export const setupControls = (): void => {
applyStoredVolume();
const {
playPause,
muteBtn,
@@ -236,12 +203,8 @@ export const setupControls = (): void => {
state.video.addEventListener('pause', () => {
updatePlayPauseIcons(false);
showControls();
void saveProgress();
});
state.video.addEventListener('volumechange', () => {
syncVolumeUI();
schedulePersistVolume();
});
state.video.addEventListener('volumechange', syncVolumeUI);
// mouse move in container shows controls
state.container.addEventListener('mousemove', showControls);

View File

@@ -1,12 +1,11 @@
import { state } from '../state';
import type { SkipSegment } from '../types';
import { SkipSegment } from '../types';
import { resolveActiveSegments, renderSegments } from '../skip/segments';
import { updateSubtitleOptions } from '../subtitles';
import { updateQualityOptions } from '../quality';
import { updateModeButtons } from '../mode';
import { updateOverlay, isAutoplayEnabled, switchEpisodeRange } from './ui';
import { markEpisodeTransition } from '../progress';
import { safeLocalStorage } from '../storage';
/**
* Handles video end: either marks complete or loads next episode.
@@ -72,12 +71,10 @@ export const goToNextEpisode = async (): Promise<void> => {
state.container.dataset.startTimeSeconds = String(state.startTimeSeconds);
// load new video (keep preferences)
const preferredQuality = safeLocalStorage.getItem('mal:preferred-quality') || 'best';
const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best';
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`;
state.video.load();
if (!state.video.paused) {
state.video.play().catch(() => undefined);
}
if (!state.video.paused) state.video.play().catch(() => {});
state.pendingSeekTime = null;
state.completionSent = false;

View File

@@ -5,14 +5,12 @@ import { state } from '../state';
* Injects images into episode cards, replaces placeholder.
*/
export const setupThumbnails = (): void => {
const episodeList = state.episodeList;
if (!episodeList) return;
fetch(`/api/watch/thumbnails/${state.malID}`)
.then(res => res.json())
.then((data: { mal_id: number; url: string; title?: string }[]) => {
.then((data: Array<{ mal_id: number; url: string; title?: string }>) => {
if (!state.episodeList) return;
data.forEach(item => {
const card = episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`);
const card = state.episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`);
if (!card) return;
// inject thumbnail image

View File

@@ -1,6 +1,4 @@
import { state } from '../state';
import { qs } from '../../q';
import { safeLocalStorage } from '../storage';
/**
* Syncs autoplay checkbox with localStorage on init.
@@ -9,11 +7,11 @@ import { safeLocalStorage } from '../storage';
export const setupAutoplayButton = (): void => {
const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
if (!btn) return;
btn.checked = safeLocalStorage.getItem('mal:autoplay-enabled') !== 'false';
btn.checked = localStorage.getItem('mal:autoplay-enabled') !== 'false';
};
export const isAutoplayEnabled = (): boolean =>
safeLocalStorage.getItem('mal:autoplay-enabled') !== 'false';
localStorage.getItem('mal:autoplay-enabled') !== 'false';
/**
* Updates video overlay text (shown briefly on episode change).
@@ -21,8 +19,7 @@ export const isAutoplayEnabled = (): boolean =>
export const updateOverlay = (episode: string, title: string): void => {
if (!state.videoOverlay) return;
const p = state.videoOverlay.querySelector('p');
if (!p) return;
p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`;
p && (p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`);
};
// helper: get all episode elements from grid and list
@@ -60,7 +57,7 @@ export const updateEpisodeHighlight = (num: number): void => {
* Updates dropdown label and hides/shows episode cards.
*/
export const switchEpisodeRange = (idx: number): void => {
const dropdown = qs<HTMLElement>('[data-episode-dropdown]');
const dropdown = state.container.querySelector('[data-episode-dropdown]') as HTMLElement | null;
if (!dropdown) return;
const btns = Array.from(dropdown.querySelectorAll('.episode-range-btn')) as HTMLButtonElement[];
const target = btns[idx];

View File

@@ -8,7 +8,6 @@ import {
seekBy,
setVolume,
} from './controls';
import { saveProgress } from './progress';
/**
* Sets up keyboard shortcuts for player control.
@@ -27,7 +26,6 @@ export const setupKeyboard = (): void => {
e.preventDefault();
togglePlayPause();
showControls();
void saveProgress();
break;
case 'ArrowLeft':
case 'KeyJ':

View File

@@ -12,7 +12,6 @@ import { resolveActiveSegments, renderSegments } from './skip/segments';
import { setupSegmentEditor } from './skip/editor';
import { setupThumbnails } from './episodes/thumbnails';
import { markEpisodeTransition, setupProgress } from './progress';
import { safeLocalStorage } from './storage';
import {
absoluteTimeFromDisplay,
absoluteTimeFromRatio,
@@ -21,38 +20,21 @@ import {
} from './timeline';
import { formatTime } from './controls';
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';
};
let initialized = false; // prevent double init on htmx swaps
const hidePreviewPopover = (): void => {
if (!state.previewPopover) return;
state.previewPopover.classList.add('hidden');
state.previewPopover.classList.add('opacity-0');
state.previewPopover.classList.remove('opacity-100');
state.previewPopover.style.left = '';
state.previewPopover.style.left = '0px';
};
const showPreviewPopover = (): void => {
if (!state.previewPopover) return;
state.previewPopover.classList.remove('hidden');
state.previewPopover.classList.remove('opacity-0');
state.previewPopover.classList.add('opacity-100');
};
const teardownPlayer = (): void => {
cleanup?.();
cleanup = null;
currentContainer = null;
};
// updates time preview on progress bar hover
const updatePreviewUI = (ratio: number): void => {
const progressWrap = state.container.querySelector('[data-progress-wrap]') as HTMLElement | null;
@@ -83,25 +65,20 @@ const updatePreviewUI = (ratio: number): void => {
const initPlayer = (): void => {
const container = document.querySelector('[data-video-player]') as HTMLElement | null;
if (!container) return;
if (container === currentContainer) return;
teardownPlayer();
if (!container || initialized) return;
if (!initState(container)) {
console.error('Video player markup is missing required controls.');
return;
}
currentContainer = container;
const abortController = new AbortController();
const signal = abortController.signal;
cleanup = () => abortController.abort();
initialized = true;
const loading = container.querySelector('[data-loading]') as HTMLElement | null;
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null;
// build video src from mode, token, and saved quality preference
// Only set if not already provided by the inline script during HTML parsing
const preferredQuality = safeLocalStorage.getItem('mal:preferred-quality') || 'best';
const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best';
const streamToken = state.modeSources[state.currentMode]?.token;
if (!state.video.src && streamToken) {
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`;
@@ -129,9 +106,7 @@ const initPlayer = (): void => {
}
const onLoadedMetadata = (): void => {
if (loading) {
loading.style.display = 'none';
}
loading && (loading.style.display = 'none');
invalidateBounds();
resolveActiveSegments();
@@ -151,193 +126,137 @@ const initPlayer = (): void => {
state.transitionEpisode = null;
}
// autoplay if not already playing (inline script may have already called play())
if (state.shouldAutoPlay || state.video.paused) {
state.video.play().catch(() => undefined);
}
if (state.shouldAutoPlay || state.video.paused) state.video.play().catch(() => {});
updateTimeline(state.video.currentTime);
updateSkipButton(state.video.currentTime);
};
state.video.addEventListener('loadedmetadata', onLoadedMetadata, { signal });
state.video.addEventListener('loadedmetadata', onLoadedMetadata);
// inline script runs during HTML parsing before initPlayer; if metadata
// already loaded, fire the handler immediately
if (state.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
onLoadedMetadata();
}
state.video.addEventListener(
'waiting',
() => {
if (loading) {
loading.style.display = 'flex';
}
},
{ signal }
);
state.video.addEventListener(
'playing',
() => {
if (loading) {
loading.style.display = 'none';
}
},
{ signal }
);
state.video.addEventListener('waiting', () => {
loading && (loading.style.display = 'flex');
});
state.video.addEventListener('playing', () => {
loading && (loading.style.display = 'none');
});
// update progress bar during buffering
state.video.addEventListener(
'progress',
() => {
updateTimeline(state.video.currentTime);
},
{ signal }
);
state.video.addEventListener('progress', () => {
updateTimeline(state.video.currentTime);
});
// main loop: update progress, subtitles, skip buttons
state.video.addEventListener(
'timeupdate',
() => {
updateTimeline(state.video.currentTime);
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime));
updateSkipButton(state.video.currentTime);
},
{ signal }
);
state.video.addEventListener('timeupdate', () => {
updateTimeline(state.video.currentTime);
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime));
updateSkipButton(state.video.currentTime);
});
state.video.addEventListener(
'ended',
() => {
goToNextEpisode();
},
{ signal }
);
state.video.addEventListener('ended', () => {
goToNextEpisode();
});
// click/drag to seek (pointer events are more consistent across fullscreen/mobile)
progressWrap?.addEventListener(
'pointerdown',
e => {
// ignore right/middle click
if ('button' in e && e.button !== 0) return;
state.isScrubbing = true;
try {
(e.currentTarget as HTMLElement).setPointerCapture((e as PointerEvent).pointerId);
} catch {}
const rect = progressWrap.getBoundingClientRect();
state.video.currentTime = absoluteTimeFromRatio(
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
);
updateTimeline(state.video.currentTime);
updateSkipButton(state.video.currentTime);
showControls();
},
{ signal }
);
progressWrap?.addEventListener('pointerdown', e => {
// ignore right/middle click
if ('button' in e && e.button !== 0) return;
state.isScrubbing = true;
try {
(e.currentTarget as HTMLElement).setPointerCapture((e as PointerEvent).pointerId);
} catch {}
const rect = progressWrap.getBoundingClientRect();
state.video.currentTime = absoluteTimeFromRatio(
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
);
updateTimeline(state.video.currentTime);
updateSkipButton(state.video.currentTime);
showControls();
});
// hover to preview time
progressWrap?.addEventListener(
'pointermove',
e => {
const rect = progressWrap.getBoundingClientRect();
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
},
{ signal }
);
progressWrap?.addEventListener('pointermove', e => {
const rect = progressWrap.getBoundingClientRect();
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
});
progressWrap?.addEventListener('pointerleave', hidePreviewPopover, { signal });
progressWrap?.addEventListener(
'pointerup',
() => {
// ensure we finish the seek even if no window mousemove fired
if (!progressWrap) return;
state.isScrubbing = false;
},
{ signal }
);
progressWrap?.addEventListener('pointerleave', hidePreviewPopover);
progressWrap?.addEventListener('pointerup', () => {
// ensure we finish the seek even if no window mousemove fired
if (!progressWrap) return;
state.isScrubbing = false;
});
// dragging outside progress bar while scrubbing
window.addEventListener(
'pointermove',
e => {
if (!state.isScrubbing || !progressWrap) return;
const rect = progressWrap.getBoundingClientRect();
state.video.currentTime = absoluteTimeFromRatio(
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
);
updateTimeline(state.video.currentTime);
updateSkipButton(state.video.currentTime);
},
{ signal }
);
window.addEventListener('pointermove', e => {
if (!state.isScrubbing || !progressWrap) return;
const rect = progressWrap.getBoundingClientRect();
state.video.currentTime = absoluteTimeFromRatio(
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
);
updateTimeline(state.video.currentTime);
updateSkipButton(state.video.currentTime);
});
// track next-episode links outside the player so they start fresh after finishing an episode
document.addEventListener(
'click',
e => {
const target = e.target;
if (!(target instanceof Element)) return;
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) return;
const url = new URL(anchor.href, location.origin);
if (url.origin !== location.origin) return;
const parts = url.pathname.split('/').filter(Boolean);
if (parts[0] !== 'anime' || parts[2] !== 'watch') return;
if (Number.parseInt(parts[1], 10) !== state.malID) return;
const nextEpisode = Number.parseInt(url.searchParams.get('ep') ?? '1', 10);
const currentEpisode = Number.parseInt(state.currentEpisode, 10);
if (nextEpisode === currentEpisode + 1) markEpisodeTransition(nextEpisode);
},
{ signal }
);
document.addEventListener('click', e => {
const target = e.target;
if (!(target instanceof Element)) return;
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) return;
const url = new URL(anchor.href, location.origin);
if (url.origin !== location.origin) return;
const parts = url.pathname.split('/').filter(Boolean);
if (parts[0] !== 'anime' || parts[2] !== 'watch') return;
if (Number.parseInt(parts[1], 10) !== state.malID) return;
const nextEpisode = Number.parseInt(url.searchParams.get('ep') ?? '1', 10);
const currentEpisode = Number.parseInt(state.currentEpisode, 10);
if (nextEpisode === currentEpisode + 1) markEpisodeTransition(nextEpisode);
});
state.video.addEventListener('click', showControls, { signal });
state.video.addEventListener('click', showControls);
const searchInput = document.querySelector('[data-episode-search]') as HTMLInputElement | null;
const dropdown = document.querySelector('[data-episode-dropdown]') as HTMLElement | null;
const dropdown = container.querySelector('[data-episode-dropdown]') as HTMLElement | null;
let searchDebounce: number | undefined;
if (searchInput) {
searchInput.addEventListener(
'input',
() => {
clearTimeout(searchDebounce);
// debounce to avoid excessive range switches while typing
searchDebounce = window.setTimeout(() => {
const val = searchInput.value.replace(/\D/g, '');
if (!val) {
// clear: jump to current episode range
const cur = Number.parseInt(state.currentEpisode, 10);
switchEpisodeRange(Math.floor((cur - 1) / 100));
updateEpisodeHighlight(cur);
return;
}
const ep = Number.parseInt(val, 10);
if (!ep || ep <= 0) return;
const maxEp = state.totalEpisodes > 0 ? state.totalEpisodes : 500;
const clamped = Math.min(ep, maxEp);
searchInput.value = String(clamped);
if (state.episodeGrid) {
switchEpisodeRange(Math.floor((clamped - 1) / 100));
updateEpisodeHighlight(clamped);
}
}, 300);
},
{ signal }
);
searchInput.addEventListener('input', () => {
clearTimeout(searchDebounce);
// debounce to avoid excessive range switches while typing
searchDebounce = window.setTimeout(() => {
const val = searchInput.value.replace(/\D/g, '');
if (!val) {
// clear: jump to current episode range
const cur = Number.parseInt(state.currentEpisode, 10);
switchEpisodeRange(Math.floor((cur - 1) / 100));
updateEpisodeHighlight(cur);
return;
}
const ep = Number.parseInt(val, 10);
if (!ep || ep <= 0) return;
const maxEp = state.totalEpisodes > 0 ? state.totalEpisodes : 500;
const clamped = Math.min(ep, maxEp);
searchInput.value = String(clamped);
if (state.episodeGrid) {
switchEpisodeRange(Math.floor((clamped - 1) / 100));
updateEpisodeHighlight(clamped);
}
}, 300);
});
}
// range buttons (100s of episodes)
if (dropdown) {
dropdown.querySelectorAll('.episode-range-btn').forEach(btn => {
btn.addEventListener(
'click',
() => {
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 }
);
btn.addEventListener('click', () => {
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? '0', 10);
switchEpisodeRange(idx);
});
});
}
@@ -354,10 +273,3 @@ document.body.addEventListener('htmx:afterSwap', (e: Event) => {
const target = (e as CustomEvent).detail?.target as HTMLElement | null;
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();
}
});

View File

@@ -3,7 +3,6 @@ import { displayTimeFromAbsolute } from './timeline';
import { showControls } from './controls';
import { updateSubtitleOptions } from './subtitles';
import { updateQualityOptions } from './quality';
import { safeLocalStorage } from './storage';
// builds stream URL with mode, token, and optional quality param
const streamUrlForMode = (mode: string, quality?: string): string => {
@@ -22,9 +21,7 @@ const loadVideo = (url: string): void => {
state.video.src = url;
state.video.load();
state.pendingSeekTime = prevTime; // restored in loadedmetadata handler
if (wasPlaying) {
state.video.play().catch(() => undefined);
}
if (wasPlaying) state.video.play().catch(() => {});
};
/**
@@ -34,11 +31,8 @@ const loadVideo = (url: string): void => {
export const switchMode = (mode: string): void => {
if (!state.availableModes.includes(mode) || mode === state.currentMode) return;
state.currentMode = mode;
safeLocalStorage.setItem('player-audio-mode', mode);
const qualitySelect = state.container.querySelector(
'[data-quality-select]'
) as HTMLSelectElement | null;
loadVideo(streamUrlForMode(mode, qualitySelect?.value));
localStorage.setItem('player-audio-mode', mode);
loadVideo(streamUrlForMode(mode, state.container.querySelector('[data-quality-select]')?.value));
updateSubtitleOptions();
updateQualityOptions();
updateModeButtons();
@@ -54,20 +48,16 @@ export const updateModeButtons = (): void => {
const m = state.currentMode;
dub?.classList.toggle('text-accent', m === 'dub');
dub?.classList.toggle('text-foreground', m !== 'dub');
dub?.classList.toggle('text-white', m !== 'dub');
dub?.classList.toggle('opacity-50', !state.availableModes.includes('dub'));
dub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('dub'));
if (dub) {
dub.disabled = !state.availableModes.includes('dub');
}
dub && (dub.disabled = !state.availableModes.includes('dub'));
sub?.classList.toggle('text-accent', m === 'sub');
sub?.classList.toggle('text-foreground', m !== 'sub');
sub?.classList.toggle('text-white', m !== 'sub');
sub?.classList.toggle('opacity-50', !state.availableModes.includes('sub'));
sub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('sub'));
if (sub) {
sub.disabled = !state.availableModes.includes('sub');
}
sub && (sub.disabled = !state.availableModes.includes('sub'));
};
/**
@@ -92,7 +82,7 @@ export const setupMode = (): void => {
const autoplayBtn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
autoplayBtn?.addEventListener('change', e => {
safeLocalStorage.setItem(
localStorage.setItem(
'mal:autoplay-enabled',
(e.target as HTMLInputElement).checked ? 'true' : 'false'
);

View File

@@ -16,49 +16,35 @@ const sendBeacon = (payload: string) => {
return true;
};
let saveProgressInFlight: Promise<void> | null = null;
/**
* Saves current progress to backend.
* Debounced: skips if within 5s of last save for same episode.
*/
export const saveProgress = async (): Promise<void> => {
if (saveProgressInFlight) return saveProgressInFlight;
if (state.transitionEpisode !== null || !state.malID || state.video.currentTime < 1) return;
// 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 request = (async (): Promise<void> => {
if (state.transitionEpisode !== null || !state.malID || state.video.currentTime < 1) return;
const episode = Number.parseInt(state.currentEpisode, 10);
if (!episode) return;
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 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;
const payload = buildPayload(episode, safeTime);
try {
await request;
} finally {
if (saveProgressInFlight === request) {
saveProgressInFlight = null;
}
}
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 {}
};
// schedules periodic save every 30s during playback
@@ -76,6 +62,8 @@ const scheduleProgressSave = (): void => {
*/
export const markEpisodeTransition = (episodeNumber: number): void => {
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) {
window.clearTimeout(state.progressSaveTimer);
state.progressSaveTimer = undefined;
@@ -89,7 +77,7 @@ export const markEpisodeTransition = (episodeNumber: number): void => {
headers: { 'Content-Type': 'application/json' },
keepalive: true,
body: payload,
}).catch(() => undefined);
}).catch(() => {});
}
};
@@ -118,6 +106,7 @@ export const setupProgress = (): void => {
// save on page close
window.addEventListener('beforeunload', () => {
if (state.transitionEpisode !== null || state.completionSent || !state.malID) return;
if (!document.cookie.includes('mal_session=')) return;
const episode = Number.parseInt(state.currentEpisode, 10);
if (!episode) return;
sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime)));

View File

@@ -1,6 +1,5 @@
import { state } from './state';
import { displayTimeFromAbsolute } from './timeline';
import { safeLocalStorage } from './storage';
// same as mode.ts - could be extracted to shared util
const streamUrlForMode = (mode: string, quality?: string): string => {
@@ -18,9 +17,7 @@ const loadVideo = (url: string): void => {
state.video.src = url;
state.video.load();
state.pendingSeekTime = prevTime;
if (wasPlaying) {
state.video.play().catch(() => undefined);
}
if (wasPlaying) state.video.play().catch(() => {});
};
/**
@@ -30,7 +27,7 @@ const loadVideo = (url: string): void => {
export const switchQuality = (quality: string): void => {
const url = streamUrlForMode(state.currentMode, quality);
if (!url) return;
safeLocalStorage.setItem('mal:preferred-quality', quality);
localStorage.setItem('mal:preferred-quality', quality);
loadVideo(url);
};
@@ -57,7 +54,7 @@ export const updateQualityOptions = (): void => {
});
// restore saved preference
const preferred = safeLocalStorage.getItem('mal:preferred-quality') || 'best';
const preferred = localStorage.getItem('mal:preferred-quality') || 'best';
select.value = qualities.includes(preferred) ? preferred : 'best';
// hide if no quality options

View File

@@ -28,17 +28,9 @@ export const setupSegmentEditor = (): void => {
const typeOptions = Array.from(
panel.querySelectorAll('[data-segment-type-option]')
) 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 endTime: number | null = null;
let lastActiveElement: HTMLElement | null = null;
const setError = (msg: string | null): void => {
if (!errorBox) return;
@@ -57,22 +49,13 @@ export const setupSegmentEditor = (): void => {
};
const open = (): void => {
lastActiveElement =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
panel.classList.remove('hidden');
panel.classList.add('flex');
panel.setAttribute('aria-hidden', 'false');
setError(null);
showControls();
const firstFocusable = panel.querySelector(focusableSelector) as HTMLElement | null;
firstFocusable?.focus();
};
const close = (): void => {
panel.classList.add('hidden');
panel.classList.remove('flex');
panel.setAttribute('aria-hidden', 'true');
setError(null);
lastActiveElement?.focus();
};
toggleBtn.addEventListener('click', () => {
@@ -81,49 +64,12 @@ export const setupSegmentEditor = (): void => {
});
closeBtn?.addEventListener('click', close);
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
// close when clicking outside the segment capture UI
document.addEventListener('pointerdown', e => {
if (panel.classList.contains('hidden')) return;
const target = e.target as Node | null;
if (!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;
if (root.contains(target)) return;
close();
});

View File

@@ -1,8 +1,6 @@
import { state } from '../state';
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from '../timeline';
import { showControls } from '../controls';
import { saveProgress } from '../progress';
import { safeLocalStorage } from '../storage';
// button label based on segment type
const skipLabel = (type: string): string => (type === 'ed' ? 'Skip outro' : 'Skip intro');
@@ -28,10 +26,9 @@ export const updateSkipButton = (currentTime: number): void => {
}
// auto-skip: jump to end if enabled
const autoSkip = safeLocalStorage.getItem('mal:autoskip-enabled') === 'true';
const autoSkip = localStorage.getItem('mal:autoskip-enabled') === 'true';
if (autoSkip && displayTime >= segment.start && displayTime < segment.end) {
state.video.currentTime = absoluteTimeFromDisplay(segment.end + 0.01);
void saveProgress();
return;
}
@@ -49,8 +46,7 @@ export const updateSkipButton = (currentTime: number): void => {
*/
export const updateAutoSkipButton = (): void => {
const btn = document.querySelector('[data-autoskip]') as HTMLInputElement | null;
if (!btn) return;
btn.checked = safeLocalStorage.getItem('mal:autoskip-enabled') === 'true';
btn && (btn.checked = localStorage.getItem('mal:autoskip-enabled') === 'true');
};
/**
@@ -60,7 +56,7 @@ export const setupSkip = (): void => {
document.addEventListener('change', e => {
const target = e.target as HTMLElement;
if (target.hasAttribute('data-autoskip')) {
safeLocalStorage.setItem(
localStorage.setItem(
'mal:autoskip-enabled',
(target as HTMLInputElement).checked ? 'true' : 'false'
);

View File

@@ -66,9 +66,8 @@ export const renderSegments = (): void => {
const bar = document.createElement('div');
// use distinct colors so segments are readable over buffered/progress fills
bar.className = 'absolute top-0 h-full opacity-95';
// distinct colors for OP/ED, rendered above buffered/progress fills
const t = (s.type || '').toLowerCase();
bar.style.backgroundColor = t === 'ed' ? '#60a5fa' : '#f5c542';
// single color for OP/ED, rendered above buffered/progress fills
bar.classList.add('bg-amber-300/90');
bar.style.left = `${(s.start / bounds) * 100}%`;
bar.style.width = `${((s.end - s.start) / bounds) * 100}%`;
track.appendChild(bar);

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