From efeaef21a1cc9c5e1364319e42d4ff45c70e4820 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Mon, 6 Apr 2026 22:29:34 +0200 Subject: [PATCH] refactor: split jikan client into smaller domain files --- internal/jikan/anime.go | 19 ++++ internal/jikan/client.go | 170 ------------------------------------ internal/jikan/relations.go | 93 ++++++++++++++++++++ internal/jikan/search.go | 59 +++++++++++++ internal/jikan/types.go | 10 +++ 5 files changed, 181 insertions(+), 170 deletions(-) create mode 100644 internal/jikan/anime.go create mode 100644 internal/jikan/relations.go create mode 100644 internal/jikan/search.go diff --git a/internal/jikan/anime.go b/internal/jikan/anime.go new file mode 100644 index 0000000..bd44838 --- /dev/null +++ b/internal/jikan/anime.go @@ -0,0 +1,19 @@ +package jikan + +import "fmt" + +// GetAnimeByID fetches full details for a single anime +func (c *Client) GetAnimeByID(id int) (Anime, error) { + if cached, ok := c.animeCache.Get(id); ok { + return cached, nil + } + + var result AnimeResponse + reqURL := fmt.Sprintf("%s/anime/%d/full", c.baseURL, id) + if err := c.fetchWithRetry(reqURL, &result); err != nil { + return Anime{}, err + } + + c.animeCache.Add(id, result.Data) + return result.Data, nil +} diff --git a/internal/jikan/client.go b/internal/jikan/client.go index c046d47..41f9b69 100644 --- a/internal/jikan/client.go +++ b/internal/jikan/client.go @@ -4,22 +4,11 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" "time" "github.com/hashicorp/golang-lru/v2/expirable" ) -type SearchResult struct { - Animes []Anime - HasNextPage bool -} - -type TopAnimeResult struct { - Animes []Anime - HasNextPage bool -} - type Client struct { httpClient *http.Client baseURL string @@ -74,162 +63,3 @@ func (c *Client) fetchWithRetry(urlStr string, out interface{}) error { } return fmt.Errorf("max retries exceeded for %s", urlStr) } - -// Search returns the anime list with pagination support -func (c *Client) Search(query string, page int) (SearchResult, error) { - if query == "" { - return SearchResult{}, nil - } - if page < 1 { - page = 1 - } - - cacheKey := fmt.Sprintf("search:%s:%d", query, page) - if cached, ok := c.cache.Get(cacheKey); ok { - return cached, nil - } - - var result SearchResponse - reqURL := fmt.Sprintf("%s/anime?q=%s&page=%d", c.baseURL, url.QueryEscape(query), page) - if err := c.fetchWithRetry(reqURL, &result); err != nil { - return SearchResult{}, err - } - - res := SearchResult{ - Animes: result.Data, - HasNextPage: result.Pagination.HasNextPage, - } - - c.cache.Add(cacheKey, res) - return res, nil -} - -// GetTopAnime fetches the top anime by popularity -func (c *Client) GetTopAnime(page int) (TopAnimeResult, error) { - if page < 1 { - page = 1 - } - if cached, ok := c.topCache.Get(page); ok { - return cached, nil - } - - var result TopAnimeResponse - reqURL := fmt.Sprintf("%s/top/anime?filter=bypopularity&page=%d", c.baseURL, page) - if err := c.fetchWithRetry(reqURL, &result); err != nil { - return TopAnimeResult{}, err - } - - res := TopAnimeResult{ - Animes: result.Data, - HasNextPage: result.Pagination.HasNextPage, - } - - c.topCache.Add(page, res) - return res, nil -} - -// GetAnimeByID fetches full details for a single anime -func (c *Client) GetAnimeByID(id int) (Anime, error) { - if cached, ok := c.animeCache.Get(id); ok { - return cached, nil - } - - var result AnimeResponse - reqURL := fmt.Sprintf("%s/anime/%d/full", c.baseURL, id) - if err := c.fetchWithRetry(reqURL, &result); err != nil { - return Anime{}, err - } - - c.animeCache.Add(id, result.Data) - return result.Data, nil -} - -// GetRelationsData fetches the raw relationships for an anime -func (c *Client) GetRelationsData(id int) (JikanRelationsResponse, error) { - if cached, ok := c.relationsCache.Get(id); ok { - return cached, nil - } - - var result JikanRelationsResponse - reqURL := fmt.Sprintf("%s/anime/%d/relations", c.baseURL, id) - if err := c.fetchWithRetry(reqURL, &result); err != nil { - return JikanRelationsResponse{}, err - } - - c.relationsCache.Add(id, result) - return result, nil -} - -// findFirstAnimeRelation extracts the first related anime ID for a specific relation type -func findFirstAnimeRelation(res JikanRelationsResponse, relType string) *int { - for _, group := range res.Data { - if group.Relation == relType { - for _, entry := range group.Entry { - if entry.Type == "anime" { - id := entry.MalID - return &id - } - } - } - } - return nil -} - -// fetchChain recursively builds the relational chain (Prequels or Sequels) -func (c *Client) fetchChain(startID int, direction string, visited map[int]bool) []RelationEntry { - rels, err := c.GetRelationsData(startID) - if err != nil { - return nil - } - - nextIDPtr := findFirstAnimeRelation(rels, direction) - if nextIDPtr == nil { - return nil - } - - nextID := *nextIDPtr - if visited[nextID] { // prevent loops - return nil - } - visited[nextID] = true - - anime, err := c.GetAnimeByID(nextID) - if err != nil { - return nil - } - - entry := RelationEntry{Anime: anime, IsCurrent: false} - rest := c.fetchChain(nextID, direction, visited) - - if direction == "Prequel" { - return append(rest, entry) - } - return append([]RelationEntry{entry}, rest...) -} - -// GetFullRelations resolves the full Prequel/Sequel chronological chain synchronously -func (c *Client) GetFullRelations(id int) []RelationEntry { - currentAnime, err := c.GetAnimeByID(id) - if err != nil { - return nil - } - - visited := map[int]bool{id: true} - - prequels := c.fetchChain(id, "Prequel", visited) - - // Clone visited set for sequels so we don't block valid paths if there's weird branching - visitedSeq := make(map[int]bool) - for k, v := range visited { - visitedSeq[k] = v - } - - sequels := c.fetchChain(id, "Sequel", visitedSeq) - - var result []RelationEntry - result = append(result, prequels...) - result = append(result, RelationEntry{Anime: currentAnime, IsCurrent: true}) - result = append(result, sequels...) - - return result -} diff --git a/internal/jikan/relations.go b/internal/jikan/relations.go new file mode 100644 index 0000000..2eef6fd --- /dev/null +++ b/internal/jikan/relations.go @@ -0,0 +1,93 @@ +package jikan + +import "fmt" + +// GetRelationsData fetches the raw relationships for an anime +func (c *Client) GetRelationsData(id int) (JikanRelationsResponse, error) { + if cached, ok := c.relationsCache.Get(id); ok { + return cached, nil + } + + var result JikanRelationsResponse + reqURL := fmt.Sprintf("%s/anime/%d/relations", c.baseURL, id) + if err := c.fetchWithRetry(reqURL, &result); err != nil { + return JikanRelationsResponse{}, err + } + + c.relationsCache.Add(id, result) + return result, nil +} + +// findFirstAnimeRelation extracts the first related anime ID for a specific relation type +func findFirstAnimeRelation(res JikanRelationsResponse, relType string) *int { + for _, group := range res.Data { + if group.Relation == relType { + for _, entry := range group.Entry { + if entry.Type == "anime" { + id := entry.MalID + return &id + } + } + } + } + return nil +} + +// fetchChain recursively builds the relational chain (Prequels or Sequels) +func (c *Client) fetchChain(startID int, direction string, visited map[int]bool) []RelationEntry { + rels, err := c.GetRelationsData(startID) + if err != nil { + return nil + } + + nextIDPtr := findFirstAnimeRelation(rels, direction) + if nextIDPtr == nil { + return nil + } + + nextID := *nextIDPtr + if visited[nextID] { // prevent loops + return nil + } + visited[nextID] = true + + anime, err := c.GetAnimeByID(nextID) + if err != nil { + return nil + } + + entry := RelationEntry{Anime: anime, IsCurrent: false} + rest := c.fetchChain(nextID, direction, visited) + + if direction == "Prequel" { + return append(rest, entry) + } + return append([]RelationEntry{entry}, rest...) +} + +// GetFullRelations resolves the full Prequel/Sequel chronological chain synchronously +func (c *Client) GetFullRelations(id int) []RelationEntry { + currentAnime, err := c.GetAnimeByID(id) + if err != nil { + return nil + } + + visited := map[int]bool{id: true} + + prequels := c.fetchChain(id, "Prequel", visited) + + // Clone visited set for sequels so we don't block valid paths if there's weird branching + visitedSeq := make(map[int]bool) + for k, v := range visited { + visitedSeq[k] = v + } + + sequels := c.fetchChain(id, "Sequel", visitedSeq) + + var result []RelationEntry + result = append(result, prequels...) + result = append(result, RelationEntry{Anime: currentAnime, IsCurrent: true}) + result = append(result, sequels...) + + return result +} diff --git a/internal/jikan/search.go b/internal/jikan/search.go new file mode 100644 index 0000000..400904a --- /dev/null +++ b/internal/jikan/search.go @@ -0,0 +1,59 @@ +package jikan + +import ( + "fmt" + "net/url" +) + +// Search returns the anime list with pagination support +func (c *Client) Search(query string, page int) (SearchResult, error) { + if query == "" { + return SearchResult{}, nil + } + if page < 1 { + page = 1 + } + + cacheKey := fmt.Sprintf("search:%s:%d", query, page) + if cached, ok := c.cache.Get(cacheKey); ok { + return cached, nil + } + + var result SearchResponse + reqURL := fmt.Sprintf("%s/anime?q=%s&page=%d", c.baseURL, url.QueryEscape(query), page) + if err := c.fetchWithRetry(reqURL, &result); err != nil { + return SearchResult{}, err + } + + res := SearchResult{ + Animes: result.Data, + HasNextPage: result.Pagination.HasNextPage, + } + + c.cache.Add(cacheKey, res) + return res, nil +} + +// GetTopAnime fetches the top anime by popularity +func (c *Client) GetTopAnime(page int) (TopAnimeResult, error) { + if page < 1 { + page = 1 + } + if cached, ok := c.topCache.Get(page); ok { + return cached, nil + } + + var result TopAnimeResponse + reqURL := fmt.Sprintf("%s/top/anime?filter=bypopularity&page=%d", c.baseURL, page) + if err := c.fetchWithRetry(reqURL, &result); err != nil { + return TopAnimeResult{}, err + } + + res := TopAnimeResult{ + Animes: result.Data, + HasNextPage: result.Pagination.HasNextPage, + } + + c.topCache.Add(page, res) + return res, nil +} diff --git a/internal/jikan/types.go b/internal/jikan/types.go index 1e95e7c..a683f57 100644 --- a/internal/jikan/types.go +++ b/internal/jikan/types.go @@ -145,3 +145,13 @@ func (a Anime) DisplayTitle() string { } return a.Title } + +type SearchResult struct { + Animes []Anime + HasNextPage bool +} + +type TopAnimeResult struct { + Animes []Anime + HasNextPage bool +}