diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..aff4418 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,54 @@ +version: '2' + +linters: + default: none + enable: + - copyloopvar + - errcheck + - govet + - ineffassign + - revive + - staticcheck + - unconvert + - unused + settings: + revive: + enable-all-rules: false + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: early-return + - name: error-naming + - name: error-return + - name: if-return + - name: increment-decrement + - name: range + - name: receiver-naming + - name: time-naming + - name: unnecessary-stmt + - name: var-declaration + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ + - node_modules$ + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/CONFLICTS.md b/CONFLICTS.md new file mode 100644 index 0000000..3eb5777 --- /dev/null +++ b/CONFLICTS.md @@ -0,0 +1,32 @@ +# Conflicts / Remaining Issues + +1. **God interface (`AnimeService`)** + - `internal/domain/anime.go` still defines a large `AnimeService` interface (catalog + discover + search + details + staff/stats/reviews). + - Needs to be split into smaller interfaces (ISP) and rewired through handlers/services. + +2. **Domain layer still leaks external models** + - While `domain.User` and `domain.Anime` are now real types, many other domain types are still direct aliases to integration/DB types (e.g. `Genre`, `Recommendation`, etc. in `internal/domain/anime.go`). + - Goal is a stable domain model that does not break if Jikan/DB structs change. + +3. **No real DB transactions for multi-write operations** + - Multi-step writes (e.g. playback completion / watchlist updates) still do not run inside a database transaction. + - Errors are no longer swallowed in several places, but atomicity is still not guaranteed. + +4. **DiceBear URL duplication** + - Default avatar URL logic is duplicated in `cmd/user/main.go` and `internal/database/migrations/016_add_avatar_url.sql`. + - Needs centralization (or migration updated to match single source of truth). + +5. **AllAnime package-level shared HTTP client** + - `integrations/playback/allanime/client.go` still has a package-level mutable `http.Client` (`allAnimeUTLSClient`). + - Should be instance-owned or injected to avoid cross-test/env coupling. + +6. **Regex-based parsing of upstream JSON-ish responses** + - `integrations/playback/allanime/extractor.go` still parses provider responses using regex. + - Should be replaced with real JSON decoding (or a more robust parser) where possible. + +7. **Template duplication / drift risk** + - `templates/watchlist.gohtml` and `templates/watchlist_partial.gohtml` are still separate with overlapping markup. + - Inline JS was removed, but the duplication itself remains and can still drift. + +8. **Remaining handler consistency** + - Some modules still have duplicated user extraction patterns and could be unified (e.g. `currentUser()` helper usage beyond playback). diff --git a/Dockerfile b/Dockerfile index 75d050f..659a03b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,14 +5,22 @@ WORKDIR /app # Enable CGO for sqlite3 ENV CGO_ENABLED=1 -# Install sqlc for code generation -RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + unzip \ + gcc \ + libc6-dev \ + libsqlite3-dev \ + && rm -rf /var/lib/apt/lists/* -# Install build dependencies for bun + assets -RUN apt-get update && apt-get install -y ca-certificates sqlite3 curl unzip && rm -rf /var/lib/apt/lists/* +# Install bun (for building frontend assets) RUN curl -fsSL https://bun.sh/install | bash ENV PATH="/root/.bun/bin:${PATH}" +# Install sqlc for code generation +RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 + ENV GOPROXY=direct COPY go.mod go.sum ./ RUN go mod download @@ -50,7 +58,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 docker/entrypoint.sh ./entrypoint.sh +COPY entrypoint.sh ./entrypoint.sh EXPOSE 3000 diff --git a/README.md b/README.md index 373f26e..3c2ac55 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,53 @@ # MyAnimeList - - - - - -
- - - MyAnimeList logo - - - MyAnimeList
- My personal anime tracker, built because nothing else felt right. -
+

+ + + MyAnimeList logo + +

Go SQLite - Tailwind + Tailwind HTMX

--- -## Why this project exists +I built this because nothing else felt right. Every tracker I tried had decent pieces but the whole never clicked — awkward UI, missing features, or it just got in the way of actually watching anime. So I built one that fits how I work. -I built this for myself. +It is a self-hosted Go server that streams anime through a proxy layer, catalogs metadata, and tracks your progress. -I was frustrated with the UI and UX of every tracker I tried. Even when something looked decent, it still felt awkward to use day-to-day, or it was missing pieces I considered essential. I wanted one place that matched how I actually watch anime: search fast, get context fast, update status fast, and move on. - -So this project is personal first and public second. I put it on GitHub because I like shipping in the open, not because it was originally designed as a general-purpose product for everyone. - -Technically, I also wanted to prove that a small, server-rendered Go app could stay reliable even when upstream anime APIs are inconsistent. A lot of this code exists because real APIs rate-limit, timeout, and occasionally fail at the worst possible moment. - -## What the application offers - -For my own workflow, MyAnimeList combines catalog browsing, seasonal discovery, quick search, detail pages with recommendations and relations, watchlist management, continue-watching, and in-app playback in one server-rendered interface. - -The interface is minimal and functional, featuring a dark theme and quick access to tracking tools. - -## Technical approach - -The application is written in Go and rendered on the server with `html/template`, with SQLite as the primary datastore and `sqlc` for typed query generation. Styling uses Tailwind CSS v4. HTMX and small TypeScript modules handle incremental interactions, which keeps the interface responsive without moving the entire product into a heavy client-side architecture. - -The external anime data source is Jikan (`https://api.jikan.moe/v4`). Because reliability is a first-class concern, the client layer includes request pacing, bounded retries, backoff behavior, stale-cache fallback, and a persisted retry queue for failed fetches. Playback proxying uses uTLS to bypass Cloudflare protections. - -Upstream APIs can fail transiently with `429` and `5xx` responses, so the app favors graceful degradation over hard failure. Cached values are used when fresh requests fail, retryable failures are persisted and replayed in a background worker, and relation synchronization is incremental so one bad fetch does not block the rest of the graph. +The frontend is Tailwind CSS v4 with HTMX handling pagination, infinite scroll, search, and watchlist interactions. TypeScript only steps in where HTMX cannot — the video player, command palette bound to Cmd+K, skip segment editor, theme toggling with system preference detection, and custom UI components. Everything lives in one process, one SQLite database, one deployment. ## Repository structure -The codebase follows standard Go project layout conventions. - | Path | Purpose | | ----------------- | ------------------------------------------------ | | `api/*` | Feature routes: anime, auth, playback, watchlist | | `cmd/server` | Application entrypoint and CLI commands | +| `cmd/user` | User management CLI (create, update, delete) | | `integrations/*` | External API clients and scraping | | `internal/*` | Core services: db, middleware, server, worker | | `pkg/middleware` | Generic HTTP middleware | | `templates/*` | Server-rendered HTML templates | -| `migrations` | Schema evolution | +| `migrations` | Schema evolution (20 migrations) | | `static` / `dist` | Frontend assets | -## Getting started +## Running locally -Requires Go `1.25+`, Bun, and [just](https://github.com/casey/just) (`brew install just`). +Requires Go `1.25+`, Bun, and [just](https://github.com/casey/just). Migrations run on startup. Configuration lives in environment variables — see `cmd/server/main.go` for the full list. ```bash -git clone https://github.com/mkelvers/mal.git && cd mal -openssl rand -base32 32 -PLAYBACK_PROXY_SECRET="your-32-char-secret" go run ./cmd/server -go run ./cmd/user +just dev ``` -The app runs at `http://localhost:3000`. +## Contributing -### 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 -``` - -## 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. +Bug reports and pull requests are welcome. This is a personal project, so there is no strict roadmap or issue triage cycle. If something is broken or missing, open an issue or send a PR. ## License -This project is released under the MIT License. See `LICENSE` for details. +MIT. See `LICENSE`. diff --git a/bun.lock b/bun.lock index 6ca612e..a1ed8a7 100644 --- a/bun.lock +++ b/bun.lock @@ -4,12 +4,9 @@ "workspaces": { "": { "name": "myanimelist-ui", - "dependencies": { - "dompurify": "^3.4.1", - }, "devDependencies": { "@tailwindcss/cli": "^4.2.4", - "@toolwind/anchors": "^1.0.10", + "@types/node": "^24.0.0", "@typescript-eslint/eslint-plugin": "^8.59.2", "@typescript-eslint/parser": "^8.59.2", "eslint": "^10.3.0", @@ -118,15 +115,13 @@ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="], - "@toolwind/anchors": ["@toolwind/anchors@1.0.10", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0" } }, "sha512-F3J/lxGGPUy+GIpT49NmYMF1X7l0d7UzdDASni29il2ro5sT4cYfPBFHBAfOM0lpgKOr/HnqINlomngt8BcvnA=="], - "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="], @@ -166,8 +161,6 @@ "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=="], @@ -340,6 +333,8 @@ "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=="], diff --git a/cmd/server/main.go b/cmd/server/main.go index 860e6a1..fde04a1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,3 +1,4 @@ +// Package main runs the MAL web server. package main import ( diff --git a/cmd/user/main.go b/cmd/user/main.go index 8545262..400dfd4 100644 --- a/cmd/user/main.go +++ b/cmd/user/main.go @@ -1,32 +1,50 @@ +// 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() { - dbConn, err := db.Open(db.GetDBFile()) + cfg, err := config.Load() if err != nil { - log.Fatalf("failed to open db: %v", err) + observability.Error("cli_config_load_failed", "cmd/user", "", nil, err) + os.Exit(1) + } + + dbConn, err := db.Open(cfg.DatabaseFile) + if err != nil { + observability.Error("cli_db_open_failed", "cmd/user", "", map[string]any{"db_file": cfg.DatabaseFile}, err) + os.Exit(1) } defer func() { _ = dbConn.Close() }() - if len(os.Args) == 2 && os.Args[1] == "update-avatar" { - updateAvatars(dbConn) - return + 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) != 3 { - log.Fatalf("Usage: go run cmd/user/main.go \n go run cmd/user/main.go update-avatar") + observability.Warn("cli_usage", "cmd/user", "invalid arguments", map[string]any{"argc": len(os.Args)}, nil) + _, _ = fmt.Fprintln(os.Stderr, "Usage: go run cmd/user/main.go \n go run cmd/user/main.go update-avatar\n go run cmd/user/main.go run-fixes") + os.Exit(2) } username := os.Args[1] @@ -35,7 +53,8 @@ func main() { var existingID string err = dbConn.QueryRow("SELECT id FROM user WHERE username = ?", username).Scan(&existingID) if err != nil && err != sql.ErrNoRows { - log.Fatalf("database error: %v", err) + observability.Error("cli_user_lookup_failed", "cmd/user", "", map[string]any{"username": username}, err) + os.Exit(1) } if err == nil { @@ -51,12 +70,14 @@ func main() { hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) if err != nil { - log.Fatalf("failed to hash password: %v", err) + observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err) + os.Exit(1) } _, err = dbConn.Exec("UPDATE user SET password_hash = ? WHERE id = ?", string(hash), existingID) if err != nil { - log.Fatalf("failed to update user: %v", err) + observability.Error("cli_user_password_update_failed", "cmd/user", "", map[string]any{"username": username}, err) + os.Exit(1) } fmt.Printf("Password for '%s' updated successfully!\n", username) @@ -65,14 +86,16 @@ func main() { hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) if err != nil { - log.Fatalf("failed to hash password: %v", err) + observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err) + os.Exit(1) } id := uuid.New().String() avatarURL := fmt.Sprintf("https://api.dicebear.com/9.x/dylan/svg?seed=%s", username) _, err = dbConn.Exec("INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)", id, username, string(hash), avatarURL) if err != nil { - log.Fatalf("failed to create user: %v", err) + observability.Error("cli_user_create_failed", "cmd/user", "", map[string]any{"username": username}, err) + os.Exit(1) } fmt.Printf("User '%s' was created successfully!\n", username) @@ -81,7 +104,8 @@ func main() { func updateAvatars(dbConn *sql.DB) { rows, err := dbConn.Query("SELECT id, username FROM user") if err != nil { - log.Fatalf("failed to fetch users: %v", err) + observability.Error("cli_users_list_failed", "cmd/user", "", nil, err) + os.Exit(1) } defer func() { _ = rows.Close() }() @@ -89,20 +113,55 @@ func updateAvatars(dbConn *sql.DB) { for rows.Next() { var id, username string if err := rows.Scan(&id, &username); err != nil { - log.Fatalf("failed to scan user: %v", err) + observability.Error("cli_user_scan_failed", "cmd/user", "", nil, err) + os.Exit(1) } avatarURL := fmt.Sprintf("https://api.dicebear.com/9.x/dylan/svg?seed=%s", username) _, err := dbConn.Exec("UPDATE user SET avatar_url = ? WHERE id = ?", avatarURL, id) if err != nil { - log.Fatalf("failed to update avatar for %s: %v", username, err) + observability.Error("cli_user_avatar_update_failed", "cmd/user", "", map[string]any{"username": username}, err) + os.Exit(1) } count++ } if err := rows.Err(); err != nil { - log.Fatalf("iteration error: %v", err) + observability.Error("cli_users_iter_failed", "cmd/user", "", nil, err) + os.Exit(1) } fmt.Printf("Updated avatars for %d user(s)\n", count) } + +func runFixes(dbConn *sql.DB) { + if err := database.RunMigrationsAndFixes(dbConn); err != nil { + observability.Error("cli_run_migrations_and_fixes_failed", "cmd/user", "", nil, err) + os.Exit(1) + } + + rows, err := dbConn.Query("SELECT id, applied_at FROM data_fixes ORDER BY id ASC") + if err != nil { + observability.Error("cli_data_fixes_list_failed", "cmd/user", "", nil, err) + os.Exit(1) + } + defer func() { _ = rows.Close() }() + + count := 0 + for rows.Next() { + var id string + var appliedAt string + if err := rows.Scan(&id, &appliedAt); err != nil { + observability.Error("cli_data_fix_scan_failed", "cmd/user", "", nil, err) + os.Exit(1) + } + fmt.Printf("%s applied_at=%s\n", id, appliedAt) + count++ + } + if err := rows.Err(); err != nil { + observability.Error("cli_data_fixes_iter_failed", "cmd/user", "", nil, err) + os.Exit(1) + } + + fmt.Printf("Applied fixes: %d\n", count) +} diff --git a/docker/entrypoint.sh b/entrypoint.sh similarity index 99% rename from docker/entrypoint.sh rename to entrypoint.sh index 20d0287..f9ed1dc 100755 --- a/docker/entrypoint.sh +++ b/entrypoint.sh @@ -9,3 +9,4 @@ if [ ! -x /app/main_server ]; then fi exec /app/main_server + diff --git a/eslint.config.ts b/eslint.config.ts index 261be59..9b6635e 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -2,27 +2,38 @@ 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: ['**/*.ts'], + files: ['static/**/*.ts'], plugins: { '@typescript-eslint': tseslint, prettier, }, languageOptions: { parser: tsParser, + parserOptions: { + project: ['./tsconfig.json'], + tsconfigRootDir, + }, }, rules: { ...eslintConfigPrettier.rules, - '@typescript-eslint/no-explicit-any': 'off', + ...tseslint.configs.recommended.rules, + ...tseslint.configs.stylistic.rules, + '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-unused-vars': [ - 'warn', + 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, ], + '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], 'prettier/prettier': 'error', }, }, diff --git a/extensions/mal-firefox/README.md b/extensions/mal-firefox/README.md deleted file mode 100644 index bdc3af3..0000000 --- a/extensions/mal-firefox/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# MAL Firefox Extension (dev) - -## Load in Firefox - -1. Open `about:debugging#/runtime/this-firefox` -2. Click **Load Temporary Add-on…** -3. Select `extensions/mal-firefox/manifest.json` - -## Usage - -- Click the toolbar icon to open the popup and log in. -- After login, select text on any page → right click → **MyAnimeList** → **Add to Watchlist** → pick a status. diff --git a/extensions/mal-firefox/background.js b/extensions/mal-firefox/background.js deleted file mode 100644 index 2e92638..0000000 --- a/extensions/mal-firefox/background.js +++ /dev/null @@ -1,103 +0,0 @@ -const MENU_ROOT_ID = 'mal-root'; -const MENU_WATCHLIST_ID = 'mal-watchlist'; -const MENU_STATUS_PREFIX = 'mal-status:'; -const STATUSES = [ - { value: 'watching', label: 'Watching' }, - { value: 'completed', label: 'Completed' }, - { value: 'on_hold', label: 'On Hold' }, - { value: 'dropped', label: 'Dropped' }, - { value: 'plan_to_watch', label: 'Plan to Watch' }, -]; - -async function getSettings() { - const { authToken, apiBaseUrl } = await browser.storage.local.get(['authToken', 'apiBaseUrl']); - return { - authToken: authToken || '', - apiBaseUrl: apiBaseUrl || 'https://mal.mkelvers.tech', - }; -} - -async function apiFetch(path, init = {}) { - const { authToken, apiBaseUrl } = await getSettings(); - const url = apiBaseUrl.replace(/\/+$/, '') + path; - const headers = new Headers(init.headers || {}); - if (authToken) headers.set('Authorization', `Bearer ${authToken}`); - const res = await fetch(url, { ...init, headers }); - if (!res.ok) { - const msg = await res.text().catch(() => ''); - throw new Error(msg || `HTTP ${res.status}`); - } - return res; -} - -async function ensureContextMenu() { - const { authToken } = await getSettings(); - await browser.contextMenus.removeAll(); - if (!authToken) return; - - browser.contextMenus.create({ - id: MENU_ROOT_ID, - title: 'MyAnimeList', - contexts: ['selection'], - }); - - browser.contextMenus.create({ - id: MENU_WATCHLIST_ID, - parentId: MENU_ROOT_ID, - title: 'Add to Watchlist', - contexts: ['selection'], - }); - - for (const s of STATUSES) { - browser.contextMenus.create({ - id: MENU_STATUS_PREFIX + s.value, - parentId: MENU_WATCHLIST_ID, - title: s.label, - contexts: ['selection'], - }); - } -} - -browser.runtime.onInstalled.addListener(() => { - ensureContextMenu(); -}); - -browser.runtime.onStartup.addListener(() => { - ensureContextMenu(); -}); - -browser.storage.onChanged.addListener((changes, area) => { - if (area !== 'local') return; - if (changes.authToken) ensureContextMenu(); -}); - -browser.contextMenus.onClicked.addListener(async info => { - if (typeof info.menuItemId !== 'string') return; - if (!info.menuItemId.startsWith(MENU_STATUS_PREFIX)) return; - - const status = info.menuItemId.slice(MENU_STATUS_PREFIX.length); - const text = (info.selectionText || '').trim().replace(/\s+/g, ' ').slice(0, 120); - if (!text) return; - - try { - const searchRes = await apiFetch(`/api/search-quick?q=${encodeURIComponent(text)}`); - const items = await searchRes.json(); - const top = items && items[0]; - if (!top || !top.id) { - await browser.notifications?.create?.({ - type: 'basic', - title: 'MyAnimeList', - message: `No matches for: ${text}`, - }); - return; - } - - await apiFetch('/api/watchlist', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ animeId: top.id, status }), - }); - } catch { - // Silent failure by default; can be extended with notifications later. - } -}); diff --git a/extensions/mal-firefox/icon.svg b/extensions/mal-firefox/icon.svg deleted file mode 100644 index 016dbb5..0000000 --- a/extensions/mal-firefox/icon.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/extensions/mal-firefox/manifest.json b/extensions/mal-firefox/manifest.json deleted file mode 100644 index 8ec94a1..0000000 --- a/extensions/mal-firefox/manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "manifest_version": 3, - "name": "MyAnimeList", - "version": "0.1.0", - "description": "Right-click selected anime titles and add them to your watchlist.", - "permissions": ["contextMenus", "storage"], - "host_permissions": [""], - "background": { - "scripts": ["background.js"] - }, - "action": { - "default_title": "MAL Watchlist", - "default_popup": "popup.html" - }, - "icons": { - "48": "icon.svg" - } -} diff --git a/extensions/mal-firefox/popup.css b/extensions/mal-firefox/popup.css deleted file mode 100644 index ff6b720..0000000 --- a/extensions/mal-firefox/popup.css +++ /dev/null @@ -1,229 +0,0 @@ -:root { - color-scheme: light dark; - --bg: #0b0f1a; - --card: rgba(255, 255, 255, 0.06); - --border: rgba(255, 255, 255, 0.12); - --text: rgba(255, 255, 255, 0.92); - --muted: rgba(255, 255, 255, 0.65); - --accent: #6ea8fe; - --danger: #ff6b6b; - --ok: #4ade80; -} - -@media (prefers-color-scheme: light) { - :root { - --bg: #f6f7fb; - --card: rgba(0, 0, 0, 0.03); - --border: rgba(0, 0, 0, 0.1); - --text: rgba(0, 0, 0, 0.88); - --muted: rgba(0, 0, 0, 0.6); - --accent: #1f6feb; - --danger: #b42318; - } -} - -html, -body { - margin: 0; - padding: 0; - background: var(--bg); - color: var(--text); - font: - 14px/1.4 system-ui, - -apple-system, - Segoe UI, - Roboto, - sans-serif; -} - -body { - width: 380px; - min-width: 380px; -} - -#app { - padding: 10px; -} - -.panel { - background: transparent; - border-radius: 0; - padding: 12px; - display: grid; - gap: 10px; -} - -.brand { - display: flex; - align-items: center; - gap: 8px; -} - -.brandIcon { - width: 28px; - height: 28px; - border-radius: 8px; -} - -.title { - font-weight: 650; - letter-spacing: 0.2px; -} - -.header { - display: flex; - align-items: center; - justify-content: space-between; -} - -.link { - background: transparent; - color: var(--accent); - border: 0; - padding: 6px 0; - cursor: pointer; -} - -.divider { - height: 1px; - background: transparent; - opacity: 0.9; -} - -.subtitle { - font-weight: 600; - color: var(--muted); -} - -.label { - display: grid; - gap: 4px; - color: var(--muted); -} - -.input { - width: 100%; - box-sizing: border-box; - padding: 9px 10px; - border-radius: 0; - border: 1px solid var(--border); - background: rgba(0, 0, 0, 0.15); - color: var(--text); - outline: none; -} - -.input:focus { - border: 1px solid var(--border); - outline: none; -} - -.btn { - width: 100%; - padding: 10px 12px; - border-radius: 0; - border: 0; - background: rgba(110, 168, 254, 0.18); - color: var(--text); - cursor: pointer; -} - -.btn.danger { - background: rgba(255, 107, 107, 0.18); -} - -.error { - color: var(--danger); -} - -.body { - color: var(--muted); -} - -.login { - display: grid; - gap: 8px; -} - -.statusRow { - display: flex; - align-items: center; - gap: 8px; - color: var(--muted); -} - -.statusDot { - width: 8px; - height: 8px; - border-radius: 999px; - background: var(--ok); -} - -.statusText { - font-size: 12px; -} - -[hidden] { - display: none !important; -} - -.list { - display: grid; - gap: 8px; -} - -.item { - display: grid; - grid-template-columns: 44px 1fr; - gap: 10px; - align-items: center; - padding: 8px; - border-radius: 10px; - border: 0; -} - -.thumb { - width: 44px; - height: 62px; - border-radius: 8px; - object-fit: cover; - background: rgba(255, 255, 255, 0.08); -} - -.meta { - display: grid; - gap: 4px; -} - -.metaTitle { - font-weight: 650; -} - -.metaSub { - color: var(--muted); - font-size: 12px; -} - -.row { - display: flex; - gap: 8px; - align-items: center; - flex-wrap: wrap; -} - -.select { - padding: 8px 10px; - border-radius: 10px; - border: 0; - background: rgba(0, 0, 0, 0.15); - color: var(--text); - flex: 1; -} - -.mini { - padding: 8px 10px; - border-radius: 10px; - border: 0; - background: rgba(110, 168, 254, 0.18); - color: var(--text); - cursor: pointer; -} diff --git a/extensions/mal-firefox/popup.html b/extensions/mal-firefox/popup.html deleted file mode 100644 index d0075e0..0000000 --- a/extensions/mal-firefox/popup.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - MAL Watchlist - - - -
-
-
-
- -
MyAnimeList
-
- -
- -
- -
- 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. -
- -
- - - - -
-
- - - - diff --git a/extensions/mal-firefox/popup.js b/extensions/mal-firefox/popup.js deleted file mode 100644 index eb72d05..0000000 --- a/extensions/mal-firefox/popup.js +++ /dev/null @@ -1,74 +0,0 @@ -function qs(id) { - return document.getElementById(id); -} - -async function getSettings() { - const { authToken, apiBaseUrl } = await browser.storage.local.get(['authToken', 'apiBaseUrl']); - return { - authToken: authToken || '', - apiBaseUrl: apiBaseUrl || 'https://mal.mkelvers.tech', - }; -} - -async function setSettings(patch) { - await browser.storage.local.set(patch); -} - -function show(el, on) { - el.hidden = !on; -} - -async function render() { - const settings = await getSettings(); - document.body.dataset.state = settings.authToken ? 'in' : 'out'; - - const logoutBtn = qs('logoutBtn'); - logoutBtn.addEventListener('click', async () => { - await setSettings({ authToken: '' }); - await render(); - }); - - const hasToken = !!settings.authToken; - show(logoutBtn, hasToken); - show(qs('login'), !hasToken); - show(qs('loggedIn'), hasToken); - - if (!hasToken) { - setupLogin(); - return; - } -} - -function setupLogin() { - const loginErr = qs('loginErr'); - show(loginErr, false); - - qs('loginBtn').onclick = async () => { - show(loginErr, false); - const username = qs('username').value.trim(); - const password = qs('password').value; - if (!username || !password) { - loginErr.textContent = 'Missing username or password'; - show(loginErr, true); - return; - } - - try { - const { apiBaseUrl } = await getSettings(); - const res = await fetch(apiBaseUrl.replace(/\/+$/, '') + '/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password, name: 'Firefox extension' }), - }); - if (!res.ok) throw new Error('Invalid username or password'); - const data = await res.json(); - await setSettings({ authToken: data.token }); - await render(); - } catch (e) { - loginErr.textContent = e.message || 'Login failed'; - show(loginErr, true); - } - }; -} - -render(); diff --git a/integrations/jikan/client.go b/integrations/jikan/client.go index 016b998..03be892 100644 --- a/integrations/jikan/client.go +++ b/integrations/jikan/client.go @@ -5,21 +5,24 @@ import ( "encoding/json" "errors" "fmt" - "log" "net" "net/http" - "os" "reflect" "strconv" "strings" "sync" "time" + "mal/internal/config" "mal/internal/db" + "mal/internal/observability" + "mal/pkg/net/useragent" "golang.org/x/sync/singleflight" ) +var traceEnabled bool + type Client struct { httpClient *http.Client baseURL string @@ -29,6 +32,7 @@ type Client struct { lastReqTime time.Time // rate limiting: last request timestamp sf singleflight.Group refreshSem chan struct{} + metrics *observability.Metrics // Random anime pool for DDoS-proof truly random "Surprise Me" randomPool []Anime @@ -38,7 +42,8 @@ type Client struct { const jikanSlowLogThreshold = 750 * time.Millisecond -func NewClient(queries *db.Queries) *Client { +func NewClient(cfg config.Config, queries *db.Queries, metrics *observability.Metrics) *Client { + traceEnabled = cfg.JikanTrace return &Client{ httpClient: &http.Client{ Timeout: 10 * time.Second, @@ -51,6 +56,7 @@ func NewClient(queries *db.Queries) *Client { }, baseURL: "https://api.jikan.moe/v4", db: queries, + metrics: metrics, retrySignal: make(chan struct{}, 1), refreshSem: make(chan struct{}, 4), randomPool: make([]Anime, 0), @@ -140,8 +146,7 @@ func waitForRetry(ctx context.Context, delay time.Duration) error { } func jikanTraceEnabled() bool { - value := strings.ToLower(strings.TrimSpace(os.Getenv("MAL_JIKAN_TRACE"))) - return value == "1" || value == "true" || value == "yes" + return traceEnabled } func logJikanCache(cacheKey string, source string, startedAt time.Time, err error) { @@ -153,17 +158,25 @@ func logJikanCache(cacheKey string, source string, startedAt time.Time, err erro return } - errorValue := "" + level := observability.LogLevelInfo if err != nil { - errorValue = err.Error() + level = observability.LogLevelError + } else if source != "fresh" && source != "refresh" { + // Stale reads are expected sometimes, but worth tracking in logs. + level = observability.LogLevelWarn } - log.Printf( - "jikan_cache key=%s source=%s duration_ms=%.2f error=%s", - strconv.Quote(cacheKey), - source, - float64(duration.Microseconds())/1000, - strconv.Quote(errorValue), + observability.LogJSON( + level, + "jikan_cache", + "jikan", + "", + map[string]any{ + "cache_key": cacheKey, + "source": source, + "duration_ms": float64(duration.Microseconds()) / 1000, + }, + err, ) } @@ -173,18 +186,26 @@ func logJikanUpstream(urlStr string, statusCode int, attempts int, startedAt tim return } - errorValue := "" - if err != nil { - errorValue = err.Error() + level := observability.LogLevelInfo + if err != nil || statusCode >= http.StatusInternalServerError { + level = observability.LogLevelError + } else if statusCode == http.StatusTooManyRequests || statusCode >= http.StatusBadRequest { + level = observability.LogLevelWarn } - log.Printf( - "jikan_upstream url=%s status=%d attempts=%d duration_ms=%.2f error=%s", - strconv.Quote(urlStr), - statusCode, - attempts, - float64(duration.Microseconds())/1000, - strconv.Quote(errorValue), + observability.LogJSON( + level, + "jikan_upstream", + "jikan", + "", + map[string]any{ + "url": urlStr, + "endpoint": metricsEndpoint(urlStr), + "status": statusCode, + "attempts": attempts, + "duration_ms": float64(duration.Microseconds()) / 1000, + }, + err, ) } @@ -262,11 +283,18 @@ func (c *Client) getCache(parentCtx context.Context, key string, out any) bool { data, err := c.db.GetJikanCache(ctx, key) if err != nil { + c.metrics.ObserveCache("jikan", "miss") return false } err = json.Unmarshal([]byte(data), out) - return err == nil + if err != nil { + c.metrics.ObserveCache("jikan", "miss") + return false + } + + c.metrics.ObserveCache("jikan", "hit") + return true } // getStaleCache retrieves expired-but-available cache by key. @@ -276,11 +304,18 @@ func (c *Client) getStaleCache(parentCtx context.Context, key string, out any) b data, err := c.db.GetJikanCacheStale(ctx, key) if err != nil { + c.metrics.ObserveCache("jikan_stale", "miss") return false } err = json.Unmarshal([]byte(data), out) - return err == nil + if err != nil { + c.metrics.ObserveCache("jikan_stale", "miss") + return false + } + + c.metrics.ObserveCache("jikan_stale", "hit") + return true } // setCache stores data in cache with specified TTL. @@ -425,7 +460,9 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err maxRetries := 5 startedAt := time.Now() attempts := 0 + endpoint := metricsEndpoint(urlStr) logAndReturn := func(statusCode int, err error) error { + c.metrics.ObserveJikanRequest(endpoint, statusCode, time.Since(startedAt), err) logJikanUpstream(urlStr, statusCode, attempts, startedAt, err) return err } @@ -446,6 +483,7 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err if err != nil { return logAndReturn(0, fmt.Errorf("failed to create jikan request: %w", err)) } + req.Header.Set("User-Agent", useragent.Generic) resp, err := c.httpClient.Do(req) if err != nil { @@ -506,3 +544,36 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err return logAndReturn(0, fmt.Errorf("max retries exceeded for %s", urlStr)) } + +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, "/") +} diff --git a/integrations/jikan/client_test.go b/integrations/jikan/client_test.go index c34a993..3c64464 100644 --- a/integrations/jikan/client_test.go +++ b/integrations/jikan/client_test.go @@ -5,7 +5,9 @@ import ( "database/sql" "encoding/json" "io" + "mal/internal/config" "mal/internal/db" + "mal/internal/observability" "net/http" "strings" "testing" @@ -41,7 +43,7 @@ func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) { } queries := db.New(sqlDB) - client := NewClient(queries) + client := NewClient(config.Config{}, queries, observability.NewMetrics()) stale := TopAnimeResponse{Data: []Anime{{MalID: 1, Title: "stale"}}} staleBytes, err := json.Marshal(stale) if err != nil { diff --git a/integrations/jikan/constants.go b/integrations/jikan/constants.go index d271d5c..abe299f 100644 --- a/integrations/jikan/constants.go +++ b/integrations/jikan/constants.go @@ -4,3 +4,4 @@ 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 diff --git a/integrations/jikan/module.go b/integrations/jikan/module.go index 811d82b..8eedc6a 100644 --- a/integrations/jikan/module.go +++ b/integrations/jikan/module.go @@ -1,8 +1,6 @@ package jikan -import ( - "go.uber.org/fx" -) +import "go.uber.org/fx" var Module = fx.Options( fx.Provide(NewClient), diff --git a/integrations/jikan/producers.go b/integrations/jikan/producers.go new file mode 100644 index 0000000..3ab0542 --- /dev/null +++ b/integrations/jikan/producers.go @@ -0,0 +1,138 @@ +package jikan + +import ( + "context" + "errors" + "fmt" + "net/url" + "strconv" + "strings" +) + +type ProducerListEntry struct { + MalID int `json:"mal_id"` + Titles []struct { + Type string `json:"type"` + Title string `json:"title"` + } `json:"titles"` +} + +type ProducersResponse struct { + Data []ProducerListEntry `json:"data"` + Pagination Pagination `json:"pagination"` +} + +type ProducerListResult struct { + Items []ProducerListEntry + HasNextPage bool +} + +func (c *Client) GetProducers(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 1 + } + + q := strings.TrimSpace(query) + if q == "" { + return c.fetchProducersPage(ctx, "", page, limit) + } + + result, err := c.fetchProducersPage(ctx, q, page, limit) + if err == nil { + return result, nil + } + + var apiErr *APIError + if !errors.As(err, &apiErr) { + return ProducerListResult{}, err + } + + return c.searchProducersFromPages(ctx, q, page, limit) +} + +func (c *Client) fetchProducersPage(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) { + q := strings.TrimSpace(query) + cacheKey := fmt.Sprintf("producers:%s:%d:%d", q, page, limit) + reqURL := fmt.Sprintf("%s/producers?page=%d&limit=%d", c.baseURL, page, limit) + if q != "" { + reqURL += "&q=" + url.QueryEscape(q) + } + + var result ProducersResponse + if err := c.getWithCache(ctx, cacheKey, producerCacheTTL, reqURL, &result); err != nil { + return ProducerListResult{}, err + } + + return ProducerListResult{ + Items: result.Data, + HasNextPage: result.Pagination.HasNextPage, + }, nil +} + +func (c *Client) searchProducersFromPages(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) { + const maxPagesToScan = 25 + + needle := strings.ToLower(strings.TrimSpace(query)) + startIndex := (page - 1) * limit + endIndex := startIndex + limit + + matches := make([]ProducerListEntry, 0, endIndex) + scannedAll := false + + for currentPage := 1; currentPage <= maxPagesToScan; currentPage++ { + result, err := c.fetchProducersPage(ctx, "", currentPage, limit) + if err != nil { + return ProducerListResult{}, err + } + + for _, item := range result.Items { + name := strings.ToLower(ProducerListEntryName(item)) + if strings.Contains(name, needle) { + matches = append(matches, item) + } + } + + if len(matches) >= endIndex { + return ProducerListResult{ + Items: matches[startIndex:endIndex], + HasNextPage: len(matches) > endIndex || result.HasNextPage, + }, nil + } + + if !result.HasNextPage { + scannedAll = true + break + } + } + + if startIndex >= len(matches) { + return ProducerListResult{ + Items: []ProducerListEntry{}, + HasNextPage: !scannedAll, + }, nil + } + + if endIndex > len(matches) { + endIndex = len(matches) + } + + return ProducerListResult{ + Items: matches[startIndex:endIndex], + HasNextPage: !scannedAll, + }, nil +} + +func ProducerListEntryName(entry ProducerListEntry) string { + for _, t := range entry.Titles { + if t.Title != "" { + return t.Title + } + } + if entry.MalID > 0 { + return strconv.Itoa(entry.MalID) + } + return "" +} diff --git a/integrations/jikan/relations.go b/integrations/jikan/relations.go index 476a3c9..faded35 100644 --- a/integrations/jikan/relations.go +++ b/integrations/jikan/relations.go @@ -4,11 +4,12 @@ import ( "context" "errors" "fmt" - "log" "sort" "strings" "time" + "mal/internal/observability" + "mal/integrations/watchorder" "golang.org/x/sync/errgroup" @@ -62,21 +63,44 @@ func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrd return watchorder.WatchOrderResult{}, watchorder.ErrWatchOrderNotFound } if errors.Is(err, watchorder.ErrWatchOrderMarkupNotFound) { - log.Printf("relations: watch-order markup missing for %d (%s): %v", id, watchOrderURL, err) + observability.Warn( + "relations_watch_order_markup_missing", + "jikan", + "", + map[string]any{ + "anime_id": id, + "url": watchOrderURL, + }, + err, + ) } else if errors.As(err, &statusError) { - log.Printf( - "relations: watch-order http error for %d (%s): status=%d server=%q cf_ray=%q location=%q content_type=%q body=%q", - id, - watchOrderURL, - statusError.StatusCode, - statusError.Server, - statusError.CFRay, - statusError.Location, - statusError.ContentType, - statusError.BodyPreview, + observability.Warn( + "relations_watch_order_http_error", + "jikan", + "", + map[string]any{ + "anime_id": id, + "url": watchOrderURL, + "status": statusError.StatusCode, + "server": statusError.Server, + "cf_ray": statusError.CFRay, + "location": statusError.Location, + "content_type": statusError.ContentType, + "body_preview": statusError.BodyPreview, + }, + err, ) } else { - log.Printf("relations: watch-order fetch failed for %d (%s): %v", id, watchOrderURL, err) + observability.Warn( + "relations_watch_order_fetch_failed", + "jikan", + "", + map[string]any{ + "anime_id": id, + "url": watchOrderURL, + }, + err, + ) } return watchorder.WatchOrderResult{}, err } @@ -107,7 +131,15 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, if errors.Is(err, watchorder.ErrWatchOrderNotFound) { return c.currentOnlyRelation(ctx, id) } - log.Printf("relations: using current-only fallback for %d: %v", id, err) + observability.Warn( + "relations_watch_order_fallback_current_only", + "jikan", + "", + map[string]any{ + "anime_id": id, + }, + err, + ) return c.currentOnlyRelation(ctx, id) } @@ -176,9 +208,6 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, IsCurrent: res.entry.ID == id, IsExtra: false, }) - if res.entry.ID == id { - relations[len(relations)-1].Relation = "Current" - } } if !seen[id] { diff --git a/integrations/jikan/search.go b/integrations/jikan/search.go index 22775e6..f0774cb 100644 --- a/integrations/jikan/search.go +++ b/integrations/jikan/search.go @@ -8,8 +8,8 @@ import ( "strings" ) -// SearchAdvanced performs a filtered anime search with type, status, ordering, and genre filters. -func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (SearchResult, error) { +// SearchAdvanced performs a filtered anime search with type, status, ordering, genre filters, and studio (producer) filters. +func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (SearchResult, error) { if page < 1 { page = 1 } @@ -26,7 +26,7 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o genresParam = strings.Join(ids, ",") } - cacheKey := fmt.Sprintf("search:%s:%s:%s:%s:%s:%s:%v:%d:%d", query, animeType, status, orderBy, sort, genresParam, sfw, page, limit) + cacheKey := fmt.Sprintf("search:%s:%s:%s:%s:%s:%s:%d:%v:%d:%d", query, animeType, status, orderBy, sort, genresParam, studioID, sfw, page, limit) var result SearchResponse reqURL := fmt.Sprintf("%s/anime?page=%d", c.baseURL, page) @@ -42,6 +42,9 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o if status != "" { reqURL += "&status=" + url.QueryEscape(status) } + if studioID > 0 { + reqURL += "&producers=" + strconv.Itoa(studioID) + } if orderBy != "" { reqURL += "&order_by=" + url.QueryEscape(orderBy) } diff --git a/integrations/jikan/studio.go b/integrations/jikan/studio.go index ae4379a..2578621 100644 --- a/integrations/jikan/studio.go +++ b/integrations/jikan/studio.go @@ -1,6 +1,9 @@ package jikan -import () +import ( + "context" + "fmt" +) type ProducerResponse struct { Data struct { @@ -24,3 +27,18 @@ type ProducerResponse struct { } `json:"external"` } `json:"data"` } + +func (c *Client) GetProducerByID(ctx context.Context, id int) (ProducerResponse, error) { + if id <= 0 { + return ProducerResponse{}, fmt.Errorf("invalid producer id") + } + + cacheKey := fmt.Sprintf("producer:%d", id) + reqURL := fmt.Sprintf("%s/producers/%d", c.baseURL, id) + + var result ProducerResponse + if err := c.getWithCache(ctx, cacheKey, producerCacheTTL, reqURL, &result); err != nil { + return ProducerResponse{}, err + } + return result, nil +} diff --git a/integrations/watchorder/watch_order_test.go b/integrations/watchorder/watch_order_test.go index 99696d3..147f000 100644 --- a/integrations/watchorder/watch_order_test.go +++ b/integrations/watchorder/watch_order_test.go @@ -141,10 +141,10 @@ Jujutsu Kaisen 0 testClient := &http.Client{ Timeout: time.Second, Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { - switch { - case request.URL.Host == "chiaki.site": + switch request.URL.Host { + case "chiaki.site": return mockResponse(http.StatusForbidden, map[string]string{"Content-Type": "text/html; charset=utf-8"}, "blocked"), nil - case request.URL.Host == "r.jina.ai": + case "r.jina.ai": // Proxy response is plain text/markdown. return mockResponse(http.StatusOK, map[string]string{"Content-Type": "text/plain; charset=utf-8"}, proxyPayload), nil default: diff --git a/internal/anime/handler/handler.go b/internal/anime/handler/handler.go index b7ace83..3f967dc 100644 --- a/internal/anime/handler/handler.go +++ b/internal/anime/handler/handler.go @@ -3,9 +3,11 @@ package handler import ( "context" "fmt" - "log" + "mal/integrations/jikan" "mal/internal/db" "mal/internal/domain" + "mal/internal/observability" + "mal/internal/server" "net/http" "net/url" "strconv" @@ -20,6 +22,14 @@ 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, @@ -59,6 +69,9 @@ 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) @@ -66,6 +79,101 @@ 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) { @@ -98,6 +206,17 @@ 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 } @@ -129,6 +248,36 @@ 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 := "" @@ -137,6 +286,17 @@ 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 } @@ -148,6 +308,45 @@ 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") @@ -155,22 +354,58 @@ 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, _ := strconv.Atoi(g) + id, err := strconv.Atoi(g) + if err != nil { + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid genre id") + return + } if id > 0 { genres = append(genres, id) } } - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + page, err := strconv.Atoi(c.DefaultQuery("page", "1")) + if err != nil { + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page") + return + } if page < 1 { page = 1 } - res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, sfw, page, 24) + res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, studioID, sfw, page, 24) if err != nil { + 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") @@ -178,12 +413,21 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) { if u, ok := user.(*domain.User); ok { userID = u.ID } - watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, res.Animes) + animes := wrapAnimes(res.Animes) + watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes) + + studioName := "" + if studioID > 0 { + name, err := h.svc.GetProducerNameByID(c.Request.Context(), studioID) + if err == nil { + studioName = name + } + } if c.GetHeader("HX-Request") == "true" && page > 1 { c.HTML(http.StatusOK, "browse.gohtml", gin.H{ "_fragment": "anime_card_scroll", - "Animes": res.Animes, + "Animes": animes, "NextPage": page + 1, "HasNextPage": res.HasNextPage, "Query": q, @@ -192,6 +436,8 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) { "OrderBy": orderBy, "Sort": sort, "Genres": genres, + "Studio": studioID, + "StudioName": studioName, "SFW": sfw, "WatchlistMap": watchlistMap, }) @@ -210,9 +456,11 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) { "OrderBy": orderBy, "Sort": sort, "Genres": genres, + "Studio": studioID, + "StudioName": studioName, "SFW": sfw, "GenresList": genresList, - "Animes": res.Animes, + "Animes": animes, "HasNextPage": res.HasNextPage, "NextPage": page + 1, "User": user, @@ -229,9 +477,11 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) { "OrderBy": orderBy, "Sort": sort, "Genres": genres, + "Studio": studioID, + "StudioName": studioName, "SFW": sfw, "GenresList": genresList, - "Animes": res.Animes, + "Animes": animes, "HasNextPage": res.HasNextPage, "NextPage": page + 1, "User": user, @@ -240,9 +490,9 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) { } func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - if id <= 0 { - c.Status(http.StatusNotFound) + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id") return } @@ -271,7 +521,16 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) { } if err != nil { - log.Printf("failed to fetch section %s: %v", section, err) + observability.Warn( + "anime_section_fetch_failed", + "anime", + "", + map[string]any{ + "section": section, + "anime_id": id, + }, + err, + ) c.Status(http.StatusNoContent) return } @@ -292,7 +551,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) { user, _ := c.Get("User") status := "" var watchlistIDs []int64 - ep := 1 + ep := 0 var cwSeconds float64 if u, ok := user.(*domain.User); ok { entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), u.ID, int64(id)) @@ -320,9 +579,9 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) { } func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) { - id, _ := strconv.Atoi(c.Query("animeId")) - if id <= 0 { - c.Status(http.StatusBadRequest) + id, err := strconv.Atoi(c.Query("animeId")) + if err != nil || id <= 0 { + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id") return } @@ -337,7 +596,15 @@ func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) { relations, err := h.svc.GetRelations(relationsCtx, id) if err != nil { - log.Printf("failed to fetch relations for anime %d: %v", id, err) + observability.Warn( + "relations_fetch_failed", + "anime", + "", + map[string]any{ + "anime_id": id, + }, + err, + ) c.Status(http.StatusNoContent) return } @@ -365,7 +632,7 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) { return } - res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, true, 1, 5) + res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, 1, 5) if err != nil { c.JSON(http.StatusOK, []any{}) return @@ -376,7 +643,8 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) { if u, ok := user.(*domain.User); ok { userID = u.ID } - watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, res.Animes) + animes := wrapAnimes(res.Animes) + watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes) type quickSearchResult struct { ID int `json:"id"` @@ -387,8 +655,8 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) { InWatchlist bool `json:"in_watchlist"` } - output := make([]quickSearchResult, len(res.Animes)) - for i, anime := range res.Animes { + output := make([]quickSearchResult, len(animes)) + for i, anime := range animes { output[i] = quickSearchResult{ ID: anime.MalID, Title: anime.DisplayTitle(), @@ -473,13 +741,14 @@ func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string) searchCtx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond) defer cancel() - res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, true, 1, 5) + res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, 0, true, 1, 5) if err != nil { return nil } - items := make([]commandPaletteItem, 0, len(res.Animes)) - for _, anime := range res.Animes { + animes := wrapAnimes(res.Animes) + items := make([]commandPaletteItem, 0, len(animes)) + for _, anime := range animes { items = append(items, commandPaletteItem{ ID: fmt.Sprintf("anime:%d", anime.MalID), Type: "anime", @@ -591,11 +860,19 @@ func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) { anime, err := h.svc.GetRandomAnime(ctx) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch random anime"}) + server.RespondError( + c, + http.StatusInternalServerError, + "random_anime_fetch_failed", + "anime", + "failed to fetch random anime", + nil, + err, + ) return } if anime.MalID == 0 { - c.JSON(http.StatusBadGateway, gin.H{"error": "Random anime unavailable"}) + server.RespondHTMLOrJSONError(c, http.StatusBadGateway, "random anime unavailable") return } @@ -613,20 +890,32 @@ func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) { } func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - if id <= 0 { - c.Status(http.StatusNotFound) + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id") return } - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + page, err := strconv.Atoi(c.DefaultQuery("page", "1")) + if err != nil { + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page") + return + } if page < 1 { page = 1 } reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), id, page) if err != nil { - c.Status(http.StatusInternalServerError) + server.RespondError( + c, + http.StatusInternalServerError, + "anime_reviews_fetch_failed", + "anime", + "failed to load reviews", + map[string]any{"anime_id": id, "page": page}, + err, + ) return } diff --git a/internal/anime/service/service.go b/internal/anime/service/service.go index 558547f..87be24f 100644 --- a/internal/anime/service/service.go +++ b/internal/anime/service/service.go @@ -2,10 +2,15 @@ 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" @@ -16,6 +21,14 @@ 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} } @@ -51,7 +64,7 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec return domain.CatalogSectionData{}, err } - animes := res.Animes + animes := wrapAnimes(res.Animes) if len(animes) > 6 { animes = animes[:6] } @@ -84,7 +97,7 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se return domain.DiscoverSectionData{}, err } - animes := res.Animes + animes := wrapAnimes(res.Animes) if len(animes) > 8 { animes = animes[:8] } @@ -94,12 +107,204 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se }, nil } -func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) { - return s.jikan.GetAnimeByID(ctx, id) +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) 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) GetAiringSchedule(ctx context.Context, userID string) ([]domain.Anime, error) { + if strings.TrimSpace(userID) == "" { + return []domain.Anime{}, nil + } + + watchlist, err := s.repo.GetUserWatchList(ctx, userID) + if err != nil { + return nil, err + } + + ids := make([]int, 0, 50) + for _, entry := range watchlist { + status := strings.TrimSpace(entry.Status) + if status != "watching" && status != "plan_to_watch" { + continue + } + if !entry.Airing.Valid || !entry.Airing.Bool { + continue + } + if entry.AnimeID <= 0 { + continue + } + ids = append(ids, int(entry.AnimeID)) + if len(ids) >= 50 { + break + } + } + + if len(ids) == 0 { + return []domain.Anime{}, nil + } + + animes := make([]domain.Anime, 0, len(ids)) + var g errgroup.Group + g.SetLimit(6) + var mu sync.Mutex + + for _, id := range ids { + g.Go(func() error { + anime, fetchErr := s.jikan.GetAnimeByID(ctx, id) + if fetchErr != nil { + return fetchErr + } + mu.Lock() + animes = append(animes, domain.Anime{Anime: anime}) + mu.Unlock() + return nil + }) + } + + if err := g.Wait(); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, err + } + observability.Warn( + "schedule_partial_fetch_failed", + "anime", + "", + map[string]any{"user_id": userID, "count": len(ids)}, + err, + ) + return animes, nil + } + + return animes, nil +} + +func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) { + anime, err := s.jikan.GetAnimeByID(ctx, id) + if err != nil { + return domain.Anime{}, err + } + return domain.Anime{Anime: anime}, nil +} + +func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error) { + return s.jikan.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, studioID, sfw, page, limit) +} + +func (s *animeService) GetProducerNameByID(ctx context.Context, id int) (string, error) { + res, err := s.jikan.GetProducerByID(ctx, id) + if err != nil { + return "", err + } + for _, t := range res.Data.Titles { + if t.Title != "" { + return t.Title, nil + } + } + return "", nil +} + +func (s *animeService) GetProducers(ctx context.Context, query string, page int, limit int) (jikan.ProducerListResult, error) { + return s.jikan.GetProducers(ctx, query, page, limit) } func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) { @@ -148,7 +353,7 @@ func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error) anime, err := s.jikan.GetRandomAnime(randomCtx) if err == nil { - return anime, nil + return domain.Anime{Anime: anime}, nil } for _, fallback := range []func(context.Context, int) (jikan.TopAnimeResult, error){ @@ -161,7 +366,7 @@ func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error) continue } r := rand.New(rand.NewSource(time.Now().UnixNano())) - return res.Animes[r.Intn(len(res.Animes))], nil + return domain.Anime{Anime: res.Animes[r.Intn(len(res.Animes))]}, nil } return domain.Anime{}, err diff --git a/internal/app/app.go b/internal/app/app.go index 733b1c9..f89fbfe 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -4,13 +4,15 @@ import ( "mal/integrations/jikan" "mal/integrations/playback/allanime" "mal/internal/anime" + "mal/internal/audit" "mal/internal/auth" + "mal/internal/config" "mal/internal/database" "mal/internal/episodes" "mal/internal/playback" "mal/internal/server" - "mal/internal/templates" "mal/internal/watchlist" + "mal/templates" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/render" @@ -19,7 +21,9 @@ import ( func NewApp() *fx.App { return fx.New( + config.Module, database.Module, + audit.Module, jikan.Module, allanime.Module, episodes.Module, diff --git a/internal/audit/middleware.go b/internal/audit/middleware.go new file mode 100644 index 0000000..3174862 --- /dev/null +++ b/internal/audit/middleware.go @@ -0,0 +1,30 @@ +package audit + +import ( + "net" + "strings" + + "github.com/gin-gonic/gin" + "mal/internal/auditctx" +) + +func ContextMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + ip := clientIP(c.ClientIP()) + userAgent := strings.TrimSpace(c.GetHeader("User-Agent")) + c.Request = c.Request.WithContext(auditctx.WithRequestInfo(c.Request.Context(), ip, userAgent)) + c.Next() + } +} + +func clientIP(ip string) string { + trimmed := strings.TrimSpace(ip) + if trimmed == "" { + return "" + } + parsed := net.ParseIP(trimmed) + if parsed == nil { + return trimmed + } + return parsed.String() +} diff --git a/internal/audit/module.go b/internal/audit/module.go new file mode 100644 index 0000000..60d7dfe --- /dev/null +++ b/internal/audit/module.go @@ -0,0 +1,11 @@ +package audit + +import ( + "mal/internal/audit/service" + + "go.uber.org/fx" +) + +var Module = fx.Options( + fx.Provide(service.NewAuditService), +) diff --git a/internal/audit/service/service.go b/internal/audit/service/service.go new file mode 100644 index 0000000..2c58258 --- /dev/null +++ b/internal/audit/service/service.go @@ -0,0 +1,73 @@ +package service + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "mal/internal/auditctx" + "mal/internal/db" + "mal/internal/domain" + "mal/internal/observability" + "strings" + + "github.com/google/uuid" +) + +type auditService struct { + queries *db.Queries +} + +func NewAuditService(queries *db.Queries) domain.AuditService { + return &auditService{queries: queries} +} + +func (s *auditService) Record(ctx context.Context, event domain.AuditEvent) error { + if s == nil || s.queries == nil { + return errors.New("audit service not configured") + } + action := strings.TrimSpace(event.Action) + if action == "" { + return errors.New("audit action missing") + } + + ip, userAgent := auditctx.RequestInfoFromContext(ctx) + if strings.TrimSpace(event.IP) != "" { + ip = event.IP + } + if strings.TrimSpace(event.UserAgent) != "" { + userAgent = event.UserAgent + } + + metadataJSON := event.MetadataJSON + if len(metadataJSON) == 0 { + metadataJSON = json.RawMessage("null") + } + + _, err := s.queries.CreateAuditLog(ctx, db.CreateAuditLogParams{ + ID: uuid.New().String(), + UserID: sql.NullString{String: strings.TrimSpace(event.UserID), Valid: strings.TrimSpace(event.UserID) != ""}, + Action: action, + ResourceType: sql.NullString{String: strings.TrimSpace(event.ResourceType), Valid: strings.TrimSpace(event.ResourceType) != ""}, + ResourceID: sql.NullString{String: strings.TrimSpace(event.ResourceID), Valid: strings.TrimSpace(event.ResourceID) != ""}, + Ip: sql.NullString{String: strings.TrimSpace(ip), Valid: strings.TrimSpace(ip) != ""}, + UserAgent: sql.NullString{String: strings.TrimSpace(userAgent), Valid: strings.TrimSpace(userAgent) != ""}, + MetadataJson: sql.NullString{String: string(metadataJSON), Valid: true}, + }) + if err != nil { + return err + } + + observability.Info( + "audit", + "audit", + action, + map[string]any{ + "user_id": event.UserID, + "resource_type": event.ResourceType, + "resource_id": event.ResourceID, + }, + ) + + return nil +} diff --git a/internal/audit/service/service_test.go b/internal/audit/service/service_test.go new file mode 100644 index 0000000..1b8c204 --- /dev/null +++ b/internal/audit/service/service_test.go @@ -0,0 +1,83 @@ +package service_test + +import ( + "context" + "encoding/json" + "os" + "testing" + + "mal/internal/audit/service" + "mal/internal/auditctx" + "mal/internal/database" + "mal/internal/db" + "mal/internal/domain" +) + +func TestRecordInsertsAuditLog(t *testing.T) { + tmp, err := os.CreateTemp("", "mal-audit-*.db") + if err != nil { + t.Fatalf("CreateTemp: %v", err) + } + _ = tmp.Close() + t.Cleanup(func() { _ = os.Remove(tmp.Name()) }) + + sqlDB, err := db.Open(tmp.Name()) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + + if err := database.RunMigrations(sqlDB); err != nil { + t.Fatalf("RunMigrations: %v", err) + } + + queries := db.New(sqlDB) + svc := service.NewAuditService(queries) + + if _, err := sqlDB.Exec("INSERT INTO user (id, username, password_hash) VALUES (?, ?, ?)", "user-1", "test", "hash"); err != nil { + t.Fatalf("insert user: %v", err) + } + + ctx := auditctx.WithRequestInfo(context.Background(), "127.0.0.1", "unit-test") + metadata, err := json.Marshal(struct { + Foo string `json:"foo"` + }{Foo: "bar"}) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + + if err := svc.Record(ctx, domain.AuditEvent{ + UserID: "user-1", + Action: "test_action", + ResourceType: "thing", + ResourceID: "123", + MetadataJSON: metadata, + }); err != nil { + t.Fatalf("Record: %v", err) + } + + rows, err := sqlDB.Query("SELECT action, resource_type, resource_id, ip, user_agent, metadata_json FROM audit_log WHERE user_id = ?", "user-1") + if err != nil { + t.Fatalf("Query: %v", err) + } + defer func() { _ = rows.Close() }() + + if !rows.Next() { + t.Fatalf("expected audit row") + } + + var action, resourceType, resourceID, ip, userAgent, metadataJSON string + if err := rows.Scan(&action, &resourceType, &resourceID, &ip, &userAgent, &metadataJSON); err != nil { + t.Fatalf("Scan: %v", err) + } + + if action != "test_action" || resourceType != "thing" || resourceID != "123" { + t.Fatalf("unexpected row action=%q resourceType=%q resourceID=%q", action, resourceType, resourceID) + } + if ip != "127.0.0.1" || userAgent != "unit-test" { + t.Fatalf("unexpected request info ip=%q userAgent=%q", ip, userAgent) + } + if metadataJSON == "" || metadataJSON == "null" { + t.Fatalf("expected metadata_json, got %q", metadataJSON) + } +} diff --git a/internal/auditctx/context.go b/internal/auditctx/context.go new file mode 100644 index 0000000..9e9ac83 --- /dev/null +++ b/internal/auditctx/context.go @@ -0,0 +1,35 @@ +package auditctx + +import "context" + +type ctxKey string + +const ( + ctxKeyIP ctxKey = "audit_ip" + ctxKeyUserAgent ctxKey = "audit_user_agent" +) + +func WithRequestInfo(ctx context.Context, ip string, userAgent string) context.Context { + if ctx == nil { + return nil + } + next := context.WithValue(ctx, ctxKeyIP, ip) + return context.WithValue(next, ctxKeyUserAgent, userAgent) +} + +func RequestInfoFromContext(ctx context.Context) (ip string, userAgent string) { + if ctx == nil { + return "", "" + } + if v := ctx.Value(ctxKeyIP); v != nil { + if s, ok := v.(string); ok { + ip = s + } + } + if v := ctx.Value(ctxKeyUserAgent); v != nil { + if s, ok := v.(string); ok { + userAgent = s + } + } + return ip, userAgent +} diff --git a/internal/auth/middleware/middleware.go b/internal/auth/middleware/middleware.go index 286202b..2da141f 100644 --- a/internal/auth/middleware/middleware.go +++ b/internal/auth/middleware/middleware.go @@ -8,15 +8,52 @@ import ( "github.com/gin-gonic/gin" ) +type publicRoute struct { + method string + path string + prefix bool +} + +var publicRoutes = []publicRoute{ + // Pages. + {method: http.MethodGet, path: "/login"}, + {method: http.MethodPost, path: "/login"}, + {method: http.MethodGet, path: "/logout"}, + + // Static assets. + {path: "/static", prefix: true}, + {path: "/dist", prefix: true}, + + // Observability endpoints. + {method: http.MethodGet, path: "/metrics"}, + + // Auth API. + {method: http.MethodPost, path: "/api/auth/login"}, +} + +func isPublicRequest(method string, path string) bool { + for _, r := range publicRoutes { + if r.method != "" && r.method != method { + continue + } + if r.prefix { + if strings.HasPrefix(path, r.path) { + return true + } + continue + } + if path == r.path { + return true + } + } + return false +} + func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc { return func(c *gin.Context) { path := c.Request.URL.Path - // Allow access to login, logout and static assets without authentication - if path == "/login" || path == "/logout" || - strings.HasPrefix(path, "/static") || - strings.HasPrefix(path, "/dist") || - path == "/api/auth/login" { + if isPublicRequest(c.Request.Method, path) { c.Next() return } diff --git a/internal/auth/repository/repository.go b/internal/auth/repository/repository.go index 74524e4..8b67d98 100644 --- a/internal/auth/repository/repository.go +++ b/internal/auth/repository/repository.go @@ -27,7 +27,7 @@ func (r *authRepository) GetUserByUsername(ctx context.Context, username string) } return nil, err } - return &u, nil + return &domain.User{User: u}, nil } func (r *authRepository) GetUserByID(ctx context.Context, id string) (*domain.User, error) { @@ -38,7 +38,7 @@ func (r *authRepository) GetUserByID(ctx context.Context, id string) (*domain.Us } return nil, err } - return &u, nil + return &domain.User{User: u}, nil } func (r *authRepository) CreateSession(ctx context.Context, userID string, sessionID string) (*domain.Session, error) { @@ -50,7 +50,7 @@ func (r *authRepository) CreateSession(ctx context.Context, userID string, sessi if err != nil { return nil, err } - return &s, nil + return &domain.Session{Session: s}, nil } func (r *authRepository) GetSession(ctx context.Context, sessionID string) (*domain.Session, error) { @@ -61,7 +61,7 @@ func (r *authRepository) GetSession(ctx context.Context, sessionID string) (*dom } return nil, err } - return &s, nil + return &domain.Session{Session: s}, nil } func (r *authRepository) RefreshSession(ctx context.Context, sessionID string, expiresAt time.Time) error { @@ -85,7 +85,7 @@ func (r *authRepository) CreateAPIToken(ctx context.Context, userID, tokenHash, if err != nil { return nil, err } - return &t, nil + return &domain.APIToken{ApiToken: t}, nil } func (r *authRepository) GetAPITokenByHash(ctx context.Context, tokenHash string) (*domain.APIToken, error) { @@ -96,7 +96,7 @@ func (r *authRepository) GetAPITokenByHash(ctx context.Context, tokenHash string } return nil, err } - return &t, nil + return &domain.APIToken{ApiToken: t}, nil } func (r *authRepository) TouchAPITokenLastUsedAt(ctx context.Context, tokenID string) error { diff --git a/internal/auth/service/service.go b/internal/auth/service/service.go index 94d5354..ecc0cff 100644 --- a/internal/auth/service/service.go +++ b/internal/auth/service/service.go @@ -6,7 +6,9 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "encoding/json" "errors" + "fmt" "mal/internal/domain" "strings" "time" @@ -16,11 +18,12 @@ import ( ) type authService struct { - repo domain.AuthRepository + repo domain.AuthRepository + auditSvc domain.AuditService } -func NewAuthService(repo domain.AuthRepository) domain.AuthService { - return &authService{repo: repo} +func NewAuthService(repo domain.AuthRepository, auditSvc domain.AuditService) domain.AuthService { + return &authService{repo: repo, auditSvc: auditSvc} } func (s *authService) Login(ctx context.Context, username, password string) (*domain.Session, error) { @@ -58,11 +61,32 @@ func (s *authService) LoginForAPIToken(ctx context.Context, username, password, trimmedName = "Firefox extension" } - rawToken, tokenHash := newOpaqueToken() + rawToken, tokenHash, err := newOpaqueToken() + if err != nil { + return "", nil, err + } if _, err := s.repo.CreateAPIToken(ctx, user.ID, tokenHash, trimmedName); err != nil { return "", nil, err } + metadataBytes, err := json.Marshal(struct { + Name string `json:"name"` + }{Name: trimmedName}) + if err == nil { + _ = s.auditSvc.Record(ctx, domain.AuditEvent{ + UserID: user.ID, + Action: "api_token_created", + ResourceType: "api_token", + MetadataJSON: metadataBytes, + }) + } else { + _ = s.auditSvc.Record(ctx, domain.AuditEvent{ + UserID: user.ID, + Action: "api_token_created", + ResourceType: "api_token", + }) + } + return rawToken, user, nil } @@ -120,15 +144,25 @@ func (s *authService) RevokeAllAPITokensForUser(ctx context.Context, userID stri if strings.TrimSpace(userID) == "" { return errors.New("user id missing") } - return s.repo.RevokeAllAPITokensForUser(ctx, userID) + if err := s.repo.RevokeAllAPITokensForUser(ctx, userID); err != nil { + return err + } + _ = s.auditSvc.Record(ctx, domain.AuditEvent{ + UserID: userID, + Action: "api_token_revoked_all", + ResourceType: "api_token", + }) + return nil } -func newOpaqueToken() (token string, tokenHash string) { +func newOpaqueToken() (token string, tokenHash string, err error) { buf := make([]byte, 32) - _, _ = rand.Read(buf) + if _, err := rand.Read(buf); err != nil { + return "", "", fmt.Errorf("generate token bytes: %w", err) + } token = base64.RawURLEncoding.EncodeToString(buf) sum := sha256.Sum256([]byte(token)) tokenHash = hex.EncodeToString(sum[:]) - return token, tokenHash + return token, tokenHash, nil } diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..23cc2c5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,84 @@ +package config + +import ( + "errors" + "fmt" + "os" + "strings" +) + +type EpisodeAvailabilityMode string + +const ( + EpisodeAvailabilityModeAuto EpisodeAvailabilityMode = "auto" + EpisodeAvailabilityModeLegacy EpisodeAvailabilityMode = "legacy" + EpisodeAvailabilityModeJikan EpisodeAvailabilityMode = "jikan" +) + +type Config struct { + Port string + + // GinMode maps to gin.SetMode. When empty, the server uses release mode by default. + GinMode string + + DatabaseFile string + + // Allow any Origin for CORS. Intended for local dev / reverse proxy setups only. + CORSAllowAll bool + + EpisodeAvailabilityMode EpisodeAvailabilityMode + + // Optional. When empty, proxy token signing is disabled. + PlaybackProxySecret string + + // Optional debug toggle for Jikan client tracing. + JikanTrace bool +} + +func Load() (Config, error) { + cfg := Config{ + Port: firstNonEmpty(strings.TrimSpace(os.Getenv("PORT")), "3000"), + GinMode: strings.TrimSpace(os.Getenv("GIN_MODE")), + DatabaseFile: firstNonEmpty(strings.TrimSpace(os.Getenv("DATABASE_FILE")), "mal.db"), + CORSAllowAll: strings.TrimSpace(os.Getenv("MAL_CORS_ALLOW_ALL")) == "1", + PlaybackProxySecret: strings.TrimSpace(os.Getenv("PLAYBACK_PROXY_SECRET")), + JikanTrace: truthy(strings.TrimSpace(os.Getenv("MAL_JIKAN_TRACE"))), + EpisodeAvailabilityMode: EpisodeAvailabilityModeAuto, + } + + if raw := strings.ToLower(strings.TrimSpace(os.Getenv("EPISODE_AVAILABILITY_MODE"))); raw != "" { + switch EpisodeAvailabilityMode(raw) { + case EpisodeAvailabilityModeAuto, EpisodeAvailabilityModeLegacy, EpisodeAvailabilityModeJikan: + cfg.EpisodeAvailabilityMode = EpisodeAvailabilityMode(raw) + default: + return Config{}, fmt.Errorf("invalid EPISODE_AVAILABILITY_MODE: %q (expected auto|legacy|jikan)", raw) + } + } + + if strings.TrimSpace(cfg.Port) == "" { + return Config{}, errors.New("PORT must not be empty") + } + if strings.TrimSpace(cfg.DatabaseFile) == "" { + return Config{}, errors.New("DATABASE_FILE must not be empty") + } + + return cfg, nil +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func truthy(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "y", "on": + return true + default: + return false + } +} diff --git a/internal/config/module.go b/internal/config/module.go new file mode 100644 index 0000000..a4b3425 --- /dev/null +++ b/internal/config/module.go @@ -0,0 +1,7 @@ +package config + +import "go.uber.org/fx" + +var Module = fx.Options( + fx.Provide(Load), +) diff --git a/internal/database/database.go b/internal/database/database.go index 93976e6..c6e180e 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -4,8 +4,9 @@ import ( "database/sql" "embed" "fmt" - "log" + "mal/internal/config" "mal/internal/db" + "mal/internal/observability" "github.com/pressly/goose/v3" "go.uber.org/fx" @@ -19,12 +20,11 @@ var Module = fx.Options( ProvideSQLDB, ProvideQueries, ), - fx.Invoke(RunMigrations), + fx.Invoke(RunMigrationsAndFixes), ) -func ProvideSQLDB() (*sql.DB, error) { - dbPath := db.GetDBFile() - dbConn, err := db.Open(dbPath) +func ProvideSQLDB(cfg config.Config) (*sql.DB, error) { + dbConn, err := db.Open(cfg.DatabaseFile) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } @@ -42,10 +42,16 @@ func RunMigrations(sqlDB *sql.DB) error { return fmt.Errorf("failed to set goose dialect: %w", err) } - log.Println("Running database migrations...") + observability.Info("db_migrations_start", "database", "", nil) if err := goose.Up(sqlDB, "migrations"); err != nil { return fmt.Errorf("failed to run migrations: %w", err) } return nil } +func RunMigrationsAndFixes(sqlDB *sql.DB) error { + if err := RunMigrations(sqlDB); err != nil { + return err + } + return RunDataFixes(sqlDB) +} diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 03b7c96..5a9d303 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -12,7 +12,7 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) { if err != nil { t.Fatalf("open sqlite: %v", err) } - defer sqlDB.Close() + defer func() { _ = sqlDB.Close() }() sqlDB.SetMaxOpenConns(1) if err := RunMigrations(sqlDB); err != nil { diff --git a/internal/database/fixes.go b/internal/database/fixes.go new file mode 100644 index 0000000..dd0a959 --- /dev/null +++ b/internal/database/fixes.go @@ -0,0 +1,97 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "time" + + dbfixes "mal/internal/database/fixes" + "mal/internal/observability" +) + +func RunDataFixes(sqlDB *sql.DB) error { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + fixes := dbfixes.All() + + if len(fixes) == 0 { + return nil + } + + if err := ensureDataFixTable(ctx, sqlDB); err != nil { + return err + } + + applied, err := loadAppliedFixes(ctx, sqlDB) + if err != nil { + return err + } + + for _, fix := range fixes { + if applied[fix.ID] { + continue + } + + observability.Info( + "db_data_fix_start", + "database", + "", + map[string]any{ + "id": fix.ID, + }, + ) + if err := fix.Apply(ctx, sqlDB); err != nil { + return fmt.Errorf("data fix %s failed: %w", fix.ID, err) + } + if err := markFixApplied(ctx, sqlDB, fix.ID); err != nil { + return err + } + } + + return nil +} + +func ensureDataFixTable(ctx context.Context, sqlDB *sql.DB) error { + // Safety for cases where migrations weren't run (or in tests). This is intentionally tiny and idempotent. + _, err := sqlDB.ExecContext(ctx, ` +CREATE TABLE IF NOT EXISTS data_fixes ( + id TEXT PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +`) + if err != nil { + return fmt.Errorf("ensure data_fixes table: %w", err) + } + return nil +} + +func loadAppliedFixes(ctx context.Context, sqlDB *sql.DB) (map[string]bool, error) { + rows, err := sqlDB.QueryContext(ctx, `SELECT id FROM data_fixes`) + if err != nil { + return nil, fmt.Errorf("load applied data fixes: %w", err) + } + defer rows.Close() + + applied := make(map[string]bool) + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan data fix id: %w", err) + } + applied[id] = true + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate data fixes: %w", err) + } + return applied, nil +} + +func markFixApplied(ctx context.Context, sqlDB *sql.DB, id string) error { + _, err := sqlDB.ExecContext(ctx, `INSERT OR IGNORE INTO data_fixes (id) VALUES (?)`, id) + if err != nil { + return fmt.Errorf("mark data fix applied id=%s: %w", id, err) + } + return nil +} diff --git a/internal/database/fixes/20260526_episode_availability_backfill_next_refresh_at.go b/internal/database/fixes/20260526_episode_availability_backfill_next_refresh_at.go new file mode 100644 index 0000000..79aca34 --- /dev/null +++ b/internal/database/fixes/20260526_episode_availability_backfill_next_refresh_at.go @@ -0,0 +1,27 @@ +package fixes + +import ( + "context" + "database/sql" + "fmt" +) + +func init() { + Register(Fix{ + ID: "20260526_episode_availability_backfill_next_refresh_at", + Apply: func(ctx context.Context, sqlDB *sql.DB) error { + // Old caches could have next_refresh_at NULL (especially for airing shows with missing broadcast metadata), + // which can result in "never refresh again" behavior on the server. + _, err := sqlDB.ExecContext(ctx, ` +UPDATE episode_availability_cache +SET next_refresh_at = datetime(CURRENT_TIMESTAMP, '+6 hours'), + updated_at = CURRENT_TIMESTAMP +WHERE next_refresh_at IS NULL; +`) + if err != nil { + return fmt.Errorf("backfill episode_availability_cache.next_refresh_at: %w", err) + } + return nil + }, + }) +} diff --git a/internal/database/fixes/registry.go b/internal/database/fixes/registry.go new file mode 100644 index 0000000..68936b5 --- /dev/null +++ b/internal/database/fixes/registry.go @@ -0,0 +1,24 @@ +package fixes + +import ( + "context" + "database/sql" + "sort" +) + +type Fix struct { + ID string + Apply func(ctx context.Context, sqlDB *sql.DB) error +} + +var registered []Fix + +func Register(fix Fix) { + registered = append(registered, fix) +} + +func All() []Fix { + out := append([]Fix(nil), registered...) + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} diff --git a/internal/database/migrations/012_remove_recovery_key.sql b/internal/database/migrations/012_remove_recovery_key.sql index d525494..69cf86d 100644 --- a/internal/database/migrations/012_remove_recovery_key.sql +++ b/internal/database/migrations/012_remove_recovery_key.sql @@ -1,6 +1,9 @@ -- +goose Up +-- +goose NO TRANSACTION PRAGMA foreign_keys = OFF; +BEGIN; + CREATE TABLE user_new ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, @@ -16,6 +19,8 @@ DROP TABLE user; ALTER TABLE user_new RENAME TO user; +COMMIT; + PRAGMA foreign_keys = ON; -- +goose Down diff --git a/internal/database/migrations/022_add_data_fixes.sql b/internal/database/migrations/022_add_data_fixes.sql new file mode 100644 index 0000000..f3411d8 --- /dev/null +++ b/internal/database/migrations/022_add_data_fixes.sql @@ -0,0 +1,8 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS data_fixes ( + id TEXT PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- +goose Down +DROP TABLE IF EXISTS data_fixes; diff --git a/internal/database/migrations/023_add_audit_log.sql b/internal/database/migrations/023_add_audit_log.sql new file mode 100644 index 0000000..2cf21c6 --- /dev/null +++ b/internal/database/migrations/023_add_audit_log.sql @@ -0,0 +1,18 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, + occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + user_id TEXT REFERENCES user(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource_type TEXT, + resource_id TEXT, + ip TEXT, + user_agent TEXT, + metadata_json TEXT +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_user_id_occurred_at ON audit_log(user_id, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_action_occurred_at ON audit_log(action, occurred_at DESC); + +-- +goose Down +DROP TABLE IF EXISTS audit_log; diff --git a/internal/db/command_palette.go b/internal/db/command_palette.go index 4422e6b..59550cc 100644 --- a/internal/db/command_palette.go +++ b/internal/db/command_palette.go @@ -44,7 +44,7 @@ LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit) if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() items := make([]GetContinueWatchingEntriesRow, 0, int(limit)) for rows.Next() { @@ -122,7 +122,7 @@ LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit) if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() items := make([]GetUserWatchListRow, 0, int(limit)) for rows.Next() { diff --git a/internal/db/db.go b/internal/db/db.go index cd5bbb8..f43598b 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package db diff --git a/internal/db/models.go b/internal/db/models.go index 7ac013e..1672321 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package db @@ -47,6 +47,18 @@ type ApiToken struct { RevokedAt sql.NullTime `json:"revoked_at"` } +type AuditLog struct { + ID string `json:"id"` + OccurredAt time.Time `json:"occurred_at"` + UserID sql.NullString `json:"user_id"` + Action string `json:"action"` + ResourceType sql.NullString `json:"resource_type"` + ResourceID sql.NullString `json:"resource_id"` + Ip sql.NullString `json:"ip"` + UserAgent sql.NullString `json:"user_agent"` + MetadataJson sql.NullString `json:"metadata_json"` +} + type ContinueWatchingEntry struct { ID string `json:"id"` UserID string `json:"user_id"` @@ -58,6 +70,11 @@ type ContinueWatchingEntry struct { DurationSeconds sql.NullFloat64 `json:"duration_seconds"` } +type DataFix struct { + ID string `json:"id"` + AppliedAt time.Time `json:"applied_at"` +} + type EpisodeAvailabilityCache struct { AnimeID int64 `json:"anime_id"` Data string `json:"data"` diff --git a/internal/db/querier.go b/internal/db/querier.go index 27492be..122d78d 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package db @@ -11,6 +11,7 @@ import ( type Querier interface { CountPendingAnimeFetchRetries(ctx context.Context) (int64, error) CreateAPIToken(ctx context.Context, arg CreateAPITokenParams) (ApiToken, error) + CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (AuditLog, error) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) DeleteAnimeFetchRetry(ctx context.Context, animeID int64) error DeleteContinueWatchingEntry(ctx context.Context, arg DeleteContinueWatchingEntryParams) error @@ -22,8 +23,11 @@ type Querier interface { GetAllCachedAnime(ctx context.Context) ([]string, error) GetAnime(ctx context.Context, id int64) (Anime, error) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error) + GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error) GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error) GetContinueWatchingEntry(ctx context.Context, arg GetContinueWatchingEntryParams) (ContinueWatchingEntry, error) + GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]GetContinueWatchingEntriesRow, error) + GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]GetUserWatchListRow, error) GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]AnimeFetchRetry, error) GetEpisodeAvailabilityCache(ctx context.Context, animeID int64) (EpisodeAvailabilityCache, error) GetEpisodeProviderMapping(ctx context.Context, arg GetEpisodeProviderMappingParams) (EpisodeProviderMapping, error) @@ -35,14 +39,18 @@ type Querier interface { GetUser(ctx context.Context, id string) (User, error) GetUserByUsername(ctx context.Context, username string) (User, error) GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) + GetUserWatchlistAnimeIDs(ctx context.Context, userID string, animeIDs []int64) ([]int64, error) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error) GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error) MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error MarkEpisodeAvailabilityRefreshFailed(ctx context.Context, arg MarkEpisodeAvailabilityRefreshFailedParams) error MarkRelationsSynced(ctx context.Context, id int64) error + RefreshSession(ctx context.Context, arg RefreshSessionParams) error RevokeAllAPITokensForUser(ctx context.Context, userID string) error SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error SetJikanCache(ctx context.Context, arg SetJikanCacheParams) error + HasSkipSegmentOverrideTable(ctx context.Context) (bool, error) + ListSkipSegmentOverrides(ctx context.Context, userID string, animeID int64, episode int64) ([]SkipSegmentOverrideRow, error) TouchAPITokenLastUsedAt(ctx context.Context, id string) error UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusParams) error UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error) @@ -50,6 +58,7 @@ type Querier interface { UpsertContinueWatchingEntry(ctx context.Context, arg UpsertContinueWatchingEntryParams) (ContinueWatchingEntry, error) UpsertEpisodeAvailabilityCache(ctx context.Context, arg UpsertEpisodeAvailabilityCacheParams) error UpsertEpisodeProviderMapping(ctx context.Context, arg UpsertEpisodeProviderMappingParams) error + UpsertSkipSegmentOverride(ctx context.Context, r SkipSegmentOverrideRow) error UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error) } diff --git a/internal/db/queries.sql b/internal/db/queries.sql index 07ed5f4..000f449 100644 --- a/internal/db/queries.sql +++ b/internal/db/queries.sql @@ -1,6 +1,18 @@ -- name: GetUser :one SELECT * FROM user WHERE id = ? LIMIT 1; +-- name: CreateAuditLog :one +INSERT INTO audit_log (id, user_id, action, resource_type, resource_id, ip, user_agent, metadata_json) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +RETURNING *; + +-- name: GetAuditLogsForUser :many +SELECT * +FROM audit_log +WHERE user_id = ? +ORDER BY occurred_at DESC +LIMIT ?; + -- name: GetUserByUsername :one SELECT * FROM user WHERE username = ? LIMIT 1; diff --git a/internal/db/queries.sql.go b/internal/db/queries.sql.go index bde28ea..edb6241 100644 --- a/internal/db/queries.sql.go +++ b/internal/db/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: queries.sql package db @@ -57,6 +57,49 @@ func (q *Queries) CreateAPIToken(ctx context.Context, arg CreateAPITokenParams) return i, err } +const createAuditLog = `-- name: CreateAuditLog :one +INSERT INTO audit_log (id, user_id, action, resource_type, resource_id, ip, user_agent, metadata_json) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id, occurred_at, user_id, "action", resource_type, resource_id, ip, user_agent, metadata_json +` + +type CreateAuditLogParams struct { + ID string `json:"id"` + UserID sql.NullString `json:"user_id"` + Action string `json:"action"` + ResourceType sql.NullString `json:"resource_type"` + ResourceID sql.NullString `json:"resource_id"` + Ip sql.NullString `json:"ip"` + UserAgent sql.NullString `json:"user_agent"` + MetadataJson sql.NullString `json:"metadata_json"` +} + +func (q *Queries) CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (AuditLog, error) { + row := q.db.QueryRowContext(ctx, createAuditLog, + arg.ID, + arg.UserID, + arg.Action, + arg.ResourceType, + arg.ResourceID, + arg.Ip, + arg.UserAgent, + arg.MetadataJson, + ) + var i AuditLog + err := row.Scan( + &i.ID, + &i.OccurredAt, + &i.UserID, + &i.Action, + &i.ResourceType, + &i.ResourceID, + &i.Ip, + &i.UserAgent, + &i.MetadataJson, + ) + return i, err +} + const createSession = `-- name: CreateSession :one INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?) @@ -124,22 +167,6 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error { return err } -const refreshSession = `-- name: RefreshSession :exec -UPDATE session -SET expires_at = ? -WHERE id = ? -` - -type RefreshSessionParams struct { - ExpiresAt time.Time `json:"expires_at"` - ID string `json:"id"` -} - -func (q *Queries) RefreshSession(ctx context.Context, arg RefreshSessionParams) error { - _, err := q.db.ExecContext(ctx, refreshSession, arg.ExpiresAt, arg.ID) - return err -} - const deleteWatchListEntry = `-- name: DeleteWatchListEntry :exec DELETE FROM watch_list_entry WHERE user_id = ? AND anime_id = ? @@ -299,6 +326,52 @@ func (q *Queries) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNe return items, nil } +const getAuditLogsForUser = `-- name: GetAuditLogsForUser :many +SELECT id, occurred_at, user_id, "action", resource_type, resource_id, ip, user_agent, metadata_json +FROM audit_log +WHERE user_id = ? +ORDER BY occurred_at DESC +LIMIT ? +` + +type GetAuditLogsForUserParams struct { + UserID sql.NullString `json:"user_id"` + Limit int64 `json:"limit"` +} + +func (q *Queries) GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error) { + rows, err := q.db.QueryContext(ctx, getAuditLogsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AuditLog + for rows.Next() { + var i AuditLog + if err := rows.Scan( + &i.ID, + &i.OccurredAt, + &i.UserID, + &i.Action, + &i.ResourceType, + &i.ResourceID, + &i.Ip, + &i.UserAgent, + &i.MetadataJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getContinueWatchingEntries = `-- name: GetContinueWatchingEntries :many SELECT c.id, @@ -918,6 +991,22 @@ func (q *Queries) MarkRelationsSynced(ctx context.Context, id int64) error { return err } +const refreshSession = `-- name: RefreshSession :exec +UPDATE session +SET expires_at = ? +WHERE id = ? +` + +type RefreshSessionParams struct { + ExpiresAt time.Time `json:"expires_at"` + ID string `json:"id"` +} + +func (q *Queries) RefreshSession(ctx context.Context, arg RefreshSessionParams) error { + _, err := q.db.ExecContext(ctx, refreshSession, arg.ExpiresAt, arg.ID) + return err +} + const revokeAllAPITokensForUser = `-- name: RevokeAllAPITokensForUser :exec UPDATE api_token SET revoked_at = CURRENT_TIMESTAMP diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index 274431f..c80516c 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -3,24 +3,21 @@ package db import ( "database/sql" "fmt" - "os" + // sqlite3 driver. _ "github.com/mattn/go-sqlite3" ) // Open connects to a sqlite3 database with foreign keys enforced func Open(dbFile string) (*sql.DB, error) { - db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on", dbFile)) + // busy_timeout avoids immediate SQLITE_BUSY errors under concurrent access. + // foreign_keys ensures FK constraints are enforced for this connection. + db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on&_busy_timeout=5000", dbFile)) if err != nil { return nil, fmt.Errorf("failed to open db: %w", err) } + // WAL improves concurrency between readers and writers. + _, _ = db.Exec("PRAGMA journal_mode=WAL;") + _, _ = db.Exec("PRAGMA busy_timeout=5000;") return db, nil } - -// GetDBFile returns the database file path, checking DATABASE_FILE env var first -func GetDBFile() string { - if f := os.Getenv("DATABASE_FILE"); f != "" { - return f - } - return "mal.db" -} diff --git a/internal/db/watchlist_ids.go b/internal/db/watchlist_ids.go index 3145881..770bc6a 100644 --- a/internal/db/watchlist_ids.go +++ b/internal/db/watchlist_ids.go @@ -24,7 +24,7 @@ func (q *Queries) GetUserWatchlistAnimeIDs(ctx context.Context, userID string, a if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() matches := make([]int64, 0, len(animeIDs)) for rows.Next() { diff --git a/internal/db/watchlist_ids_test.go b/internal/db/watchlist_ids_test.go index 06ce863..2926e07 100644 --- a/internal/db/watchlist_ids_test.go +++ b/internal/db/watchlist_ids_test.go @@ -14,7 +14,7 @@ func TestGetUserWatchlistAnimeIDsFiltersRequestedIDs(t *testing.T) { if err != nil { t.Fatalf("open sqlite: %v", err) } - defer sqlDB.Close() + defer func() { _ = sqlDB.Close() }() _, err = sqlDB.Exec(` CREATE TABLE watch_list_entry ( diff --git a/internal/domain/anime.go b/internal/domain/anime.go index 671b04a..7372cf4 100644 --- a/internal/domain/anime.go +++ b/internal/domain/anime.go @@ -6,7 +6,9 @@ import ( "mal/internal/db" ) -type Anime = jikan.Anime +type Anime struct { + jikan.Anime +} type TopAnimeResult = jikan.TopAnimeResult type Genre = jikan.Genre type Character = jikan.CharacterEntry @@ -19,8 +21,12 @@ 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, sfw bool, page, limit int) (jikan.SearchResult, error) + SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error) + GetProducerNameByID(ctx context.Context, id int) (string, error) + GetProducers(ctx context.Context, query string, page int, limit int) (jikan.ProducerListResult, error) GetGenres(ctx context.Context) ([]Genre, error) GetCharacters(ctx context.Context, id int) ([]Character, error) GetRecommendations(ctx context.Context, id int) ([]Recommendation, error) diff --git a/internal/domain/audit.go b/internal/domain/audit.go new file mode 100644 index 0000000..49ec37f --- /dev/null +++ b/internal/domain/audit.go @@ -0,0 +1,20 @@ +package domain + +import ( + "context" + "encoding/json" +) + +type AuditEvent struct { + UserID string + Action string + ResourceType string + ResourceID string + MetadataJSON json.RawMessage + IP string + UserAgent string +} + +type AuditService interface { + Record(ctx context.Context, event AuditEvent) error +} diff --git a/internal/domain/auth.go b/internal/domain/auth.go index 71bf372..e89a0d2 100644 --- a/internal/domain/auth.go +++ b/internal/domain/auth.go @@ -6,9 +6,17 @@ import ( "time" ) -type User = db.User -type Session = db.Session -type APIToken = db.ApiToken +type User struct { + db.User +} + +type Session struct { + db.Session +} + +type APIToken struct { + db.ApiToken +} const SessionLifetime = 90 * 24 * time.Hour diff --git a/internal/episodes/module.go b/internal/episodes/module.go index 951b70a..30d1673 100644 --- a/internal/episodes/module.go +++ b/internal/episodes/module.go @@ -1,31 +1,28 @@ package episodes import ( - "os" - "strings" - "mal/integrations/jikan" "mal/integrations/playback/allanime" + "mal/internal/config" "mal/internal/db" "mal/internal/domain" episodeService "mal/internal/episodes/service" + "mal/internal/observability" "go.uber.org/fx" ) -func episodeAvailabilityEnabled() bool { - value := strings.ToLower(strings.TrimSpace(os.Getenv("EPISODE_AVAILABILITY_MODE"))) - return value != "legacy" && value != "jikan" +func episodeAvailabilityEnabled(cfg config.Config) bool { + return cfg.EpisodeAvailabilityMode != config.EpisodeAvailabilityModeLegacy && cfg.EpisodeAvailabilityMode != config.EpisodeAvailabilityModeJikan } var Module = fx.Options( fx.Provide( episodeAvailabilityEnabled, fx.Annotate( - func(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool) domain.EpisodeService { - return episodeService.NewEpisodeService(queries, jikanClient, providers, enabled) + func(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, metrics *observability.Metrics) domain.EpisodeService { + return episodeService.NewEpisodeService(queries, jikanClient, providers, enabled, metrics) }, - fx.ParamTags(``, ``, ``, ``), ), ), fx.Provide(func(p *allanime.AllAnimeProvider) []domain.EpisodeAvailabilityProvider { diff --git a/internal/episodes/service/service.go b/internal/episodes/service/service.go index ede335f..50a8d4d 100644 --- a/internal/episodes/service/service.go +++ b/internal/episodes/service/service.go @@ -6,18 +6,19 @@ 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 + retryInterval = 15 * time.Minute + retryWindow = 3 * time.Hour + airingFallbackRefreshInterval = 6 * time.Hour ) type Clock interface { @@ -34,19 +35,21 @@ type EpisodeService struct { providers []domain.EpisodeAvailabilityProvider clock Clock enabled bool + metrics *observability.Metrics } -func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool) domain.EpisodeService { - return NewEpisodeServiceWithClock(queries, jikanClient, providers, enabled, realClock{}) +func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, metrics *observability.Metrics) domain.EpisodeService { + return NewEpisodeServiceWithClock(queries, jikanClient, providers, enabled, realClock{}, metrics) } -func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock) *EpisodeService { +func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock, metrics *observability.Metrics) *EpisodeService { return &EpisodeService{ queries: queries, jikan: jikanClient, providers: providers, clock: clock, enabled: enabled, + metrics: metrics, } } @@ -56,7 +59,7 @@ func (s *EpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain. } if !forceRefresh { - if cached, ok := s.getFreshCached(ctx, anime.MalID); ok { + if cached, ok := s.getFreshCached(ctx, anime); ok { return cached, nil } } @@ -80,11 +83,27 @@ 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 { - log.Printf("episodes: failed to fetch anime for refresh anime_id=%d error=%v", id, err) + observability.Warn( + "episodes_refresh_fetch_anime_failed", + "episodes", + "", + map[string]any{ + "anime_id": id, + }, + err, + ) continue } - if _, err := s.refresh(ctx, anime); err != nil { - log.Printf("episodes: refresh failed anime_id=%d error=%v", id, err) + if _, err := s.refresh(ctx, domain.Anime{Anime: anime}); err != nil { + observability.Warn( + "episodes_refresh_failed", + "episodes", + "", + map[string]any{ + "anime_id": id, + }, + err, + ) } } @@ -93,18 +112,43 @@ func (s *EpisodeService) RefreshTrackedDue(ctx context.Context, limit int) error func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) { now := s.clock.Now() - log.Printf("episodes: refresh start anime_id=%d title=%q airing=%t", anime.MalID, anime.DisplayTitle(), anime.Airing) + observability.Info( + "episodes_refresh_start", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "title": anime.DisplayTitle(), + "airing": anime.Airing, + }, + ) jikanEpisodes, jikanErr := s.jikan.GetAllEpisodes(ctx, anime.MalID) if jikanErr != nil { - log.Printf("episodes: jikan episode metadata failed anime_id=%d error=%v", anime.MalID, jikanErr) + observability.Warn( + "episodes_jikan_metadata_failed", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + }, + jikanErr, + ) } providerAvailability, source, providerErr := s.fetchProviderAvailability(ctx, anime) if providerErr != nil { s.markFailure(ctx, anime, providerErr) if cached, ok := s.getCached(ctx, anime.MalID); ok { - log.Printf("episodes: serving stale cache after provider failure anime_id=%d error=%v", anime.MalID, providerErr) + observability.Warn( + "episodes_provider_failed_serving_stale_cache", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + }, + providerErr, + ) return cached, nil } if jikanErr == nil { @@ -121,16 +165,44 @@ func (s *EpisodeService) fetchProviderAvailability(ctx context.Context, anime do for _, provider := range s.providers { providerID, err := s.providerID(ctx, anime, provider, titles) if err != nil { - log.Printf("episodes: provider id miss anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err) + observability.Warn( + "episodes_provider_id_miss", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "provider": provider.Name(), + }, + err, + ) continue } available, err := provider.GetEpisodeAvailabilityByProviderID(ctx, providerID) if err != nil { - log.Printf("episodes: provider availability miss anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err) + observability.Warn( + "episodes_provider_availability_miss", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "provider": provider.Name(), + }, + err, + ) continue } - log.Printf("episodes: provider availability hit anime_id=%d provider=%s sub=%d dub=%d", anime.MalID, provider.Name(), len(available.Sub), len(available.Dub)) + observability.Info( + "episodes_provider_availability_hit", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "provider": provider.Name(), + "sub": len(available.Sub), + "dub": len(available.Dub), + }, + ) return available, provider.Name(), nil } return domain.EpisodeAvailability{}, "", fmt.Errorf("no episode availability provider matched anime_id=%d", anime.MalID) @@ -143,14 +215,38 @@ func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, pro }) if err == nil { if row.FailedUntil.Valid && row.FailedUntil.Time.After(s.clock.Now()) { + s.metrics.ObserveCache("episode_provider_mapping", "hit") return "", fmt.Errorf("cached provider mapping failure active until %s: %s", row.FailedUntil.Time.Format(time.RFC3339), row.LastError) } if strings.TrimSpace(row.ProviderShowID) != "" { - log.Printf("episodes: provider id cache hit anime_id=%d provider=%s provider_id=%s", anime.MalID, provider.Name(), row.ProviderShowID) + s.metrics.ObserveCache("episode_provider_mapping", "hit") + observability.Info( + "episodes_provider_id_cache_hit", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "provider": provider.Name(), + "provider_id": row.ProviderShowID, + }, + ) return row.ProviderShowID, nil } + s.metrics.ObserveCache("episode_provider_mapping", "miss") } else if !errors.Is(err, sql.ErrNoRows) { - log.Printf("episodes: provider id cache read failed anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err) + s.metrics.ObserveCache("episode_provider_mapping", "miss") + observability.Warn( + "episodes_provider_id_cache_read_failed", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "provider": provider.Name(), + }, + err, + ) + } else { + s.metrics.ObserveCache("episode_provider_mapping", "miss") } providerID, err := provider.ResolveEpisodeProviderID(ctx, anime.MalID, titles) @@ -173,17 +269,48 @@ func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, pro LastError: "", }) if err != nil { - log.Printf("episodes: provider id cache write failed anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err) + observability.Warn( + "episodes_provider_id_cache_write_failed", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "provider": provider.Name(), + }, + err, + ) } - log.Printf("episodes: provider id resolved anime_id=%d provider=%s provider_id=%s", anime.MalID, provider.Name(), providerID) + observability.Info( + "episodes_provider_id_resolved", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "provider": provider.Name(), + "provider_id": providerID, + }, + ) return providerID, nil } func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, source string, now time.Time, providerSuccess bool) (domain.CanonicalEpisodeList, error) { - nextRefresh := nextBroadcastAfter(anime, now) var nextRefreshSQL sql.NullTime - if anime.Airing && !nextRefresh.IsZero() { - nextRefreshSQL = sql.NullTime{Time: nextRefresh, Valid: true} + if anime.Airing { + // During the hours immediately following a broadcast time, providers can lag. + // Keep retrying for a short window, even if the provider request succeeded. + lastBroadcast := nextBroadcastBeforeOrAt(anime, now) + if !lastBroadcast.IsZero() && now.Before(lastBroadcast.Add(retryWindow)) { + nextRefreshSQL = sql.NullTime{Time: now.Add(retryInterval).UTC(), Valid: true} + } else { + next := nextBroadcastAfter(anime, now) + if !next.IsZero() { + nextRefreshSQL = sql.NullTime{Time: next, Valid: true} + } else { + // Broadcast metadata is often missing or wrong for currently airing shows. + // Avoid "never refresh again" caches by falling back to a fixed interval. + nextRefreshSQL = sql.NullTime{Time: now.Add(airingFallbackRefreshInterval).UTC(), Valid: true} + } + } } episodes := mergeEpisodes(jikanEpisodes, availability) @@ -217,11 +344,30 @@ func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpi LastError: "", }) if err != nil { - log.Printf("episodes: cache write failed anime_id=%d source=%s error=%v", anime.MalID, source, err) + observability.Warn( + "episodes_cache_write_failed", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "source": source, + }, + err, + ) return payload, nil } - log.Printf("episodes: refresh success anime_id=%d source=%s episodes=%d next_refresh=%s", anime.MalID, source, len(episodes), payload.NextRefreshAt) + observability.Info( + "episodes_refresh_success", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "source": source, + "episodes": len(episodes), + "next_refresh": payload.NextRefreshAt, + }, + ) return payload, nil } @@ -247,41 +393,114 @@ func (s *EpisodeService) markFailure(ctx context.Context, anime domain.Anime, ca AnimeID: int64(anime.MalID), }) if err != nil { - log.Printf("episodes: failed to mark refresh failure anime_id=%d error=%v", anime.MalID, err) + observability.Warn( + "episodes_mark_failure_failed", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + }, + err, + ) return } - log.Printf("episodes: refresh failure recorded anime_id=%d next_retry=%s error=%v", anime.MalID, next.Format(time.RFC3339), cause) + observability.Warn( + "episodes_refresh_failure_recorded", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "next_retry": next.Format(time.RFC3339), + }, + cause, + ) } func (s *EpisodeService) getCached(ctx context.Context, animeID int) (domain.CanonicalEpisodeList, bool) { row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID)) if err != nil { + s.metrics.ObserveCache("episode_availability", "miss") return domain.CanonicalEpisodeList{}, false } var payload domain.CanonicalEpisodeList if err := json.Unmarshal([]byte(row.Data), &payload); err != nil { - log.Printf("episodes: invalid cached payload anime_id=%d error=%v", animeID, err) + s.metrics.ObserveCache("episode_availability", "miss") + observability.Warn( + "episodes_cached_payload_invalid", + "episodes", + "", + map[string]any{ + "anime_id": animeID, + }, + err, + ) return domain.CanonicalEpisodeList{}, false } + s.metrics.ObserveCache("episode_availability", "hit") return payload, true } -func (s *EpisodeService) getFreshCached(ctx context.Context, animeID int) (domain.CanonicalEpisodeList, bool) { - row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID)) +func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, bool) { + row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(anime.MalID)) if err != nil { + s.metrics.ObserveCache("episode_availability_fresh", "miss") return domain.CanonicalEpisodeList{}, false } - if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(s.clock.Now()) { - log.Printf("episodes: cached availability due for refresh anime_id=%d next_refresh=%s", animeID, row.NextRefreshAt.Time.Format(time.RFC3339)) + + now := s.clock.Now() + if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(now) { + s.metrics.ObserveCache("episode_availability_fresh", "miss") + observability.Info( + "episodes_cache_due_for_refresh", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "next_refresh": row.NextRefreshAt.Time.Format(time.RFC3339), + }, + ) + return domain.CanonicalEpisodeList{}, false + } + + if anime.Airing && row.UpdatedAt.Before(now.Add(-airingFallbackRefreshInterval)) { + s.metrics.ObserveCache("episode_availability_fresh", "miss") + observability.Info( + "episodes_cache_too_old_for_airing", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "updated_at": row.UpdatedAt.Format(time.RFC3339), + }, + ) return domain.CanonicalEpisodeList{}, false } var payload domain.CanonicalEpisodeList if err := json.Unmarshal([]byte(row.Data), &payload); err != nil { - log.Printf("episodes: invalid cached payload anime_id=%d error=%v", animeID, err) + s.metrics.ObserveCache("episode_availability_fresh", "miss") + observability.Warn( + "episodes_cached_payload_invalid", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + }, + err, + ) return domain.CanonicalEpisodeList{}, false } - log.Printf("episodes: served cached availability anime_id=%d episodes=%d next_refresh=%s", animeID, len(payload.Episodes), payload.NextRefreshAt) + s.metrics.ObserveCache("episode_availability_fresh", "hit") + observability.Info( + "episodes_cache_served", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "episodes": len(payload.Episodes), + "next_refresh": payload.NextRefreshAt, + }, + ) return payload, true } @@ -403,13 +622,31 @@ func nextBroadcastAfter(anime domain.Anime, after time.Time) time.Time { if loaded, err := time.LoadLocation(tz); err == nil { loc = loaded } else { - log.Printf("episodes: failed to parse broadcast timezone anime_id=%d timezone=%q error=%v", anime.MalID, tz, err) + observability.Warn( + "episodes_broadcast_timezone_parse_failed", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "timezone": tz, + }, + err, + ) } } hour, minute, ok := parseBroadcastTime(anime.Broadcast.Time) if !ok { - log.Printf("episodes: failed to parse broadcast time anime_id=%d time=%q", anime.MalID, anime.Broadcast.Time) + observability.Warn( + "episodes_broadcast_time_parse_failed", + "episodes", + "", + map[string]any{ + "anime_id": anime.MalID, + "time": anime.Broadcast.Time, + }, + nil, + ) return time.Time{} } diff --git a/internal/episodes/service/service_test.go b/internal/episodes/service/service_test.go index 986ccf4..4c58f15 100644 --- a/internal/episodes/service/service_test.go +++ b/internal/episodes/service/service_test.go @@ -29,7 +29,7 @@ func TestMergeEpisodesUsesUnionAndSynthesizesProviderOnlyEntries(t *testing.T) { } func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) { - anime := domain.Anime{MalID: 1} + anime := domain.Anime{Anime: jikan.Anime{MalID: 1}} anime.Broadcast.Day = "Saturdays" anime.Broadcast.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{MalID: 1} + anime := domain.Anime{Anime: jikan.Anime{MalID: 1}} anime.Broadcast.Day = "Saturdays" anime.Broadcast.Time = "12:00" anime.Broadcast.Timezone = "UTC" diff --git a/internal/episodes/worker.go b/internal/episodes/worker.go index a080556..e112475 100644 --- a/internal/episodes/worker.go +++ b/internal/episodes/worker.go @@ -2,8 +2,8 @@ package episodes import ( "context" - "log" "mal/internal/domain" + "mal/internal/observability" "time" "go.uber.org/fx" @@ -11,25 +11,44 @@ import ( const workerInterval = time.Minute -func RegisterWorker(lc fx.Lifecycle, svc domain.EpisodeService) { +func RegisterWorker(lc fx.Lifecycle, svc domain.EpisodeService, metrics *observability.Metrics) { ctx, cancel := context.WithCancel(context.Background()) lc.Append(fx.Hook{ - OnStart: func(context.Context) error { + OnStart: func(startCtx context.Context) error { + // Tie worker lifetime to fx lifecycle start context cancellation. go func() { - log.Println("episodes: availability worker started") + <-startCtx.Done() + cancel() + }() + go func() { + observability.Info("episodes_worker_start", "episodes", "", nil) ticker := time.NewTicker(workerInterval) defer ticker.Stop() for { - if err := svc.RefreshTrackedDue(ctx, 25); err != nil { - log.Printf("episodes: availability worker tick failed error=%v", err) + tickCtx, tickCancel := context.WithTimeout(ctx, 45*time.Second) + err := svc.RefreshTrackedDue(tickCtx, 25) + tickCancel() + if err != nil { + metrics.ObserveWorkerTick("episodes_availability", err) + observability.Warn( + "episodes_worker_tick_failed", + "episodes", + "", + map[string]any{ + "worker": "episodes_availability", + }, + err, + ) + } else { + metrics.ObserveWorkerTick("episodes_availability", nil) } select { case <-ticker.C: case <-ctx.Done(): - log.Println("episodes: availability worker stopped") + observability.Info("episodes_worker_stop", "episodes", "", nil) return } } diff --git a/internal/observability/helpers.go b/internal/observability/helpers.go new file mode 100644 index 0000000..316e0e1 --- /dev/null +++ b/internal/observability/helpers.go @@ -0,0 +1,15 @@ +package observability + +// Small helpers to keep logging consistent and low-friction across the codebase. + +func Info(event string, component string, message string, fields map[string]any) { + LogJSON(LogLevelInfo, event, component, message, fields, nil) +} + +func Warn(event string, component string, message string, fields map[string]any, err error) { + LogJSON(LogLevelWarn, event, component, message, fields, err) +} + +func Error(event string, component string, message string, fields map[string]any, err error) { + LogJSON(LogLevelError, event, component, message, fields, err) +} diff --git a/internal/observability/log.go b/internal/observability/log.go new file mode 100644 index 0000000..04d56d0 --- /dev/null +++ b/internal/observability/log.go @@ -0,0 +1,58 @@ +package observability + +import ( + "encoding/json" + "log" + "time" +) + +type LogLevel string + +const ( + LogLevelInfo LogLevel = "info" + LogLevelWarn LogLevel = "warn" + LogLevelError LogLevel = "error" +) + +type LogEvent struct { + TS string `json:"ts"` + Level LogLevel `json:"level"` + Event string `json:"event"` + Message string `json:"message,omitempty"` + Fields map[string]any `json:"fields,omitempty"` + Error string `json:"error,omitempty"` + Component string `json:"component,omitempty"` +} + +func LogJSON(level LogLevel, event string, component string, message string, fields map[string]any, err error) { + errorValue := "" + if err != nil { + errorValue = err.Error() + } + + entry := LogEvent{ + TS: time.Now().UTC().Format(time.RFC3339Nano), + Level: level, + Event: event, + Message: message, + Fields: fields, + Error: errorValue, + Component: component, + } + + // Best-effort. If encoding fails, fall back to a minimal line. + bytes, marshalErr := json.Marshal(entry) + if marshalErr != nil { + // Keep output JSON-only even on failures by constructing a minimal entry. + // Marshal individual strings to ensure proper escaping. + tsBytes, _ := json.Marshal(time.Now().UTC().Format(time.RFC3339Nano)) + levelBytes, _ := json.Marshal(level) + eventBytes, _ := json.Marshal("log_marshal_failed") + componentBytes, _ := json.Marshal(component) + errBytes, _ := json.Marshal(marshalErr.Error()) + log.Printf(`{"ts":%s,"level":%s,"event":%s,"component":%s,"error":%s}`, tsBytes, levelBytes, eventBytes, componentBytes, errBytes) + return + } + + log.Print(string(bytes)) +} diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go new file mode 100644 index 0000000..c03df95 --- /dev/null +++ b/internal/observability/metrics.go @@ -0,0 +1,297 @@ +package observability + +import ( + "fmt" + "maps" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +var defaultDurationBuckets = []float64{ + 0.005, + 0.01, + 0.025, + 0.05, + 0.1, + 0.25, + 0.5, + 1, + 2.5, + 5, + 10, +} + +type counterSample struct { + labels map[string]string + value uint64 +} + +type histogramSample struct { + labels map[string]string + buckets []uint64 + count uint64 + sum float64 +} + +type counterVec struct { + mu sync.Mutex + labelNames []string + samples map[string]*counterSample +} + +type histogramVec struct { + mu sync.Mutex + labelNames []string + bounds []float64 + samples map[string]*histogramSample +} + +type Metrics struct { + httpRequests *counterVec + httpRequestLatency *histogramVec + jikanRequests *counterVec + jikanRequestErrors *counterVec + jikanLatency *histogramVec + workerTicks *counterVec + cacheOperations *counterVec +} + +func NewMetrics() *Metrics { + return &Metrics{ + httpRequests: newCounterVec("method", "route", "status"), + httpRequestLatency: newHistogramVec(defaultDurationBuckets, "method", "route", "status"), + jikanRequests: newCounterVec("endpoint", "status"), + jikanRequestErrors: newCounterVec("endpoint", "status"), + jikanLatency: newHistogramVec(defaultDurationBuckets, "endpoint", "status"), + workerTicks: newCounterVec("worker", "result"), + cacheOperations: newCounterVec("cache", "result"), + } +} + +func (m *Metrics) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + w.WriteHeader(http.StatusOK) + m.writePrometheus(w) + }) +} + +func (m *Metrics) ObserveHTTPRequest(method string, route string, status int, duration time.Duration) { + statusLabel := strconv.Itoa(status) + m.httpRequests.Inc(method, route, statusLabel) + m.httpRequestLatency.Observe(duration.Seconds(), method, route, statusLabel) +} + +func (m *Metrics) ObserveJikanRequest(endpoint string, status int, duration time.Duration, err error) { + statusLabel := strconv.Itoa(status) + m.jikanRequests.Inc(endpoint, statusLabel) + m.jikanLatency.Observe(duration.Seconds(), endpoint, statusLabel) + if err != nil || status >= http.StatusBadRequest { + m.jikanRequestErrors.Inc(endpoint, statusLabel) + } +} + +func (m *Metrics) ObserveWorkerTick(worker string, err error) { + if err != nil { + m.workerTicks.Inc(worker, "failure") + return + } + m.workerTicks.Inc(worker, "success") +} + +func (m *Metrics) ObserveCache(cache string, result string) { + m.cacheOperations.Inc(cache, result) +} + +func (m *Metrics) writePrometheus(w http.ResponseWriter) { + writeCounterMetric(w, "mal_http_requests_total", "Total HTTP requests by method, route, and status.", m.httpRequests.snapshot()) + writeHistogramMetric(w, "mal_http_request_duration_seconds", "HTTP request latency in seconds.", m.httpRequestLatency.snapshot(), m.httpRequestLatency.bounds) + writeCounterMetric(w, "mal_jikan_upstream_requests_total", "Total upstream Jikan requests by endpoint and status.", m.jikanRequests.snapshot()) + writeCounterMetric(w, "mal_jikan_upstream_errors_total", "Total upstream Jikan errors by endpoint and status.", m.jikanRequestErrors.snapshot()) + writeHistogramMetric(w, "mal_jikan_upstream_request_duration_seconds", "Upstream Jikan request latency in seconds.", m.jikanLatency.snapshot(), m.jikanLatency.bounds) + writeCounterMetric(w, "mal_worker_ticks_total", "Total background worker ticks by worker and result.", m.workerTicks.snapshot()) + writeCounterMetric(w, "mal_cache_operations_total", "Total cache hits and misses by cache name.", m.cacheOperations.snapshot()) +} + +func newCounterVec(labelNames ...string) *counterVec { + return &counterVec{ + labelNames: append([]string(nil), labelNames...), + samples: make(map[string]*counterSample), + } +} + +func (c *counterVec) Inc(labelValues ...string) { + c.mu.Lock() + defer c.mu.Unlock() + + key, labels := buildLabelKey(c.labelNames, labelValues) + if labels == nil { + return + } + sample, ok := c.samples[key] + if !ok { + sample = &counterSample{labels: labels} + c.samples[key] = sample + } + sample.value++ +} + +func (c *counterVec) snapshot() []counterSample { + c.mu.Lock() + defer c.mu.Unlock() + + keys := sortedCounterSampleKeys(c.samples) + out := make([]counterSample, 0, len(keys)) + for _, key := range keys { + sample := c.samples[key] + out = append(out, counterSample{ + labels: copyLabels(sample.labels), + value: sample.value, + }) + } + return out +} + +func newHistogramVec(bounds []float64, labelNames ...string) *histogramVec { + return &histogramVec{ + labelNames: append([]string(nil), labelNames...), + bounds: append([]float64(nil), bounds...), + samples: make(map[string]*histogramSample), + } +} + +func (h *histogramVec) Observe(value float64, labelValues ...string) { + h.mu.Lock() + defer h.mu.Unlock() + + key, labels := buildLabelKey(h.labelNames, labelValues) + if labels == nil { + return + } + sample, ok := h.samples[key] + if !ok { + sample = &histogramSample{ + labels: labels, + buckets: make([]uint64, len(h.bounds)), + } + h.samples[key] = sample + } + + sample.count++ + sample.sum += value + for idx, bound := range h.bounds { + if value <= bound { + sample.buckets[idx]++ + } + } +} + +func (h *histogramVec) snapshot() []histogramSample { + h.mu.Lock() + defer h.mu.Unlock() + + keys := sortedHistogramSampleKeys(h.samples) + out := make([]histogramSample, 0, len(keys)) + for _, key := range keys { + sample := h.samples[key] + buckets := make([]uint64, len(sample.buckets)) + copy(buckets, sample.buckets) + out = append(out, histogramSample{ + labels: copyLabels(sample.labels), + buckets: buckets, + count: sample.count, + sum: sample.sum, + }) + } + return out +} + +func buildLabelKey(labelNames []string, labelValues []string) (string, map[string]string) { + if len(labelNames) != len(labelValues) { + return "", nil + } + + labels := make(map[string]string, len(labelNames)) + parts := make([]string, 0, len(labelNames)*2) + for idx, name := range labelNames { + value := labelValues[idx] + labels[name] = value + parts = append(parts, name, value) + } + return strings.Join(parts, "\xff"), labels +} + +func copyLabels(labels map[string]string) map[string]string { + out := make(map[string]string, len(labels)) + maps.Copy(out, labels) + return out +} + +func sortedCounterSampleKeys(samples map[string]*counterSample) []string { + keys := make([]string, 0, len(samples)) + for key := range samples { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func sortedHistogramSampleKeys(samples map[string]*histogramSample) []string { + keys := make([]string, 0, len(samples)) + for key := range samples { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func writeCounterMetric(w http.ResponseWriter, name string, help string, samples []counterSample) { + _, _ = fmt.Fprintf(w, "# HELP %s %s\n", name, help) + _, _ = fmt.Fprintf(w, "# TYPE %s counter\n", name) + for _, sample := range samples { + _, _ = fmt.Fprintf(w, "%s%s %d\n", name, formatLabels(sample.labels), sample.value) + } +} + +func writeHistogramMetric(w http.ResponseWriter, name string, help string, samples []histogramSample, bounds []float64) { + _, _ = fmt.Fprintf(w, "# HELP %s %s\n", name, help) + _, _ = fmt.Fprintf(w, "# TYPE %s histogram\n", name) + for _, sample := range samples { + for idx, bound := range bounds { + labels := copyLabels(sample.labels) + labels["le"] = formatFloat(bound) + _, _ = fmt.Fprintf(w, "%s_bucket%s %d\n", name, formatLabels(labels), sample.buckets[idx]) + } + labels := copyLabels(sample.labels) + labels["le"] = "+Inf" + _, _ = fmt.Fprintf(w, "%s_bucket%s %d\n", name, formatLabels(labels), sample.count) + _, _ = fmt.Fprintf(w, "%s_sum%s %s\n", name, formatLabels(sample.labels), formatFloat(sample.sum)) + _, _ = fmt.Fprintf(w, "%s_count%s %d\n", name, formatLabels(sample.labels), sample.count) + } +} + +func formatLabels(labels map[string]string) string { + if len(labels) == 0 { + return "" + } + + keys := make([]string, 0, len(labels)) + for key := range labels { + keys = append(keys, key) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, fmt.Sprintf(`%s=%q`, key, labels[key])) + } + return "{" + strings.Join(parts, ",") + "}" +} + +func formatFloat(value float64) string { + return strconv.FormatFloat(value, 'f', -1, 64) +} diff --git a/internal/observability/metrics_test.go b/internal/observability/metrics_test.go new file mode 100644 index 0000000..78f8379 --- /dev/null +++ b/internal/observability/metrics_test.go @@ -0,0 +1,47 @@ +package observability + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestMetricsHandlerRendersPrometheusFamilies(t *testing.T) { + metrics := NewMetrics() + metrics.ObserveHTTPRequest(http.MethodGet, "/anime/:id", http.StatusOK, 125*time.Millisecond) + metrics.ObserveJikanRequest("/anime/{id}", http.StatusTooManyRequests, 800*time.Millisecond, assertErr{}) + metrics.ObserveWorkerTick("episodes_availability", nil) + metrics.ObserveCache("jikan", "hit") + metrics.ObserveCache("episode_availability", "miss") + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec := httptest.NewRecorder() + metrics.Handler().ServeHTTP(rec, req) + + body, err := io.ReadAll(rec.Result().Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + + text := string(body) + assertContains(t, text, `mal_http_requests_total{method="GET",route="/anime/:id",status="200"} 1`) + assertContains(t, text, `mal_http_request_duration_seconds_count{method="GET",route="/anime/:id",status="200"} 1`) + assertContains(t, text, `mal_jikan_upstream_requests_total{endpoint="/anime/{id}",status="429"} 1`) + assertContains(t, text, `mal_jikan_upstream_errors_total{endpoint="/anime/{id}",status="429"} 1`) + assertContains(t, text, `mal_worker_ticks_total{result="success",worker="episodes_availability"} 1`) + assertContains(t, text, `mal_cache_operations_total{cache="episode_availability",result="miss"} 1`) +} + +type assertErr struct{} + +func (assertErr) Error() string { return "boom" } + +func assertContains(t *testing.T, text string, want string) { + t.Helper() + if !strings.Contains(text, want) { + t.Fatalf("missing metric line %q in:\n%s", want, text) + } +} diff --git a/internal/playback/handler/handler.go b/internal/playback/handler/handler.go index f5f3c54..571cfde 100644 --- a/internal/playback/handler/handler.go +++ b/internal/playback/handler/handler.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "mal/internal/domain" + "mal/internal/server" "mal/pkg/net/limits" "mal/pkg/net/proxytransport" "mal/pkg/net/useragent" @@ -86,13 +87,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 { - c.Status(http.StatusBadRequest) + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id") return } episode := c.Param("episode") if episode == "" { - c.Status(http.StatusBadRequest) + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "missing episode") return } @@ -106,7 +107,15 @@ func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) { data, err := h.svc.BuildWatchData(c.Request.Context(), animeID, []string{}, episode, mode, userID) if err != nil { - c.Status(http.StatusInternalServerError) + server.RespondError( + c, + http.StatusInternalServerError, + "watch_episode_data_build_failed", + "playback", + "failed to load episode data", + map[string]any{"anime_id": animeID, "episode": episode, "mode": mode, "user_id": userID}, + err, + ) return } @@ -148,7 +157,7 @@ func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) { } if userID == "" { // Avoid spamming 500s for anonymous playback; progress is user-scoped. - c.Status(http.StatusUnauthorized) + server.RespondHTMLOrJSONError(c, http.StatusUnauthorized, "unauthorized") return } @@ -159,13 +168,21 @@ func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) { } if err := c.ShouldBindJSON(&req); err != nil { - c.Status(http.StatusBadRequest) + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid request body") return } err := h.svc.SaveProgress(c.Request.Context(), userID, req.MalID, req.Episode, req.TimeSeconds) if err != nil { - c.Status(http.StatusInternalServerError) + server.RespondError( + c, + http.StatusInternalServerError, + "watch_progress_save_failed", + "playback", + "failed to save progress", + map[string]any{"mal_id": req.MalID, "episode": req.Episode, "user_id": userID}, + err, + ) return } @@ -185,13 +202,21 @@ func (h *PlaybackHandler) HandleWatchComplete(c *gin.Context) { } if err := c.ShouldBindJSON(&req); err != nil { - c.Status(http.StatusBadRequest) + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid request body") return } err := h.svc.CompleteAnime(c.Request.Context(), userID, req.MalID) if err != nil { - c.Status(http.StatusInternalServerError) + server.RespondError( + c, + http.StatusInternalServerError, + "watch_complete_failed", + "playback", + "failed to complete anime", + map[string]any{"mal_id": req.MalID, "user_id": userID}, + err, + ) return } @@ -238,6 +263,8 @@ 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) diff --git a/internal/playback/handler/subtitle_cache.go b/internal/playback/handler/subtitle_cache.go index 7cf0d97..d2deb89 100644 --- a/internal/playback/handler/subtitle_cache.go +++ b/internal/playback/handler/subtitle_cache.go @@ -47,7 +47,11 @@ func (c *subtitleCache) Get(key string, now time.Time) (data []byte, contentType if el == nil { return nil, "", false } - entry := el.Value.(subtitleCacheEntry) + entry, ok := el.Value.(subtitleCacheEntry) + if !ok { + c.removeElement(el) + return nil, "", false + } if !entry.expiresAt.IsZero() && now.After(entry.expiresAt) { c.removeElement(el) return nil, "", false @@ -61,7 +65,11 @@ func (c *subtitleCache) Set(key string, data []byte, contentType string, now tim defer c.mu.Unlock() if el := c.entries[key]; el != nil { - entry := el.Value.(subtitleCacheEntry) + entry, ok := el.Value.(subtitleCacheEntry) + if !ok { + c.removeElement(el) + return + } entry.data = data entry.contentType = contentType entry.expiresAt = now.Add(c.ttl) @@ -89,7 +97,11 @@ func (c *subtitleCache) Set(key string, data []byte, contentType string, now tim } func (c *subtitleCache) removeElement(el *list.Element) { - entry := el.Value.(subtitleCacheEntry) + entry, ok := el.Value.(subtitleCacheEntry) + if !ok { + c.lru.Remove(el) + return + } delete(c.entries, entry.key) c.lru.Remove(el) } diff --git a/internal/playback/module.go b/internal/playback/module.go index 4128613..a2e728a 100644 --- a/internal/playback/module.go +++ b/internal/playback/module.go @@ -1,10 +1,9 @@ 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" @@ -14,18 +13,17 @@ import ( "go.uber.org/fx" ) -func provideProxyTokenKey() string { - return os.Getenv("PLAYBACK_PROXY_SECRET") +func provideProxyTokenKey(cfg config.Config) service.ProxyTokenKey { + return service.ProxyTokenKey(cfg.PlaybackProxySecret) } var Module = fx.Options( fx.Provide( repository.NewPlaybackRepository, fx.Annotate( - 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) + 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) }, - fx.ParamTags(``, ``, ``, ``, ``), ), func(svc domain.PlaybackService, animeSvc domain.AnimeService) *handler.PlaybackHandler { return handler.NewPlaybackHandler(svc, animeSvc) diff --git a/internal/playback/service/service.go b/internal/playback/service/service.go index 3f0e134..06c9567 100644 --- a/internal/playback/service/service.go +++ b/internal/playback/service/service.go @@ -12,6 +12,7 @@ import ( "mal/integrations/jikan" "mal/internal/db" "mal/internal/domain" + "mal/internal/observability" "mal/pkg/net/limits" "mal/pkg/net/useragent" "net/http" @@ -31,8 +32,11 @@ 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"` @@ -40,8 +44,16 @@ type proxyTokenPayload struct { ExpiresAt int64 `json:"exp"` } -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 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 (s *playbackService) SignProxyToken(targetURL, referer, scope string) (string, error) { @@ -59,7 +71,9 @@ func (s *playbackService) SignProxyToken(targetURL, referer, scope string) (stri return "", err } mac := hmac.New(sha256.New, []byte(s.proxyTokenKey)) - mac.Write(body) + if _, err := mac.Write(body); err != nil { + return "", fmt.Errorf("sign proxy token: %w", err) + } signature := mac.Sum(nil) encodedBody := base64.RawURLEncoding.EncodeToString(body) encodedSignature := base64.RawURLEncoding.EncodeToString(signature) @@ -78,11 +92,16 @@ 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)) - mac.Write(body) - signature := mac.Sum(nil) - encodedSig := base64.RawURLEncoding.EncodeToString(signature) - if encodedSig != parts[1] { + if _, err := mac.Write(body); err != nil { + return proxyTokenPayload{}, fmt.Errorf("verify proxy token: %w", err) + } + expectedSig := mac.Sum(nil) + if !hmac.Equal(expectedSig, decodedSig) { return proxyTokenPayload{}, fmt.Errorf("invalid signature") } var payload proxyTokenPayload @@ -124,7 +143,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title } } - canonicalEpisodes, err := s.episodes.GetCanonicalEpisodes(ctx, anime, false) + canonicalEpisodes, err := s.episodes.GetCanonicalEpisodes(ctx, domain.Anime{Anime: anime}, false) if err != nil { return domain.WatchPageData{}, fmt.Errorf("failed to fetch episodes: %w", err) } @@ -272,7 +291,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title return domain.WatchPageData{ WatchData: watchData, - Anime: anime, + Anime: domain.Anime{Anime: anime}, Episodes: canonicalEpisodes.Episodes, CurrentEpID: episode, WatchlistStatus: watchlistStatus, @@ -300,16 +319,35 @@ func (s *playbackService) CompleteAnime(ctx context.Context, userID string, anim } } - _ = s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{ + if err := s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{ UserID: userID, AnimeID: animeID, - }) - return s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{ + }); err != nil { + return err + } + if err := s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{ UserID: userID, AnimeID: animeID, CurrentEpisode: sql.NullInt64{Valid: false}, CurrentTimeSeconds: 0, - }) + }); err != nil { + return err + } + if err := s.auditSvc.Record(ctx, domain.AuditEvent{ + UserID: userID, + Action: "watch_completed", + 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 { @@ -321,7 +359,31 @@ func (s *playbackService) SaveProgress(ctx context.Context, userID string, anime CurrentTimeSeconds: timeSeconds, DurationSeconds: sql.NullFloat64{Valid: false}, }) - return err + if err != nil { + return err + } + + metadataBytes, marshalErr := json.Marshal(struct { + Episode int `json:"episode"` + TimeSeconds float64 `json:"time_seconds"` + }{Episode: episode, TimeSeconds: timeSeconds}) + if marshalErr == nil { + _ = s.auditSvc.Record(ctx, domain.AuditEvent{ + UserID: userID, + Action: "watch_progress_saved", + ResourceType: "anime", + ResourceID: strconv.FormatInt(animeID, 10), + MetadataJSON: metadataBytes, + }) + } else { + _ = s.auditSvc.Record(ctx, domain.AuditEvent{ + UserID: userID, + Action: "watch_progress_saved", + ResourceType: "anime", + ResourceID: strconv.FormatInt(animeID, 10), + }) + } + return nil } func (s *playbackService) UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error { diff --git a/internal/server/cors.go b/internal/server/cors.go index 02e716e..c6adb5c 100644 --- a/internal/server/cors.go +++ b/internal/server/cors.go @@ -1,16 +1,19 @@ package server import ( + "mal/internal/config" "net/http" - "os" "strings" "github.com/gin-gonic/gin" ) func CORSMiddleware() gin.HandlerFunc { - allowAll := os.Getenv("MAL_CORS_ALLOW_ALL") == "1" + return CORSMiddlewareWithConfig(config.Config{}) +} +func CORSMiddlewareWithConfig(cfg config.Config) gin.HandlerFunc { + allowAll := cfg.CORSAllowAll return func(c *gin.Context) { origin := c.GetHeader("Origin") if origin != "" && (allowAll || isAllowedOrigin(origin)) { @@ -32,9 +35,6 @@ func CORSMiddleware() 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 } diff --git a/internal/server/observability.go b/internal/server/observability.go index a281540..e729199 100644 --- a/internal/server/observability.go +++ b/internal/server/observability.go @@ -1,14 +1,13 @@ package server import ( - "log" - "strconv" + "mal/internal/observability" "time" "github.com/gin-gonic/gin" ) -func RequestLogger() gin.HandlerFunc { +func RequestLogger(metrics *observability.Metrics) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() path := c.Request.URL.Path @@ -21,17 +20,34 @@ func RequestLogger() gin.HandlerFunc { route = path } - 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()), + 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, ) } } diff --git a/internal/server/respond.go b/internal/server/respond.go new file mode 100644 index 0000000..f73d6b3 --- /dev/null +++ b/internal/server/respond.go @@ -0,0 +1,42 @@ +package server + +import ( + "mal/internal/observability" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +type ErrorResponse struct { + Error string `json:"error"` +} + +func RespondHTMLOrJSONError(c *gin.Context, status int, message string) { + if acceptsHTML(c) { + c.String(status, message) + c.Abort() + return + } + c.JSON(status, ErrorResponse{Error: message}) + c.Abort() +} + +func RespondError(c *gin.Context, status int, event string, component string, message string, fields map[string]any, err error) { + level := observability.LogLevelWarn + if status >= http.StatusInternalServerError { + level = observability.LogLevelError + } + observability.LogJSON(level, event, component, "", fields, err) + RespondHTMLOrJSONError(c, status, message) +} + +func acceptsHTML(c *gin.Context) bool { + if strings.Contains(c.GetHeader("Accept"), "text/html") { + return true + } + if strings.EqualFold(strings.TrimSpace(c.GetHeader("HX-Request")), "true") { + return true + } + return false +} diff --git a/internal/server/server.go b/internal/server/server.go index c708fae..a63f4a7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,9 +2,10 @@ package server import ( "context" - "log" + "mal/internal/audit" + "mal/internal/config" + "mal/internal/observability" "net/http" - "os" "time" "github.com/gin-gonic/gin" @@ -13,43 +14,59 @@ import ( ) var Module = fx.Options( + fx.Provide(observability.NewMetrics), fx.Provide(ProvideRouter), fx.Invoke(RunServer), ) -func ProvideRouter(htmlRender render.HTMLRender) *gin.Engine { - if os.Getenv("GIN_MODE") == "" { +func ProvideRouter(cfg config.Config, htmlRender render.HTMLRender, metrics *observability.Metrics) *gin.Engine { + if cfg.GinMode == "" { gin.SetMode(gin.ReleaseMode) + } else { + gin.SetMode(cfg.GinMode) } r := gin.New() - r.Use(CORSMiddleware(), RequestLogger(), gin.Recovery()) + r.Use(CORSMiddlewareWithConfig(cfg), audit.ContextMiddleware(), RequestLogger(metrics), gin.Recovery()) r.Static("/static", "./static") r.Static("/dist", "./dist") + r.GET("/metrics", gin.WrapH(metrics.Handler())) r.HTMLRender = htmlRender return r } -func RunServer(lifecycle fx.Lifecycle, r *gin.Engine) { - port := os.Getenv("PORT") - if port == "" { - port = "3000" - } +func RunServer(cfg config.Config, lifecycle fx.Lifecycle, r *gin.Engine) { + port := cfg.Port srv := newHTTPServer(":"+port, r) lifecycle.Append(fx.Hook{ OnStart: func(context.Context) error { - log.Printf("Starting server on http://localhost:%s", port) + observability.Info( + "server_start", + "server", + "", + map[string]any{ + "port": port, + }, + ) go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { // Avoid exiting the process from a goroutine; let the process supervisor handle restarts. - log.Printf("server listen error: %s", err) + observability.Error( + "server_listen_error", + "server", + "", + map[string]any{ + "port": port, + }, + err, + ) } }() return nil }, OnStop: func(ctx context.Context) error { - log.Println("Shutting down server...") + observability.Info("server_stop", "server", "", nil) ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() return srv.Shutdown(ctx) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4317e2f..76153ba 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -4,6 +4,7 @@ import ( "bytes" "io" "log" + "mal/internal/observability" "net/http" "net/http/httptest" "strings" @@ -42,7 +43,7 @@ func TestRequestLoggerUsesMatchedRoute(t *testing.T) { defer log.SetOutput(previousOutput) router := gin.New() - router.Use(RequestLogger()) + router.Use(RequestLogger(observability.NewMetrics())) router.GET("/anime/:id", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) @@ -57,10 +58,13 @@ func TestRequestLoggerUsesMatchedRoute(t *testing.T) { } logLine := string(output) - if !strings.Contains(logLine, `route="/anime/:id"`) { + if !strings.Contains(logLine, `"event":"http_request"`) { + t.Fatalf("log line missing event: %s", logLine) + } + if !strings.Contains(logLine, `"route":"/anime/:id"`) { t.Fatalf("log line missing route: %s", logLine) } - if !strings.Contains(logLine, `status=200`) { + if !strings.Contains(logLine, `"status":200`) { t.Fatalf("log line missing status: %s", logLine) } } diff --git a/internal/templates/renderer.go b/internal/templates/renderer.go deleted file mode 100644 index 2a72e16..0000000 --- a/internal/templates/renderer.go +++ /dev/null @@ -1,248 +0,0 @@ -package templates - -import ( - "encoding/json" - "fmt" - "html/template" - "io" - "net/http" - "os" - "path/filepath" - "slices" - "strconv" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/gin-gonic/gin/render" - "go.uber.org/fx" -) - -// FS is the interface for template filesystem, to be provided by the main app or a mock. -type FS interface { - ReadFile(name string) ([]byte, error) - ReadDir(name string) ([]os.DirEntry, error) -} - -// We will use embed.FS but wrapped in an interface if needed, or just use it directly. -// For now let's assume we pass the root embed.FS to the constructor. - -type Renderer struct { - templates map[string]*template.Template -} - -var Module = fx.Options( - fx.Provide(ProvideRenderer), -) - -func ProvideRenderer() (*Renderer, error) { - // In the final version, this will use an embedded FS. - // For now, let's keep it working with the local filesystem but as an fx service. - r := &Renderer{ - templates: make(map[string]*template.Template), - } - - funcs := template.FuncMap{ - "dict": func(values ...any) map[string]any { - m := make(map[string]any) - for i := 0; i < len(values)-1; i += 2 { - key, ok := values[i].(string) - if !ok { - continue - } - m[key] = values[i+1] - } - return m - }, - "json": func(v any) template.HTMLAttr { - b, _ := json.Marshal(v) - return template.HTMLAttr(b) - }, - "genresParams": func(genres []int) string { - if len(genres) == 0 { - return "" - } - var s strings.Builder - for _, g := range genres { - s.WriteString("genres=" + fmt.Sprintf("%d", g) + "&") - } - return s.String()[:len(s.String())-1] - }, - "hasGenre": func(id int, genres []int) bool { - return slices.Contains(genres, id) - }, - "add": func(a, b int) int { - return a + b - }, - "sub": func(a, b int) int { - return a - b - }, - "mul": func(a, b float64) float64 { - return a * b - }, - "imul": func(a, b int) int { - return a * b - }, - "div": func(a, b float64) float64 { - if b == 0 { - return 0 - } - return a / b - }, - "ceilDiv": func(a, b int) int { - if b == 0 { - return 0 - } - return (a + b - 1) / b - }, - "toFloat": func(a int) float64 { - return float64(a) - }, - "seq": func(v any) []int { - var count int - switch n := v.(type) { - case int: - count = n - case int64: - count = int(n) - default: - count = 0 - } - res := make([]int, count) - for i := 0; i < count; i++ { - res[i] = i - } - return res - }, - "min": func(a, b int) int { - if a < b { - return a - } - return b - }, - "int": func(v any) int { - switch n := v.(type) { - case int: - return n - case int64: - return int(n) - case float64: - return int(n) - case string: - i, _ := strconv.Atoi(n) - return i - default: - return 0 - } - }, - "percent": func(current, total float64) float64 { - if total == 0 { - return 0 - } - return (current / total) * 100 - }, - "formatDate": func(dateStr string) string { - t, err := time.Parse(time.RFC3339, dateStr) - if err != nil { - t, err = time.Parse("2006-01-02T15:04:05+00:00", dateStr) - if err != nil { - return dateStr - } - } - return t.Format("Jan 2, 2006") - }, - } - - pages, err := filepath.Glob(filepath.Join(".", "templates", "*.gohtml")) - if err != nil { - return nil, err - } - - subpages, err := filepath.Glob(filepath.Join(".", "templates", "anime", "*.gohtml")) - if err != nil { - return nil, err - } - - allPages := append(pages, subpages...) - - components, err := filepath.Glob(filepath.Join(".", "templates", "components", "*.gohtml")) - if err != nil { - return nil, err - } - - basePath := filepath.Join(".", "templates", "base.gohtml") - - for _, page := range allPages { - name := filepath.Base(page) - if name == "base.gohtml" { - continue - } - - tmpl := template.New("base.gohtml").Funcs(funcs) - tmpl = template.Must(tmpl.ParseFiles(basePath)) - if len(components) > 0 { - tmpl = template.Must(tmpl.ParseFiles(components...)) - } - tmpl = template.Must(tmpl.ParseFiles(page)) - - r.templates[name] = tmpl - } - - return r, nil -} - -func (r *Renderer) Instance(name string, data any) render.Render { - return HTMLRender{ - Renderer: r, - Name: name, - Data: data, - } -} - -type HTMLRender struct { - Renderer *Renderer - Name string - Data any -} - -type templateFragmentData interface { - TemplateFragment() string -} - -func (h HTMLRender) Render(w http.ResponseWriter) error { - tmpl, ok := h.Renderer.templates[h.Name] - if !ok { - return fmt.Errorf("template %s not found", h.Name) - } - - var block any - - // Handle both map[string]any and gin.H (which is map[string]any but might - // behave differently depending on the Go version/compiler in type assertions) - if dataMap, ok := h.Data.(map[string]any); ok { - block = dataMap["_fragment"] - } else if ginH, ok := h.Data.(gin.H); ok { - block = ginH["_fragment"] - } else if fragmentData, ok := h.Data.(templateFragmentData); ok { - block = fragmentData.TemplateFragment() - } - - if blockStr, ok := block.(string); ok && blockStr != "" { - return tmpl.ExecuteTemplate(w, blockStr, h.Data) - } - - return tmpl.ExecuteTemplate(w, "base.gohtml", h.Data) -} - -func (h HTMLRender) WriteContentType(w http.ResponseWriter) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") -} - -// ExecuteFragment is for HTMX partials -func (r *Renderer) ExecuteFragment(w io.Writer, name string, block string, data any) error { - tmpl, ok := r.templates[name] - if !ok { - return fmt.Errorf("template %s not found", name) - } - return tmpl.ExecuteTemplate(w, block, data) -} diff --git a/internal/watchlist/handler/handler.go b/internal/watchlist/handler/handler.go index 2066a80..2b0608a 100644 --- a/internal/watchlist/handler/handler.go +++ b/internal/watchlist/handler/handler.go @@ -2,6 +2,7 @@ package handler import ( "mal/internal/domain" + "mal/internal/server" "net/http" "strconv" @@ -35,13 +36,21 @@ func (h *WatchlistHandler) HandleUpdateWatchlist(c *gin.Context) { Status string `json:"status"` } if err := c.ShouldBindJSON(&body); err != nil || body.AnimeID <= 0 || body.Status == "" { - c.Status(http.StatusBadRequest) + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid request body") return } err := h.svc.UpdateEntry(c.Request.Context(), userID, body.AnimeID, body.Status) if err != nil { - c.Status(http.StatusInternalServerError) + server.RespondError( + c, + http.StatusInternalServerError, + "watchlist_update_failed", + "watchlist", + "failed to update watchlist entry", + map[string]any{"user_id": userID, "anime_id": body.AnimeID, "status": body.Status}, + err, + ) return } @@ -55,16 +64,24 @@ func (h *WatchlistHandler) HandleDeleteWatchlist(c *gin.Context) { userID = u.ID } - animeID, _ := strconv.ParseInt(c.Param("id"), 10, 64) + animeID, err := strconv.ParseInt(c.Param("id"), 10, 64) - if animeID <= 0 { - c.Status(http.StatusBadRequest) + if err != nil || animeID <= 0 { + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id") return } - err := h.svc.RemoveEntry(c.Request.Context(), userID, animeID) + err = h.svc.RemoveEntry(c.Request.Context(), userID, animeID) if err != nil { - c.Status(http.StatusInternalServerError) + server.RespondError( + c, + http.StatusInternalServerError, + "watchlist_remove_failed", + "watchlist", + "failed to remove watchlist entry", + map[string]any{"user_id": userID, "anime_id": animeID}, + err, + ) return } @@ -78,16 +95,24 @@ func (h *WatchlistHandler) HandleDeleteContinueWatching(c *gin.Context) { userID = u.ID } - animeID, _ := strconv.ParseInt(c.Param("id"), 10, 64) + animeID, err := strconv.ParseInt(c.Param("id"), 10, 64) - if animeID <= 0 { - c.Status(http.StatusBadRequest) + if err != nil || animeID <= 0 { + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id") return } - err := h.svc.DeleteContinueWatching(c.Request.Context(), userID, animeID) + err = h.svc.DeleteContinueWatching(c.Request.Context(), userID, animeID) if err != nil { - c.Status(http.StatusInternalServerError) + server.RespondError( + c, + http.StatusInternalServerError, + "continue_watching_delete_failed", + "watchlist", + "failed to delete continue watching entry", + map[string]any{"user_id": userID, "anime_id": animeID}, + err, + ) return } @@ -103,7 +128,15 @@ func (h *WatchlistHandler) HandleGetWatchlist(c *gin.Context) { entries, err := h.svc.GetWatchlist(c.Request.Context(), userID) if err != nil { - c.Status(http.StatusInternalServerError) + server.RespondError( + c, + http.StatusInternalServerError, + "watchlist_load_failed", + "watchlist", + "failed to load watchlist", + map[string]any{"user_id": userID}, + err, + ) return } diff --git a/internal/watchlist/service/service.go b/internal/watchlist/service/service.go index bbb1f2c..7c85e3f 100644 --- a/internal/watchlist/service/service.go +++ b/internal/watchlist/service/service.go @@ -23,15 +23,18 @@ 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 { - _, _ = 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}, - }) + 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 } } @@ -96,10 +99,12 @@ func (s *watchlistService) GetContinueWatchingEntry(ctx context.Context, userID } func (s *watchlistService) DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error { - _ = s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{ + if err := s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{ UserID: userID, AnimeID: animeID, - }) + }); err != nil { + return err + } return s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{ UserID: userID, AnimeID: animeID, diff --git a/justfile b/justfile index 8550c92..331d4e7 100644 --- a/justfile +++ b/justfile @@ -1,13 +1,17 @@ set shell := ["bash", "-c"] set dotenv-load := true -export GOCACHE := justfile_directory() + "/.cache/go-build" - fmt: go fmt ./... lint: - go fmt ./... && go vet ./... + bun run lint:go + +lint-ts: + bun run lint:ts + +lint-go: + bun run lint:go test: go test ./... @@ -37,3 +41,9 @@ 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 diff --git a/lefthook.yml b/lefthook.yml index 27f2d6f..ce16878 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -13,7 +13,7 @@ 'commands': { 'go-fmt': { 'run': 'go fmt ./...' }, - 'go-vet': { 'run': 'go vet ./...' }, + 'go-lint': { 'run': 'bun run lint:go' }, 'go-test': { 'run': 'go test ./...' }, 'ts-typecheck': { 'run': 'bunx tsc -p tsconfig.json --noEmit' }, 'build-assets': { 'run': 'bun run build:assets' }, diff --git a/package.json b/package.json index 2782684..bbe7eb5 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,22 @@ { - "name": "myanimelist-ui", + "name": "mal", "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 --target browser", + "build:ts": "bun build ./static/player/main.ts --outdir ./dist/static/player --target browser --splitting && bun build ./static/*.ts --outdir ./dist/static --target browser --root ./static --entry-naming \"[name].js\"", "typecheck": "bunx tsc -p tsconfig.json --noEmit", "build:assets": "bun run build:css && bun run build:ts", "format": "bunx prettier . --write", - "lint": "bunx eslint . --fix" + "lint": "bun run lint:ts && bun run lint:go", + "lint:ts": "bunx eslint . --max-warnings 0", + "lint:ts:fix": "bunx eslint . --fix", + "lint:go": "golangci-lint run ./..." }, "devDependencies": { "@tailwindcss/cli": "^4.2.4", - "@toolwind/anchors": "^1.0.10", + "@types/node": "^24.0.0", "@typescript-eslint/eslint-plugin": "^8.59.2", "@typescript-eslint/parser": "^8.59.2", "eslint": "^10.3.0", @@ -25,7 +28,5 @@ "tailwindcss": "^4.2.4", "typescript": "^6.0.3" }, - "dependencies": { - "dompurify": "^3.4.1" - } + "dependencies": {} } diff --git a/scripts/new-data-fix.ts b/scripts/new-data-fix.ts new file mode 100644 index 0000000..8d65d07 --- /dev/null +++ b/scripts/new-data-fix.ts @@ -0,0 +1,73 @@ +import { mkdir, writeFile, access } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import path from 'node:path'; + +function toSlug(raw: string): string { + const trimmed = raw.trim().toLowerCase(); + const slug = trimmed.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); + return slug; +} + +function formatYYYYMMDD(date: Date): string { + const year = String(date.getFullYear()); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}${month}${day}`; +} + +async function fileExists(filePath: string): Promise { + try { + await access(filePath, fsConstants.F_OK); + return true; + } catch { + return false; + } +} + +async function main(): Promise { + const rawName = process.argv[2] ?? ''; + const slug = toSlug(rawName); + if (slug.length === 0) { + throw new Error('usage: bun scripts/new-data-fix.ts '); + } + + 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; +}); diff --git a/static/anime.ts b/static/anime.ts index 4df914b..31fda8c 100644 --- a/static/anime.ts +++ b/static/anime.ts @@ -1,5 +1,28 @@ import { parseClassList } from './utils'; +const initSynopsisToggle = (): void => { + document.addEventListener('click', e => { + const target = e.target; + if (!(target instanceof Element)) return; + + const btn = target.closest('[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')); diff --git a/static/assets/style.css b/static/assets/style.css index a9662d9..848d8e6 100644 --- a/static/assets/style.css +++ b/static/assets/style.css @@ -1,42 +1,34 @@ @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(#f7f6f3, #0b0c10); - --color-background-sidebar: light-dark(#fbfbfa, #0f1115); - --color-background-header: light-dark(#fbfbfa, #0f1115); + --color-background: light-dark(#ffffff, #0b0c10); + --color-background-sidebar: light-dark(#f7f7f7, #0f1115); + --color-background-header: light-dark(#fbfbfb, #0f1115); --color-background-surface: light-dark(#ffffff, #17181c); - --color-background-button: light-dark(#ffffff, #131417); - --color-background-button-hover: light-dark(#f7f6f3, #1c1d22); - - --color-foreground-muted: light-dark(#787774, #a1a1aa); + --color-background-button: light-dark(#f5f5f5, #131417); + --color-background-button-hover: light-dark(#ececec, #1c1d22); --color-foreground: light-dark(#111111, #f3f4f6); - - --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)); + --color-accent: #00b3c4; + --color-surface-hover: light-dark(rgba(0, 0, 0, 0.04), rgba(255, 255, 255, 0.05)); } :root { color-scheme: light dark; - --bg: var(--color-background); - --panel: light-dark(#f5f5f4, #181818); - --panel-soft: light-dark(#e7e5e4, #202020); + --panel: light-dark(#f7f7f7, #181818); + --panel-soft: light-dark(#ececec, #202020); --header: light-dark(#ffffff, #101010); - --text: light-dark(#1c1917, #e7e5e4); - --text-muted: light-dark(#57534e, #a8a29e); - --text-faint: light-dark(#a8a29e, #78716c); + --text: light-dark(#111111, #e7e5e4); + --text-muted: light-dark(#666666, #a8a29e); + --text-faint: light-dark(#9a9a9a, #78716c); --accent: var(--color-accent); --danger: #dc2626; - --surface-thumb: light-dark(#e7e5e4, #44403c); - --surface-tab-hover: light-dark(#e7e5e4, #202020); - --surface-tab-active: light-dark(#1c1917, #fafaf9); + --surface-thumb: light-dark(#cccccc, #44403c); + --surface-tab-hover: light-dark(#e4e4e4, #202020); + --surface-tab-active: light-dark(#1e1b17, #fafaf9); --text-tab-active: light-dark(#fafaf9, #0c0a09); --surface-select: light-dark(#ffffff, #181818); --text-on-accent: light-dark(#fafaf9, #0c0a09); @@ -44,6 +36,9 @@ --shadow-subtle: light-dark(0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18)); --shadow-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; @@ -58,106 +53,18 @@ --radius: 0px; } -[data-theme='light'] { - color-scheme: light; -} - -[data-theme='dark'] { - color-scheme: dark; -} - html, body { background-color: var(--color-background); color: var(--text); } -@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] .watchlist-icon, +[data-watchlist-toggle] .watchlist-icon path { + fill: none; } -/* 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; +[data-watchlist-toggle][data-watchlist-state='in'] .watchlist-icon, +[data-watchlist-toggle][data-watchlist-state='in'] .watchlist-icon path { + fill: currentColor; } diff --git a/static/discover.ts b/static/discover.ts index fceeb18..5ca087f 100644 --- a/static/discover.ts +++ b/static/discover.ts @@ -41,3 +41,56 @@ const initDiscoverTabs = (): void => { }; initDiscoverTabs(); + +const initSurpriseMe = (): void => { + let isFetchingRandom = false; + + const onClick = async (): Promise => { + 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(); diff --git a/static/dropdown.ts b/static/dropdown.ts index 5fd41c3..c4b496e 100644 --- a/static/dropdown.ts +++ b/static/dropdown.ts @@ -1,7 +1,7 @@ class UIDropdown extends HTMLElement { - isOpen: boolean = false; + isOpen = false; contentEl: HTMLElement | null = null; - isClosing: boolean = false; // debounce flag + isClosing = false; // debounce flag constructor() { super(); @@ -64,3 +64,25 @@ 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('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(); diff --git a/static/htmx.ts b/static/htmx.ts new file mode 100644 index 0000000..271462a --- /dev/null +++ b/static/htmx.ts @@ -0,0 +1,71 @@ +export {}; + +type ToastFn = (opts: { message: string; duration?: number }) => void; + +const getToast = (): ToastFn | null => { + const anyWindow = window as unknown as { showToast?: ToastFn }; + return typeof anyWindow.showToast === 'function' ? anyWindow.showToast : null; +}; + +const toast = (message: string): void => { + getToast()?.({ message }); +}; + +const setBusy = (el: Element | null, busy: boolean): void => { + if (!(el instanceof HTMLElement)) return; + el.toggleAttribute('aria-busy', busy); + el.dataset.htmxLoading = busy ? 'true' : 'false'; + + if (el instanceof HTMLButtonElement) { + el.disabled = busy; + } + + if (busy) { + el.dataset.htmxBusy = 'true'; + return; + } + + delete el.dataset.htmxBusy; +}; + +const getTriggerFromHtmxEvent = (event: Event): Element | null => { + const detail = event as unknown as { detail?: { elt?: Element } }; + return detail.detail?.elt ?? null; +}; + +const onReady = (fn: () => void): void => { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', fn, { once: true }); + return; + } + + fn(); +}; + +onReady(() => { + document.addEventListener('htmx:beforeRequest', event => { + setBusy(getTriggerFromHtmxEvent(event), true); + }); + + document.addEventListener('htmx:afterRequest', event => { + setBusy(getTriggerFromHtmxEvent(event), false); + + const remaining = document.querySelectorAll('.continue-watching-item').length; + if (remaining !== 0) return; + + const section = document.getElementById('continue-watching-section'); + section?.remove(); + }); + + document.addEventListener('htmx:responseError', () => { + toast('Something went wrong'); + }); + + document.addEventListener('htmx:sendError', () => { + toast('Network error'); + }); + + document.addEventListener('htmx:timeout', () => { + toast('Request timed out'); + }); +}); diff --git a/static/images/background.png b/static/images/background.png new file mode 100644 index 0000000..cb3bedc Binary files /dev/null and b/static/images/background.png differ diff --git a/static/player/controls.ts b/static/player/controls.ts index f9e6df6..b931528 100644 --- a/static/player/controls.ts +++ b/static/player/controls.ts @@ -1,4 +1,6 @@ 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'; @@ -77,6 +79,35 @@ 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; @@ -137,6 +168,8 @@ const updateMuteIcons = (isMuted: boolean): void => { * Sets up video event listeners for icon sync. */ export const setupControls = (): void => { + applyStoredVolume(); + const { playPause, muteBtn, @@ -203,8 +236,12 @@ 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); diff --git a/static/player/episodes/nav.ts b/static/player/episodes/nav.ts index c68d3c0..b5fcc44 100644 --- a/static/player/episodes/nav.ts +++ b/static/player/episodes/nav.ts @@ -1,11 +1,12 @@ import { state } from '../state'; -import { SkipSegment } from '../types'; +import type { SkipSegment } from '../types'; import { resolveActiveSegments, renderSegments } from '../skip/segments'; import { updateSubtitleOptions } from '../subtitles'; import { updateQualityOptions } from '../quality'; import { updateModeButtons } from '../mode'; import { updateOverlay, isAutoplayEnabled, switchEpisodeRange } from './ui'; import { markEpisodeTransition } from '../progress'; +import { safeLocalStorage } from '../storage'; /** * Handles video end: either marks complete or loads next episode. @@ -71,10 +72,12 @@ export const goToNextEpisode = async (): Promise => { state.container.dataset.startTimeSeconds = String(state.startTimeSeconds); // load new video (keep preferences) - const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best'; + const preferredQuality = safeLocalStorage.getItem('mal:preferred-quality') || 'best'; state.video.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`; state.video.load(); - if (!state.video.paused) state.video.play().catch(() => {}); + if (!state.video.paused) { + state.video.play().catch(() => undefined); + } state.pendingSeekTime = null; state.completionSent = false; diff --git a/static/player/episodes/thumbnails.ts b/static/player/episodes/thumbnails.ts index 990375d..b421fed 100644 --- a/static/player/episodes/thumbnails.ts +++ b/static/player/episodes/thumbnails.ts @@ -5,12 +5,14 @@ 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: Array<{ mal_id: number; url: string; title?: string }>) => { - if (!state.episodeList) return; + .then((data: { mal_id: number; url: string; title?: string }[]) => { data.forEach(item => { - const card = state.episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`); + const card = episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`); if (!card) return; // inject thumbnail image diff --git a/static/player/episodes/ui.ts b/static/player/episodes/ui.ts index 7e442cf..6bc984c 100644 --- a/static/player/episodes/ui.ts +++ b/static/player/episodes/ui.ts @@ -1,4 +1,6 @@ import { state } from '../state'; +import { qs } from '../../q'; +import { safeLocalStorage } from '../storage'; /** * Syncs autoplay checkbox with localStorage on init. @@ -7,11 +9,11 @@ import { state } from '../state'; export const setupAutoplayButton = (): void => { const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null; if (!btn) return; - btn.checked = localStorage.getItem('mal:autoplay-enabled') !== 'false'; + btn.checked = safeLocalStorage.getItem('mal:autoplay-enabled') !== 'false'; }; export const isAutoplayEnabled = (): boolean => - localStorage.getItem('mal:autoplay-enabled') !== 'false'; + safeLocalStorage.getItem('mal:autoplay-enabled') !== 'false'; /** * Updates video overlay text (shown briefly on episode change). @@ -19,7 +21,8 @@ export const isAutoplayEnabled = (): boolean => export const updateOverlay = (episode: string, title: string): void => { if (!state.videoOverlay) return; const p = state.videoOverlay.querySelector('p'); - p && (p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`); + if (!p) return; + p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`; }; // helper: get all episode elements from grid and list @@ -57,7 +60,7 @@ export const updateEpisodeHighlight = (num: number): void => { * Updates dropdown label and hides/shows episode cards. */ export const switchEpisodeRange = (idx: number): void => { - const dropdown = state.container.querySelector('[data-episode-dropdown]') as HTMLElement | null; + const dropdown = qs('[data-episode-dropdown]'); if (!dropdown) return; const btns = Array.from(dropdown.querySelectorAll('.episode-range-btn')) as HTMLButtonElement[]; const target = btns[idx]; diff --git a/static/player/keyboard.ts b/static/player/keyboard.ts index 3f571d8..ac1f5c1 100644 --- a/static/player/keyboard.ts +++ b/static/player/keyboard.ts @@ -8,6 +8,7 @@ import { seekBy, setVolume, } from './controls'; +import { saveProgress } from './progress'; /** * Sets up keyboard shortcuts for player control. @@ -26,6 +27,7 @@ export const setupKeyboard = (): void => { e.preventDefault(); togglePlayPause(); showControls(); + void saveProgress(); break; case 'ArrowLeft': case 'KeyJ': diff --git a/static/player/main.ts b/static/player/main.ts index b8b3b78..4ef5797 100644 --- a/static/player/main.ts +++ b/static/player/main.ts @@ -12,6 +12,7 @@ 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, @@ -20,21 +21,38 @@ import { } from './timeline'; import { formatTime } from './controls'; -let initialized = false; // prevent double init on htmx swaps +let currentContainer: HTMLElement | null = null; +let cleanup: (() => void) | null = null; + +type ClosableDropdown = HTMLElement & { close: () => void }; +const isClosableDropdown = (el: Element | null): el is ClosableDropdown => { + if (!el) return false; + if (!(el instanceof HTMLElement)) return false; + const maybe = el as Partial<{ close: unknown }>; + return typeof maybe.close === 'function'; +}; const hidePreviewPopover = (): void => { 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 = '0px'; + state.previewPopover.style.left = ''; }; 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; @@ -65,20 +83,25 @@ const updatePreviewUI = (ratio: number): void => { const initPlayer = (): void => { const container = document.querySelector('[data-video-player]') as HTMLElement | null; - if (!container || initialized) return; + if (!container) return; + if (container === currentContainer) return; + teardownPlayer(); if (!initState(container)) { console.error('Video player markup is missing required controls.'); return; } - initialized = true; + currentContainer = container; + const abortController = new AbortController(); + const signal = abortController.signal; + cleanup = () => abortController.abort(); const loading = container.querySelector('[data-loading]') as HTMLElement | null; const 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 = localStorage.getItem('mal:preferred-quality') || 'best'; + const preferredQuality = safeLocalStorage.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)}` : ''}`; @@ -106,7 +129,9 @@ const initPlayer = (): void => { } const onLoadedMetadata = (): void => { - loading && (loading.style.display = 'none'); + if (loading) { + loading.style.display = 'none'; + } invalidateBounds(); resolveActiveSegments(); @@ -126,137 +151,193 @@ 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(() => {}); + if (state.shouldAutoPlay || state.video.paused) { + state.video.play().catch(() => undefined); + } updateTimeline(state.video.currentTime); updateSkipButton(state.video.currentTime); }; - state.video.addEventListener('loadedmetadata', onLoadedMetadata); + state.video.addEventListener('loadedmetadata', onLoadedMetadata, { signal }); // inline script runs during HTML parsing before initPlayer; if metadata // already loaded, fire the handler immediately if (state.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { onLoadedMetadata(); } - state.video.addEventListener('waiting', () => { - loading && (loading.style.display = 'flex'); - }); - state.video.addEventListener('playing', () => { - loading && (loading.style.display = 'none'); - }); + state.video.addEventListener( + 'waiting', + () => { + if (loading) { + loading.style.display = 'flex'; + } + }, + { signal } + ); + state.video.addEventListener( + 'playing', + () => { + if (loading) { + loading.style.display = 'none'; + } + }, + { signal } + ); // update progress bar during buffering - state.video.addEventListener('progress', () => { - updateTimeline(state.video.currentTime); - }); + state.video.addEventListener( + 'progress', + () => { + updateTimeline(state.video.currentTime); + }, + { signal } + ); // main loop: update progress, subtitles, skip buttons - state.video.addEventListener('timeupdate', () => { - updateTimeline(state.video.currentTime); - updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime)); - updateSkipButton(state.video.currentTime); - }); + state.video.addEventListener( + 'timeupdate', + () => { + updateTimeline(state.video.currentTime); + updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime)); + updateSkipButton(state.video.currentTime); + }, + { signal } + ); - state.video.addEventListener('ended', () => { - goToNextEpisode(); - }); + state.video.addEventListener( + 'ended', + () => { + goToNextEpisode(); + }, + { signal } + ); // 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(); - }); + 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 } + ); // 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))); - }); + progressWrap?.addEventListener( + 'pointermove', + e => { + const rect = progressWrap.getBoundingClientRect(); + updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))); + }, + { signal } + ); - progressWrap?.addEventListener('pointerleave', hidePreviewPopover); - progressWrap?.addEventListener('pointerup', () => { - // ensure we finish the seek even if no window mousemove fired - if (!progressWrap) return; - state.isScrubbing = false; - }); + 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 } + ); // 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); - }); + 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 } + ); // 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); - }); + 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 } + ); - state.video.addEventListener('click', showControls); + state.video.addEventListener('click', showControls, { signal }); const searchInput = document.querySelector('[data-episode-search]') as HTMLInputElement | null; - const dropdown = container.querySelector('[data-episode-dropdown]') as HTMLElement | null; + const dropdown = document.querySelector('[data-episode-dropdown]') as HTMLElement | null; let searchDebounce: number | undefined; 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); - }); + 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 } + ); } // 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); - }); + 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 } + ); }); } @@ -273,3 +354,10 @@ 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(); + } +}); diff --git a/static/player/mode.ts b/static/player/mode.ts index 7a6e201..e7bcbe5 100644 --- a/static/player/mode.ts +++ b/static/player/mode.ts @@ -3,6 +3,7 @@ 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 => { @@ -21,7 +22,9 @@ 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(() => {}); + if (wasPlaying) { + state.video.play().catch(() => undefined); + } }; /** @@ -31,8 +34,11 @@ const loadVideo = (url: string): void => { export const switchMode = (mode: string): void => { if (!state.availableModes.includes(mode) || mode === state.currentMode) return; state.currentMode = mode; - localStorage.setItem('player-audio-mode', mode); - loadVideo(streamUrlForMode(mode, state.container.querySelector('[data-quality-select]')?.value)); + safeLocalStorage.setItem('player-audio-mode', mode); + const qualitySelect = state.container.querySelector( + '[data-quality-select]' + ) as HTMLSelectElement | null; + loadVideo(streamUrlForMode(mode, qualitySelect?.value)); updateSubtitleOptions(); updateQualityOptions(); updateModeButtons(); @@ -48,16 +54,20 @@ export const updateModeButtons = (): void => { const m = state.currentMode; dub?.classList.toggle('text-accent', m === 'dub'); - dub?.classList.toggle('text-white', m !== 'dub'); + dub?.classList.toggle('text-foreground', m !== 'dub'); dub?.classList.toggle('opacity-50', !state.availableModes.includes('dub')); dub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('dub')); - dub && (dub.disabled = !state.availableModes.includes('dub')); + if (dub) { + dub.disabled = !state.availableModes.includes('dub'); + } sub?.classList.toggle('text-accent', m === 'sub'); - sub?.classList.toggle('text-white', m !== 'sub'); + sub?.classList.toggle('text-foreground', m !== 'sub'); sub?.classList.toggle('opacity-50', !state.availableModes.includes('sub')); sub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('sub')); - sub && (sub.disabled = !state.availableModes.includes('sub')); + if (sub) { + sub.disabled = !state.availableModes.includes('sub'); + } }; /** @@ -82,7 +92,7 @@ export const setupMode = (): void => { const autoplayBtn = document.querySelector('[data-autoplay]') as HTMLInputElement | null; autoplayBtn?.addEventListener('change', e => { - localStorage.setItem( + safeLocalStorage.setItem( 'mal:autoplay-enabled', (e.target as HTMLInputElement).checked ? 'true' : 'false' ); diff --git a/static/player/progress.ts b/static/player/progress.ts index 7e478db..e8dd688 100644 --- a/static/player/progress.ts +++ b/static/player/progress.ts @@ -16,35 +16,49 @@ const sendBeacon = (payload: string) => { return true; }; +let saveProgressInFlight: Promise | null = null; + /** * Saves current progress to backend. * Debounced: skips if within 5s of last save for same episode. */ export const saveProgress = async (): Promise => { - 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; + if (saveProgressInFlight) return saveProgressInFlight; - 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 request = (async (): Promise => { + if (state.transitionEpisode !== null || !state.malID || state.video.currentTime < 1) return; + const episode = Number.parseInt(state.currentEpisode, 10); + if (!episode) return; - const payload = buildPayload(episode, safeTime); + const safeTime = displayTimeFromAbsolute(state.video.currentTime); + // skip if recently saved + if ( + state.lastSavedProgress.episode === state.currentEpisode && + Math.abs(state.lastSavedProgress.seconds - safeTime) < 5 + ) { + return; + } + + const payload = buildPayload(episode, safeTime); + try { + const res = await fetch('/api/watch-progress', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + }); + if (!res.ok) return; + state.lastSavedProgress = { episode: state.currentEpisode, seconds: safeTime }; + } catch {} + })(); + + saveProgressInFlight = request; try { - 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 {} + await request; + } finally { + if (saveProgressInFlight === request) { + saveProgressInFlight = null; + } + } }; // schedules periodic save every 30s during playback @@ -62,8 +76,6 @@ 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; @@ -77,7 +89,7 @@ export const markEpisodeTransition = (episodeNumber: number): void => { headers: { 'Content-Type': 'application/json' }, keepalive: true, body: payload, - }).catch(() => {}); + }).catch(() => undefined); } }; @@ -106,7 +118,6 @@ 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))); diff --git a/static/player/quality.ts b/static/player/quality.ts index e41d612..2acc633 100644 --- a/static/player/quality.ts +++ b/static/player/quality.ts @@ -1,5 +1,6 @@ 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 => { @@ -17,7 +18,9 @@ const loadVideo = (url: string): void => { state.video.src = url; state.video.load(); state.pendingSeekTime = prevTime; - if (wasPlaying) state.video.play().catch(() => {}); + if (wasPlaying) { + state.video.play().catch(() => undefined); + } }; /** @@ -27,7 +30,7 @@ const loadVideo = (url: string): void => { export const switchQuality = (quality: string): void => { const url = streamUrlForMode(state.currentMode, quality); if (!url) return; - localStorage.setItem('mal:preferred-quality', quality); + safeLocalStorage.setItem('mal:preferred-quality', quality); loadVideo(url); }; @@ -54,7 +57,7 @@ export const updateQualityOptions = (): void => { }); // restore saved preference - const preferred = localStorage.getItem('mal:preferred-quality') || 'best'; + const preferred = safeLocalStorage.getItem('mal:preferred-quality') || 'best'; select.value = qualities.includes(preferred) ? preferred : 'best'; // hide if no quality options diff --git a/static/player/skip/editor.ts b/static/player/skip/editor.ts index a5fc07c..6b262ed 100644 --- a/static/player/skip/editor.ts +++ b/static/player/skip/editor.ts @@ -28,9 +28,17 @@ 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; @@ -49,13 +57,22 @@ 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', () => { @@ -64,12 +81,49 @@ export const setupSegmentEditor = (): void => { }); closeBtn?.addEventListener('click', close); - // close when clicking outside the segment capture UI + document.addEventListener('keydown', e => { + if (panel.classList.contains('hidden')) return; + if (e.key === 'Escape') { + e.preventDefault(); + close(); + return; + } + + if (e.key !== 'Tab') return; + const focusables = Array.from(panel.querySelectorAll(focusableSelector)).filter( + el => + el instanceof HTMLElement && !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden') + ) as HTMLElement[]; + if (focusables.length === 0) return; + + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement; + if (!(active instanceof HTMLElement)) return; + + if (e.shiftKey && active === first) { + e.preventDefault(); + last.focus(); + return; + } + + if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + }); + + // close when clicking the backdrop outside the modal content document.addEventListener('pointerdown', e => { if (panel.classList.contains('hidden')) return; const target = e.target as Node | null; if (!target) return; - if (root.contains(target)) return; + if ( + (e.target as HTMLElement | null)?.closest('[data-segment-editor] [data-segment-editor-close]') + ) + return; + const content = panel.firstElementChild; + if (content && content.contains(target)) return; close(); }); diff --git a/static/player/skip/index.ts b/static/player/skip/index.ts index b83bcab..fa0142b 100644 --- a/static/player/skip/index.ts +++ b/static/player/skip/index.ts @@ -1,6 +1,8 @@ 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'); @@ -26,9 +28,10 @@ export const updateSkipButton = (currentTime: number): void => { } // auto-skip: jump to end if enabled - const autoSkip = localStorage.getItem('mal:autoskip-enabled') === 'true'; + const autoSkip = safeLocalStorage.getItem('mal:autoskip-enabled') === 'true'; if (autoSkip && displayTime >= segment.start && displayTime < segment.end) { state.video.currentTime = absoluteTimeFromDisplay(segment.end + 0.01); + void saveProgress(); return; } @@ -46,7 +49,8 @@ export const updateSkipButton = (currentTime: number): void => { */ export const updateAutoSkipButton = (): void => { const btn = document.querySelector('[data-autoskip]') as HTMLInputElement | null; - btn && (btn.checked = localStorage.getItem('mal:autoskip-enabled') === 'true'); + if (!btn) return; + btn.checked = safeLocalStorage.getItem('mal:autoskip-enabled') === 'true'; }; /** @@ -56,7 +60,7 @@ export const setupSkip = (): void => { document.addEventListener('change', e => { const target = e.target as HTMLElement; if (target.hasAttribute('data-autoskip')) { - localStorage.setItem( + safeLocalStorage.setItem( 'mal:autoskip-enabled', (target as HTMLInputElement).checked ? 'true' : 'false' ); diff --git a/static/player/skip/segments.ts b/static/player/skip/segments.ts index 693b12c..fe38c31 100644 --- a/static/player/skip/segments.ts +++ b/static/player/skip/segments.ts @@ -66,8 +66,9 @@ 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'; - // single color for OP/ED, rendered above buffered/progress fills - bar.classList.add('bg-amber-300/90'); + // distinct colors for OP/ED, rendered above buffered/progress fills + const t = (s.type || '').toLowerCase(); + bar.style.backgroundColor = t === 'ed' ? '#60a5fa' : '#f5c542'; bar.style.left = `${(s.start / bounds) * 100}%`; bar.style.width = `${((s.end - s.start) / bounds) * 100}%`; track.appendChild(bar); diff --git a/static/player/state.ts b/static/player/state.ts index a495077..4d68bcc 100644 --- a/static/player/state.ts +++ b/static/player/state.ts @@ -1,5 +1,6 @@ -import { ModeSource, SkipSegment, SubtitleCue, SubtitleTrack, ActiveSegment } from './types'; +import type { ModeSource, SkipSegment, SubtitleCue, SubtitleTrack, ActiveSegment } from './types'; import { q, qs, dataset } from '../q'; +import { safeLocalStorage } from './storage'; export interface PlayerState { container: HTMLElement; @@ -105,27 +106,18 @@ const findElement = ( }; const requiredPlayerElements = (container: HTMLElement): RequiredPlayerElements | null => { - const elements = { - video: findElement(container, 'video', HTMLVideoElement), - progress: findElement(container, '[data-progress]', HTMLElement), - scrubber: findElement(container, '[data-scrubber]', HTMLElement), - buffered: findElement(container, '[data-buffered]', HTMLElement), - timeDisplay: findElement(container, '[data-time]', HTMLElement), - durationDisplay: findElement(container, '[data-duration]', HTMLElement), - }; + const video = findElement(container, 'video', HTMLVideoElement); + const progress = findElement(container, '[data-progress]', HTMLElement); + const scrubber = findElement(container, '[data-scrubber]', HTMLElement); + const buffered = findElement(container, '[data-buffered]', HTMLElement); + const timeDisplay = findElement(container, '[data-time]', HTMLElement); + const durationDisplay = findElement(container, '[data-duration]', HTMLElement); - if ( - !elements.video || - !elements.progress || - !elements.scrubber || - !elements.buffered || - !elements.timeDisplay || - !elements.durationDisplay - ) { + if (!video || !progress || !scrubber || !buffered || !timeDisplay || !durationDisplay) { return null; } - return elements; + return { video, progress, scrubber, buffered, timeDisplay, durationDisplay }; }; /** @@ -163,22 +155,68 @@ export const initState = (c: HTMLElement): boolean => { state.episodeGrid = qs('[data-episode-grid]'); state.episodeList = qs('[data-episode-list]'); - const safeJson = (raw: string | undefined, fallback: T): T => { + const safeJsonUnknown = (raw: string | undefined): unknown => { try { - return JSON.parse(raw ?? '') as T; + return JSON.parse(raw ?? ''); } catch { - return fallback; + return null; } }; + const isRecord = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + + const isStringArray = (v: unknown): v is string[] => + Array.isArray(v) && v.every(item => typeof item === 'string'); + + const isSubtitleItemArray = (v: unknown): v is { lang: string; token: string }[] => + Array.isArray(v) && + v.every( + item => isRecord(item) && typeof item.lang === 'string' && typeof item.token === 'string' + ); + + const parseModeSources = (v: unknown): Record => { + if (!isRecord(v)) return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(v)) { + if (!isRecord(value)) continue; + if (typeof value.token !== 'string' || value.token === '') continue; + if (!isSubtitleItemArray(value.subtitles)) continue; + const qualities = value.qualities; + out[key] = { + token: value.token, + subtitles: value.subtitles, + qualities: isStringArray(qualities) ? qualities : undefined, + }; + } + return out; + }; + + const parseAvailableModes = (v: unknown): string[] => (isStringArray(v) ? v : []); + + const parseSegments = (v: unknown): SkipSegment[] => { + if (!Array.isArray(v)) return []; + const out: SkipSegment[] = []; + for (const item of v) { + if (!isRecord(item)) continue; + const type = typeof item.type === 'string' ? item.type : ''; + const start = typeof item.start === 'number' ? item.start : Number(item.start); + const end = typeof item.end === 'number' ? item.end : Number(item.end); + const source = typeof item.source === 'string' ? item.source : undefined; + if (!type || !Number.isFinite(start) || !Number.isFinite(end)) continue; + out.push({ type, start, end, source }); + } + return out; + }; + // mode sources = { sub: { token, subtitles, qualities }, dub: { ... } } - state.modeSources = safeJson(dataset(c, 'modeSources'), {} as Record); - state.availableModes = safeJson(dataset(c, 'availableModes'), [] as string[]); + state.modeSources = parseModeSources(safeJsonUnknown(dataset(c, 'modeSources'))); + state.availableModes = parseAvailableModes(safeJsonUnknown(dataset(c, 'availableModes'))); // resolve initial mode: localStorage > backend default > first available > 'dub' const backendInitialMode = dataset(c, 'initialMode') || 'dub'; state.modeSwitchedFrom = dataset(c, 'modeSwitchedFrom') || ''; - const storedMode = localStorage.getItem('player-audio-mode'); + const storedMode = safeLocalStorage.getItem('player-audio-mode'); const initialMode = storedMode && state.availableModes.includes(storedMode) ? storedMode : backendInitialMode; const fallbackMode = Object.keys(state.modeSources).find(m => state.modeSources[m]?.token); @@ -187,7 +225,7 @@ export const initState = (c: HTMLElement): boolean => { : (fallbackMode ?? state.availableModes[0] ?? 'dub'); // parse skip segments from data attribute - const segments = safeJson(dataset(c, 'segments'), [] as SkipSegment[]); + const segments = parseSegments(safeJsonUnknown(dataset(c, 'segments'))); state.parsedSegments = segments .map(s => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 })) .filter(s => s.end > s.start); diff --git a/static/player/storage.ts b/static/player/storage.ts new file mode 100644 index 0000000..06854d4 --- /dev/null +++ b/static/player/storage.ts @@ -0,0 +1,39 @@ +export type StorageLike = Pick; + +const getLocalStorage = (): StorageLike | null => { + try { + return window.localStorage; + } catch { + return null; + } +}; + +export const safeLocalStorage = { + getItem(key: string): string | null { + const storage = getLocalStorage(); + if (!storage) return null; + try { + return storage.getItem(key); + } catch { + return null; + } + }, + setItem(key: string, value: string): void { + const storage = getLocalStorage(); + if (!storage) return; + try { + storage.setItem(key, value); + } catch { + // ignore + } + }, + removeItem(key: string): void { + const storage = getLocalStorage(); + if (!storage) return; + try { + storage.removeItem(key); + } catch { + // ignore + } + }, +}; diff --git a/static/player/subtitles/index.ts b/static/player/subtitles/index.ts index 5f994db..885e112 100644 --- a/static/player/subtitles/index.ts +++ b/static/player/subtitles/index.ts @@ -1,4 +1,4 @@ -import { SubtitleCue, SubtitleTrack } from '../types'; +import type { SubtitleCue, SubtitleTrack } from '../types'; import { state } from '../state'; import { parseVtt } from './vtt'; @@ -80,8 +80,24 @@ export const updateSubtitleRender = (time: number): void => { return; } - // find cue containing current time - const cue = state.activeSubtitles.find(c => time >= c.start && time <= c.end); + // binary search: cues are sorted by start time + let lo = 0; + let hi = state.activeSubtitles.length - 1; + let cue: SubtitleCue | undefined; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const c = state.activeSubtitles[mid]; + if (time < c.start) { + hi = mid - 1; + continue; + } + if (time > c.end) { + lo = mid + 1; + continue; + } + cue = c; + break; + } if (!cue) { hideSubtitleText(); return; @@ -110,6 +126,8 @@ export const setupSubtitles = (): void => { state.activeSubtitles = []; return; } - state.activeSubtitles = await loadSubtitle(track.url); + const cues = await loadSubtitle(track.url); + cues.sort((a, b) => a.start - b.start); + state.activeSubtitles = cues; }); }; diff --git a/static/player/timeline.ts b/static/player/timeline.ts index 29002c8..79ac2c9 100644 --- a/static/player/timeline.ts +++ b/static/player/timeline.ts @@ -1,13 +1,6 @@ -import { TimelineBounds } from './types'; +import type { TimelineBounds } from './types'; import { state } from './state'; - -// mm:ss formatter -const formatTime = (seconds: number): string => { - if (!Number.isFinite(seconds) || seconds < 0) return '00:00'; - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; -}; +import { formatTime } from './controls'; // cached to avoid recalc on every timeupdate let cachedBounds: TimelineBounds = { start: 0, end: 0, duration: 0 }; @@ -65,7 +58,6 @@ export const getBounds = (): TimelineBounds => { const duration = getDuration(); const seekableEnd = getSeekableEnd(); if ( - !cachedBounds || cachedBounds.duration <= 0 || duration !== cachedDuration || seekableEnd !== cachedSeekableEnd diff --git a/static/search.ts b/static/search.ts index c42da88..11d57b8 100644 --- a/static/search.ts +++ b/static/search.ts @@ -1,4 +1,4 @@ -type CommandPaletteItem = { +interface CommandPaletteItem { id: string; type: string; label: string; @@ -6,7 +6,7 @@ type CommandPaletteItem = { href: string; image?: string; icon?: string; -}; +} const commandPaletteInitializedKey = Symbol('commandPaletteInitialized'); const globalWindow = window as Window & { [commandPaletteInitializedKey]?: boolean }; @@ -193,7 +193,7 @@ const buildRow = (item: CommandPaletteItem, index: number): HTMLAnchorElement => const row = document.createElement('a'); row.href = item.href; row.className = - 'flex min-h-12 items-center gap-3 px-4 py-2 text-foreground no-underline transition-colors hover:bg-surface-hover hover:no-underline'; + 'flex min-h-12 items-center gap-3 px-4 py-2 text-foreground no-underline transition-colors hover:bg-surface-hover hover:no-underline focus-visible:bg-surface-hover focus-visible:outline-none'; row.dataset.commandPaletteItem = item.id; row.setAttribute('role', 'option'); row.setAttribute('aria-selected', String(index === selectedIndex)); @@ -206,13 +206,13 @@ const buildRow = (item: CommandPaletteItem, index: number): HTMLAnchorElement => copy.className = 'grid min-w-0 flex-1 gap-0.5'; const label = document.createElement('div'); - label.className = 'truncate text-sm font-medium text-foreground'; + label.className = 'truncate text-sm font-normal text-foreground'; label.textContent = item.label; copy.appendChild(label); if (item.subtitle && item.type !== 'navigation') { const subtitle = document.createElement('div'); - subtitle.className = 'truncate text-xs text-foreground-muted'; + subtitle.className = 'truncate text-xs font-normal text-foreground-muted'; subtitle.textContent = item.subtitle; copy.appendChild(subtitle); } @@ -223,7 +223,7 @@ const buildRow = (item: CommandPaletteItem, index: number): HTMLAnchorElement => const removeButton = document.createElement('button'); removeButton.type = 'button'; removeButton.className = - 'flex h-8 w-8 shrink-0 items-center justify-center text-red-500/70 transition-colors hover:text-red-500'; + 'flex h-8 w-8 shrink-0 items-center justify-center text-red-500/70 transition-colors hover:text-red-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent'; removeButton.setAttribute('aria-label', 'Remove from Continue Watching'); removeButton.appendChild(buildSvgIcon('M18 6 6 18 M6 6l12 12', 'size-4')); removeButton.addEventListener('click', event => { @@ -246,7 +246,7 @@ const buildContinueToggle = (hiddenCount: number): HTMLButtonElement => { const button = document.createElement('button'); button.type = 'button'; button.className = - 'flex w-full items-center gap-3 px-4 py-2 text-left text-xs font-medium text-foreground-muted transition-colors hover:bg-surface-hover hover:text-foreground'; + 'flex w-full items-center gap-3 px-4 py-2 text-left text-xs font-normal text-foreground-muted transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent'; const spacer = document.createElement('span'); spacer.className = 'h-8 w-8 shrink-0'; @@ -304,7 +304,7 @@ const renderItems = (items: CommandPaletteItem[]): void => { if (paletteItems.length === 0) { const empty = document.createElement('div'); - empty.className = 'px-4 py-8 text-center text-sm text-foreground-muted'; + empty.className = 'px-4 py-8 text-center text-sm font-normal text-foreground-muted'; empty.textContent = 'No commands found'; paletteResults.replaceChildren(empty); return; diff --git a/static/shell.ts b/static/shell.ts new file mode 100644 index 0000000..1b66d9a --- /dev/null +++ b/static/shell.ts @@ -0,0 +1,94 @@ +export {}; + +const onReady = (fn: () => void): void => { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', fn, { once: true }); + return; + } + + fn(); +}; + +const isMobileViewport = (): boolean => window.matchMedia('(max-width: 1023px)').matches; + +const initSidebarTransitions = (): void => { + requestAnimationFrame(() => { + document.documentElement.classList.add('sidebar-ready'); + }); +}; + +const initMobileMenu = (): void => { + const menu = document.getElementById('mobile-menu'); + const backdrop = document.getElementById('mobile-menu-backdrop'); + const toggle = document.querySelector('[data-mobile-menu-toggle]'); + + if (!(menu instanceof HTMLElement)) return; + if (!(backdrop instanceof HTMLElement)) return; + if (!(toggle instanceof HTMLElement)) return; + + const body = document.body; + let lastFocused: HTMLElement | null = null; + + const setOpen = (nextOpen: boolean): void => { + menu.dataset.mobileOpen = nextOpen ? 'true' : 'false'; + backdrop.dataset.mobileOpen = nextOpen ? 'true' : 'false'; + backdrop.classList.toggle('hidden', !nextOpen); + toggle.setAttribute('aria-expanded', nextOpen ? 'true' : 'false'); + body.classList.toggle('overflow-hidden', nextOpen); + }; + + const openMenu = (): void => { + if (!isMobileViewport()) return; + if (menu.dataset.mobileOpen === 'true') return; + + lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + setOpen(true); + + const focusTarget = menu.querySelector( + 'a, button, input, [tabindex]:not([tabindex="-1"])' + ); + focusTarget?.focus(); + }; + + const closeMenu = (): void => { + if (menu.dataset.mobileOpen !== 'true') return; + setOpen(false); + lastFocused?.focus(); + }; + + toggle.addEventListener('click', () => { + if (menu.dataset.mobileOpen === 'true') { + closeMenu(); + return; + } + + openMenu(); + }); + + backdrop.addEventListener('click', closeMenu); + + document.addEventListener('keydown', event => { + if (event.key !== 'Escape') return; + if (menu.dataset.mobileOpen !== 'true') return; + event.preventDefault(); + closeMenu(); + }); + + menu.querySelectorAll('a, button').forEach(el => { + el.addEventListener('click', () => { + if (!isMobileViewport()) return; + closeMenu(); + }); + }); + + window.addEventListener('resize', () => { + if (!isMobileViewport()) { + setOpen(false); + } + }); +}; + +onReady(() => { + initSidebarTransitions(); + initMobileMenu(); +}); diff --git a/static/theme.ts b/static/theme.ts index 61bbc6c..3f07af2 100644 --- a/static/theme.ts +++ b/static/theme.ts @@ -2,17 +2,41 @@ type Theme = 'light' | 'dark'; const STORAGE_KEY = 'theme'; -const getSavedTheme = (): Theme => { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw === 'light' || raw === 'dark') { - return raw; +const getLocalStorage = (): Storage | null => { + try { + return window.localStorage; + } catch { + return null; } - return 'dark'; // default to dark +}; + +const getPreferredTheme = (): Theme => { + const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false; + return prefersDark ? 'dark' : 'light'; +}; + +const normalizeTheme = (raw: string | null): Theme | null => { + if (raw === 'light' || raw === 'dark') return raw; + return null; +}; + +const getSavedTheme = (): Theme => { + const storage = getLocalStorage(); + const fromStorage = normalizeTheme(storage?.getItem(STORAGE_KEY) ?? null); + if (fromStorage) return fromStorage; + + return getPreferredTheme(); }; const applyTheme = (theme: Theme): void => { document.documentElement.setAttribute('data-theme', theme); - localStorage.setItem(STORAGE_KEY, theme); + document.documentElement.style.colorScheme = theme; + const storage = getLocalStorage(); + try { + storage?.setItem(STORAGE_KEY, theme); + } catch { + // ignore + } }; const cycleTheme = (): void => { @@ -27,11 +51,11 @@ const initTheme = (): void => { // delegated click handler on theme buttons document.addEventListener('click', e => { - const target = e.target as HTMLElement; - const btn = target.closest('#theme-toggle, #footer-theme-toggle') as HTMLButtonElement | null; - if (btn) { - cycleTheme(); - } + const target = e.target; + if (!(target instanceof Element)) return; + const btn = target.closest('#theme-toggle, #footer-theme-toggle'); + if (!(btn instanceof HTMLButtonElement)) return; + cycleTheme(); }); }; diff --git a/static/timezone.ts b/static/timezone.ts index 6acd2c7..db9efd9 100644 --- a/static/timezone.ts +++ b/static/timezone.ts @@ -3,11 +3,11 @@ export {}; // JST offset from UTC in minutes (UTC+9) const jstOffsetMinutes = 9 * 60; -type ParsedBroadcast = { +interface ParsedBroadcast { day: string; hour: number; minute: number; -}; +} const parseBroadcastTime = (value: string | null): { hour: number; minute: number } | null => { if (!value || typeof value !== 'string') { diff --git a/static/toast.ts b/static/toast.ts index 8ceffd4..57b7d16 100644 --- a/static/toast.ts +++ b/static/toast.ts @@ -11,7 +11,10 @@ const toastContainer = (): HTMLElement => { if (!container) { container = document.createElement('div'); container.id = 'toast-container'; - container.className = 'fixed bottom-4 right-4 z-100 flex flex-col gap-2'; + container.className = 'fixed bottom-4 right-4 z-[100] flex flex-col gap-2'; + container.setAttribute('role', 'status'); + container.setAttribute('aria-live', 'polite'); + container.setAttribute('aria-relevant', 'additions'); document.body.appendChild(container); } return container; @@ -32,6 +35,8 @@ const showToast = ({ message, duration = 3000 }: ToastOptions): void => { const toast = (template.content.cloneNode(true) as DocumentFragment) .firstElementChild as HTMLElement; if (!toast) return; + toast.setAttribute('role', 'status'); + toast.setAttribute('aria-live', 'polite'); const messageEl = toast.querySelector('.toast-message'); const closeBtn = toast.querySelector('.toast-close'); @@ -39,7 +44,19 @@ const showToast = ({ message, duration = 3000 }: ToastOptions): void => { messageEl.textContent = message; } - closeBtn?.addEventListener('click', () => toast.remove()); + let removed = false; + let dismissTimeout: number | undefined; + let removeTimeout: number | undefined; + + const remove = (): void => { + if (removed) return; + removed = true; + if (typeof dismissTimeout === 'number') window.clearTimeout(dismissTimeout); + if (typeof removeTimeout === 'number') window.clearTimeout(removeTimeout); + toast.remove(); + }; + + closeBtn?.addEventListener('click', remove); container.appendChild(toast); @@ -49,9 +66,9 @@ const showToast = ({ message, duration = 3000 }: ToastOptions): void => { }); // auto-dismiss with exit animation - setTimeout(() => { + dismissTimeout = window.setTimeout(() => { toast.classList.add('translate-y-2', 'opacity-0'); - setTimeout(() => toast.remove(), 300); + removeTimeout = window.setTimeout(remove, 300); }, duration); }; diff --git a/static/watchlist.ts b/static/watchlist.ts new file mode 100644 index 0000000..d6fbac2 --- /dev/null +++ b/static/watchlist.ts @@ -0,0 +1,537 @@ +export {}; + +type WatchlistStatus = 'watching' | 'completed' | 'plan_to_watch' | 'dropped'; + +type WatchlistUpdateDisplay = + | 'Watching' + | 'Completed' + | 'Plan to Watch' + | 'Dropped' + | 'Add to Watchlist'; + +const watchlistIds = new Set(); +const inflight = new Set(); + +const getShowToast = (): ((opts: { message: string; duration?: number }) => void) | null => { + const anyWindow = window as unknown as { + showToast?: (opts: { message: string; duration?: number }) => void; + }; + return typeof anyWindow.showToast === 'function' ? anyWindow.showToast : null; +}; + +const toast = (message: string): void => { + getShowToast()?.({ message }); +}; + +const toInt = (value: string | undefined): number | null => { + if (!value) return null; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; +}; + +const withTimeout = async (promise: Promise, ms: number): Promise => { + let timeoutId: number | undefined; + const timeout = new Promise((_, reject) => { + timeoutId = window.setTimeout(() => reject(new Error('timeout')), ms); + }); + + try { + return await Promise.race([promise, timeout]); + } finally { + if (typeof timeoutId === 'number') { + window.clearTimeout(timeoutId); + } + } +}; + +const requestJson = async (input: string, init: RequestInit): Promise => + withTimeout(fetch(input, init), 12_000); + +const syncRemoveButtonVisibility = (id: number): void => { + const container = document.getElementById(`remove-watchlist-container-${id}`); + if (!container) return; + container.classList.toggle('hidden', !watchlistIds.has(id)); +}; + +const syncWatchlistDropdown = (id: number, inWatchlist: boolean): void => { + const statusDisplay = document.getElementById(`watchlist-status-display-${id}`); + if (!statusDisplay) return; + statusDisplay.textContent = inWatchlist ? 'Plan to Watch' : 'Add to Watchlist'; + syncRemoveButtonVisibility(id); +}; + +const syncIconsForId = (id: number): void => { + const shouldBeInWatchlist = watchlistIds.has(id); + document.querySelectorAll('[data-watchlist-toggle][data-mal-id]').forEach(button => { + const malId = toInt(button.dataset.malId); + if (malId !== id) return; + button.classList.toggle('in-watchlist', shouldBeInWatchlist); + button.dataset.watchlistState = shouldBeInWatchlist ? 'in' : 'out'; + button.setAttribute( + 'aria-label', + shouldBeInWatchlist ? 'Remove from Watchlist' : 'Add to Watchlist' + ); + button.toggleAttribute('aria-busy', inflight.has(id)); + }); +}; + +const setBusy = (id: number, busy: boolean): void => { + if (busy) { + inflight.add(id); + } else { + inflight.delete(id); + } + + document + .querySelectorAll('[data-watchlist-toggle][data-mal-id]') + .forEach(button => { + const malId = toInt(button.dataset.malId); + if (malId !== id) return; + button.disabled = busy; + button.toggleAttribute('aria-busy', busy); + }); + + document + .querySelectorAll( + '[data-watchlist-update][data-mal-id], [data-watchlist-remove][data-mal-id]' + ) + .forEach(button => { + const malId = toInt(button.dataset.malId); + if (malId !== id) return; + button.disabled = busy; + button.toggleAttribute('aria-busy', busy); + }); +}; + +const closeClosestDropdown = (from: HTMLElement): void => { + requestAnimationFrame(() => { + const dropdown = from.closest('ui-dropdown') as { close?: () => void } | null; + dropdown?.close?.(); + }); +}; + +const toggleWatchlist = async ( + id: number, + title: string, + renderedState: string | undefined +): Promise => { + if (inflight.has(id)) return; + if (renderedState === 'in') { + watchlistIds.add(id); + } else if (renderedState === 'out') { + watchlistIds.delete(id); + } + + const isInWatchlist = watchlistIds.has(id); + + setBusy(id, true); + + const optimisticNext = !isInWatchlist; + if (optimisticNext) { + watchlistIds.add(id); + } else { + watchlistIds.delete(id); + } + syncIconsForId(id); + syncWatchlistDropdown(id, optimisticNext); + + const url = isInWatchlist ? `/api/watchlist/${id}` : '/api/watchlist'; + const method: 'DELETE' | 'POST' = isInWatchlist ? 'DELETE' : 'POST'; + const body = isInWatchlist + ? null + : JSON.stringify({ animeId: id, status: 'plan_to_watch' satisfies WatchlistStatus }); + + try { + const response = await requestJson(url, { + method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ?? undefined, + }); + + if (!response.ok) { + throw new Error('not ok'); + } + + toast(optimisticNext ? `Added ${title} to watchlist` : `Removed ${title} from watchlist`); + } catch { + if (optimisticNext) { + watchlistIds.delete(id); + } else { + watchlistIds.add(id); + } + syncIconsForId(id); + syncWatchlistDropdown(id, watchlistIds.has(id)); + toast('Failed to update watchlist'); + } finally { + setBusy(id, false); + syncIconsForId(id); + syncRemoveButtonVisibility(id); + } +}; + +type WatchlistSort = 'date' | 'title'; + +const csvEscape = (value: unknown): string => { + const str = String(value ?? ''); + if (/[",\r\n]/.test(str)) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +}; + +const watchlistItems = (): HTMLElement[] => + Array.from(document.querySelectorAll('.watchlist-item')); + +const sortVisibleWatchlistItems = (sortBy: WatchlistSort, desc: boolean): void => { + const grids: HTMLElement[] = []; + + const singleGrid = document.getElementById('watchlist-items'); + if (singleGrid) { + grids.push(singleGrid); + } + + document + .querySelectorAll('.watchlist-section .grid') + .forEach(grid => grids.push(grid)); + + const sortItemsInGrid = (grid: HTMLElement): void => { + const items = Array.from(grid.querySelectorAll('.watchlist-item')); + items.sort((a, b) => { + let comparison = 0; + if (sortBy === 'title') { + const titleA = (a.querySelector('h3')?.textContent ?? '').toLowerCase().trim(); + const titleB = (b.querySelector('h3')?.textContent ?? '').toLowerCase().trim(); + comparison = titleA.localeCompare(titleB); + } else { + const dateA = Number.parseInt(a.dataset.updatedAt ?? '0', 10) || 0; + const dateB = Number.parseInt(b.dataset.updatedAt ?? '0', 10) || 0; + comparison = dateA - dateB; + } + return desc ? -comparison : comparison; + }); + items.forEach(item => grid.appendChild(item)); + }; + + grids.forEach(sortItemsInGrid); +}; + +const setActiveFilterButton = (clicked: HTMLButtonElement): void => { + const parent = clicked.parentElement; + if (!parent) return; + parent.querySelectorAll('button').forEach(b => { + b.classList.remove('text-foreground'); + b.classList.add('text-foreground-muted'); + b.classList.remove('border-accent'); + b.classList.add('border-transparent'); + }); + clicked.classList.remove('text-foreground-muted'); + clicked.classList.add('text-foreground'); + clicked.classList.remove('border-transparent'); + clicked.classList.add('border-accent'); +}; + +const applyWatchlistFilter = (status: string): void => { + const sections = Array.from(document.querySelectorAll('.watchlist-section')); + if (sections.length) { + sections.forEach(section => { + if (status === 'all') { + section.style.display = 'block'; + return; + } + section.style.display = section.dataset.status === status ? 'block' : 'none'; + }); + return; + } + + watchlistItems().forEach(item => { + if (status === 'all') { + item.style.display = 'flex'; + return; + } + item.style.display = item.dataset.status === status ? 'flex' : 'none'; + }); +}; + +const exportWatchlistCsv = (): void => { + const rows = watchlistItems() + .slice() + .sort((a, b) => { + const dateA = Number.parseInt(a.dataset.updatedAt ?? '0', 10) || 0; + const dateB = Number.parseInt(b.dataset.updatedAt ?? '0', 10) || 0; + return dateB - dateA; + }) + .map(item => { + const updatedAt = Number.parseInt(item.dataset.updatedAt ?? '0', 10) || 0; + const updatedAtISO = updatedAt > 0 ? new Date(updatedAt * 1000).toISOString() : ''; + const title = item.dataset.title || item.querySelector('h3')?.textContent?.trim() || ''; + return [item.dataset.malId || '', title, item.dataset.status || '', updatedAtISO]; + }); + + const csv = [['mal_id', 'title', 'status', 'updated_at'], ...rows] + .map(row => row.map(csvEscape).join(',')) + .join('\r\n'); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'watchlist.csv'; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +}; + +const initWatchlistPage = (): void => { + let currentSortBy: WatchlistSort = 'date'; + let sortOrderDesc = true; + + sortVisibleWatchlistItems(currentSortBy, sortOrderDesc); + + document.addEventListener('click', e => { + const target = e.target; + if (!(target instanceof Element)) return; + + const filterBtn = target.closest('button[data-watchlist-filter]'); + if (filterBtn) { + const status = filterBtn.dataset.watchlistFilter ?? 'all'; + setActiveFilterButton(filterBtn); + applyWatchlistFilter(status); + sortVisibleWatchlistItems(currentSortBy, sortOrderDesc); + return; + } + + const sortBtn = target.closest('button[data-watchlist-sort]'); + if (sortBtn) { + const sortBy = sortBtn.dataset.watchlistSort === 'title' ? 'title' : 'date'; + currentSortBy = sortBy; + const display = document.getElementById('sort-by-display'); + if (display) { + display.textContent = currentSortBy === 'date' ? 'Date Added' : 'Title'; + } + + const dropdownContent = sortBtn.closest('[data-content]'); + dropdownContent?.querySelectorAll('button').forEach(b => { + b.classList.remove('text-foreground'); + b.classList.add('text-foreground-muted'); + }); + sortBtn.classList.remove('text-foreground-muted'); + sortBtn.classList.add('text-foreground'); + + sortVisibleWatchlistItems(currentSortBy, sortOrderDesc); + + const parentDropdown = sortBtn.closest('ui-dropdown') as { close?: () => void } | null; + parentDropdown?.close?.(); + return; + } + + const sortOrderBtn = target.closest('button[data-watchlist-sort-order]'); + if (sortOrderBtn) { + sortOrderDesc = !sortOrderDesc; + const icon = sortOrderBtn.querySelector('svg'); + icon?.classList.toggle('rotate-180', !sortOrderDesc); + sortVisibleWatchlistItems(currentSortBy, sortOrderDesc); + return; + } + + const exportBtn = target.closest('button[data-watchlist-export]'); + if (exportBtn) { + exportWatchlistCsv(); + return; + } + }); +}; + +const updateWatchlist = async ( + id: number, + status: WatchlistStatus, + display: WatchlistUpdateDisplay, + title: string, + source: HTMLElement +): Promise => { + if (inflight.has(id)) return; + setBusy(id, true); + + const wasInWatchlist = watchlistIds.has(id); + watchlistIds.add(id); + syncIconsForId(id); + syncRemoveButtonVisibility(id); + + try { + const response = await requestJson('/api/watchlist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ animeId: id, status }), + }); + + if (!response.ok) { + throw new Error('not ok'); + } + + const statusDisplay = document.getElementById(`watchlist-status-display-${id}`); + if (statusDisplay) { + statusDisplay.textContent = display; + } + + closeClosestDropdown(source); + toast(`Marked ${title} as ${display}`); + } catch { + if (!wasInWatchlist) { + watchlistIds.delete(id); + } + syncIconsForId(id); + syncRemoveButtonVisibility(id); + toast('Failed to update watchlist'); + } finally { + setBusy(id, false); + } +}; + +const removeWatchlist = async (id: number, title: string, source: HTMLElement): Promise => { + if (inflight.has(id)) return; + setBusy(id, true); + + const wasInWatchlist = watchlistIds.has(id); + watchlistIds.delete(id); + syncIconsForId(id); + syncWatchlistDropdown(id, false); + + try { + const response = await requestJson(`/api/watchlist/${id}`, { method: 'DELETE' }); + if (!response.ok) { + throw new Error('not ok'); + } + + closeClosestDropdown(source); + toast(`Removed ${title} from watchlist`); + + const card = source.closest('.watchlist-item'); + if (card instanceof HTMLElement) { + card.remove(); + const remaining = document.querySelectorAll('.watchlist-item').length; + if (remaining === 0) { + window.setTimeout(() => window.location.reload(), 50); + } + } + } catch { + if (wasInWatchlist) { + watchlistIds.add(id); + } + syncIconsForId(id); + syncWatchlistDropdown(id, watchlistIds.has(id)); + toast('Failed to update watchlist'); + } finally { + setBusy(id, false); + syncRemoveButtonVisibility(id); + } +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initWatchlistPage); +} else { + initWatchlistPage(); +} + +const initWatchlist = (ids: number[]): void => { + ids.forEach(id => watchlistIds.add(id)); + ids.forEach(id => { + syncRemoveButtonVisibility(id); + syncIconsForId(id); + }); +}; + +const getRenderedWatchlistIds = (): number[] => { + const ids = new Set(); + + document + .querySelectorAll( + '[data-watchlist-toggle][data-watchlist-state="in"][data-mal-id]' + ) + .forEach(button => { + const id = toInt(button.dataset.malId); + if (id === null) return; + ids.add(id); + }); + + return Array.from(ids); +}; + +const onReady = (fn: () => void): void => { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', fn, { once: true }); + return; + } + + fn(); +}; + +const installDelegatedHandlers = (): void => { + document.addEventListener('click', event => { + const target = event.target; + if (!(target instanceof Element)) return; + + const toggleButton = target.closest('[data-watchlist-toggle]') as HTMLElement | null; + if (toggleButton) { + event.preventDefault(); + event.stopPropagation(); + + const id = toInt(toggleButton.getAttribute('data-mal-id') ?? undefined); + if (id === null) return; + const title = toggleButton.getAttribute('data-watchlist-title') ?? 'anime'; + void toggleWatchlist(id, title, toggleButton.dataset.watchlistState); + return; + } + + const updateButton = target.closest('[data-watchlist-update]') as HTMLElement | null; + if (updateButton) { + event.preventDefault(); + event.stopPropagation(); + const id = toInt(updateButton.getAttribute('data-mal-id') ?? undefined); + if (id === null) return; + const status = updateButton.getAttribute('data-watchlist-status') as WatchlistStatus | null; + const display = updateButton.getAttribute( + 'data-watchlist-display' + ) as WatchlistUpdateDisplay | null; + const title = updateButton.getAttribute('data-watchlist-title') ?? 'anime'; + if (!status || !display) return; + void updateWatchlist(id, status, display, title, updateButton); + return; + } + + const removeButton = target.closest('[data-watchlist-remove]') as HTMLElement | null; + if (removeButton) { + event.preventDefault(); + event.stopPropagation(); + const id = toInt(removeButton.getAttribute('data-mal-id') ?? undefined); + if (id === null) return; + const title = removeButton.getAttribute('data-watchlist-title') ?? 'anime'; + void removeWatchlist(id, title, removeButton); + } + }); +}; + +declare global { + interface Window { + initWatchlist: (ids: number[]) => void; + __WATCHLIST_IDS__?: unknown; + } +} + +window.initWatchlist = initWatchlist; + +onReady(() => { + const raw = window.__WATCHLIST_IDS__; + if (Array.isArray(raw)) { + const ids: number[] = raw.filter((entry): entry is number => typeof entry === 'number'); + if (ids.length > 0) { + initWatchlist(ids); + } + } + + const renderedWatchlistIds = getRenderedWatchlistIds(); + if (renderedWatchlistIds.length > 0) { + initWatchlist(renderedWatchlistIds); + } + + installDelegatedHandlers(); +}); diff --git a/templates/anime.gohtml b/templates/anime.gohtml index ddf27e1..3123da3 100644 --- a/templates/anime.gohtml +++ b/templates/anime.gohtml @@ -3,7 +3,7 @@

Characters & Cast

{{range (slice .Items 0 (min (len .Items) 10))}} -
+
{{.Character.Name}}
@@ -92,13 +92,13 @@ {{define "title"}}{{.Anime.DisplayTitle}}{{end}} {{define "content"}} -{{if .WatchlistIDs}}{{end}} +{{if .WatchlistIDs}}{{end}} {{$anime := .Anime}}
-
+
{{$imageUrl := "https://placehold.co/400x600?text=No+Image"}} {{if $anime.Images.Webp.LargeImageURL}} {{$imageUrl = $anime.Images.Webp.LargeImageURL}} @@ -111,7 +111,7 @@
-

+

{{$anime.DisplayTitle}}

{{if and $anime.TitleEnglish (ne $anime.Title $anime.TitleEnglish)}} @@ -122,7 +122,7 @@
{{if $anime.Score}}
- + {{$anime.Score}}
{{end}} @@ -137,39 +137,33 @@ {{if $anime.ShortRating}}{{$anime.ShortRating}}{{end}}
- {{template "watchlist_actions" dict "Anime" $anime "User" .User "Status" .Status "ContinueWatchingEp" .ContinueWatchingEp "ContinueWatchingTime" .ContinueWatchingTime}} +
+ {{template "watchlist_actions" dict "Anime" $anime "User" .User "Status" .Status "ContinueWatchingEp" .ContinueWatchingEp "ContinueWatchingTime" .ContinueWatchingTime}} +
-

Synopsis

+

Synopsis

{{if $anime.Synopsis}}{{$anime.Synopsis}}{{else}}No synopsis available.{{end}}

{{if and $anime.Synopsis (gt (len $anime.Synopsis) 400)}} - {{end}}
-
@@ -266,18 +260,18 @@
-

Statistics

+

Statistics

{{range (seq 5)}} -
+
{{end}}
-

More

+

More

{{if $anime.External}}
@@ -290,7 +284,7 @@
{{end}} - Reviews @@ -307,7 +301,7 @@

Characters & Cast

{{range (seq 5)}} -
+
@@ -322,7 +316,7 @@
-
+
Loading watch order sequence...
@@ -344,10 +338,10 @@