From d94f1516cec2f117f249c8db920982be0b7bfe40 Mon Sep 17 00:00:00 2001 From: Milas Holsting Date: Wed, 20 May 2026 16:47:39 +0200 Subject: [PATCH] YOLO --- integrations/jikan/client.go | 6 ++ integrations/jikan/seasons.go | 151 +++++++++++++++++++++++++++++++--- internal/db/db.go | 2 +- internal/db/models.go | 14 +++- internal/db/querier.go | 3 +- internal/db/queries.sql | 4 + internal/db/queries.sql.go | 30 ++++++- 7 files changed, 196 insertions(+), 14 deletions(-) diff --git a/integrations/jikan/client.go b/integrations/jikan/client.go index 3ea010d..016b998 100644 --- a/integrations/jikan/client.go +++ b/integrations/jikan/client.go @@ -29,6 +29,11 @@ type Client struct { lastReqTime time.Time // rate limiting: last request timestamp sf singleflight.Group refreshSem chan struct{} + + // Random anime pool for DDoS-proof truly random "Surprise Me" + randomPool []Anime + poolMu sync.RWMutex + poolInitialized bool } const jikanSlowLogThreshold = 750 * time.Millisecond @@ -48,6 +53,7 @@ func NewClient(queries *db.Queries) *Client { db: queries, retrySignal: make(chan struct{}, 1), refreshSem: make(chan struct{}, 4), + randomPool: make([]Anime, 0), } } diff --git a/integrations/jikan/seasons.go b/integrations/jikan/seasons.go index eefbb23..ca1b559 100644 --- a/integrations/jikan/seasons.go +++ b/integrations/jikan/seasons.go @@ -2,7 +2,10 @@ package jikan import ( "context" + "encoding/json" "fmt" + "math/rand" + "time" ) type ScheduleResult struct { @@ -52,20 +55,148 @@ func (c *Client) GetSeasonsUpcoming(ctx context.Context, page int) (TopAnimeResu }, nil } +// seedRandomPool seeds the in-memory pool of random anime +func (c *Client) seedRandomPool(ctx context.Context) error { + c.poolMu.Lock() + if c.poolInitialized { + c.poolMu.Unlock() + return nil + } + c.poolInitialized = true + c.poolMu.Unlock() + + // 1. Try to load all cached anime from the database + cachedJSONs, err := c.db.GetAllCachedAnime(ctx) + if err == nil && len(cachedJSONs) > 0 { + var loadedAnimes []Anime + for _, dataStr := range cachedJSONs { + var anime Anime + if err := json.Unmarshal([]byte(dataStr), &anime); err == nil && anime.MalID > 0 { + loadedAnimes = append(loadedAnimes, anime) + } + } + + if len(loadedAnimes) > 0 { + c.poolMu.Lock() + c.randomPool = append(c.randomPool, loadedAnimes...) + c.poolMu.Unlock() + } + } + + // 2. Fetch Top Anime page 1 & 2 to ensure we have a robust baseline of high-quality popular anime + go func() { + bgCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var fetchedAnimes []Anime + + top, err := c.GetTopAnime(bgCtx, 1) + if err == nil && len(top.Animes) > 0 { + fetchedAnimes = append(fetchedAnimes, top.Animes...) + } + + top2, err := c.GetTopAnime(bgCtx, 2) + if err == nil && len(top2.Animes) > 0 { + fetchedAnimes = append(fetchedAnimes, top2.Animes...) + } + + now, err := c.GetSeasonsNow(bgCtx, 1) + if err == nil && len(now.Animes) > 0 { + fetchedAnimes = append(fetchedAnimes, now.Animes...) + } + + if len(fetchedAnimes) > 0 { + c.poolMu.Lock() + // Use map to de-duplicate any anime + seen := make(map[int]bool) + for _, a := range c.randomPool { + seen[a.MalID] = true + } + for _, a := range fetchedAnimes { + if !seen[a.MalID] { + c.randomPool = append(c.randomPool, a) + seen[a.MalID] = true + } + } + c.poolMu.Unlock() + } + + // Start background refresher once seeding completes + c.startPoolRefresher() + }() + + return nil +} + +// startPoolRefresher runs in the background to slowly mix in true random anime +func (c *Client) startPoolRefresher() { + ticker := time.NewTicker(30 * time.Second) + ctx := context.Background() + + for range ticker.C { + var result struct { + Data Anime `json:"data"` + } + reqURL := fmt.Sprintf("%s/random/anime", c.baseURL) + err := c.fetchWithRetry(ctx, reqURL, &result) + if err != nil { + continue + } + + if result.Data.MalID == 0 { + continue + } + + c.poolMu.Lock() + if len(c.randomPool) >= 1000 { + idx := rand.Intn(len(c.randomPool)) + c.randomPool[idx] = result.Data + } else { + duplicate := false + for _, a := range c.randomPool { + if a.MalID == result.Data.MalID { + duplicate = true + break + } + } + if !duplicate { + c.randomPool = append(c.randomPool, result.Data) + } + } + c.poolMu.Unlock() + } +} + // GetRandomAnime returns a random anime from the database. func (c *Client) GetRandomAnime(ctx context.Context) (Anime, error) { - var result struct { - Data Anime `json:"data"` + c.poolMu.Lock() + initialized := c.poolInitialized + c.poolMu.Unlock() + + if !initialized { + _ = c.seedRandomPool(ctx) } - reqURL := fmt.Sprintf("%s/random/anime", c.baseURL) - err := c.fetchWithRetry(ctx, reqURL, &result) - if err != nil { - return Anime{}, err - } - if result.Data.MalID == 0 { - return Anime{}, fmt.Errorf("jikan: empty response for random/anime") + c.poolMu.RLock() + defer c.poolMu.RUnlock() + + if len(c.randomPool) == 0 { + var result struct { + Data Anime `json:"data"` + } + + reqURL := fmt.Sprintf("%s/random/anime", c.baseURL) + err := c.fetchWithRetry(ctx, reqURL, &result) + if err != nil { + return Anime{}, err + } + if result.Data.MalID == 0 { + return Anime{}, fmt.Errorf("jikan: empty response for random/anime") + } + + return result.Data, nil } - return result.Data, nil + idx := rand.Intn(len(c.randomPool)) + return c.randomPool[idx], nil } diff --git a/internal/db/db.go b/internal/db/db.go index f43598b..cd5bbb8 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 package db diff --git a/internal/db/models.go b/internal/db/models.go index 53c360e..7ac013e 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 package db @@ -93,6 +93,18 @@ type Session struct { CreatedAt time.Time `json:"created_at"` } +type SkipSegmentOverride struct { + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + Episode int64 `json:"episode"` + SkipType string `json:"skip_type"` + StartTime float64 `json:"start_time"` + EndTime float64 `json:"end_time"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type User struct { ID string `json:"id"` Username string `json:"username"` diff --git a/internal/db/querier.go b/internal/db/querier.go index 97fecee..27492be 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 package db @@ -19,6 +19,7 @@ type Querier interface { DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListEntryParams) error EnqueueAnimeFetchRetry(ctx context.Context, arg EnqueueAnimeFetchRetryParams) error GetAPITokenByHash(ctx context.Context, tokenHash string) (ApiToken, error) + GetAllCachedAnime(ctx context.Context) ([]string, error) GetAnime(ctx context.Context, id int64) (Anime, error) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error) GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error) diff --git a/internal/db/queries.sql b/internal/db/queries.sql index 5b7e1b0..d3fbd62 100644 --- a/internal/db/queries.sql +++ b/internal/db/queries.sql @@ -337,3 +337,7 @@ LEFT JOIN episode_availability_cache e ON e.anime_id = tracked.anime_id WHERE e.anime_id IS NULL OR e.next_refresh_at IS NULL OR e.next_refresh_at <= CURRENT_TIMESTAMP ORDER BY tracked.anime_id LIMIT ?; + +-- name: GetAllCachedAnime :many +SELECT data FROM jikan_cache +WHERE key LIKE 'anime:%' LIMIT 1000; diff --git a/internal/db/queries.sql.go b/internal/db/queries.sql.go index b64cfff..61a64fe 100644 --- a/internal/db/queries.sql.go +++ b/internal/db/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: queries.sql package db @@ -182,6 +182,34 @@ func (q *Queries) GetAPITokenByHash(ctx context.Context, tokenHash string) (ApiT return i, err } +const getAllCachedAnime = `-- name: GetAllCachedAnime :many +SELECT data FROM jikan_cache +WHERE key LIKE 'anime:%' LIMIT 1000 +` + +func (q *Queries) GetAllCachedAnime(ctx context.Context) ([]string, error) { + rows, err := q.db.QueryContext(ctx, getAllCachedAnime) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var data string + if err := rows.Scan(&data); err != nil { + return nil, err + } + items = append(items, data) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getAnime = `-- name: GetAnime :one SELECT id, title_original, image_url, created_at, title_english, title_japanese, airing, status, relations_synced_at, duration_seconds FROM anime WHERE id = ? LIMIT 1 `