From 767e056aad91bcf565e125ce7a64e20566c5bd5e Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 23 May 2026 16:32:08 +0200 Subject: [PATCH 001/158] feat: remove firefox extension --- extensions/mal-firefox/README.md | 12 -- extensions/mal-firefox/background.js | 103 ------------ extensions/mal-firefox/icon.svg | 23 --- extensions/mal-firefox/manifest.json | 18 --- extensions/mal-firefox/popup.css | 229 --------------------------- extensions/mal-firefox/popup.html | 51 ------ extensions/mal-firefox/popup.js | 74 --------- internal/server/cors.go | 3 - 8 files changed, 513 deletions(-) delete mode 100644 extensions/mal-firefox/README.md delete mode 100644 extensions/mal-firefox/background.js delete mode 100644 extensions/mal-firefox/icon.svg delete mode 100644 extensions/mal-firefox/manifest.json delete mode 100644 extensions/mal-firefox/popup.css delete mode 100644 extensions/mal-firefox/popup.html delete mode 100644 extensions/mal-firefox/popup.js diff --git a/extensions/mal-firefox/README.md b/extensions/mal-firefox/README.md deleted file mode 100644 index bdc3af3..0000000 --- a/extensions/mal-firefox/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# MAL Firefox Extension (dev) - -## Load in Firefox - -1. Open `about:debugging#/runtime/this-firefox` -2. Click **Load Temporary Add-on…** -3. Select `extensions/mal-firefox/manifest.json` - -## Usage - -- Click the toolbar icon to open the popup and log in. -- After login, select text on any page → right click → **MyAnimeList** → **Add to Watchlist** → pick a status. diff --git a/extensions/mal-firefox/background.js b/extensions/mal-firefox/background.js deleted file mode 100644 index 2e92638..0000000 --- a/extensions/mal-firefox/background.js +++ /dev/null @@ -1,103 +0,0 @@ -const MENU_ROOT_ID = 'mal-root'; -const MENU_WATCHLIST_ID = 'mal-watchlist'; -const MENU_STATUS_PREFIX = 'mal-status:'; -const STATUSES = [ - { value: 'watching', label: 'Watching' }, - { value: 'completed', label: 'Completed' }, - { value: 'on_hold', label: 'On Hold' }, - { value: 'dropped', label: 'Dropped' }, - { value: 'plan_to_watch', label: 'Plan to Watch' }, -]; - -async function getSettings() { - const { authToken, apiBaseUrl } = await browser.storage.local.get(['authToken', 'apiBaseUrl']); - return { - authToken: authToken || '', - apiBaseUrl: apiBaseUrl || 'https://mal.mkelvers.tech', - }; -} - -async function apiFetch(path, init = {}) { - const { authToken, apiBaseUrl } = await getSettings(); - const url = apiBaseUrl.replace(/\/+$/, '') + path; - const headers = new Headers(init.headers || {}); - if (authToken) headers.set('Authorization', `Bearer ${authToken}`); - const res = await fetch(url, { ...init, headers }); - if (!res.ok) { - const msg = await res.text().catch(() => ''); - throw new Error(msg || `HTTP ${res.status}`); - } - return res; -} - -async function ensureContextMenu() { - const { authToken } = await getSettings(); - await browser.contextMenus.removeAll(); - if (!authToken) return; - - browser.contextMenus.create({ - id: MENU_ROOT_ID, - title: 'MyAnimeList', - contexts: ['selection'], - }); - - browser.contextMenus.create({ - id: MENU_WATCHLIST_ID, - parentId: MENU_ROOT_ID, - title: 'Add to Watchlist', - contexts: ['selection'], - }); - - for (const s of STATUSES) { - browser.contextMenus.create({ - id: MENU_STATUS_PREFIX + s.value, - parentId: MENU_WATCHLIST_ID, - title: s.label, - contexts: ['selection'], - }); - } -} - -browser.runtime.onInstalled.addListener(() => { - ensureContextMenu(); -}); - -browser.runtime.onStartup.addListener(() => { - ensureContextMenu(); -}); - -browser.storage.onChanged.addListener((changes, area) => { - if (area !== 'local') return; - if (changes.authToken) ensureContextMenu(); -}); - -browser.contextMenus.onClicked.addListener(async info => { - if (typeof info.menuItemId !== 'string') return; - if (!info.menuItemId.startsWith(MENU_STATUS_PREFIX)) return; - - const status = info.menuItemId.slice(MENU_STATUS_PREFIX.length); - const text = (info.selectionText || '').trim().replace(/\s+/g, ' ').slice(0, 120); - if (!text) return; - - try { - const searchRes = await apiFetch(`/api/search-quick?q=${encodeURIComponent(text)}`); - const items = await searchRes.json(); - const top = items && items[0]; - if (!top || !top.id) { - await browser.notifications?.create?.({ - type: 'basic', - title: 'MyAnimeList', - message: `No matches for: ${text}`, - }); - return; - } - - await apiFetch('/api/watchlist', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ animeId: top.id, status }), - }); - } catch { - // Silent failure by default; can be extended with notifications later. - } -}); diff --git a/extensions/mal-firefox/icon.svg b/extensions/mal-firefox/icon.svg deleted file mode 100644 index 016dbb5..0000000 --- a/extensions/mal-firefox/icon.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/extensions/mal-firefox/manifest.json b/extensions/mal-firefox/manifest.json deleted file mode 100644 index 8ec94a1..0000000 --- a/extensions/mal-firefox/manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "manifest_version": 3, - "name": "MyAnimeList", - "version": "0.1.0", - "description": "Right-click selected anime titles and add them to your watchlist.", - "permissions": ["contextMenus", "storage"], - "host_permissions": [""], - "background": { - "scripts": ["background.js"] - }, - "action": { - "default_title": "MAL Watchlist", - "default_popup": "popup.html" - }, - "icons": { - "48": "icon.svg" - } -} diff --git a/extensions/mal-firefox/popup.css b/extensions/mal-firefox/popup.css deleted file mode 100644 index ff6b720..0000000 --- a/extensions/mal-firefox/popup.css +++ /dev/null @@ -1,229 +0,0 @@ -:root { - color-scheme: light dark; - --bg: #0b0f1a; - --card: rgba(255, 255, 255, 0.06); - --border: rgba(255, 255, 255, 0.12); - --text: rgba(255, 255, 255, 0.92); - --muted: rgba(255, 255, 255, 0.65); - --accent: #6ea8fe; - --danger: #ff6b6b; - --ok: #4ade80; -} - -@media (prefers-color-scheme: light) { - :root { - --bg: #f6f7fb; - --card: rgba(0, 0, 0, 0.03); - --border: rgba(0, 0, 0, 0.1); - --text: rgba(0, 0, 0, 0.88); - --muted: rgba(0, 0, 0, 0.6); - --accent: #1f6feb; - --danger: #b42318; - } -} - -html, -body { - margin: 0; - padding: 0; - background: var(--bg); - color: var(--text); - font: - 14px/1.4 system-ui, - -apple-system, - Segoe UI, - Roboto, - sans-serif; -} - -body { - width: 380px; - min-width: 380px; -} - -#app { - padding: 10px; -} - -.panel { - background: transparent; - border-radius: 0; - padding: 12px; - display: grid; - gap: 10px; -} - -.brand { - display: flex; - align-items: center; - gap: 8px; -} - -.brandIcon { - width: 28px; - height: 28px; - border-radius: 8px; -} - -.title { - font-weight: 650; - letter-spacing: 0.2px; -} - -.header { - display: flex; - align-items: center; - justify-content: space-between; -} - -.link { - background: transparent; - color: var(--accent); - border: 0; - padding: 6px 0; - cursor: pointer; -} - -.divider { - height: 1px; - background: transparent; - opacity: 0.9; -} - -.subtitle { - font-weight: 600; - color: var(--muted); -} - -.label { - display: grid; - gap: 4px; - color: var(--muted); -} - -.input { - width: 100%; - box-sizing: border-box; - padding: 9px 10px; - border-radius: 0; - border: 1px solid var(--border); - background: rgba(0, 0, 0, 0.15); - color: var(--text); - outline: none; -} - -.input:focus { - border: 1px solid var(--border); - outline: none; -} - -.btn { - width: 100%; - padding: 10px 12px; - border-radius: 0; - border: 0; - background: rgba(110, 168, 254, 0.18); - color: var(--text); - cursor: pointer; -} - -.btn.danger { - background: rgba(255, 107, 107, 0.18); -} - -.error { - color: var(--danger); -} - -.body { - color: var(--muted); -} - -.login { - display: grid; - gap: 8px; -} - -.statusRow { - display: flex; - align-items: center; - gap: 8px; - color: var(--muted); -} - -.statusDot { - width: 8px; - height: 8px; - border-radius: 999px; - background: var(--ok); -} - -.statusText { - font-size: 12px; -} - -[hidden] { - display: none !important; -} - -.list { - display: grid; - gap: 8px; -} - -.item { - display: grid; - grid-template-columns: 44px 1fr; - gap: 10px; - align-items: center; - padding: 8px; - border-radius: 10px; - border: 0; -} - -.thumb { - width: 44px; - height: 62px; - border-radius: 8px; - object-fit: cover; - background: rgba(255, 255, 255, 0.08); -} - -.meta { - display: grid; - gap: 4px; -} - -.metaTitle { - font-weight: 650; -} - -.metaSub { - color: var(--muted); - font-size: 12px; -} - -.row { - display: flex; - gap: 8px; - align-items: center; - flex-wrap: wrap; -} - -.select { - padding: 8px 10px; - border-radius: 10px; - border: 0; - background: rgba(0, 0, 0, 0.15); - color: var(--text); - flex: 1; -} - -.mini { - padding: 8px 10px; - border-radius: 10px; - border: 0; - background: rgba(110, 168, 254, 0.18); - color: var(--text); - cursor: pointer; -} diff --git a/extensions/mal-firefox/popup.html b/extensions/mal-firefox/popup.html deleted file mode 100644 index d0075e0..0000000 --- a/extensions/mal-firefox/popup.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - MAL Watchlist - - - -
-
-
-
- -
MyAnimeList
-
- -
- -
- -
- Select an anime title on any page, then right click to open the context menu. Under - “MyAnimeList”, choose “Add to Watchlist” and pick a status to save it to your watchlist. -
- -
- - - - -
-
- - - - diff --git a/extensions/mal-firefox/popup.js b/extensions/mal-firefox/popup.js deleted file mode 100644 index eb72d05..0000000 --- a/extensions/mal-firefox/popup.js +++ /dev/null @@ -1,74 +0,0 @@ -function qs(id) { - return document.getElementById(id); -} - -async function getSettings() { - const { authToken, apiBaseUrl } = await browser.storage.local.get(['authToken', 'apiBaseUrl']); - return { - authToken: authToken || '', - apiBaseUrl: apiBaseUrl || 'https://mal.mkelvers.tech', - }; -} - -async function setSettings(patch) { - await browser.storage.local.set(patch); -} - -function show(el, on) { - el.hidden = !on; -} - -async function render() { - const settings = await getSettings(); - document.body.dataset.state = settings.authToken ? 'in' : 'out'; - - const logoutBtn = qs('logoutBtn'); - logoutBtn.addEventListener('click', async () => { - await setSettings({ authToken: '' }); - await render(); - }); - - const hasToken = !!settings.authToken; - show(logoutBtn, hasToken); - show(qs('login'), !hasToken); - show(qs('loggedIn'), hasToken); - - if (!hasToken) { - setupLogin(); - return; - } -} - -function setupLogin() { - const loginErr = qs('loginErr'); - show(loginErr, false); - - qs('loginBtn').onclick = async () => { - show(loginErr, false); - const username = qs('username').value.trim(); - const password = qs('password').value; - if (!username || !password) { - loginErr.textContent = 'Missing username or password'; - show(loginErr, true); - return; - } - - try { - const { apiBaseUrl } = await getSettings(); - const res = await fetch(apiBaseUrl.replace(/\/+$/, '') + '/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password, name: 'Firefox extension' }), - }); - if (!res.ok) throw new Error('Invalid username or password'); - const data = await res.json(); - await setSettings({ authToken: data.token }); - await render(); - } catch (e) { - loginErr.textContent = e.message || 'Login failed'; - show(loginErr, true); - } - }; -} - -render(); diff --git a/internal/server/cors.go b/internal/server/cors.go index 02e716e..4d22d2b 100644 --- a/internal/server/cors.go +++ b/internal/server/cors.go @@ -32,9 +32,6 @@ func CORSMiddleware() gin.HandlerFunc { } func isAllowedOrigin(origin string) bool { - if strings.HasPrefix(origin, "moz-extension://") { - return true - } if strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "https://localhost:") { return true } From c2e4cae253f032c736cb209ddf2de691dab68e2d Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 23 May 2026 17:13:13 +0200 Subject: [PATCH 002/158] feat: add observability metrics --- integrations/jikan/client.go | 60 ++++- integrations/jikan/client_test.go | 3 +- integrations/jikan/module.go | 4 +- internal/auth/middleware/middleware.go | 1 + internal/episodes/module.go | 7 +- internal/episodes/service/service.go | 22 +- internal/episodes/worker.go | 6 +- internal/observability/metrics.go | 292 +++++++++++++++++++++++++ internal/observability/metrics_test.go | 47 ++++ internal/server/observability.go | 8 +- internal/server/server.go | 7 +- internal/server/server_test.go | 3 +- 12 files changed, 441 insertions(+), 19 deletions(-) create mode 100644 internal/observability/metrics.go create mode 100644 internal/observability/metrics_test.go diff --git a/integrations/jikan/client.go b/integrations/jikan/client.go index 016b998..69a774f 100644 --- a/integrations/jikan/client.go +++ b/integrations/jikan/client.go @@ -16,6 +16,7 @@ import ( "time" "mal/internal/db" + "mal/internal/observability" "golang.org/x/sync/singleflight" ) @@ -29,6 +30,7 @@ type Client struct { lastReqTime time.Time // rate limiting: last request timestamp sf singleflight.Group refreshSem chan struct{} + metrics *observability.Metrics // Random anime pool for DDoS-proof truly random "Surprise Me" randomPool []Anime @@ -38,7 +40,7 @@ type Client struct { const jikanSlowLogThreshold = 750 * time.Millisecond -func NewClient(queries *db.Queries) *Client { +func NewClient(queries *db.Queries, metrics *observability.Metrics) *Client { return &Client{ httpClient: &http.Client{ Timeout: 10 * time.Second, @@ -51,6 +53,7 @@ func NewClient(queries *db.Queries) *Client { }, baseURL: "https://api.jikan.moe/v4", db: queries, + metrics: metrics, retrySignal: make(chan struct{}, 1), refreshSem: make(chan struct{}, 4), randomPool: make([]Anime, 0), @@ -262,11 +265,18 @@ func (c *Client) getCache(parentCtx context.Context, key string, out any) bool { data, err := c.db.GetJikanCache(ctx, key) if err != nil { + c.metrics.ObserveCache("jikan", "miss") return false } err = json.Unmarshal([]byte(data), out) - return err == nil + if err != nil { + c.metrics.ObserveCache("jikan", "miss") + return false + } + + c.metrics.ObserveCache("jikan", "hit") + return true } // getStaleCache retrieves expired-but-available cache by key. @@ -276,11 +286,18 @@ func (c *Client) getStaleCache(parentCtx context.Context, key string, out any) b data, err := c.db.GetJikanCacheStale(ctx, key) if err != nil { + c.metrics.ObserveCache("jikan_stale", "miss") return false } err = json.Unmarshal([]byte(data), out) - return err == nil + if err != nil { + c.metrics.ObserveCache("jikan_stale", "miss") + return false + } + + c.metrics.ObserveCache("jikan_stale", "hit") + return true } // setCache stores data in cache with specified TTL. @@ -425,7 +442,9 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err maxRetries := 5 startedAt := time.Now() attempts := 0 + endpoint := metricsEndpoint(urlStr) logAndReturn := func(statusCode int, err error) error { + c.metrics.ObserveJikanRequest(endpoint, statusCode, time.Since(startedAt), err) logJikanUpstream(urlStr, statusCode, attempts, startedAt, err) return err } @@ -506,3 +525,38 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err return logAndReturn(0, fmt.Errorf("max retries exceeded for %s", urlStr)) } + +func metricsEndpoint(urlStr string) string { + trimmed := strings.TrimSpace(urlStr) + if trimmed == "" { + return "unknown" + } + + prefix := "https://api.jikan.moe/v4" + if strings.HasPrefix(trimmed, prefix) { + trimmed = strings.TrimPrefix(trimmed, prefix) + } + + if idx := strings.Index(trimmed, "?"); idx >= 0 { + trimmed = trimmed[:idx] + } + + parts := strings.Split(trimmed, "/") + out := make([]string, 0, len(parts)) + for _, part := range parts { + if part == "" { + continue + } + if _, err := strconv.Atoi(part); err == nil { + out = append(out, "{id}") + continue + } + out = append(out, part) + } + + if len(out) == 0 { + return "/" + } + + return "/" + strings.Join(out, "/") +} diff --git a/integrations/jikan/client_test.go b/integrations/jikan/client_test.go index c34a993..1d85b84 100644 --- a/integrations/jikan/client_test.go +++ b/integrations/jikan/client_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "io" "mal/internal/db" + "mal/internal/observability" "net/http" "strings" "testing" @@ -41,7 +42,7 @@ func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) { } queries := db.New(sqlDB) - client := NewClient(queries) + client := NewClient(queries, observability.NewMetrics()) stale := TopAnimeResponse{Data: []Anime{{MalID: 1, Title: "stale"}}} staleBytes, err := json.Marshal(stale) if err != nil { diff --git a/integrations/jikan/module.go b/integrations/jikan/module.go index 811d82b..8eedc6a 100644 --- a/integrations/jikan/module.go +++ b/integrations/jikan/module.go @@ -1,8 +1,6 @@ package jikan -import ( - "go.uber.org/fx" -) +import "go.uber.org/fx" var Module = fx.Options( fx.Provide(NewClient), diff --git a/internal/auth/middleware/middleware.go b/internal/auth/middleware/middleware.go index 286202b..a0dfd4b 100644 --- a/internal/auth/middleware/middleware.go +++ b/internal/auth/middleware/middleware.go @@ -16,6 +16,7 @@ func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc { if path == "/login" || path == "/logout" || strings.HasPrefix(path, "/static") || strings.HasPrefix(path, "/dist") || + path == "/metrics" || path == "/api/auth/login" { c.Next() return diff --git a/internal/episodes/module.go b/internal/episodes/module.go index 951b70a..d20cee3 100644 --- a/internal/episodes/module.go +++ b/internal/episodes/module.go @@ -9,6 +9,7 @@ import ( "mal/internal/db" "mal/internal/domain" episodeService "mal/internal/episodes/service" + "mal/internal/observability" "go.uber.org/fx" ) @@ -22,10 +23,10 @@ var Module = fx.Options( fx.Provide( episodeAvailabilityEnabled, fx.Annotate( - func(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool) domain.EpisodeService { - return episodeService.NewEpisodeService(queries, jikanClient, providers, enabled) + func(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, metrics *observability.Metrics) domain.EpisodeService { + return episodeService.NewEpisodeService(queries, jikanClient, providers, enabled, metrics) }, - fx.ParamTags(``, ``, ``, ``), + fx.ParamTags(``, ``, ``, ``, ``), ), ), fx.Provide(func(p *allanime.AllAnimeProvider) []domain.EpisodeAvailabilityProvider { diff --git a/internal/episodes/service/service.go b/internal/episodes/service/service.go index ede335f..207081d 100644 --- a/internal/episodes/service/service.go +++ b/internal/episodes/service/service.go @@ -10,6 +10,7 @@ import ( "mal/integrations/jikan" "mal/internal/db" "mal/internal/domain" + "mal/internal/observability" "sort" "strings" "time" @@ -34,19 +35,21 @@ type EpisodeService struct { providers []domain.EpisodeAvailabilityProvider clock Clock enabled bool + metrics *observability.Metrics } -func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool) domain.EpisodeService { - return NewEpisodeServiceWithClock(queries, jikanClient, providers, enabled, realClock{}) +func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, metrics *observability.Metrics) domain.EpisodeService { + return NewEpisodeServiceWithClock(queries, jikanClient, providers, enabled, realClock{}, metrics) } -func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock) *EpisodeService { +func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock, metrics *observability.Metrics) *EpisodeService { return &EpisodeService{ queries: queries, jikan: jikanClient, providers: providers, clock: clock, enabled: enabled, + metrics: metrics, } } @@ -143,14 +146,20 @@ func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, pro }) if err == nil { if row.FailedUntil.Valid && row.FailedUntil.Time.After(s.clock.Now()) { + s.metrics.ObserveCache("episode_provider_mapping", "hit") return "", fmt.Errorf("cached provider mapping failure active until %s: %s", row.FailedUntil.Time.Format(time.RFC3339), row.LastError) } if strings.TrimSpace(row.ProviderShowID) != "" { + s.metrics.ObserveCache("episode_provider_mapping", "hit") log.Printf("episodes: provider id cache hit anime_id=%d provider=%s provider_id=%s", anime.MalID, provider.Name(), row.ProviderShowID) return row.ProviderShowID, nil } + s.metrics.ObserveCache("episode_provider_mapping", "miss") } else if !errors.Is(err, sql.ErrNoRows) { + s.metrics.ObserveCache("episode_provider_mapping", "miss") log.Printf("episodes: provider id cache read failed anime_id=%d provider=%s error=%v", anime.MalID, provider.Name(), err) + } else { + s.metrics.ObserveCache("episode_provider_mapping", "miss") } providerID, err := provider.ResolveEpisodeProviderID(ctx, anime.MalID, titles) @@ -256,31 +265,38 @@ func (s *EpisodeService) markFailure(ctx context.Context, anime domain.Anime, ca func (s *EpisodeService) getCached(ctx context.Context, animeID int) (domain.CanonicalEpisodeList, bool) { row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID)) if err != nil { + s.metrics.ObserveCache("episode_availability", "miss") return domain.CanonicalEpisodeList{}, false } var payload domain.CanonicalEpisodeList if err := json.Unmarshal([]byte(row.Data), &payload); err != nil { + s.metrics.ObserveCache("episode_availability", "miss") log.Printf("episodes: invalid cached payload anime_id=%d error=%v", animeID, err) return domain.CanonicalEpisodeList{}, false } + s.metrics.ObserveCache("episode_availability", "hit") return payload, true } func (s *EpisodeService) getFreshCached(ctx context.Context, animeID int) (domain.CanonicalEpisodeList, bool) { row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID)) if err != nil { + s.metrics.ObserveCache("episode_availability_fresh", "miss") return domain.CanonicalEpisodeList{}, false } if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(s.clock.Now()) { + s.metrics.ObserveCache("episode_availability_fresh", "miss") log.Printf("episodes: cached availability due for refresh anime_id=%d next_refresh=%s", animeID, row.NextRefreshAt.Time.Format(time.RFC3339)) return domain.CanonicalEpisodeList{}, false } var payload domain.CanonicalEpisodeList if err := json.Unmarshal([]byte(row.Data), &payload); err != nil { + s.metrics.ObserveCache("episode_availability_fresh", "miss") log.Printf("episodes: invalid cached payload anime_id=%d error=%v", animeID, err) return domain.CanonicalEpisodeList{}, false } + s.metrics.ObserveCache("episode_availability_fresh", "hit") log.Printf("episodes: served cached availability anime_id=%d episodes=%d next_refresh=%s", animeID, len(payload.Episodes), payload.NextRefreshAt) return payload, true } diff --git a/internal/episodes/worker.go b/internal/episodes/worker.go index a080556..49f7504 100644 --- a/internal/episodes/worker.go +++ b/internal/episodes/worker.go @@ -4,6 +4,7 @@ import ( "context" "log" "mal/internal/domain" + "mal/internal/observability" "time" "go.uber.org/fx" @@ -11,7 +12,7 @@ import ( const workerInterval = time.Minute -func RegisterWorker(lc fx.Lifecycle, svc domain.EpisodeService) { +func RegisterWorker(lc fx.Lifecycle, svc domain.EpisodeService, metrics *observability.Metrics) { ctx, cancel := context.WithCancel(context.Background()) lc.Append(fx.Hook{ @@ -23,7 +24,10 @@ func RegisterWorker(lc fx.Lifecycle, svc domain.EpisodeService) { for { if err := svc.RefreshTrackedDue(ctx, 25); err != nil { + metrics.ObserveWorkerTick("episodes_availability", err) log.Printf("episodes: availability worker tick failed error=%v", err) + } else { + metrics.ObserveWorkerTick("episodes_availability", nil) } select { diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go new file mode 100644 index 0000000..a6d51dc --- /dev/null +++ b/internal/observability/metrics.go @@ -0,0 +1,292 @@ +package observability + +import ( + "fmt" + "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) + 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) + 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) { + panic("label cardinality mismatch") + } + + 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)) + for key, value := range labels { + out[key] = value + } + return out +} + +func sortedCounterSampleKeys(samples map[string]*counterSample) []string { + keys := make([]string, 0, len(samples)) + for key := range samples { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func sortedHistogramSampleKeys(samples map[string]*histogramSample) []string { + keys := make([]string, 0, len(samples)) + for key := range samples { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func writeCounterMetric(w http.ResponseWriter, name string, help string, samples []counterSample) { + _, _ = fmt.Fprintf(w, "# HELP %s %s\n", name, help) + _, _ = fmt.Fprintf(w, "# TYPE %s counter\n", name) + for _, sample := range samples { + _, _ = fmt.Fprintf(w, "%s%s %d\n", name, formatLabels(sample.labels), sample.value) + } +} + +func writeHistogramMetric(w http.ResponseWriter, name string, help string, samples []histogramSample, bounds []float64) { + _, _ = fmt.Fprintf(w, "# HELP %s %s\n", name, help) + _, _ = fmt.Fprintf(w, "# TYPE %s histogram\n", name) + for _, sample := range samples { + for idx, bound := range bounds { + labels := copyLabels(sample.labels) + labels["le"] = formatFloat(bound) + _, _ = fmt.Fprintf(w, "%s_bucket%s %d\n", name, formatLabels(labels), sample.buckets[idx]) + } + labels := copyLabels(sample.labels) + labels["le"] = "+Inf" + _, _ = fmt.Fprintf(w, "%s_bucket%s %d\n", name, formatLabels(labels), sample.count) + _, _ = fmt.Fprintf(w, "%s_sum%s %s\n", name, formatLabels(sample.labels), formatFloat(sample.sum)) + _, _ = fmt.Fprintf(w, "%s_count%s %d\n", name, formatLabels(sample.labels), sample.count) + } +} + +func formatLabels(labels map[string]string) string { + if len(labels) == 0 { + return "" + } + + keys := make([]string, 0, len(labels)) + for key := range labels { + keys = append(keys, key) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, fmt.Sprintf(`%s=%q`, key, labels[key])) + } + return "{" + strings.Join(parts, ",") + "}" +} + +func formatFloat(value float64) string { + return strconv.FormatFloat(value, 'f', -1, 64) +} diff --git a/internal/observability/metrics_test.go b/internal/observability/metrics_test.go new file mode 100644 index 0000000..78f8379 --- /dev/null +++ b/internal/observability/metrics_test.go @@ -0,0 +1,47 @@ +package observability + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestMetricsHandlerRendersPrometheusFamilies(t *testing.T) { + metrics := NewMetrics() + metrics.ObserveHTTPRequest(http.MethodGet, "/anime/:id", http.StatusOK, 125*time.Millisecond) + metrics.ObserveJikanRequest("/anime/{id}", http.StatusTooManyRequests, 800*time.Millisecond, assertErr{}) + metrics.ObserveWorkerTick("episodes_availability", nil) + metrics.ObserveCache("jikan", "hit") + metrics.ObserveCache("episode_availability", "miss") + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec := httptest.NewRecorder() + metrics.Handler().ServeHTTP(rec, req) + + body, err := io.ReadAll(rec.Result().Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + + text := string(body) + assertContains(t, text, `mal_http_requests_total{method="GET",route="/anime/:id",status="200"} 1`) + assertContains(t, text, `mal_http_request_duration_seconds_count{method="GET",route="/anime/:id",status="200"} 1`) + assertContains(t, text, `mal_jikan_upstream_requests_total{endpoint="/anime/{id}",status="429"} 1`) + assertContains(t, text, `mal_jikan_upstream_errors_total{endpoint="/anime/{id}",status="429"} 1`) + assertContains(t, text, `mal_worker_ticks_total{result="success",worker="episodes_availability"} 1`) + assertContains(t, text, `mal_cache_operations_total{cache="episode_availability",result="miss"} 1`) +} + +type assertErr struct{} + +func (assertErr) Error() string { return "boom" } + +func assertContains(t *testing.T, text string, want string) { + t.Helper() + if !strings.Contains(text, want) { + t.Fatalf("missing metric line %q in:\n%s", want, text) + } +} diff --git a/internal/server/observability.go b/internal/server/observability.go index a281540..86d3fa4 100644 --- a/internal/server/observability.go +++ b/internal/server/observability.go @@ -2,13 +2,14 @@ package server import ( "log" + "mal/internal/observability" "strconv" "time" "github.com/gin-gonic/gin" ) -func RequestLogger() gin.HandlerFunc { +func RequestLogger(metrics *observability.Metrics) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() path := c.Request.URL.Path @@ -21,6 +22,9 @@ func RequestLogger() gin.HandlerFunc { route = path } + duration := time.Since(start) + metrics.ObserveHTTPRequest(c.Request.Method, route, c.Writer.Status(), duration) + log.Printf( "http_request method=%s route=%s path=%s query=%s status=%d duration_ms=%.2f bytes=%d client_ip=%s errors=%s", c.Request.Method, @@ -28,7 +32,7 @@ func RequestLogger() gin.HandlerFunc { strconv.Quote(path), strconv.Quote(query), c.Writer.Status(), - float64(time.Since(start).Microseconds())/1000, + float64(duration.Microseconds())/1000, c.Writer.Size(), strconv.Quote(c.ClientIP()), strconv.Quote(c.Errors.ByType(gin.ErrorTypePrivate).String()), diff --git a/internal/server/server.go b/internal/server/server.go index c708fae..e57bee9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,6 +3,7 @@ package server import ( "context" "log" + "mal/internal/observability" "net/http" "os" "time" @@ -13,18 +14,20 @@ import ( ) var Module = fx.Options( + fx.Provide(observability.NewMetrics), fx.Provide(ProvideRouter), fx.Invoke(RunServer), ) -func ProvideRouter(htmlRender render.HTMLRender) *gin.Engine { +func ProvideRouter(htmlRender render.HTMLRender, metrics *observability.Metrics) *gin.Engine { if os.Getenv("GIN_MODE") == "" { gin.SetMode(gin.ReleaseMode) } r := gin.New() - r.Use(CORSMiddleware(), RequestLogger(), gin.Recovery()) + r.Use(CORSMiddleware(), RequestLogger(metrics), gin.Recovery()) r.Static("/static", "./static") r.Static("/dist", "./dist") + r.GET("/metrics", gin.WrapH(metrics.Handler())) r.HTMLRender = htmlRender return r } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4317e2f..8b20078 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -4,6 +4,7 @@ import ( "bytes" "io" "log" + "mal/internal/observability" "net/http" "net/http/httptest" "strings" @@ -42,7 +43,7 @@ func TestRequestLoggerUsesMatchedRoute(t *testing.T) { defer log.SetOutput(previousOutput) router := gin.New() - router.Use(RequestLogger()) + router.Use(RequestLogger(observability.NewMetrics())) router.GET("/anime/:id", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) From f33c2e18af7108a9f7c33a59b34387921cab6634 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 23 May 2026 18:06:35 +0200 Subject: [PATCH 003/158] refactor: emit structured json logs --- integrations/jikan/client.go | 48 ++++++++++++++++++------------ internal/observability/log.go | 51 ++++++++++++++++++++++++++++++++ internal/server/observability.go | 30 +++++++++++-------- internal/server/server_test.go | 7 +++-- 4 files changed, 102 insertions(+), 34 deletions(-) create mode 100644 internal/observability/log.go diff --git a/integrations/jikan/client.go b/integrations/jikan/client.go index 69a774f..c381127 100644 --- a/integrations/jikan/client.go +++ b/integrations/jikan/client.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "log" "net" "net/http" "os" @@ -156,17 +155,22 @@ func logJikanCache(cacheKey string, source string, startedAt time.Time, err erro return } - errorValue := "" + level := observability.LogLevelInfo if err != nil { - errorValue = err.Error() + level = observability.LogLevelError } - log.Printf( - "jikan_cache key=%s source=%s duration_ms=%.2f error=%s", - strconv.Quote(cacheKey), - source, - float64(duration.Microseconds())/1000, - strconv.Quote(errorValue), + observability.LogJSON( + level, + "jikan_cache", + "jikan", + "", + map[string]any{ + "cache_key": cacheKey, + "source": source, + "duration_ms": float64(duration.Microseconds()) / 1000, + }, + err, ) } @@ -176,18 +180,24 @@ func logJikanUpstream(urlStr string, statusCode int, attempts int, startedAt tim return } - errorValue := "" - if err != nil { - errorValue = err.Error() + level := observability.LogLevelInfo + if err != nil || statusCode >= http.StatusBadRequest { + level = observability.LogLevelError } - log.Printf( - "jikan_upstream url=%s status=%d attempts=%d duration_ms=%.2f error=%s", - strconv.Quote(urlStr), - statusCode, - attempts, - float64(duration.Microseconds())/1000, - strconv.Quote(errorValue), + observability.LogJSON( + level, + "jikan_upstream", + "jikan", + "", + map[string]any{ + "url": urlStr, + "endpoint": metricsEndpoint(urlStr), + "status": statusCode, + "attempts": attempts, + "duration_ms": float64(duration.Microseconds()) / 1000, + }, + err, ) } diff --git a/internal/observability/log.go b/internal/observability/log.go new file mode 100644 index 0000000..ab30209 --- /dev/null +++ b/internal/observability/log.go @@ -0,0 +1,51 @@ +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 { + log.Printf("level=%s event=%s component=%s error=%q", level, event, component, marshalErr.Error()) + return + } + + log.Print(string(bytes)) +} diff --git a/internal/server/observability.go b/internal/server/observability.go index 86d3fa4..13d726c 100644 --- a/internal/server/observability.go +++ b/internal/server/observability.go @@ -1,9 +1,7 @@ package server import ( - "log" "mal/internal/observability" - "strconv" "time" "github.com/gin-gonic/gin" @@ -25,17 +23,23 @@ func RequestLogger(metrics *observability.Metrics) gin.HandlerFunc { duration := time.Since(start) metrics.ObserveHTTPRequest(c.Request.Method, route, c.Writer.Status(), duration) - log.Printf( - "http_request method=%s route=%s path=%s query=%s status=%d duration_ms=%.2f bytes=%d client_ip=%s errors=%s", - c.Request.Method, - strconv.Quote(route), - strconv.Quote(path), - strconv.Quote(query), - c.Writer.Status(), - float64(duration.Microseconds())/1000, - c.Writer.Size(), - strconv.Quote(c.ClientIP()), - strconv.Quote(c.Errors.ByType(gin.ErrorTypePrivate).String()), + observability.LogJSON( + observability.LogLevelInfo, + "http_request", + "http", + "", + map[string]any{ + "method": c.Request.Method, + "route": route, + "path": path, + "query": query, + "status": c.Writer.Status(), + "duration_ms": float64(duration.Microseconds()) / 1000, + "bytes": c.Writer.Size(), + "client_ip": c.ClientIP(), + "errors": c.Errors.ByType(gin.ErrorTypePrivate).String(), + }, + nil, ) } } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 8b20078..76153ba 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -58,10 +58,13 @@ func TestRequestLoggerUsesMatchedRoute(t *testing.T) { } logLine := string(output) - if !strings.Contains(logLine, `route="/anime/:id"`) { + if !strings.Contains(logLine, `"event":"http_request"`) { + t.Fatalf("log line missing event: %s", logLine) + } + if !strings.Contains(logLine, `"route":"/anime/:id"`) { t.Fatalf("log line missing route: %s", logLine) } - if !strings.Contains(logLine, `status=200`) { + if !strings.Contains(logLine, `"status":200`) { t.Fatalf("log line missing status: %s", logLine) } } From 7a18461ca68c532bbbc2c8201bda8cfac936baf8 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 23 May 2026 18:16:02 +0200 Subject: [PATCH 004/158] fix: add warn levels to observability logs --- integrations/jikan/client.go | 7 ++++++- internal/server/observability.go | 12 ++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/integrations/jikan/client.go b/integrations/jikan/client.go index c381127..f69b81c 100644 --- a/integrations/jikan/client.go +++ b/integrations/jikan/client.go @@ -158,6 +158,9 @@ func logJikanCache(cacheKey string, source string, startedAt time.Time, err erro level := observability.LogLevelInfo if err != nil { level = observability.LogLevelError + } else if source != "fresh" && source != "refresh" { + // Stale reads are expected sometimes, but worth tracking in logs. + level = observability.LogLevelWarn } observability.LogJSON( @@ -181,8 +184,10 @@ func logJikanUpstream(urlStr string, statusCode int, attempts int, startedAt tim } level := observability.LogLevelInfo - if err != nil || statusCode >= http.StatusBadRequest { + if err != nil || statusCode >= http.StatusInternalServerError { level = observability.LogLevelError + } else if statusCode == http.StatusTooManyRequests || statusCode >= http.StatusBadRequest { + level = observability.LogLevelWarn } observability.LogJSON( diff --git a/internal/server/observability.go b/internal/server/observability.go index 13d726c..e729199 100644 --- a/internal/server/observability.go +++ b/internal/server/observability.go @@ -23,8 +23,16 @@ func RequestLogger(metrics *observability.Metrics) gin.HandlerFunc { duration := time.Since(start) metrics.ObserveHTTPRequest(c.Request.Method, route, c.Writer.Status(), duration) + level := observability.LogLevelInfo + status := c.Writer.Status() + if status >= 500 { + level = observability.LogLevelError + } else if status >= 400 { + level = observability.LogLevelWarn + } + observability.LogJSON( - observability.LogLevelInfo, + level, "http_request", "http", "", @@ -33,7 +41,7 @@ func RequestLogger(metrics *observability.Metrics) gin.HandlerFunc { "route": route, "path": path, "query": query, - "status": c.Writer.Status(), + "status": status, "duration_ms": float64(duration.Microseconds()) / 1000, "bytes": c.Writer.Size(), "client_ip": c.ClientIP(), From bfb8cc02745862b437c157e1c8afea1e11068526 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sun, 24 May 2026 01:45:39 +0200 Subject: [PATCH 005/158] fix: player dropdown light-mode visibility --- static/player/mode.ts | 4 ++-- templates/components/video_player.gohtml | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/static/player/mode.ts b/static/player/mode.ts index 7a6e201..99e069d 100644 --- a/static/player/mode.ts +++ b/static/player/mode.ts @@ -48,13 +48,13 @@ export const updateModeButtons = (): void => { const m = state.currentMode; dub?.classList.toggle('text-accent', m === 'dub'); - dub?.classList.toggle('text-white', m !== 'dub'); + dub?.classList.toggle('text-foreground', m !== 'dub'); dub?.classList.toggle('opacity-50', !state.availableModes.includes('dub')); dub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('dub')); dub && (dub.disabled = !state.availableModes.includes('dub')); sub?.classList.toggle('text-accent', m === 'sub'); - sub?.classList.toggle('text-white', m !== 'sub'); + sub?.classList.toggle('text-foreground', m !== 'sub'); sub?.classList.toggle('opacity-50', !state.availableModes.includes('sub')); sub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('sub')); sub && (sub.disabled = !state.availableModes.includes('sub')); diff --git a/templates/components/video_player.gohtml b/templates/components/video_player.gohtml index b05a3ab..9259f0f 100644 --- a/templates/components/video_player.gohtml +++ b/templates/components/video_player.gohtml @@ -76,27 +76,27 @@