This commit is contained in:
2026-05-20 16:47:39 +02:00
committed by Mikkel Elvers
parent 68396c591e
commit d94f1516ce
7 changed files with 196 additions and 14 deletions

View File

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

View File

@@ -2,7 +2,10 @@ package jikan
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"time"
)
type ScheduleResult struct {
@@ -52,8 +55,132 @@ 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) {
c.poolMu.Lock()
initialized := c.poolInitialized
c.poolMu.Unlock()
if !initialized {
_ = c.seedRandomPool(ctx)
}
c.poolMu.RLock()
defer c.poolMu.RUnlock()
if len(c.randomPool) == 0 {
var result struct {
Data Anime `json:"data"`
}
@@ -68,4 +195,8 @@ func (c *Client) GetRandomAnime(ctx context.Context) (Anime, error) {
}
return result.Data, nil
}
idx := rand.Intn(len(c.randomPool))
return c.randomPool[idx], nil
}

View File

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

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.30.0
package db
@@ -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"`

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.30.0
// source: queries.sql
package db
@@ -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
`