aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--repositories/genre.go24
-rw-r--r--repositories/meta.go52
-rw-r--r--repositories/producer.go26
-rw-r--r--types/anilist.go246
-rw-r--r--utils/api/anilist/anilist.go219
-rw-r--r--utils/api/anilist/types.go213
-rw-r--r--utils/api/jikan/jikan.go388
7 files changed, 488 insertions, 680 deletions
diff --git a/repositories/genre.go b/repositories/genre.go
new file mode 100644
index 0000000..ed27db4
--- /dev/null
+++ b/repositories/genre.go
@@ -0,0 +1,24 @@
+package repositories
+
+import (
+ "errors"
+ "metachan/database"
+ "metachan/entities"
+ "metachan/utils/logger"
+
+ "gorm.io/gorm/clause"
+)
+
+func CreateOrUpdateGenre(genre *entities.Genre) error {
+ result := database.DB.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "genre_id"}},
+ DoUpdates: clause.AssignmentColumns([]string{"name", "url", "count"}),
+ }).Create(genre)
+
+ if result.Error != nil {
+ logger.Errorf("Genre", "Failed to create or update genre: %v", result.Error)
+ return errors.New("failed to create or update genre")
+ }
+
+ return nil
+}
diff --git a/repositories/meta.go b/repositories/meta.go
new file mode 100644
index 0000000..3ab4543
--- /dev/null
+++ b/repositories/meta.go
@@ -0,0 +1,52 @@
+package repositories
+
+import (
+ "errors"
+ "metachan/database"
+ "metachan/entities"
+ "metachan/utils/logger"
+
+ "gorm.io/gorm/clause"
+)
+
+func CreateOrUpdateSimpleImage(image *entities.SimpleImage) (uint, error) {
+ result := database.DB.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "image_url"}},
+ DoUpdates: clause.AssignmentColumns([]string{"image_url"}),
+ }).Create(image)
+
+ if result.Error != nil {
+ logger.Errorf("Meta", "Failed to create or update image: %v", result.Error)
+ return 0, errors.New("failed to create or update image")
+ }
+
+ return image.ID, nil
+}
+
+func CreateOrUpdateSimpleTitle(title *entities.SimpleTitle) (uint, error) {
+ result := database.DB.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "type"}, {Name: "title"}},
+ DoUpdates: clause.AssignmentColumns([]string{"type", "title"}),
+ }).Create(title)
+
+ if result.Error != nil {
+ logger.Errorf("Meta", "Failed to create or update title: %v", result.Error)
+ return 0, errors.New("failed to create or update title")
+ }
+
+ return title.ID, nil
+}
+
+func CreateOrUpdateExternalURL(url *entities.ExternalURL) (uint, error) {
+ result := database.DB.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "name"}, {Name: "url"}},
+ DoUpdates: clause.AssignmentColumns([]string{"name", "url"}),
+ }).Create(url)
+
+ if result.Error != nil {
+ logger.Errorf("Meta", "Failed to create or update external URL: %v", result.Error)
+ return 0, errors.New("failed to create or update external URL")
+ }
+
+ return url.ID, nil
+}
diff --git a/repositories/producer.go b/repositories/producer.go
new file mode 100644
index 0000000..dade0d1
--- /dev/null
+++ b/repositories/producer.go
@@ -0,0 +1,26 @@
+package repositories
+
+import (
+ "errors"
+ "metachan/database"
+ "metachan/entities"
+ "metachan/utils/logger"
+
+ "gorm.io/gorm/clause"
+)
+
+func CreateOrUpdateProducer(producer *entities.Producer) error {
+ result := database.DB.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "mal_id"}},
+ DoUpdates: clause.AssignmentColumns([]string{
+ "url", "favorites", "count", "established", "about", "image_id",
+ }),
+ }).Create(producer)
+
+ if result.Error != nil {
+ logger.Errorf("Producer", "Failed to create or update producer: %v", result.Error)
+ return errors.New("failed to create or update producer")
+ }
+
+ return nil
+}
diff --git a/types/anilist.go b/types/anilist.go
new file mode 100644
index 0000000..8f14951
--- /dev/null
+++ b/types/anilist.go
@@ -0,0 +1,246 @@
+package types
+
+type AnilistTitle struct {
+ Romaji string `json:"romaji"`
+ English string `json:"english"`
+ Native string `json:"native"`
+ UserPreferred string `json:"userPreferred"`
+}
+
+type AnilistDate struct {
+ Year int `json:"year"`
+ Month int `json:"month"`
+ Day int `json:"day"`
+}
+
+type AnilistTrailer struct {
+ ID string `json:"id"`
+ Site string `json:"site"`
+ Thumbnail string `json:"thumbnail"`
+}
+
+type AnilistCoverImage struct {
+ ExtraLarge string `json:"extraLarge"`
+ Large string `json:"large"`
+ Medium string `json:"medium"`
+ Color string `json:"color"`
+}
+
+type AnilistImage struct {
+ Large string `json:"large"`
+ Medium string `json:"medium"`
+}
+
+type AnilistName struct {
+ First string `json:"first"`
+ Last string `json:"last"`
+ Middle string `json:"middle"`
+ Full string `json:"full"`
+ Native string `json:"native"`
+ UserPreferred string `json:"userPreferred"`
+}
+
+type AnilistTag struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Category string `json:"category"`
+ Rank int `json:"rank"`
+ IsGeneralSpoiler bool `json:"isGeneralSpoiler"`
+ IsMediaSpoiler bool `json:"isMediaSpoiler"`
+ IsAdult bool `json:"isAdult"`
+}
+
+type AnilistRelationNode struct {
+ ID int `json:"id"`
+ Title AnilistTitle `json:"title"`
+ Format string `json:"format"`
+ Type string `json:"type"`
+ Status string `json:"status"`
+ CoverImage AnilistCoverImage `json:"coverImage"`
+ BannerImage string `json:"bannerImage"`
+}
+
+type AnilistRelationEdge struct {
+ ID int `json:"id"`
+ RelationType string `json:"relationType"`
+ Node AnilistRelationNode `json:"node"`
+}
+
+type AnilistRelations struct {
+ Edges []AnilistRelationEdge `json:"edges"`
+}
+
+type AnilistCharacterNode struct {
+ ID int `json:"id"`
+ Name AnilistName `json:"name"`
+ Image AnilistImage `json:"image"`
+ Description string `json:"description"`
+ Age string `json:"age"`
+}
+
+type AnilistCharacterEdge struct {
+ Role string `json:"role"`
+ Node AnilistCharacterNode `json:"node"`
+}
+
+type AnilistCharacters struct {
+ Edges []AnilistCharacterEdge `json:"edges"`
+}
+
+type AnilistStaffNode struct {
+ ID int `json:"id"`
+ Name AnilistName `json:"name"`
+ Image AnilistImage `json:"image"`
+ Description string `json:"description"`
+ PrimaryOccupations []string `json:"primaryOccupations"`
+ Gender string `json:"gender"`
+ Age int `json:"age"`
+ LanguageV2 string `json:"languageV2"`
+}
+
+type AnilistStaffEdge struct {
+ Role string `json:"role"`
+ Node AnilistStaffNode `json:"node"`
+}
+
+type AnilistStaff struct {
+ Edges []AnilistStaffEdge `json:"edges"`
+}
+
+type AnilistStudioNode struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+}
+
+type AnilistStudioEdge struct {
+ IsMain bool `json:"isMain"`
+ Node AnilistStudioNode `json:"node"`
+}
+
+type AnilistStudios struct {
+ Edges []AnilistStudioEdge `json:"edges"`
+}
+
+type AnilistNextAiringEpisode struct {
+ ID int `json:"id"`
+ AiringAt int `json:"airingAt"`
+ TimeUntilAiring int `json:"timeUntilAiring"`
+ Episode int `json:"episode"`
+}
+
+type AnilistScheduleNode struct {
+ ID int `json:"id"`
+ Episode int `json:"episode"`
+ AiringAt int `json:"airingAt"`
+ TimeUntilAiring int `json:"timeUntilAiring"`
+}
+
+type AnilistAiringSchedule struct {
+ Nodes []AnilistScheduleNode `json:"nodes"`
+}
+
+type AnilistTrendNode struct {
+ Date int `json:"date"`
+ Trending int `json:"trending"`
+ Popularity int `json:"popularity"`
+ InProgress int `json:"inProgress"`
+}
+
+type AnilistTrends struct {
+ Nodes []AnilistTrendNode `json:"nodes"`
+}
+
+type AnilistExternalLink struct {
+ ID int `json:"id"`
+ URL string `json:"url"`
+ Site string `json:"site"`
+}
+
+type AnilistStreamingEpisode struct {
+ Title string `json:"title"`
+ Thumbnail string `json:"thumbnail"`
+ URL string `json:"url"`
+ Site string `json:"site"`
+}
+
+type AnilistRanking struct {
+ ID int `json:"id"`
+ Rank int `json:"rank"`
+ Type string `json:"type"`
+ Format string `json:"format"`
+ Year int `json:"year"`
+ Season string `json:"season"`
+ AllTime bool `json:"allTime"`
+ Context string `json:"context"`
+}
+
+type AnilistScoreDistribution struct {
+ Score int `json:"score"`
+ Amount int `json:"amount"`
+}
+
+type AnilistStatusDistribution struct {
+ Status string `json:"status"`
+ Amount int `json:"amount"`
+}
+
+type AnilistStats struct {
+ ScoreDistribution []AnilistScoreDistribution `json:"scoreDistribution"`
+ StatusDistribution []AnilistStatusDistribution `json:"statusDistribution"`
+}
+
+type AnilistMedia struct {
+ ID int `json:"id"`
+ MALID int `json:"idMal"`
+ Title AnilistTitle `json:"title"`
+ Type string `json:"type"`
+ Format string `json:"format"`
+ Status string `json:"status"`
+ Description string `json:"description"`
+ StartDate AnilistDate `json:"startDate"`
+ EndDate AnilistDate `json:"endDate"`
+ Season string `json:"season"`
+ SeasonYear int `json:"seasonYear"`
+ Episodes int `json:"episodes"`
+ Duration int `json:"duration"`
+ Chapters int `json:"chapters"`
+ Volumes int `json:"volumes"`
+ CountryOfOrigin string `json:"countryOfOrigin"`
+ IsLicensed bool `json:"isLicensed"`
+ Source string `json:"source"`
+ Hashtag string `json:"hashtag"`
+ Trailer AnilistTrailer `json:"trailer"`
+ CoverImage AnilistCoverImage `json:"coverImage"`
+ BannerImage string `json:"bannerImage"`
+ Genres []string `json:"genres"`
+ Synonyms []string `json:"synonyms"`
+ AverageScore int `json:"averageScore"`
+ MeanScore int `json:"meanScore"`
+ Popularity int `json:"popularity"`
+ IsLocked bool `json:"isLocked"`
+ Trending int `json:"trending"`
+ Favorites int `json:"favorites"`
+ Tags []AnilistTag `json:"tags"`
+ Relations AnilistRelations `json:"relations"`
+ Characters AnilistCharacters `json:"characters"`
+ Staff AnilistStaff `json:"staff"`
+ Studios AnilistStudios `json:"studios"`
+ IsAdult bool `json:"isAdult"`
+ NextAiringEpisode AnilistNextAiringEpisode `json:"nextAiringEpisode"`
+ AiringSchedule AnilistAiringSchedule `json:"airingSchedule"`
+ Trends AnilistTrends `json:"trends"`
+ ExternalLinks []AnilistExternalLink `json:"externalLinks"`
+ StreamingEpisodes []AnilistStreamingEpisode `json:"streamingEpisodes"`
+ Rankings []AnilistRanking `json:"rankings"`
+ Stats AnilistStats `json:"stats"`
+ SiteURL string `json:"siteUrl"`
+}
+
+type AnilistAnimeData struct {
+ Media AnilistMedia `json:"media"`
+}
+
+type AnilistAnimeResponse struct {
+ Data AnilistAnimeData `json:"data"`
+}
diff --git a/utils/api/anilist/anilist.go b/utils/api/anilist/anilist.go
index 61e3fbb..7d8036b 100644
--- a/utils/api/anilist/anilist.go
+++ b/utils/api/anilist/anilist.go
@@ -2,29 +2,131 @@ package anilist
import (
"bytes"
+ "context"
"encoding/json"
+ "errors"
"fmt"
+ "io"
+ "math"
+ "metachan/types"
"metachan/utils/logger"
"net/http"
+ "strconv"
+ "time"
)
-// AniListClient provides methods for interacting with the AniList API
-type AniListClient struct {
- client *http.Client
- maxRetries int
-}
+const (
+ anilistAPIBaseURL = "https://graphql.anilist.co"
+ contextTimeout = 60 * time.Second
+)
-// NewAniListClient creates a new AniList API client
-func NewAniListClient() *AniListClient {
- return &AniListClient{
- client: &http.Client{},
+var (
+ clientInstance = &client{
+ httpClient: &http.Client{
+ Timeout: 15 * time.Second,
+ },
maxRetries: 3,
+ backoff: 1 * time.Second,
+ }
+)
+
+func (c *client) getBackOffDuration(attempt int) time.Duration {
+ return time.Duration(float64(c.backoff) * math.Pow(2, float64(attempt-1)))
+}
+
+func (c *client) getRetryAfterDuration(resp *http.Response) time.Duration {
+ if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
+ if seconds, err := strconv.Atoi(retryAfter); err == nil {
+ return time.Duration(seconds) * time.Second
+ }
}
+ return c.backoff
}
-// GetAnime fetches anime details from AniList by ID using a simpler approach
-func (c *AniListClient) GetAnime(anilistID int) (*AnilistAnimeResponse, error) {
- // Create a much simpler request with minimal formatting that might trigger Cloudflare
+func (c *client) handleRetry(retries *int, reason string, retryAfter time.Duration) bool {
+ *retries++
+ if *retries >= c.maxRetries {
+ return false
+ }
+
+ backoffDuration := c.getBackOffDuration(*retries)
+ if retryAfter > backoffDuration {
+ backoffDuration = retryAfter
+ }
+
+ logger.Warnf("AnilistClient", "%s (attempt %d/%d)", reason, *retries, c.maxRetries)
+ time.Sleep(backoffDuration)
+ return true
+}
+
+func (c *client) makeRequest(ctx context.Context, query string, variables map[string]interface{}) ([]byte, error) {
+ var response *http.Response
+ var retries int
+
+ requestBody := map[string]interface{}{
+ "query": query,
+ "variables": variables,
+ }
+
+ jsonData, err := json.Marshal(requestBody)
+ if err != nil {
+ logger.Errorf("AnilistClient", "Failed to marshal request body: %v", err)
+ return nil, errors.New("failed to create request to Anilist API")
+ }
+
+ for retries < c.maxRetries {
+ request, err := http.NewRequestWithContext(ctx, "POST", anilistAPIBaseURL, bytes.NewBuffer(jsonData))
+ if err != nil {
+ logger.Errorf("AnilistClient", "Failed to create request: %v", err)
+ return nil, errors.New("failed to create request to Anilist API")
+ }
+
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Accept", "application/json")
+ request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
+
+ response, err = c.httpClient.Do(request)
+ if err != nil {
+ if !c.handleRetry(&retries, fmt.Sprintf("Request failed: %v", err), 0) {
+ logger.Errorf("AnilistClient", "All retries exhausted for request: %v", err)
+ return nil, errors.New("failed to make request to Anilist API after max retries")
+ }
+ continue
+ }
+
+ defer response.Body.Close()
+
+ switch response.StatusCode {
+ case http.StatusTooManyRequests:
+ retryAfter := c.getRetryAfterDuration(response)
+ if !c.handleRetry(&retries, "Rate limited", retryAfter) {
+ logger.Errorf("AnilistClient", "All retries exhausted for request")
+ return nil, errors.New("failed to make request to Anilist API after max retries")
+ }
+ case http.StatusOK:
+ bytes, err := io.ReadAll(response.Body)
+
+ if err != nil {
+ logger.Errorf("AnilistClient", "Failed to read response body: %v", err)
+ return nil, errors.New("failed to read response from Anilist API")
+ }
+
+ return bytes, nil
+ default:
+ retries++
+ backoffDuration := c.getBackOffDuration(retries)
+
+ logger.Warnf("AnilistClient", "Request returned status %d (attempt %d/%d)", response.StatusCode, retries, c.maxRetries)
+
+ time.Sleep(backoffDuration)
+ }
+ }
+
+ logger.Errorf("AnilistClient", "All retries exhausted for request")
+ return nil, errors.New("failed to make request to Anilist API after max retries")
+}
+
+func GetAnimeByAnilistID(id int) (*types.AnilistAnimeResponse, error) {
query := `
query($id: Int) {
Media(id: $id, type: ANIME) {
@@ -187,88 +289,29 @@ func (c *AniListClient) GetAnime(anilistID int) (*AnilistAnimeResponse, error) {
}
`
- // Create a simple JSON structure with variables
- requestBody := map[string]interface{}{
- "query": query,
- "variables": map[string]interface{}{
- "id": anilistID,
- },
+ variables := map[string]interface{}{
+ "id": id,
}
- jsonData, err := json.Marshal(requestBody)
+ ctx, cancel := context.WithTimeout(context.Background(), contextTimeout)
+ defer cancel()
+
+ bytes, err := clientInstance.makeRequest(ctx, query, variables)
if err != nil {
- return nil, fmt.Errorf("failed to marshal request body: %w", err)
+ logger.Errorf("AnilistClient", "GetAnime failed for ID %d: %v", id, err)
+ return nil, errors.New("failed to fetch anime data from Anilist API")
}
- // Log the request for debugging
- logger.Log(fmt.Sprintf("Sending request to AniList for ID %d", anilistID), logger.LogOptions{
- Level: logger.Debug,
- Prefix: "AniList",
- })
-
- var resp *http.Response
- var lastErr error
- success := false
-
- for i := 0; i <= c.maxRetries && !success; i++ {
- req, err := http.NewRequest("POST", "https://graphql.anilist.co", bytes.NewBuffer(jsonData))
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
-
- // Add User-Agent to make the request look more like a browser
- req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
-
- resp, err = c.client.Do(req)
- if err != nil {
- lastErr = err
- logger.Log(fmt.Sprintf("AniList request attempt %d failed: %v", i+1, err), logger.LogOptions{
- Level: logger.Debug,
- Prefix: "AniList",
- })
- continue
- }
-
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body := make([]byte, 1024)
- n, _ := resp.Body.Read(body)
- lastErr = fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body[:n]))
- logger.Log(fmt.Sprintf("AniList returned non-200 status on attempt %d: %v", i+1, lastErr), logger.LogOptions{
- Level: logger.Debug,
- Prefix: "AniList",
- })
- continue
- }
-
- var anilistResponse AnilistAnimeResponse
- if err := json.NewDecoder(resp.Body).Decode(&anilistResponse); err != nil {
- lastErr = fmt.Errorf("failed to decode response: %w", err)
- continue
- }
-
- if anilistResponse.Data.Media.ID == 0 {
- lastErr = fmt.Errorf("no data found for Anilist ID %d", anilistID)
- continue
- }
-
- // Log cover image data for debugging
- if anilistResponse.Data.Media.CoverImage.ExtraLarge != "" {
- logger.Log(fmt.Sprintf("Found cover data - Color: %s, Image: %s",
- anilistResponse.Data.Media.CoverImage.Color,
- anilistResponse.Data.Media.CoverImage.ExtraLarge), logger.LogOptions{
- Level: logger.Debug,
- Prefix: "AniList",
- })
- }
+ var response types.AnilistAnimeResponse
+ if err := json.Unmarshal(bytes, &response); err != nil {
+ logger.Errorf("AnilistClient", "Failed to unmarshal response for ID %d: %v", id, err)
+ return nil, errors.New("failed to parse anime data from Anilist API")
+ }
- success = true
- return &anilistResponse, nil
+ if response.Data.Media.ID == 0 {
+ logger.Errorf("AnilistClient", "No data found for Anilist ID %d", id)
+ return nil, fmt.Errorf("no data found for Anilist ID %d", id)
}
- return nil, fmt.Errorf("failed after %d retries: %w", c.maxRetries, lastErr)
+ return &response, nil
}
diff --git a/utils/api/anilist/types.go b/utils/api/anilist/types.go
index 367719b..4a49d57 100644
--- a/utils/api/anilist/types.go
+++ b/utils/api/anilist/types.go
@@ -1,207 +1,12 @@
package anilist
-// AnilistAnimeResponse represents the response from AniList API
-type AnilistAnimeResponse struct {
- Data struct {
- Media struct {
- ID int `json:"id"`
- MALID int `json:"idMal"`
- Title struct {
- Romaji string `json:"romaji"`
- English string `json:"english"`
- Native string `json:"native"`
- UserPreferred string `json:"userPreferred"`
- } `json:"title"`
- Type string `json:"type"`
- Format string `json:"format"`
- Status string `json:"status"`
- Description string `json:"description"`
- StartDate struct {
- Year int `json:"year"`
- Month int `json:"month"`
- Day int `json:"day"`
- } `json:"startDate"`
- EndDate struct {
- Year int `json:"year"`
- Month int `json:"month"`
- Day int `json:"day"`
- } `json:"endDate"`
- Season string `json:"season"`
- SeasonYear int `json:"seasonYear"`
- Episodes int `json:"episodes"`
- Duration int `json:"duration"`
- Chapters int `json:"chapters"`
- Volumes int `json:"volumes"`
- CountryOfOrigin string `json:"countryOfOrigin"`
- IsLicensed bool `json:"isLicensed"`
- Source string `json:"source"`
- Hashtag string `json:"hashtag"`
- Trailer struct {
- ID string `json:"id"`
- Site string `json:"site"`
- Thumbnail string `json:"thumbnail"`
- } `json:"trailer"`
- CoverImage struct {
- ExtraLarge string `json:"extraLarge"`
- Large string `json:"large"`
- Medium string `json:"medium"`
- Color string `json:"color"`
- } `json:"coverImage"`
- BannerImage string `json:"bannerImage"`
- Genres []string `json:"genres"`
- Synonyms []string `json:"synonyms"`
- AverageScore int `json:"averageScore"`
- MeanScore int `json:"meanScore"`
- Popularity int `json:"popularity"`
- IsLocked bool `json:"isLocked"`
- Trending int `json:"trending"`
- Favorites int `json:"favorites"`
- Tags []struct {
- ID int `json:"id"`
- Name string `json:"name"`
- Description string `json:"description"`
- Category string `json:"category"`
- Rank int `json:"rank"`
- IsGeneralSpoiler bool `json:"isGeneralSpoiler"`
- IsMediaSpoiler bool `json:"isMediaSpoiler"`
- IsAdult bool `json:"isAdult"`
- } `json:"tags"`
- Relations struct {
- Edges []struct {
- ID int `json:"id"`
- RelationType string `json:"relationType"`
- Node struct {
- ID int `json:"id"`
- Title struct {
- Romaji string `json:"romaji"`
- English string `json:"english"`
- Native string `json:"native"`
- UserPreferred string `json:"userPreferred"`
- } `json:"title"`
- Format string `json:"format"`
- Type string `json:"type"`
- Status string `json:"status"`
- CoverImage struct {
- ExtraLarge string `json:"extraLarge"`
- Large string `json:"large"`
- Medium string `json:"medium"`
- Color string `json:"color"`
- } `json:"coverImage"`
- BannerImage string `json:"bannerImage"`
- } `json:"node"`
- } `json:"edges"`
- } `json:"relations"`
- Characters struct {
- Edges []struct {
- Role string `json:"role"`
- Node struct {
- ID int `json:"id"`
- Name struct {
- First string `json:"first"`
- Last string `json:"last"`
- Middle string `json:"middle"`
- Full string `json:"full"`
- Native string `json:"native"`
- UserPreferred string `json:"userPreferred"`
- } `json:"name"`
- Image struct {
- Large string `json:"large"`
- Medium string `json:"medium"`
- } `json:"image"`
- Description string `json:"description"`
- Age string `json:"age"`
- } `json:"node"`
- } `json:"edges"`
- } `json:"characters"`
- Staff struct {
- Edges []struct {
- Role string `json:"role"`
- Node struct {
- ID int `json:"id"`
- Name struct {
- First string `json:"first"`
- Last string `json:"last"`
- Middle string `json:"middle"`
- Full string `json:"full"`
- Native string `json:"native"`
- UserPreferred string `json:"userPreferred"`
- } `json:"name"`
- Image struct {
- Large string `json:"large"`
- Medium string `json:"medium"`
- } `json:"image"`
- Description string `json:"description"`
- PrimaryOccupations []string `json:"primaryOccupations"`
- Gender string `json:"gender"`
- Age int `json:"age"`
- LanguageV2 string `json:"languageV2"`
- } `json:"node"`
- } `json:"edges"`
- } `json:"staff"`
- Studios struct {
- Edges []struct {
- IsMain bool `json:"isMain"`
- Node struct {
- ID int `json:"id"`
- Name string `json:"name"`
- } `json:"node"`
- } `json:"edges"`
- } `json:"studios"`
- IsAdult bool `json:"isAdult"`
- NextAiringEpisode struct {
- ID int `json:"id"`
- AiringAt int `json:"airingAt"`
- TimeUntilAiring int `json:"timeUntilAiring"`
- Episode int `json:"episode"`
- } `json:"nextAiringEpisode"`
- AiringSchedule struct {
- Nodes []struct {
- ID int `json:"id"`
- Episode int `json:"episode"`
- AiringAt int `json:"airingAt"`
- TimeUntilAiring int `json:"timeUntilAiring"`
- } `json:"nodes"`
- } `json:"airingSchedule"`
- Trends struct {
- Nodes []struct {
- Date int `json:"date"`
- Trending int `json:"trending"`
- Popularity int `json:"popularity"`
- InProgress int `json:"inProgress"`
- } `json:"nodes"`
- } `json:"trends"`
- ExternalLinks []struct {
- ID int `json:"id"`
- URL string `json:"url"`
- Site string `json:"site"`
- } `json:"externalLinks"`
- StreamingEpisodes []struct {
- Title string `json:"title"`
- Thumbnail string `json:"thumbnail"`
- URL string `json:"url"`
- Site string `json:"site"`
- } `json:"streamingEpisodes"`
- Rankings []struct {
- ID int `json:"id"`
- Rank int `json:"rank"`
- Type string `json:"type"`
- Format string `json:"format"`
- Year int `json:"year"`
- Season string `json:"season"`
- AllTime bool `json:"allTime"`
- Context string `json:"context"`
- } `json:"rankings"`
- Stats struct {
- ScoreDistribution []struct {
- Score int `json:"score"`
- Amount int `json:"amount"`
- } `json:"scoreDistribution"`
- StatusDistribution []struct {
- Status string `json:"status"`
- Amount int `json:"amount"`
- } `json:"statusDistribution"`
- } `json:"stats"`
- SiteURL string `json:"siteUrl"`
- } `json:"media"`
- } `json:"data"`
+import (
+ "net/http"
+ "time"
+)
+
+type client struct {
+ httpClient *http.Client
+ maxRetries int
+ backoff time.Duration
}
diff --git a/utils/api/jikan/jikan.go b/utils/api/jikan/jikan.go
index e94223e..0121659 100644
--- a/utils/api/jikan/jikan.go
+++ b/utils/api/jikan/jikan.go
@@ -304,391 +304,3 @@ func GetProducerByID(producerID int) (*types.JikanSingleProducerResponse, error)
}
return &response, nil
}
-
-// var (
-// // Global Jikan rate limiters
-// jikanPerSecLimiter = ratelimit.NewRateLimiter(3, time.Second)
-// jikanPerMinLimiter = ratelimit.NewRateLimiter(60, time.Minute)
-// jikanLimiter = ratelimit.NewMultiLimiter(jikanPerSecLimiter, jikanPerMinLimiter)
-// )
-
-// // JikanClient provides methods to interact with the Jikan API
-// type JikanClient struct {
-// client *http.Client
-// maxRetries int
-// baseBackoff time.Duration
-// }
-
-// // NewJikanClient creates a new Jikan API client
-// func NewJikanClient() *JikanClient {
-// return &JikanClient{
-// client: &http.Client{
-// Timeout: 15 * time.Second,
-// },
-// maxRetries: 3,
-// baseBackoff: 1 * time.Second,
-// }
-// }
-
-// // WaitForRateLimit waits until a request can be made according to rate limiting rules
-// func (c *JikanClient) WaitForRateLimit() {
-// jikanLimiter.Wait()
-// }
-
-// // makeRequest makes an HTTP request with retries and proper error handling
-// func (c *JikanClient) makeRequest(ctx context.Context, url string) ([]byte, error) {
-// var bodyBytes []byte
-// var statusCode int
-
-// retries := 0
-// for retries <= c.maxRetries {
-// // Wait for rate limiter before attempting request
-// c.WaitForRateLimit()
-
-// // Create the request with timeout context
-// req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
-// if err != nil {
-// return nil, fmt.Errorf("failed to create request: %w", err)
-// }
-
-// // Execute the request
-// resp, err := c.client.Do(req)
-// if err != nil {
-// if retries < c.maxRetries {
-// retries++
-// backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(2, float64(retries-1)))
-// time.Sleep(backoffTime)
-// continue
-// }
-// return nil, fmt.Errorf("failed to execute request after %d retries: %w", c.maxRetries, err)
-// }
-// defer resp.Body.Close()
-
-// statusCode = resp.StatusCode
-
-// // Handle rate limiting with exponential backoff
-// if statusCode == http.StatusTooManyRequests {
-// if retries < c.maxRetries {
-// retries++
-// backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(1.5, float64(retries-1)))
-
-// // Respect Retry-After header if available
-// if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
-// if seconds, err := strconv.Atoi(retryAfter); err == nil {
-// backoffTime = time.Duration(seconds) * time.Second
-// }
-// }
-
-// time.Sleep(backoffTime)
-// continue
-// }
-// return nil, fmt.Errorf("rate limited after %d retries", c.maxRetries)
-// } else if statusCode != http.StatusOK {
-// if retries < c.maxRetries {
-// retries++
-// backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(2, float64(retries-1)))
-// time.Sleep(backoffTime)
-// continue
-// }
-// return nil, fmt.Errorf("request failed with status: %d", statusCode)
-// }
-
-// // Limit response body size to prevent memory issues
-// bodyBytes, err = io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit
-// if err != nil {
-// if retries < c.maxRetries {
-// retries++
-// backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(2, float64(retries-1)))
-// time.Sleep(backoffTime)
-// continue
-// }
-// return nil, fmt.Errorf("failed to read response body: %w", err)
-// }
-
-// // Success, break the retry loop
-// return bodyBytes, nil
-// }
-
-// return nil, fmt.Errorf("exhausted all retries with status code: %d", statusCode)
-// }
-
-// // GetAnime fetches basic anime information by MAL ID
-// func (c *JikanClient) GetAnime(malID int) (*JikanAnimeResponse, error) {
-// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d", malID)
-
-// ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
-// defer cancel()
-
-// bodyBytes, err := c.makeRequest(ctx, apiURL)
-// if err != nil {
-// return nil, fmt.Errorf("failed to get anime data: %w", err)
-// }
-
-// var animeResponse JikanAnimeResponse
-// if err := json.Unmarshal(bodyBytes, &animeResponse); err != nil {
-// return nil, fmt.Errorf("failed to decode response: %w", err)
-// }
-
-// if animeResponse.Data.MALID == 0 {
-// return nil, fmt.Errorf("no data found for MAL ID %d", malID)
-// }
-
-// return &animeResponse, nil
-// }
-
-// // GetFullAnime fetches detailed anime information by MAL ID
-// func (c *JikanClient) GetFullAnime(malID int) (*JikanAnimeResponse, error) {
-// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/full", malID)
-
-// ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
-// defer cancel()
-
-// bodyBytes, err := c.makeRequest(ctx, apiURL)
-// if err != nil {
-// // Fallback to curl if HTTP client fails
-// var curlErr error
-// bodyBytes, curlErr = c.makeRequestWithCurl(apiURL)
-// if curlErr != nil {
-// return nil, fmt.Errorf("failed to get anime full data via HTTP (%w) and curl (%v)", err, curlErr)
-// }
-// }
-
-// var animeResponse JikanAnimeResponse
-// if err := json.Unmarshal(bodyBytes, &animeResponse); err != nil {
-// return nil, fmt.Errorf("failed to decode response: %w", err)
-// }
-
-// if animeResponse.Data.MALID == 0 {
-// return nil, fmt.Errorf("no data found for MAL ID %d", malID)
-// }
-
-// return &animeResponse, nil
-// }
-
-// // makeRequestWithCurl uses curl as a fallback when Go HTTP client fails
-// func (c *JikanClient) makeRequestWithCurl(url string) ([]byte, error) {
-// c.WaitForRateLimit()
-
-// cmd := exec.Command("curl", "-s", "-H", "Accept: application/json", url)
-// output, err := cmd.Output()
-// if err != nil {
-// return nil, fmt.Errorf("curl command failed: %w", err)
-// }
-
-// return output, nil
-// }
-
-// // GetAnimeEpisodes fetches all episodes for an anime by MAL ID
-// func (c *JikanClient) GetAnimeEpisodes(malID int) (*JikanAnimeEpisodeResponse, error) {
-// result := JikanAnimeEpisodeResponse{
-// Data: []JikanAnimeEpisode{},
-// }
-
-// maxPages := 25 // Safety limit to avoid excessive requests
-// page := 1
-// maxAttempts := 15 // Maximum number of attempts across all pages
-// totalAttempts := 0
-
-// for page <= maxPages && totalAttempts < maxAttempts {
-// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/episodes?page=%d", malID, page)
-
-// ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
-
-// totalAttempts++
-
-// bodyBytes, err := c.makeRequest(ctx, apiURL)
-// cancel()
-
-// if err != nil {
-// // If we have some episodes already, return them rather than failing
-// if len(result.Data) > 0 {
-// result.Pagination.HasNextPage = false
-// break
-// }
-// return nil, fmt.Errorf("failed to get anime episodes page %d: %w", page, err)
-// }
-
-// var pageResponse JikanAnimeEpisodeResponse
-// if err := json.Unmarshal(bodyBytes, &pageResponse); err != nil {
-// // Return what we have if we got some pages successfully
-// if len(result.Data) > 0 {
-// result.Pagination.HasNextPage = false
-// break
-// }
-// return nil, fmt.Errorf("failed to decode episodes response: %w", err)
-// }
-
-// // Append episodes from this page
-// result.Data = append(result.Data, pageResponse.Data...)
-// result.Pagination = pageResponse.Pagination
-
-// // Check if we need to fetch more pages
-// if !pageResponse.Pagination.HasNextPage {
-// break
-// }
-
-// page++
-// }
-
-// return &result, nil
-// }
-
-// // GetAnimeCharacters fetches all characters for an anime by MAL ID
-// func (c *JikanClient) GetAnimeCharacters(malID int) (*JikanAnimeCharacterResponse, error) {
-// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/characters", malID)
-
-// ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
-// defer cancel()
-
-// bodyBytes, err := c.makeRequest(ctx, apiURL)
-// if err != nil {
-// return nil, fmt.Errorf("failed to get anime characters: %w", err)
-// }
-
-// var characterResponse JikanAnimeCharacterResponse
-// if err := json.Unmarshal(bodyBytes, &characterResponse); err != nil {
-// return nil, fmt.Errorf("failed to decode characters response: %w", err)
-// }
-
-// return &characterResponse, nil
-// }
-
-// // GetAnimeGenres fetches all anime genres from MAL
-// func (c *JikanClient) GetAnimeGenres() (*JikanGenresResponse, error) {
-// apiURL := "https://api.jikan.moe/v4/genres/anime"
-
-// ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
-// defer cancel()
-
-// bodyBytes, err := c.makeRequest(ctx, apiURL)
-// if err != nil {
-// return nil, fmt.Errorf("failed to get anime genres: %w", err)
-// }
-
-// var genresResponse JikanGenresResponse
-// if err := json.Unmarshal(bodyBytes, &genresResponse); err != nil {
-// return nil, fmt.Errorf("failed to decode genres response: %w", err)
-// }
-
-// return &genresResponse, nil
-// }
-
-// // GetAnimeByGenre fetches paginated anime list for a specific genre
-// func (c *JikanClient) GetAnimeByGenre(genreID int, page int, limit int) (*JikanAnimeListResponse, error) {
-// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?genres=%d&page=%d&limit=%d", genreID, page, limit)
-
-// ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
-// defer cancel()
-
-// bodyBytes, err := c.makeRequest(ctx, apiURL)
-// if err != nil {
-// return nil, fmt.Errorf("failed to get anime by genre: %w", err)
-// }
-
-// var listResponse JikanAnimeListResponse
-// if err := json.Unmarshal(bodyBytes, &listResponse); err != nil {
-// return nil, fmt.Errorf("failed to decode anime list response: %w", err)
-// }
-
-// return &listResponse, nil
-// }
-
-// // GetAnimeProducers fetches all producers from Jikan API (paginated)
-// func (c *JikanClient) GetAnimeProducers(page int) (*JikanProducersFullResponse, error) {
-// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/producers?page=%d", page)
-
-// ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
-// defer cancel()
-
-// bodyBytes, err := c.makeRequest(ctx, apiURL)
-// if err != nil {
-// return nil, fmt.Errorf("failed to get producers: %w", err)
-// }
-
-// var response JikanProducersFullResponse
-// if err := json.Unmarshal(bodyBytes, &response); err != nil {
-// return nil, fmt.Errorf("failed to decode producers response: %w", err)
-// }
-
-// return &response, nil
-// }
-
-// // GetProducerExternal fetches external URLs for a specific producer
-// func (c *JikanClient) GetProducerExternal(producerID int) (*JikanProducerExternalResponse, error) {
-// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/producers/%d/external", producerID)
-
-// ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
-// defer cancel()
-
-// bodyBytes, err := c.makeRequest(ctx, apiURL)
-// if err != nil {
-// return nil, fmt.Errorf("failed to get producer external: %w", err)
-// }
-
-// var response JikanProducerExternalResponse
-// if err := json.Unmarshal(bodyBytes, &response); err != nil {
-// return nil, fmt.Errorf("failed to decode producer external response: %w", err)
-// }
-
-// return &response, nil
-// }
-
-// // GetAnimeByProducer fetches paginated anime list by producer ID
-// func (c *JikanClient) GetAnimeByProducer(producerID int, page int, limit int) (*JikanAnimeListResponse, error) {
-// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?producers=%d&page=%d&limit=%d", producerID, page, limit)
-
-// ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
-// defer cancel()
-
-// bodyBytes, err := c.makeRequest(ctx, apiURL)
-// if err != nil {
-// return nil, fmt.Errorf("failed to get anime by producer: %w", err)
-// }
-
-// var listResponse JikanAnimeListResponse
-// if err := json.Unmarshal(bodyBytes, &listResponse); err != nil {
-// return nil, fmt.Errorf("failed to decode anime list response: %w", err)
-// }
-
-// return &listResponse, nil
-// }
-
-// // GetAnimeByStudio fetches paginated anime list by studio ID (uses producers endpoint)
-// func (c *JikanClient) GetAnimeByStudio(studioID int, page int, limit int) (*JikanAnimeListResponse, error) {
-// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?producers=%d&page=%d&limit=%d", studioID, page, limit)
-
-// ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
-// defer cancel()
-
-// bodyBytes, err := c.makeRequest(ctx, apiURL)
-// if err != nil {
-// return nil, fmt.Errorf("failed to get anime by studio: %w", err)
-// }
-
-// var listResponse JikanAnimeListResponse
-// if err := json.Unmarshal(bodyBytes, &listResponse); err != nil {
-// return nil, fmt.Errorf("failed to decode anime list response: %w", err)
-// }
-
-// return &listResponse, nil
-// }
-
-// // GetAnimeByLicensor fetches paginated anime list by licensor ID (uses producers endpoint)
-// func (c *JikanClient) GetAnimeByLicensor(licensorID int, page int, limit int) (*JikanAnimeListResponse, error) {
-// apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?producers=%d&page=%d&limit=%d", licensorID, page, limit)
-
-// ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
-// defer cancel()
-
-// bodyBytes, err := c.makeRequest(ctx, apiURL)
-// if err != nil {
-// return nil, fmt.Errorf("failed to get anime by licensor: %w", err)
-// }
-
-// var listResponse JikanAnimeListResponse
-// if err := json.Unmarshal(bodyBytes, &listResponse); err != nil {
-// return nil, fmt.Errorf("failed to decode anime list response: %w", err)
-// }
-
-// return &listResponse, nil
-// }