YOLO
This commit is contained in:
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
|
||||
package db
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user