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 lastReqTime time.Time // rate limiting: last request timestamp
sf singleflight.Group sf singleflight.Group
refreshSem chan struct{} 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 const jikanSlowLogThreshold = 750 * time.Millisecond
@@ -48,6 +53,7 @@ func NewClient(queries *db.Queries) *Client {
db: queries, db: queries,
retrySignal: make(chan struct{}, 1), retrySignal: make(chan struct{}, 1),
refreshSem: make(chan struct{}, 4), refreshSem: make(chan struct{}, 4),
randomPool: make([]Anime, 0),
} }
} }

View File

@@ -2,7 +2,10 @@ package jikan
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"math/rand"
"time"
) )
type ScheduleResult struct { type ScheduleResult struct {
@@ -52,20 +55,148 @@ func (c *Client) GetSeasonsUpcoming(ctx context.Context, page int) (TopAnimeResu
}, nil }, 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. // GetRandomAnime returns a random anime from the database.
func (c *Client) GetRandomAnime(ctx context.Context) (Anime, error) { func (c *Client) GetRandomAnime(ctx context.Context) (Anime, error) {
var result struct { c.poolMu.Lock()
Data Anime `json:"data"` initialized := c.poolInitialized
c.poolMu.Unlock()
if !initialized {
_ = c.seedRandomPool(ctx)
} }
reqURL := fmt.Sprintf("%s/random/anime", c.baseURL) c.poolMu.RLock()
err := c.fetchWithRetry(ctx, reqURL, &result) defer c.poolMu.RUnlock()
if err != nil {
return Anime{}, err if len(c.randomPool) == 0 {
} var result struct {
if result.Data.MalID == 0 { Data Anime `json:"data"`
return Anime{}, fmt.Errorf("jikan: empty response for random/anime") }
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
} }

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.30.0
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.31.1 // sqlc v1.30.0
package db package db
@@ -93,6 +93,18 @@ type Session struct {
CreatedAt time.Time `json:"created_at"` 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 { type User struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.30.0
package db package db
@@ -19,6 +19,7 @@ type Querier interface {
DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListEntryParams) error DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListEntryParams) error
EnqueueAnimeFetchRetry(ctx context.Context, arg EnqueueAnimeFetchRetryParams) error EnqueueAnimeFetchRetry(ctx context.Context, arg EnqueueAnimeFetchRetryParams) error
GetAPITokenByHash(ctx context.Context, tokenHash string) (ApiToken, error) GetAPITokenByHash(ctx context.Context, tokenHash string) (ApiToken, 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)
GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, 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 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 ORDER BY tracked.anime_id
LIMIT ?; 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. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.30.0
// source: queries.sql // source: queries.sql
package db package db
@@ -182,6 +182,34 @@ func (q *Queries) GetAPITokenByHash(ctx context.Context, tokenHash string) (ApiT
return i, err 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 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 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
` `