Compare commits

...

160 Commits

Author SHA1 Message Date
Gitea Action
6f3ca3e21b chore(deploy): update image to latest
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 7m39s
2026-05-27 09:48:29 +02:00
331d6fbbb9 Merge branch 'main' of github.com:/mkelvers/mal into dev 2026-05-27 09:47:21 +02:00
6450233fea feat: persist volume to localStorage 2026-05-26 23:18:06 +02:00
25bd91934c fix: add root and entry-naming flags to ts build 2026-05-26 23:14:39 +02:00
95116de349 feat: add input placeholders to login form 2026-05-26 23:13:07 +02:00
91db8a5fe0 refactor: remove cookie-based theme persistence 2026-05-26 23:11:33 +02:00
f70e2e4bcd fix: add POST /login to public routes 2026-05-26 23:08:03 +02:00
eb9e682b75 chore: formatting 2026-05-26 22:51:50 +02:00
509ce93904 chore: remove fix checklist 2026-05-26 22:50:16 +02:00
447f540b44 chore: trim conflicts 2026-05-26 22:49:12 +02:00
a5fdd8b999 chore: format 2026-05-26 22:49:00 +02:00
95ca4dd892 docs: add conflicts 2026-05-26 22:48:53 +02:00
e9576d7584 refactor: domain anime type 2026-05-26 22:45:16 +02:00
5a054d250e refactor: domain auth types 2026-05-26 22:41:29 +02:00
65a7b0f50d refactor: typed proxy key 2026-05-26 22:40:09 +02:00
b8521d2219 fix: validate player json 2026-05-26 22:39:03 +02:00
edbd83f8e8 refactor: share time formatter 2026-05-26 22:38:19 +02:00
c9059be57b fix: color skip segments 2026-05-26 22:37:51 +02:00
afbe74d975 perf: subtitles binary search 2026-05-26 22:37:30 +02:00
9938bf6c57 fix: stop swallowing errors 2026-05-26 22:36:41 +02:00
91bf399ebc fix: remove inline onclick 2026-05-26 22:35:02 +02:00
b63a5c48a2 fix: remove inline watchlist js 2026-05-26 22:33:27 +02:00
2a266c6b1e fix: wire nav collapse 2026-05-26 22:30:14 +02:00
28df1fc5f7 chore: drop empty fxtags 2026-05-26 22:28:57 +02:00
1165458cfa fix: complete db querier 2026-05-26 22:28:19 +02:00
8bed032a44 chore: update checklist 2026-05-26 22:27:46 +02:00
f2a319af4d fix: goose tx for user rebuild 2026-05-26 22:26:15 +02:00
627421255d fix: wrap user rebuild migration 2026-05-26 22:25:49 +02:00
cce840e7f5 fix: harden subtitle cache 2026-05-26 22:25:22 +02:00
7279eac949 fix: avoid metrics panic 2026-05-26 22:24:59 +02:00
4ffa6af298 fix: add jikan user-agent 2026-05-26 22:24:45 +02:00
7bff60f08a fix: browse genres params 2026-05-26 22:24:29 +02:00
4e8ba7205b fix: unify handler errors 2026-05-26 22:23:59 +02:00
c6090604ef fix: sqlite concurrency defaults 2026-05-26 22:21:09 +02:00
30441c3e1f fix: reinit player safely 2026-05-26 22:20:26 +02:00
6da80df655 build: fix dist static output 2026-05-26 22:12:18 +02:00
083c0ee0c9 chore: small fixes 2026-05-26 21:40:54 +02:00
8785c19b66 chore: go fixes 2026-05-26 21:38:05 +02:00
3e79f62805 style: wrap long query selector in getRenderedWatchlistIds 2026-05-26 20:29:39 +02:00
50159286b4 fix: sync server-rendered watchlist state to client 2026-05-26 20:29:19 +02:00
749a275dc0 feat: add schedule page 2026-05-26 20:16:14 +02:00
71dd130744 feat: add For You recommendations to discover 2026-05-26 20:16:09 +02:00
f2b4a7994a fix: remove redundant anime_id conversion 2026-05-26 16:20:43 +02:00
518370842c fix: satisfy staticcheck in json logger 2026-05-26 16:20:31 +02:00
68225cbb52 fix: pass config to jikan client in test 2026-05-26 16:18:06 +02:00
e24ae1d113 style: fix import ordering in app and audit test 2026-05-26 16:18:00 +02:00
9c3636f31a style: align struct fields in config, domain, and auth 2026-05-26 16:17:54 +02:00
ff8f760750 chore: remove trailing newlines across packages 2026-05-26 16:17:48 +02:00
5f4010901a chore: remove unused strings import from renderer 2026-05-26 16:14:43 +02:00
57be9a5d70 feat: record audit events for watch progress and completion 2026-05-26 16:14:37 +02:00
6dd84976de feat: record audit events for api token creation and revocation 2026-05-26 16:14:31 +02:00
a303c131f1 feat: wire audit module and middleware into app 2026-05-26 16:14:26 +02:00
dfe3c6b7d8 feat: add audit service and request context middleware 2026-05-26 16:14:20 +02:00
51bfc9d2af feat: add audit log sqlc queries and generated code 2026-05-26 16:14:14 +02:00
90e7a9323a feat: add audit_log table migration 2026-05-26 16:14:08 +02:00
1feee731cf feat: add audit request info context helpers 2026-05-26 16:14:02 +02:00
fa91c2a22d feat: add audit event domain type and service interface 2026-05-26 16:13:56 +02:00
f196862aeb refactor: extract template funcs into separate file 2026-05-26 15:59:21 +02:00
118c028873 feat: add structured error response helpers 2026-05-26 15:57:29 +02:00
28251876e1 fix: handle mac.Write errors in proxy token signing 2026-05-26 15:56:55 +02:00
3331c96c06 fix: propagate rand.Read error in token generation 2026-05-26 15:56:49 +02:00
4fc79bc692 refactor: migrate user CLI logs to observability 2026-05-26 15:56:43 +02:00
96307d2979 refactor: migrate database logs to observability 2026-05-26 15:56:38 +02:00
e08a0e1f71 refactor: migrate episodes logs to observability 2026-05-26 15:56:33 +02:00
d64dbaf7df refactor: migrate handler logs to observability 2026-05-26 15:56:27 +02:00
d787625435 refactor: migrate jikan relations logs to observability 2026-05-26 15:56:22 +02:00
3f496ac65c refactor: migrate server logs to observability 2026-05-26 15:56:16 +02:00
8daad49061 feat: add observability Info/Warn/Error helpers 2026-05-26 15:56:10 +02:00
e99070c6d4 fix: use config.Config for database path 2026-05-26 15:41:49 +02:00
513bfe07f2 refactor: migrate template renderer to embedded fs 2026-05-26 15:41:22 +02:00
1e9874a482 refactor: migrate env-var reads to config package 2026-05-26 15:38:14 +02:00
26ff84d70f feat: add central config package 2026-05-26 15:38:08 +02:00
82072b256d refactor: extract public route check into declarative table 2026-05-26 15:32:28 +02:00
f8ba6db3d6 fix: use constant-time comparison for proxy token signature 2026-05-26 15:31:37 +02:00
a190ca417d chore: remove trailing newlines in data fixes 2026-05-26 15:30:33 +02:00
4bf31fb511 fix: log and abort on missing catalog/discover sections 2026-05-26 15:30:28 +02:00
46cff45d0e refactor: extract data fixes into dedicated package 2026-05-26 15:19:40 +02:00
ab5476d3d2 chore: chmod entrypoint executable 2026-05-26 14:04:50 +02:00
f4061c0213 chore: add run-fixes cli 2026-05-26 13:56:57 +02:00
1eb28dad64 fix: formatting and typecheck 2026-05-26 13:49:44 +02:00
76a32e1dc4 feat: add new-data-fix scaffolding script 2026-05-26 13:48:38 +02:00
4af68021f6 feat: backfill null next_refresh_at in episode cache 2026-05-26 13:48:33 +02:00
36213edd60 feat: add data fix framework 2026-05-26 13:48:31 +02:00
f5dfb91ffe chore: formatting 2026-05-26 13:40:27 +02:00
f5fd50d472 fix: episode refresh lag for airing shows 2026-05-26 13:17:59 +02:00
698fcc9b5b docs: tighten README to opener and essentials only 2026-05-25 20:28:04 +02:00
b95427998c chore: delete screenshot 2026-05-25 20:20:56 +02:00
b6e06870aa docs: rewrite README with prose focus and screenshot 2026-05-25 20:19:28 +02:00
246fa7439d chore: delete docker/entrypoint.sh 2026-05-25 19:56:37 +02:00
53abdace1c chore: restructure Dockerfile and move entrypoint to root 2026-05-25 19:55:21 +02:00
76a92894e8 chore: formatting 2026-05-25 18:24:09 +02:00
3a0e04dda9 feat: add studio filter UI and studio links on anime page 2026-05-25 17:59:22 +02:00
29c0c0bb18 feat: add studio filter to search pipeline 2026-05-25 17:59:17 +02:00
e54d6b8142 feat: add producer data types and caching 2026-05-25 17:59:11 +02:00
f4a9453514 fix: standardize watchlist partial styles 2026-05-25 01:57:21 +02:00
a9dfb77bc4 fix: standardize command palette styles
Add ring, border separator, font-normal, and focus-visible styles to search and command palette.
2026-05-25 01:55:28 +02:00
48b5523d95 style: format segment editor 2026-05-25 01:55:23 +02:00
345c3b05f7 fix: standardize watch page and player dropdown styles 2026-05-25 01:54:30 +02:00
585b02b37a fix: improve segment editor accessibility and modal behavior 2026-05-25 01:54:25 +02:00
c480a9be1f fix: standardize anime detail page and review styles 2026-05-25 01:46:53 +02:00
fe39e094d8 fix: standardize watchlist filter tabs and empty state 2026-05-25 01:43:56 +02:00
f9c1fc9391 fix: standardize empty state and grid styles 2026-05-25 01:41:31 +02:00
900e56d7ca fix: standardize headings and button styles 2026-05-25 01:37:30 +02:00
019a519b81 fix: improve accessibility and visual consistency 2026-05-25 01:34:54 +02:00
28bfbe5257 fix: improve accessibility and keyboard navigation 2026-05-25 01:31:05 +02:00
6932d4b8d0 refactor: extract inline JS to modules 2026-05-25 01:16:02 +02:00
83f64a1dfe fix: add aria attributes and cleanup to toast system 2026-05-25 01:15:56 +02:00
44a36e3fb7 feat: improve theme system with cookie and prefers-color-scheme 2026-05-25 01:15:50 +02:00
931398fa67 refactor: use maps.Copy from stdlib 2026-05-25 01:15:45 +02:00
f13f7b7fc6 style: fix gofmt indentation 2026-05-25 01:15:39 +02:00
e0749066ec chore: add node types for typecheck 2026-05-24 22:47:52 +02:00
233beb609c fix: satisfy typecheck in player 2026-05-24 22:47:44 +02:00
e87b79bbe1 fix: add package comments to cmd 2026-05-24 22:46:21 +02:00
624a02c49d fix: satisfy staticcheck in integrations 2026-05-24 22:46:14 +02:00
5d7518afd9 fix: ignore close errors in tests and queries 2026-05-24 22:46:08 +02:00
4606c790f1 fix: handle backend errors and driver import 2026-05-24 22:46:02 +02:00
05e963151c chore: configure strict golangci-lint 2026-05-24 22:45:51 +02:00
6012ba824f fix: use type-only imports in player 2026-05-24 22:45:36 +02:00
2324d2a8e6 fix: use array shorthand in thumbnails 2026-05-24 22:45:24 +02:00
36f1961c9e fix: remove noop arrow functions in player 2026-05-24 22:45:04 +02:00
aa650068b1 fix: avoid unused expressions in overlay 2026-05-24 22:44:53 +02:00
0edc8feb8d fix: prefer interfaces in static types 2026-05-24 22:44:47 +02:00
258c676e89 fix: simplify dropdown boolean fields 2026-05-24 22:44:39 +02:00
fc1883a6c3 feat: setup stricter linting 2026-05-24 22:36:41 +02:00
e022b60920 chore: remove @toolwind/anchors 2026-05-24 22:32:29 +02:00
ea831b3e2d refactor: restyle progress bar, scrubber and preview popover 2026-05-24 21:14:23 +02:00
6e41bb2789 fix: manage preview popover hidden class properly 2026-05-24 21:14:13 +02:00
650b2e614a refactor: use explicit hex color for skip segments 2026-05-24 21:13:42 +02:00
7c1045df93 refactor: update accent color to #00b3c4 2026-05-24 21:13:34 +02:00
31b763b714 refactor: remove redundant current relation override 2026-05-24 20:50:52 +02:00
679c26e43f feat: show only episodes in current range, update label 2026-05-24 20:31:06 +02:00
bdf09ccdb7 refactor: close episode dropdown on range selection 2026-05-24 20:30:44 +02:00
ae0ac66c2a feat: add atoi and idiv template functions 2026-05-24 20:30:04 +02:00
2cf5bc2017 refactor: restructure episode controls for high episode counts 2026-05-24 20:20:50 +02:00
e25b0acf7d refactor: remove rounded from watch order dropdowns 2026-05-24 20:15:58 +02:00
54aca51e2b refactor: remove watch page borders and update filler/recap indicator 2026-05-24 20:07:36 +02:00
3cd7302c9c refactor: remove remaining border and ring classes 2026-05-24 20:07:28 +02:00
df0c00a2f9 feat: add theme toggle to sidebar 2026-05-24 20:07:09 +02:00
125b2e2510 feat: add login page background image 2026-05-24 20:07:01 +02:00
7e3e138fee feat: redesign login page with password toggle 2026-05-24 20:06:53 +02:00
79a518d941 refactor: inline scrollbar styles as tailwind arbitrary 2026-05-24 20:06:44 +02:00
cfaf6e6640 refactor: replace custom css utilities with tailwind arbitrary 2026-05-24 20:06:32 +02:00
da9bb56d80 fix: continue watching label 2026-05-24 02:48:07 +02:00
4403301f72 fix: allow progress requests 2026-05-24 02:34:05 +02:00
c0606ef938 fix: use session cookie for progress 2026-05-24 02:31:27 +02:00
2ac8660435 fix: save progress on player actions 2026-05-24 02:29:54 +02:00
9da9edae7f fix: restore command palette overlay 2026-05-24 02:27:35 +02:00
323c503581 fix: unstyle watch list menu 2026-05-24 02:13:22 +02:00
0e1bf7a36f fix: unstyle watchlist options 2026-05-24 02:12:28 +02:00
f6f95bc164 fix: unstyle settings mode buttons 2026-05-24 02:10:53 +02:00
391a4f750c fix: normalize button styling 2026-05-24 02:09:25 +02:00
905e00ef6a fix: restore mobile drawer 2026-05-24 02:09:10 +02:00
07a6b6e4aa fix: keep sidebar collapsed 2026-05-24 02:04:28 +02:00
ad3817dfee fix: reserve continue watching space 2026-05-24 01:50:24 +02:00
065e3fd7d6 fix: improve form accessibility 2026-05-24 01:48:14 +02:00
bfb8cc0274 fix: player dropdown light-mode visibility 2026-05-24 01:45:39 +02:00
7a18461ca6 fix: add warn levels to observability logs 2026-05-23 18:16:03 +02:00
f33c2e18af refactor: emit structured json logs 2026-05-23 18:08:43 +02:00
c2e4cae253 feat: add observability metrics 2026-05-23 17:13:18 +02:00
767e056aad feat: remove firefox extension 2026-05-23 16:32:08 +02:00
135 changed files with 5594 additions and 2393 deletions

54
.golangci.yml Normal file
View File

@@ -0,0 +1,54 @@
version: '2'
linters:
default: none
enable:
- copyloopvar
- errcheck
- govet
- ineffassign
- revive
- staticcheck
- unconvert
- unused
settings:
revive:
enable-all-rules: false
rules:
- name: blank-imports
- name: context-as-argument
- name: context-keys-type
- name: early-return
- name: error-naming
- name: error-return
- name: if-return
- name: increment-decrement
- name: range
- name: receiver-naming
- name: time-naming
- name: unnecessary-stmt
- name: var-declaration
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
- node_modules$
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

32
CONFLICTS.md Normal file
View File

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

View File

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

113
README.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

View File

@@ -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.
}
});

View File

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

Before

Width:  |  Height:  |  Size: 685 B

View File

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

View File

@@ -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;
}

View File

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

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

11
internal/audit/module.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

84
internal/config/config.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
},
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,24 +3,21 @@ package db
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"os"
// sqlite3 driver.
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
// Open connects to a sqlite3 database with foreign keys enforced // Open connects to a sqlite3 database with foreign keys enforced
func Open(dbFile string) (*sql.DB, error) { func Open(dbFile string) (*sql.DB, error) {
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on", dbFile)) // busy_timeout avoids immediate SQLITE_BUSY errors under concurrent access.
// foreign_keys ensures FK constraints are enforced for this connection.
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on&_busy_timeout=5000", dbFile))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open db: %w", err) return nil, fmt.Errorf("failed to open db: %w", err)
} }
// WAL improves concurrency between readers and writers.
_, _ = db.Exec("PRAGMA journal_mode=WAL;")
_, _ = db.Exec("PRAGMA busy_timeout=5000;")
return db, nil return db, nil
} }
// GetDBFile returns the database file path, checking DATABASE_FILE env var first
func GetDBFile() string {
if f := os.Getenv("DATABASE_FILE"); f != "" {
return f
}
return "mal.db"
}

View File

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

View File

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

View File

@@ -6,7 +6,9 @@ import (
"mal/internal/db" "mal/internal/db"
) )
type Anime = jikan.Anime type Anime struct {
jikan.Anime
}
type TopAnimeResult = jikan.TopAnimeResult type TopAnimeResult = jikan.TopAnimeResult
type Genre = jikan.Genre type Genre = jikan.Genre
type Character = jikan.CharacterEntry type Character = jikan.CharacterEntry
@@ -19,8 +21,12 @@ type ReviewEntry = jikan.ReviewEntry
type AnimeService interface { type AnimeService interface {
GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error) GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error)
GetDiscoverSection(ctx context.Context, userID string, section string) (DiscoverSectionData, error) GetDiscoverSection(ctx context.Context, userID string, section string) (DiscoverSectionData, error)
GetDiscoverForYou(ctx context.Context, userID string) (DiscoverSectionData, error)
GetAiringSchedule(ctx context.Context, userID string) ([]Anime, error)
GetAnimeByID(ctx context.Context, id int) (Anime, error) GetAnimeByID(ctx context.Context, id int) (Anime, error)
SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error)
GetProducerNameByID(ctx context.Context, id int) (string, error)
GetProducers(ctx context.Context, query string, page int, limit int) (jikan.ProducerListResult, error)
GetGenres(ctx context.Context) ([]Genre, error) GetGenres(ctx context.Context) ([]Genre, error)
GetCharacters(ctx context.Context, id int) ([]Character, error) GetCharacters(ctx context.Context, id int) ([]Character, error)
GetRecommendations(ctx context.Context, id int) ([]Recommendation, error) GetRecommendations(ctx context.Context, id int) ([]Recommendation, error)

20
internal/domain/audit.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
package observability
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestMetricsHandlerRendersPrometheusFamilies(t *testing.T) {
metrics := NewMetrics()
metrics.ObserveHTTPRequest(http.MethodGet, "/anime/:id", http.StatusOK, 125*time.Millisecond)
metrics.ObserveJikanRequest("/anime/{id}", http.StatusTooManyRequests, 800*time.Millisecond, assertErr{})
metrics.ObserveWorkerTick("episodes_availability", nil)
metrics.ObserveCache("jikan", "hit")
metrics.ObserveCache("episode_availability", "miss")
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rec := httptest.NewRecorder()
metrics.Handler().ServeHTTP(rec, req)
body, err := io.ReadAll(rec.Result().Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
text := string(body)
assertContains(t, text, `mal_http_requests_total{method="GET",route="/anime/:id",status="200"} 1`)
assertContains(t, text, `mal_http_request_duration_seconds_count{method="GET",route="/anime/:id",status="200"} 1`)
assertContains(t, text, `mal_jikan_upstream_requests_total{endpoint="/anime/{id}",status="429"} 1`)
assertContains(t, text, `mal_jikan_upstream_errors_total{endpoint="/anime/{id}",status="429"} 1`)
assertContains(t, text, `mal_worker_ticks_total{result="success",worker="episodes_availability"} 1`)
assertContains(t, text, `mal_cache_operations_total{cache="episode_availability",result="miss"} 1`)
}
type assertErr struct{}
func (assertErr) Error() string { return "boom" }
func assertContains(t *testing.T, text string, want string) {
t.Helper()
if !strings.Contains(text, want) {
t.Fatalf("missing metric line %q in:\n%s", want, text)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
package server
import (
"mal/internal/observability"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
type ErrorResponse struct {
Error string `json:"error"`
}
func RespondHTMLOrJSONError(c *gin.Context, status int, message string) {
if acceptsHTML(c) {
c.String(status, message)
c.Abort()
return
}
c.JSON(status, ErrorResponse{Error: message})
c.Abort()
}
func RespondError(c *gin.Context, status int, event string, component string, message string, fields map[string]any, err error) {
level := observability.LogLevelWarn
if status >= http.StatusInternalServerError {
level = observability.LogLevelError
}
observability.LogJSON(level, event, component, "", fields, err)
RespondHTMLOrJSONError(c, status, message)
}
func acceptsHTML(c *gin.Context) bool {
if strings.Contains(c.GetHeader("Accept"), "text/html") {
return true
}
if strings.EqualFold(strings.TrimSpace(c.GetHeader("HX-Request")), "true") {
return true
}
return false
}

View File

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

View File

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

View File

@@ -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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

73
scripts/new-data-fix.ts Normal file
View File

@@ -0,0 +1,73 @@
import { mkdir, writeFile, access } from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import path from 'node:path';
function toSlug(raw: string): string {
const trimmed = raw.trim().toLowerCase();
const slug = trimmed.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
return slug;
}
function formatYYYYMMDD(date: Date): string {
const year = String(date.getFullYear());
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await access(filePath, fsConstants.F_OK);
return true;
} catch {
return false;
}
}
async function main(): Promise<void> {
const rawName = process.argv[2] ?? '';
const slug = toSlug(rawName);
if (slug.length === 0) {
throw new Error('usage: bun scripts/new-data-fix.ts <name>');
}
const id = `${formatYYYYMMDD(new Date())}_${slug}`;
const dir = path.join(process.cwd(), 'internal', 'database', 'fixes');
const filePath = path.join(dir, `${id}.go`);
await mkdir(dir, { recursive: true });
if (await fileExists(filePath)) {
throw new Error(`data fix already exists: ${filePath}`);
}
const contents = `package fixes
import (
"context"
"database/sql"
"fmt"
)
func init() {
Register(Fix{
ID: "${id}",
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
// TODO: implement fix
// _, err := sqlDB.ExecContext(ctx, \`UPDATE ...\`)
// if err != nil { return fmt.Errorf("...: %w", err) }
return fmt.Errorf("unimplemented data fix: ${id}")
},
})
}
`;
await writeFile(filePath, contents, { encoding: 'utf8' });
process.stdout.write(`${filePath}\n`);
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});

View File

@@ -1,5 +1,28 @@
import { parseClassList } from './utils'; import { parseClassList } from './utils';
const initSynopsisToggle = (): void => {
document.addEventListener('click', e => {
const target = e.target;
if (!(target instanceof Element)) return;
const btn = target.closest<HTMLButtonElement>('[data-synopsis-toggle]');
if (!btn) return;
const container = document.getElementById('synopsis-container');
if (!container) return;
const isClamped = container.classList.contains('line-clamp-6');
if (isClamped) {
container.classList.remove('line-clamp-6');
btn.textContent = 'Show less';
return;
}
container.classList.add('line-clamp-6');
btn.textContent = 'Read more';
});
};
initSynopsisToggle();
const setDropdownMenuState = (menu: HTMLElement, isOpen: boolean): void => { const setDropdownMenuState = (menu: HTMLElement, isOpen: boolean): void => {
// data attributes store the class lists to add/remove // data attributes store the class lists to add/remove
const openClasses = parseClassList(menu.getAttribute('data-dropdown-open-classes')); const openClasses = parseClassList(menu.getAttribute('data-dropdown-open-classes'));

View File

@@ -1,42 +1,34 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,600&display=swap');
@import '@toolwind/anchors';
@source "../../templates/**/*.gohtml"; @source "../../templates/**/*.gohtml";
@source "../**/*.ts"; @source "../**/*.ts";
@theme { @theme {
--color-background: light-dark(#f7f6f3, #0b0c10); --color-background: light-dark(#ffffff, #0b0c10);
--color-background-sidebar: light-dark(#fbfbfa, #0f1115); --color-background-sidebar: light-dark(#f7f7f7, #0f1115);
--color-background-header: light-dark(#fbfbfa, #0f1115); --color-background-header: light-dark(#fbfbfb, #0f1115);
--color-background-surface: light-dark(#ffffff, #17181c); --color-background-surface: light-dark(#ffffff, #17181c);
--color-background-button: light-dark(#ffffff, #131417); --color-background-button: light-dark(#f5f5f5, #131417);
--color-background-button-hover: light-dark(#f7f6f3, #1c1d22); --color-background-button-hover: light-dark(#ececec, #1c1d22);
--color-foreground-muted: light-dark(#787774, #a1a1aa);
--color-foreground: light-dark(#111111, #f3f4f6); --color-foreground: light-dark(#111111, #f3f4f6);
--color-accent: #00b3c4;
--color-accent: #1f6c9f; --color-surface-hover: light-dark(rgba(0, 0, 0, 0.04), rgba(255, 255, 255, 0.05));
--color-border: light-dark(rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.1));
--color-surface-hover: light-dark(rgba(0, 0, 0, 0.03), rgba(255, 255, 255, 0.05));
} }
:root { :root {
color-scheme: light dark; color-scheme: light dark;
--bg: var(--color-background); --bg: var(--color-background);
--panel: light-dark(#f5f5f4, #181818); --panel: light-dark(#f7f7f7, #181818);
--panel-soft: light-dark(#e7e5e4, #202020); --panel-soft: light-dark(#ececec, #202020);
--header: light-dark(#ffffff, #101010); --header: light-dark(#ffffff, #101010);
--text: light-dark(#1c1917, #e7e5e4); --text: light-dark(#111111, #e7e5e4);
--text-muted: light-dark(#57534e, #a8a29e); --text-muted: light-dark(#666666, #a8a29e);
--text-faint: light-dark(#a8a29e, #78716c); --text-faint: light-dark(#9a9a9a, #78716c);
--accent: var(--color-accent); --accent: var(--color-accent);
--danger: #dc2626; --danger: #dc2626;
--surface-thumb: light-dark(#e7e5e4, #44403c); --surface-thumb: light-dark(#cccccc, #44403c);
--surface-tab-hover: light-dark(#e7e5e4, #202020); --surface-tab-hover: light-dark(#e4e4e4, #202020);
--surface-tab-active: light-dark(#1c1917, #fafaf9); --surface-tab-active: light-dark(#1e1b17, #fafaf9);
--text-tab-active: light-dark(#fafaf9, #0c0a09); --text-tab-active: light-dark(#fafaf9, #0c0a09);
--surface-select: light-dark(#ffffff, #181818); --surface-select: light-dark(#ffffff, #181818);
--text-on-accent: light-dark(#fafaf9, #0c0a09); --text-on-accent: light-dark(#fafaf9, #0c0a09);
@@ -44,6 +36,9 @@
--shadow-subtle: light-dark(0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18)); --shadow-subtle: light-dark(0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18));
--shadow-card: light-dark(0 2px 8px rgba(0, 0, 0, 0.04), 0 2px 10px rgba(0, 0, 0, 0.28)); --shadow-card: light-dark(0 2px 8px rgba(0, 0, 0, 0.04), 0 2px 10px rgba(0, 0, 0, 0.28));
--shadow-card-hover: light-dark(0 6px 18px rgba(0, 0, 0, 0.06), 0 6px 20px rgba(0, 0, 0, 0.34)); --shadow-card-hover: light-dark(0 6px 18px rgba(0, 0, 0, 0.06), 0 6px 20px rgba(0, 0, 0, 0.34));
--scrollbar-track: light-dark(rgba(0, 0, 0, 0.04), rgba(255, 255, 255, 0.05));
--scrollbar-thumb: light-dark(rgba(0, 0, 0, 0.16), rgba(255, 255, 255, 0.2));
--scrollbar-thumb-hover: light-dark(rgba(0, 0, 0, 0.26), rgba(255, 255, 255, 0.3));
--space-1: 0.25rem; --space-1: 0.25rem;
--space-2: 0.5rem; --space-2: 0.5rem;
--space-3: 0.75rem; --space-3: 0.75rem;
@@ -58,106 +53,18 @@
--radius: 0px; --radius: 0px;
} }
[data-theme='light'] {
color-scheme: light;
}
[data-theme='dark'] {
color-scheme: dark;
}
html, html,
body { body {
background-color: var(--color-background); background-color: var(--color-background);
color: var(--text); color: var(--text);
} }
@layer utilities { [data-watchlist-toggle] .watchlist-icon,
.shadow-soft { [data-watchlist-toggle] .watchlist-icon path {
box-shadow: var(--shadow-card); fill: none;
} }
.shadow-soft-hover:hover { [data-watchlist-toggle][data-watchlist-state='in'] .watchlist-icon,
box-shadow: var(--shadow-card-hover); [data-watchlist-toggle][data-watchlist-state='in'] .watchlist-icon path {
} fill: currentColor;
.border-hairline {
border: 1px solid var(--color-border);
}
.heading-serif {
font-family: var(--font-serif);
letter-spacing: -0.03em;
line-height: 1.15;
}
.mono {
font-family: var(--font-mono);
}
}
/* Default to square corners; opt back in selectively (e.g. inputs). */
:where(input, textarea, select) {
border-radius: 6px !important;
}
:where(.rounded-keep) {
border-radius: 6px !important;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
@media (min-width: 1024px) {
.scrollbar-hide::-webkit-scrollbar {
display: block;
height: 8px;
}
.scrollbar-hide::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 0;
}
.scrollbar-hide::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 0;
}
.scrollbar-hide::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
button.in-watchlist .watchlist-icon {
fill: currentColor !important;
}
.scrollbar-hide {
-ms-overflow-style: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.05);
}
}
.show-controls [data-video-overlay] {
opacity: 1;
}
[data-video-player].fullscreen:not(.show-controls) [data-video-overlay] {
opacity: 0 !important;
pointer-events: none;
}
[data-video-player].fullscreen:not(.show-controls) {
cursor: none;
}
[data-video-player].fullscreen:not(.show-controls) video {
cursor: none;
} }

View File

@@ -41,3 +41,56 @@ const initDiscoverTabs = (): void => {
}; };
initDiscoverTabs(); initDiscoverTabs();
const initSurpriseMe = (): void => {
let isFetchingRandom = false;
const onClick = async (): Promise<void> => {
if (isFetchingRandom) return;
const btn = document.getElementById('surprise-btn') as HTMLButtonElement | null;
if (!btn) return;
isFetchingRandom = true;
const spinner = document.getElementById('surprise-spinner');
const text = document.getElementById('surprise-text');
const icon = document.getElementById('surprise-icon');
btn.disabled = true;
spinner?.classList.remove('hidden');
icon?.classList.add('hidden');
if (text) text.textContent = 'Picking…';
try {
const res = await fetch(`/api/jikan/random/anime?t=${Date.now()}`, { cache: 'no-store' });
if (!res.ok) throw new Error('Failed to fetch random anime');
const json = (await res.json()) as unknown;
const data = (json as { data?: unknown }).data as { mal_id?: unknown } | undefined;
const malId = typeof data?.mal_id === 'number' ? data.mal_id : 0;
if (malId > 0) {
window.location.href = `/anime/${malId}`;
return;
}
throw new Error('Random anime missing mal_id');
} catch (error) {
console.error(error);
alert('Could not pick a random anime right now. Please try again.');
} finally {
isFetchingRandom = false;
btn.disabled = false;
spinner?.classList.add('hidden');
icon?.classList.remove('hidden');
if (text) text.textContent = 'Surprise Me';
}
};
document.addEventListener('click', e => {
const target = e.target;
if (!(target instanceof Element)) return;
const surprise = target.closest('[data-surprise-me]');
if (!surprise) return;
void onClick();
});
};
initSurpriseMe();

View File

@@ -1,7 +1,7 @@
class UIDropdown extends HTMLElement { class UIDropdown extends HTMLElement {
isOpen: boolean = false; isOpen = false;
contentEl: HTMLElement | null = null; contentEl: HTMLElement | null = null;
isClosing: boolean = false; // debounce flag isClosing = false; // debounce flag
constructor() { constructor() {
super(); super();
@@ -64,3 +64,25 @@ class UIDropdown extends HTMLElement {
} }
customElements.define('ui-dropdown', UIDropdown); customElements.define('ui-dropdown', UIDropdown);
const initStudioDropdown = (): void => {
document.addEventListener('click', e => {
const target = e.target;
if (!(target instanceof Element)) return;
const btn = target.closest<HTMLButtonElement>('button[data-studio-select]');
if (!btn) return;
const input = document.getElementById('studio-input') as HTMLInputElement | null;
const form = document.getElementById('browse-search-form') as HTMLFormElement | null;
if (!input || !form) return;
input.value = btn.dataset.studioSelect ?? '';
form.requestSubmit();
const dropdown = btn.closest('ui-dropdown') as { close?: () => void } | null;
dropdown?.close?.();
});
};
initStudioDropdown();

71
static/htmx.ts Normal file
View File

@@ -0,0 +1,71 @@
export {};
type ToastFn = (opts: { message: string; duration?: number }) => void;
const getToast = (): ToastFn | null => {
const anyWindow = window as unknown as { showToast?: ToastFn };
return typeof anyWindow.showToast === 'function' ? anyWindow.showToast : null;
};
const toast = (message: string): void => {
getToast()?.({ message });
};
const setBusy = (el: Element | null, busy: boolean): void => {
if (!(el instanceof HTMLElement)) return;
el.toggleAttribute('aria-busy', busy);
el.dataset.htmxLoading = busy ? 'true' : 'false';
if (el instanceof HTMLButtonElement) {
el.disabled = busy;
}
if (busy) {
el.dataset.htmxBusy = 'true';
return;
}
delete el.dataset.htmxBusy;
};
const getTriggerFromHtmxEvent = (event: Event): Element | null => {
const detail = event as unknown as { detail?: { elt?: Element } };
return detail.detail?.elt ?? null;
};
const onReady = (fn: () => void): void => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn, { once: true });
return;
}
fn();
};
onReady(() => {
document.addEventListener('htmx:beforeRequest', event => {
setBusy(getTriggerFromHtmxEvent(event), true);
});
document.addEventListener('htmx:afterRequest', event => {
setBusy(getTriggerFromHtmxEvent(event), false);
const remaining = document.querySelectorAll('.continue-watching-item').length;
if (remaining !== 0) return;
const section = document.getElementById('continue-watching-section');
section?.remove();
});
document.addEventListener('htmx:responseError', () => {
toast('Something went wrong');
});
document.addEventListener('htmx:sendError', () => {
toast('Network error');
});
document.addEventListener('htmx:timeout', () => {
toast('Request timed out');
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 MiB

View File

@@ -1,4 +1,6 @@
import { state } from './state'; import { state } from './state';
import { saveProgress } from './progress';
import { safeLocalStorage } from './storage';
export const formatTime = (seconds: number): string => { export const formatTime = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds < 0) return '00:00'; if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
@@ -77,6 +79,35 @@ export const syncVolumeUI = (): void => {
updateMuteIcons(state.video.muted || state.video.volume === 0); updateMuteIcons(state.video.muted || state.video.volume === 0);
}; };
const VOLUME_STORAGE_KEY = 'player-volume';
const parseStoredVolume = (raw: string | null): number | null => {
if (!raw) return null;
const v = Number.parseFloat(raw);
if (!Number.isFinite(v)) return null;
if (v < 0 || v > 1) return null;
return v;
};
const applyStoredVolume = (): void => {
const stored = parseStoredVolume(safeLocalStorage.getItem(VOLUME_STORAGE_KEY));
if (stored === null) return;
state.video.volume = stored;
state.video.muted = stored === 0;
if (stored > 0) state.lastKnownVolume = stored;
};
let volumeSaveTimer: number | undefined;
const schedulePersistVolume = (): void => {
window.clearTimeout(volumeSaveTimer);
volumeSaveTimer = window.setTimeout(() => {
if (!Number.isFinite(state.video.volume)) return;
const clamped = Math.max(0, Math.min(1, state.video.volume));
safeLocalStorage.setItem(VOLUME_STORAGE_KEY, clamped.toFixed(3));
}, 250);
};
interface Controls { interface Controls {
playPause: HTMLButtonElement | null; playPause: HTMLButtonElement | null;
muteBtn: HTMLButtonElement | null; muteBtn: HTMLButtonElement | null;
@@ -137,6 +168,8 @@ const updateMuteIcons = (isMuted: boolean): void => {
* Sets up video event listeners for icon sync. * Sets up video event listeners for icon sync.
*/ */
export const setupControls = (): void => { export const setupControls = (): void => {
applyStoredVolume();
const { const {
playPause, playPause,
muteBtn, muteBtn,
@@ -203,8 +236,12 @@ export const setupControls = (): void => {
state.video.addEventListener('pause', () => { state.video.addEventListener('pause', () => {
updatePlayPauseIcons(false); updatePlayPauseIcons(false);
showControls(); showControls();
void saveProgress();
});
state.video.addEventListener('volumechange', () => {
syncVolumeUI();
schedulePersistVolume();
}); });
state.video.addEventListener('volumechange', syncVolumeUI);
// mouse move in container shows controls // mouse move in container shows controls
state.container.addEventListener('mousemove', showControls); state.container.addEventListener('mousemove', showControls);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,9 +28,17 @@ export const setupSegmentEditor = (): void => {
const typeOptions = Array.from( const typeOptions = Array.from(
panel.querySelectorAll('[data-segment-type-option]') panel.querySelectorAll('[data-segment-type-option]')
) as HTMLButtonElement[]; ) as HTMLButtonElement[];
const focusableSelector = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
let startTime: number | null = null; let startTime: number | null = null;
let endTime: number | null = null; let endTime: number | null = null;
let lastActiveElement: HTMLElement | null = null;
const setError = (msg: string | null): void => { const setError = (msg: string | null): void => {
if (!errorBox) return; if (!errorBox) return;
@@ -49,13 +57,22 @@ export const setupSegmentEditor = (): void => {
}; };
const open = (): void => { const open = (): void => {
lastActiveElement =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
panel.classList.remove('hidden'); panel.classList.remove('hidden');
panel.classList.add('flex');
panel.setAttribute('aria-hidden', 'false');
setError(null); setError(null);
showControls(); showControls();
const firstFocusable = panel.querySelector(focusableSelector) as HTMLElement | null;
firstFocusable?.focus();
}; };
const close = (): void => { const close = (): void => {
panel.classList.add('hidden'); panel.classList.add('hidden');
panel.classList.remove('flex');
panel.setAttribute('aria-hidden', 'true');
setError(null); setError(null);
lastActiveElement?.focus();
}; };
toggleBtn.addEventListener('click', () => { toggleBtn.addEventListener('click', () => {
@@ -64,12 +81,49 @@ export const setupSegmentEditor = (): void => {
}); });
closeBtn?.addEventListener('click', close); closeBtn?.addEventListener('click', close);
// close when clicking outside the segment capture UI document.addEventListener('keydown', e => {
if (panel.classList.contains('hidden')) return;
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (e.key !== 'Tab') return;
const focusables = Array.from(panel.querySelectorAll(focusableSelector)).filter(
el =>
el instanceof HTMLElement && !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')
) as HTMLElement[];
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (!(active instanceof HTMLElement)) return;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
return;
}
if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
});
// close when clicking the backdrop outside the modal content
document.addEventListener('pointerdown', e => { document.addEventListener('pointerdown', e => {
if (panel.classList.contains('hidden')) return; if (panel.classList.contains('hidden')) return;
const target = e.target as Node | null; const target = e.target as Node | null;
if (!target) return; if (!target) return;
if (root.contains(target)) return; if (
(e.target as HTMLElement | null)?.closest('[data-segment-editor] [data-segment-editor-close]')
)
return;
const content = panel.firstElementChild;
if (content && content.contains(target)) return;
close(); close();
}); });

View File

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

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