feat: add comments and cleanup unused imports across codebase

This commit is contained in:
2026-05-10 20:00:04 +02:00
parent b152e246ff
commit e48d95cb4e
68 changed files with 560 additions and 88 deletions

View File

@@ -6,6 +6,7 @@ import (
"time"
)
// GetAnimeCharacters returns character list for an anime with voice actor info.
func (c *Client) GetAnimeCharacters(ctx context.Context, id int) ([]CharacterEntry, error) {
url := fmt.Sprintf("%s/anime/%d/characters", c.baseURL, id)
cacheKey := fmt.Sprintf("anime:characters:%d", id)
@@ -18,6 +19,7 @@ func (c *Client) GetAnimeCharacters(ctx context.Context, id int) ([]CharacterEnt
return resp.Data, nil
}
// GetAnimeRecommendations returns user-submitted recommendations for an anime.
func (c *Client) GetAnimeRecommendations(ctx context.Context, id int) ([]RecommendationEntry, error) {
url := fmt.Sprintf("%s/anime/%d/recommendations", c.baseURL, id)
cacheKey := fmt.Sprintf("anime:recommendations:%d", id)
@@ -30,6 +32,7 @@ func (c *Client) GetAnimeRecommendations(ctx context.Context, id int) ([]Recomme
return resp.Data, nil
}
// GetAnimeByID returns full anime details; finished series cached 30 days, airing cached 1 day.
func (c *Client) GetAnimeByID(ctx context.Context, id int) (Anime, error) {
cacheKey := fmt.Sprintf("anime:%d", id)

View File

@@ -20,9 +20,9 @@ type Client struct {
httpClient *http.Client
baseURL string
db db.Querier
retrySignal chan struct{}
retrySignal chan struct{} // signals retry worker to process queued retries
mu sync.Mutex
lastReqTime time.Time
lastReqTime time.Time // rate limiting: last request timestamp
}
func NewClient(db db.Querier) *Client {
@@ -51,6 +51,7 @@ func (e *APIError) Error() string {
return fmt.Sprintf("jikan api returned status %d", e.StatusCode)
}
// IsNotFoundError returns true if the error is an APIError with 404 status.
func IsNotFoundError(err error) bool {
var apiErr *APIError
if errors.As(err, &apiErr) {
@@ -60,6 +61,7 @@ func IsNotFoundError(err error) bool {
return false
}
// IsRetryableError returns true if the error should trigger a retry.
func IsRetryableError(err error) bool {
if err == nil {
return false
@@ -90,6 +92,7 @@ func isRetryableStatus(statusCode int) bool {
return statusCode >= 500 && statusCode <= 504
}
// retryDelay returns exponential backoff delay: 500ms, 1s, 2s, 4s, 8s (capped).
func retryDelay(attempt int) time.Duration {
base := 500 * time.Millisecond
delay := base * time.Duration(1<<attempt)
@@ -100,6 +103,7 @@ func retryDelay(attempt int) time.Duration {
return delay
}
// parseRetryAfter parses Retry-After header value (seconds) into duration.
func parseRetryAfter(value string) (time.Duration, bool) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
@@ -138,6 +142,7 @@ func truncateErrorMessage(message string) string {
return message[:400]
}
// notifyRetryWorker signals the retry worker, non-blocking.
func (c *Client) notifyRetryWorker() {
select {
case c.retrySignal <- struct{}{}:
@@ -145,10 +150,12 @@ func (c *Client) notifyRetryWorker() {
}
}
// RetrySignal returns channel that signals when retries are enqueued.
func (c *Client) RetrySignal() <-chan struct{} {
return c.retrySignal
}
// EnqueueAnimeFetchRetry queues a failed anime fetch for later retry if the error is retryable.
func (c *Client) EnqueueAnimeFetchRetry(parentCtx context.Context, animeID int, cause error) {
if animeID <= 0 || !IsRetryableError(cause) {
return
@@ -168,6 +175,7 @@ func (c *Client) EnqueueAnimeFetchRetry(parentCtx context.Context, animeID int,
c.notifyRetryWorker()
}
// waitRateLimit enforces Jikan's 3 req/sec rate limit with 400ms spacing.
func (c *Client) waitRateLimit(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
@@ -193,6 +201,7 @@ func (c *Client) waitRateLimit(ctx context.Context) error {
return nil
}
// getCache retrieves cached data by key, returns true on cache hit.
func (c *Client) getCache(parentCtx context.Context, key string, out any) bool {
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()
@@ -206,6 +215,7 @@ func (c *Client) getCache(parentCtx context.Context, key string, out any) bool {
return err == nil
}
// getStaleCache retrieves expired-but-available cache by key.
func (c *Client) getStaleCache(parentCtx context.Context, key string, out any) bool {
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()
@@ -219,6 +229,7 @@ func (c *Client) getStaleCache(parentCtx context.Context, key string, out any) b
return err == nil
}
// setCache stores data in cache with specified TTL.
func (c *Client) setCache(parentCtx context.Context, key string, data any, ttl time.Duration) {
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()
@@ -240,6 +251,7 @@ type cacheResult struct {
hasStale bool
}
// isEmptyResult detects if response contains no meaningful data.
func isEmptyResult(out any) bool {
switch v := out.(type) {
case *TopAnimeResponse:
@@ -254,6 +266,7 @@ func isEmptyResult(out any) bool {
return false
}
// getWithCache fetches URL with cache-aside pattern: checks cache first, falls back to stale on error.
func (c *Client) getWithCache(ctx context.Context, cacheKey string, ttl time.Duration, url string, out any) error {
if c.getCache(ctx, cacheKey, out) {
if !isEmptyResult(out) {
@@ -289,6 +302,7 @@ func (c *Client) getWithCache(ctx context.Context, cacheKey string, ttl time.Dur
return nil
}
// fetchWithRetry makes HTTP request with exponential backoff retry on transient failures.
func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) error {
maxRetries := 5
for attempt := range maxRetries {

View File

@@ -2,5 +2,5 @@ package jikan
import "time"
const shortCacheTTL = time.Hour
const longCacheTTL = time.Hour * 24
const shortCacheTTL = time.Hour // 1 hour - for frequently changing data
const longCacheTTL = time.Hour * 24 // 24 hours - for stable data like genres

View File

@@ -7,6 +7,7 @@ import (
"time"
)
// GetEpisodes returns episode list for a specific page.
func (c *Client) GetEpisodes(ctx context.Context, animeID int, page int) (EpisodesResponse, error) {
if page < 1 {
page = 1
@@ -20,6 +21,7 @@ func (c *Client) GetEpisodes(ctx context.Context, animeID int, page int) (Episod
return result, err
}
// GetAllEpisodes fetches all pages of episodes in parallel and flattens results.
func (c *Client) GetAllEpisodes(ctx context.Context, animeID int) ([]Episode, error) {
// First page to get total pages
first, err := c.GetEpisodes(ctx, animeID, 1)
@@ -67,6 +69,7 @@ func (c *Client) GetAllEpisodes(ctx context.Context, animeID int) ([]Episode, er
return result, nil
}
// GetEpisodesRange fetches episodes from startPage to endPage sequentially.
func (c *Client) GetEpisodesRange(ctx context.Context, animeID int, startPage, endPage int) ([]Episode, error) {
var all []Episode
for page := startPage; page <= endPage; page++ {

View File

@@ -14,10 +14,12 @@ import (
"golang.org/x/sync/errgroup"
)
// chiaki.watchOrderURL is the external watch order tool used for relation ordering.
const chiakiWatchOrderURL = "https://chiaki.site/?/tools/watch_order/id/%d"
const watchOrderCacheTTL = time.Hour * 24
const maxWatchOrderEntries = 120
const maxWatchOrderEntries = 120 // cap to prevent huge relation chains
// watchOrderTypeLabel normalizes watch order type to display-friendly labels.
func watchOrderTypeLabel(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
switch normalized {
@@ -30,6 +32,7 @@ func watchOrderTypeLabel(value string) string {
}
}
// isAllowedWatchOrderType returns true only for TV and Movie types (filters out specials, etc).
func isAllowedWatchOrderType(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
return normalized == "tv" || normalized == "movie"
@@ -39,6 +42,7 @@ func relationCacheKey(id int) string {
return fmt.Sprintf("relations:watch-order:%d", id)
}
// getWatchOrder fetches watch order from chiaki, caches result for 24h.
func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
cacheKey := relationCacheKey(id)
@@ -81,6 +85,7 @@ func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrd
return result, nil
}
// currentOnlyRelation returns just the current anime when watch order lookup fails.
func (c *Client) currentOnlyRelation(ctx context.Context, id int) ([]RelationEntry, error) {
currentAnime, err := c.GetAnimeByID(ctx, id)
if err != nil {
@@ -95,6 +100,7 @@ func (c *Client) currentOnlyRelation(ctx context.Context, id int) ([]RelationEnt
}}, nil
}
// GetFullRelations returns related anime based on watch order, with parallel fetching (3 concurrent).
func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, error) {
result, err := c.getWatchOrder(ctx, id)
if err != nil {

View File

@@ -8,10 +8,12 @@ import (
"strings"
)
// Search performs a basic anime search by query string.
func (c *Client) Search(ctx context.Context, query string, page int) (SearchResult, error) {
return c.search(ctx, query, page, 0)
}
// SearchAdvanced performs a filtered anime search with type, status, ordering, and genre filters.
func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (SearchResult, error) {
if page < 1 {
page = 1
@@ -122,6 +124,7 @@ func (c *Client) search(ctx context.Context, query string, page int, limit int)
}, nil
}
// GetTopAnime returns the top-rated anime list for a given page.
func (c *Client) GetTopAnime(ctx context.Context, page int) (TopAnimeResult, error) {
if page < 1 {
page = 1
@@ -145,6 +148,7 @@ func (c *Client) GetTopAnime(ctx context.Context, page int) (TopAnimeResult, err
}, nil
}
// GetAnimeGenres returns list of all anime genres, cached long-term.
func (c *Client) GetAnimeGenres(ctx context.Context) ([]Genre, error) {
const cacheKey = "anime_genres"

View File

@@ -8,9 +8,10 @@ import (
type ScheduleResult struct {
Animes []Anime
HasNextPage bool
HasNextPage bool // whether more pages available
}
// GetSchedule returns anime airing on a specific day of the week.
func (c *Client) GetSchedule(ctx context.Context, day string) (ScheduleResult, error) {
day = strings.ToLower(day)
cacheKey := fmt.Sprintf("schedule_%s", day)
@@ -29,6 +30,7 @@ func (c *Client) GetSchedule(ctx context.Context, day string) (ScheduleResult, e
}, nil
}
// GetFullSchedule returns anime airing schedule for all seven days.
func (c *Client) GetFullSchedule(ctx context.Context) (map[string][]Anime, error) {
days := []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}
schedule := make(map[string][]Anime)
@@ -44,6 +46,7 @@ func (c *Client) GetFullSchedule(ctx context.Context) (map[string][]Anime, error
return schedule, nil
}
// GetSeasonsNow returns currently airing anime for the current season.
func (c *Client) GetSeasonsNow(ctx context.Context, page int) (TopAnimeResult, error) {
if page < 1 {
page = 1
@@ -64,6 +67,7 @@ func (c *Client) GetSeasonsNow(ctx context.Context, page int) (TopAnimeResult, e
}, nil
}
// GetSeasonsUpcoming returns anime scheduled to air in upcoming seasons.
func (c *Client) GetSeasonsUpcoming(ctx context.Context, page int) (TopAnimeResult, error) {
if page < 1 {
page = 1
@@ -84,6 +88,7 @@ func (c *Client) GetSeasonsUpcoming(ctx context.Context, page int) (TopAnimeResu
}, nil
}
// GetRandomAnime returns a random anime from the database.
func (c *Client) GetRandomAnime(ctx context.Context) (Anime, error) {
var result struct {
Data Anime `json:"data"`

View File

@@ -28,6 +28,7 @@ type ProducerResponse struct {
} `json:"data"`
}
// GetAnimeByProducer returns anime list for a producer/studio, includes producer name.
func (c *Client) GetAnimeByProducer(ctx context.Context, producerID int, page int) (StudioAnimeResult, error) {
if page < 1 {
page = 1
@@ -75,6 +76,7 @@ func (c *Client) GetAnimeByProducer(ctx context.Context, producerID int, page in
return res, nil
}
// GetProducerByID returns full producer/studio details.
func (c *Client) GetProducerByID(ctx context.Context, producerID int) (ProducerResponse, error) {
cacheKey := fmt.Sprintf("producer:info:%d", producerID)

View File

@@ -155,18 +155,22 @@ type RecommendationsResponse struct {
Data []RecommendationEntry `json:"data"`
}
// ScoredByFormatted returns formatted count (e.g. "1 234 567").
func (a Anime) ScoredByFormatted() string {
return formatNumber(a.ScoredBy)
}
// MembersFormatted returns formatted count (e.g. "1 234 567").
func (a Anime) MembersFormatted() string {
return formatNumber(a.Members)
}
// FavoritesFormatted returns formatted count (e.g. "1 234 567").
func (a Anime) FavoritesFormatted() string {
return formatNumber(a.Favorites)
}
// formatNumber adds space separators to a number (1234567 -> "1 234 567").
func formatNumber(n int) string {
if n == 0 {
return ""
@@ -180,10 +184,12 @@ func formatNumber(n int) string {
return strings.Join(res, " ")
}
// ImageURL returns the webp large image URL for the anime.
func (a Anime) ImageURL() string {
return a.Images.Webp.LargeImageURL
}
// ShortRating extracts just the rating code (e.g. "PG-13") from full rating string.
func (a Anime) ShortRating() string {
if a.Rating == "" {
return ""
@@ -197,6 +203,7 @@ func (a Anime) ShortRating() string {
return a.Rating
}
// ShortDuration extracts numeric duration in minutes (e.g. "23m" from "23 min per ep").
func (a Anime) ShortDuration() string {
if a.Duration == "" {
return ""
@@ -216,6 +223,7 @@ func (a Anime) ShortDuration() string {
return a.Duration
}
// DurationSeconds converts duration string to total seconds (e.g. "1 hr 30 min" -> 5400).
func (a Anime) DurationSeconds() float64 {
if a.Duration == "" {
return 0
@@ -256,6 +264,7 @@ func (a Anime) DurationSeconds() float64 {
return float64(hours*60+minutes) * 60
}
// Premiered returns formatted premiere string (e.g. "Winter 2020").
func (a Anime) Premiered() string {
if a.Season != "" && a.Year > 0 {
return fmt.Sprintf("%s %d", seasonLabel(a.Season), a.Year)
@@ -263,6 +272,7 @@ func (a Anime) Premiered() string {
return ""
}
// seasonLabel normalizes season string to title case (fall/autumn -> Fall).
func seasonLabel(season string) string {
switch strings.ToLower(season) {
case "winter":
@@ -356,6 +366,7 @@ type RelationEntry struct {
IsExtra bool
}
// DisplayTitle returns English title if available, otherwise Japanese, then default.
func (a Anime) DisplayTitle() string {
if a.TitleEnglish != "" {
return a.TitleEnglish

View File

@@ -15,7 +15,9 @@ import (
const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
// idPattern extracts the watch order ID from chiaki.site URLs
var idPattern = regexp.MustCompile(`/id/(\d+)`)
// malLinkPattern extracts MAL IDs from watch order entries
var malLinkPattern = regexp.MustCompile(`myanimelist\.net/anime/(\d+)`)
var ErrInvalidWatchOrderURL = errors.New("invalid watch order url")
@@ -46,10 +48,10 @@ func (e *HTTPStatusError) Error() string {
}
type WatchOrderEntry struct {
ID int `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
TitleAlt string `json:"title_alt,omitempty"`
ID int `json:"id"` // MAL anime ID
Type string `json:"type"` // anime type label (e.g. "TV", "Movie")
Title string `json:"title"` // primary title
TitleAlt string `json:"title_alt,omitempty"` // alternative title
}
type WatchOrderResult struct {
@@ -106,6 +108,7 @@ func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*g
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
// limit body read for error context; avoid reading large error pages
body, _ := io.ReadAll(io.LimitReader(response.Body, 512))
return nil, &HTTPStatusError{
StatusCode: response.StatusCode,
@@ -198,6 +201,8 @@ func hasWatchOrderTable(doc *goquery.Document) bool {
return doc.Find("#wo_list").Length() > 0
}
// shouldTryProxy returns true for transient errors where the Jina proxy may help
// (e.g. Cloudflare blocking, rate limits)
func shouldTryProxy(err error) bool {
var statusError *HTTPStatusError
if errors.As(err, &statusError) {
@@ -243,6 +248,8 @@ func fetchProxyText(ctx context.Context, httpClient *http.Client, url string) (s
return string(body), nil
}
// parseJinaEntries parses Jina proxy output, which contains one line per entry
// in format: "title | type | https://myanimelist.net/anime/ID"
func parseJinaEntries(text string) []WatchOrderEntry {
lines := strings.Split(text, "\n")
entries := make([]WatchOrderEntry, 0)
@@ -312,6 +319,8 @@ func isNoiseTitleLine(value string) bool {
return false
}
// titleFromContext looks backward from metaIndex to find the actual title lines.
// It skips noise lines (URLs, metadata prefixes, etc.) and returns (primary, alt).
func titleFromContext(lines []string, metaIndex int) (string, string) {
collected := make([]string, 0, 2)
@@ -340,6 +349,7 @@ func titleFromContext(lines []string, metaIndex int) (string, string) {
return collected[0], ""
}
// reversed order: older lines first -> title, newer -> alt
return collected[1], collected[0]
}
@@ -357,6 +367,8 @@ func fetchViaProxy(ctx context.Context, httpClient *http.Client, url string, roo
return WatchOrderResult{ID: rootID, WatchOrder: entries}, nil
}
// FetchWatchOrder fetches the watch order from chiaki.site.
// Falls back to the Jina proxy if the site is blocked or returns an empty table.
func FetchWatchOrder(ctx context.Context, httpClient *http.Client, url string) (WatchOrderResult, error) {
rootID, err := parseRootID(url)
if err != nil {
@@ -371,6 +383,7 @@ func FetchWatchOrder(ctx context.Context, httpClient *http.Client, url string) (
return WatchOrderResult{}, err
}
// empty table indicates JS-rendered content; need proxy
if !hasWatchOrderTable(doc) {
return fetchViaProxy(ctx, httpClient, url, rootID)
}