aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2025-05-02 17:39:31 +0530
committerBobby <[email protected]>2025-05-02 17:39:31 +0530
commit8fa6d4dd33abe412bb09af949810ee0a1f9678bf (patch)
treef14f0b11eee05415e83d96fb1fa45ca2491dc0f2
parentefce17623215f4f2183c66a832b23a14ee662880 (diff)
downloadmetachan-8fa6d4dd33abe412bb09af949810ee0a1f9678bf.tar.xz
metachan-8fa6d4dd33abe412bb09af949810ee0a1f9678bf.zip
malsync + crunchyroll integration; anime logos and episode thumbnails in response
-rw-r--r--types/anime.go66
-rw-r--r--utils/anime/anilist.go261
-rw-r--r--utils/anime/anime.go665
-rw-r--r--utils/anime/crunchyroll.go201
-rw-r--r--utils/anime/jikan.go368
-rw-r--r--utils/anime/malsync.go98
-rw-r--r--utils/anime/tmdb.go57
7 files changed, 1051 insertions, 665 deletions
diff --git a/types/anime.go b/types/anime.go
index a644d7d..4e45bfb 100644
--- a/types/anime.go
+++ b/types/anime.go
@@ -29,14 +29,15 @@ type EpisodeTitles struct {
}
type AnimeSingleEpisode struct {
- Titles EpisodeTitles `json:"titles"`
- Description string `json:"description"`
- Aired string `json:"aired"`
- Score float64 `json:"score"`
- Filler bool `json:"filler"`
- Recap bool `json:"recap"`
- ForumURL string `json:"forum_url"`
- URL string `json:"url"`
+ Titles EpisodeTitles `json:"titles"`
+ Description string `json:"description"`
+ Aired string `json:"aired"`
+ Score float64 `json:"score"`
+ Filler bool `json:"filler"`
+ Recap bool `json:"recap"`
+ ForumURL string `json:"forum_url"`
+ URL string `json:"url"`
+ ThumbnailURL string `json:"thumbnail_url"`
}
type AnimeEpisodes struct {
@@ -45,6 +46,24 @@ type AnimeEpisodes struct {
Episodes []AnimeSingleEpisode `json:"episodes"`
}
+type AnimeLogos struct {
+ Small string `json:"small,omitempty"`
+ Medium string `json:"medium,omitempty"`
+ Large string `json:"large,omitempty"`
+ XLarge string `json:"xlarge,omitempty"`
+ Original string `json:"original,omitempty"`
+}
+
+// type AnimeSeason struct {
+// MALID int `json:"mal_id"`
+// Titles AnimeTitles `json:"titles"`
+// Synopsis string `json:"synopsis"`
+// Type AniSyncType `json:"type"`
+// Source string `json:"source"`
+// Status string `json:"status"`
+// Duration string `json:"duration"`
+// Mappings AnimeMappings `json:"mappings"`
+
type Anime struct {
MALID int `json:"id"`
Titles AnimeTitles `json:"titles"`
@@ -53,6 +72,7 @@ type Anime struct {
Source string `json:"source"`
Status string `json:"status"`
Duration string `json:"duration"`
+ Logos AnimeLogos `json:"logos"`
Episodes AnimeEpisodes `json:"episodes"`
Mappings AnimeMappings `json:"mappings"`
}
@@ -394,3 +414,33 @@ type AnilistAnimeResponse struct {
} `json:"media"`
} `json:"data"`
}
+
+// MALSyncStreamingSite represents a single streaming site entry in the MALSync API
+type MALSyncStreamingSite struct {
+ ID int `json:"id,omitempty"`
+ Identifier any `json:"identifier"`
+ Image string `json:"image,omitempty"`
+ MalID int `json:"malId,omitempty"`
+ AniID int `json:"aniId,omitempty"`
+ Page string `json:"page"`
+ Title string `json:"title"`
+ Type string `json:"type"`
+ URL string `json:"url"`
+ External bool `json:"external,omitempty"`
+}
+
+// MALSyncSitesCollection represents the nested structure of streaming sites
+// Format: map[platformName]map[identifier]siteObject
+type MALSyncSitesCollection map[string]map[string]MALSyncStreamingSite
+
+// MALSyncAnimeResponse is the top-level response from the MALSync API
+type MALSyncAnimeResponse struct {
+ ID int `json:"id"`
+ Type string `json:"type"`
+ Title string `json:"title"`
+ URL string `json:"url"`
+ Total int `json:"total"`
+ Image string `json:"image"`
+ AnidbID int `json:"anidbId,omitempty"`
+ Sites MALSyncSitesCollection `json:"Sites"`
+}
diff --git a/utils/anime/anilist.go b/utils/anime/anilist.go
new file mode 100644
index 0000000..7a5842e
--- /dev/null
+++ b/utils/anime/anilist.go
@@ -0,0 +1,261 @@
+package anime
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "metachan/types"
+ "net/http"
+ "strings"
+)
+
+func getAnimeViaAnilist(anilistID int) (*types.AnilistAnimeResponse, error) {
+ graphQLQuery := fmt.Sprintf(`query {
+ Media(id: %d) {
+ id
+ idMal
+ title {
+ romaji
+ english
+ native
+ userPreferred
+ }
+ type
+ format
+ status
+ description
+ startDate {
+ year
+ month
+ day
+ }
+ endDate {
+ year
+ month
+ day
+ }
+ season
+ seasonYear
+ episodes
+ duration
+ chapters
+ volumes
+ countryOfOrigin
+ isLicensed
+ source
+ hashtag
+ trailer {
+ id
+ site
+ thumbnail
+ }
+ updatedAt
+ coverImage {
+ extraLarge
+ large
+ medium
+ color
+ }
+ bannerImage
+ genres
+ synonyms
+ averageScore
+ meanScore
+ popularity
+ isLocked
+ trending
+ favourites
+ tags {
+ id
+ name
+ description
+ category
+ rank
+ isGeneralSpoiler
+ isMediaSpoiler
+ isAdult
+ }
+ relations {
+ edges {
+ id
+ relationType
+ node {
+ id
+ title {
+ romaji
+ english
+ native
+ userPreferred
+ }
+ format
+ type
+ status
+ coverImage {
+ extraLarge
+ large
+ medium
+ color
+ }
+ bannerImage
+ }
+ }
+ }
+ characters {
+ edges {
+ role
+ node {
+ id
+ name {
+ first
+ last
+ middle
+ full
+ native
+ userPreferred
+ }
+ image {
+ large
+ medium
+ }
+ description
+ age
+ }
+ }
+ }
+ staff {
+ edges {
+ role
+ node {
+ id
+ name {
+ first
+ middle
+ last
+ full
+ native
+ userPreferred
+ }
+ image {
+ large
+ medium
+ }
+ description
+ primaryOccupations
+ gender
+ age
+ languageV2
+ }
+ }
+ }
+ studios {
+ edges {
+ isMain
+ node {
+ id
+ name
+ }
+ }
+ }
+ isAdult
+ nextAiringEpisode {
+ id
+ airingAt
+ timeUntilAiring
+ episode
+ }
+ airingSchedule {
+ nodes {
+ id
+ episode
+ airingAt
+ timeUntilAiring
+ }
+ }
+ trends {
+ nodes {
+ date
+ trending
+ popularity
+ inProgress
+ }
+ }
+ externalLinks {
+ id
+ url
+ site
+ }
+ streamingEpisodes {
+ title
+ thumbnail
+ url
+ site
+ }
+ rankings {
+ id
+ rank
+ type
+ format
+ year
+ season
+ allTime
+ context
+ }
+ stats {
+ scoreDistribution {
+ score
+ amount
+ }
+ statusDistribution {
+ status
+ amount
+ }
+ }
+ siteUrl
+ }
+ }`, anilistID)
+
+ // Remove debug print that can cause issues with large queries
+ // fmt.Printf("GraphQL Query: %s\n", graphQLQuery)
+
+ apiURL := "https://graphql.anilist.co"
+
+ // Escape quotes in the query to make valid JSON
+ escapedQuery := strings.Replace(graphQLQuery, `"`, `\"`, -1)
+ escapedQuery = strings.Replace(escapedQuery, "\n", " ", -1)
+ escapedQuery = strings.Replace(escapedQuery, "\t", "", -1)
+
+ // Create the JSON payload
+ jsonData := []byte(fmt.Sprintf(`{"query": "%s"}`, escapedQuery))
+
+ // Create a request with the proper body
+ req, err := http.NewRequest("POST", apiURL, 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")
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to execute request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ // Read error response body for better debugging
+ bodyBytes, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("failed to get anime data: %s - %s", resp.Status, string(bodyBytes))
+ }
+
+ var anilistResponse types.AnilistAnimeResponse
+ if err := json.NewDecoder(resp.Body).Decode(&anilistResponse); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+ if anilistResponse.Data.Media.ID == 0 {
+ return nil, fmt.Errorf("no data found for Anilist ID %d", anilistID)
+ }
+ return &anilistResponse, nil
+}
diff --git a/utils/anime/anime.go b/utils/anime/anime.go
index 0b079b9..885c9f9 100644
--- a/utils/anime/anime.go
+++ b/utils/anime/anime.go
@@ -1,19 +1,10 @@
package anime
import (
- "bytes"
- "context"
- "encoding/json"
"fmt"
- "io"
- "math"
"metachan/entities"
"metachan/types"
"metachan/utils/logger"
- "net/http"
- "strconv"
- "strings"
- "time"
)
func GetAnimeDetails(animeMapping *entities.AnimeMapping) (*types.Anime, error) {
@@ -43,6 +34,18 @@ func GetAnimeDetails(animeMapping *entities.AnimeMapping) (*types.Anime, error)
animeMapping.TMDB,
)
+ var logos types.AnimeLogos
+ malSyncData, err := getAnimeViaMalSync(malID)
+ if err == nil {
+ logos = extractLogosFromMALSync(malSyncData)
+ } else {
+ logger.Log(fmt.Sprintf("Failed to get MALSync data for logos: %v", err), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ logos = types.AnimeLogos{}
+ }
+
animeDetails := &types.Anime{
MALID: malID,
Titles: types.AnimeTitles{
@@ -56,6 +59,7 @@ func GetAnimeDetails(animeMapping *entities.AnimeMapping) (*types.Anime, error)
Source: anime.Data.Source,
Status: anime.Data.Status,
Duration: anime.Data.Duration,
+ Logos: logos,
Episodes: types.AnimeEpisodes{
Total: getEpisodeCount(anime, anilistAnime),
Aired: len(episodes.Data),
@@ -79,615 +83,6 @@ func GetAnimeDetails(animeMapping *entities.AnimeMapping) (*types.Anime, error)
return animeDetails, nil
}
-func getAnimeViaJikan(malID int) (*types.JikanAnimeResponse, error) {
- apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/full", malID)
- maxRetries := 3
- baseBackoff := 1 * time.Second
-
- var animeResponse types.JikanAnimeResponse
- success := false
- retries := 0
-
- for !success && retries <= maxRetries {
- // Use rate limiter before making the request
- logger.Log(fmt.Sprintf("Waiting for rate limiter before requesting anime %d details", malID), types.LogOptions{
- Level: types.Debug,
- Prefix: "AnimeAPI",
- })
- WaitForJikanRequest()
-
- req, err := http.NewRequest("GET", apiURL, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- client := &http.Client{
- Timeout: 10 * time.Second, // Add timeout to prevent hanging requests
- }
- resp, err := client.Do(req)
-
- if err != nil {
- if retries < maxRetries {
- retries++
- backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1)))
- logger.Log(fmt.Sprintf("Request error for anime details, retrying in %v (retry %d/%d): %v",
- backoffTime, retries, maxRetries, err), types.LogOptions{
- Level: types.Warn,
- Prefix: "AnimeAPI",
- })
- time.Sleep(backoffTime)
- continue
- }
- return nil, fmt.Errorf("failed to execute request after %d retries: %w", maxRetries, err)
- }
-
- defer resp.Body.Close()
-
- // Handle rate limiting with exponential backoff
- if resp.StatusCode == http.StatusTooManyRequests {
- if retries < maxRetries {
- retries++
- backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1)))
-
- // If we have a Retry-After header, respect it
- if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
- if seconds, err := strconv.Atoi(retryAfter); err == nil {
- backoffTime = time.Duration(seconds) * time.Second
- }
- }
-
- logger.Log(fmt.Sprintf("Rate limited on anime details, backing off for %v (retry %d/%d)",
- backoffTime, retries, maxRetries), types.LogOptions{
- Level: types.Warn,
- Prefix: "AnimeAPI",
- })
- time.Sleep(backoffTime)
- continue
- }
- return nil, fmt.Errorf("failed to get anime data: rate limited after %d retries", maxRetries)
- } else if resp.StatusCode != http.StatusOK {
- if retries < maxRetries {
- retries++
- backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1)))
- logger.Log(fmt.Sprintf("HTTP error %d for anime details, retrying in %v (retry %d/%d)",
- resp.StatusCode, backoffTime, retries, maxRetries), types.LogOptions{
- Level: types.Warn,
- Prefix: "AnimeAPI",
- })
- time.Sleep(backoffTime)
- continue
- }
- return nil, fmt.Errorf("failed to get anime data: %s", resp.Status)
- }
-
- if err := json.NewDecoder(resp.Body).Decode(&animeResponse); err != nil {
- if retries < maxRetries {
- retries++
- backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1)))
- logger.Log(fmt.Sprintf("JSON decode error for anime details, retrying in %v (retry %d/%d): %v",
- backoffTime, retries, maxRetries, err), types.LogOptions{
- Level: types.Warn,
- Prefix: "AnimeAPI",
- })
- time.Sleep(backoffTime)
- continue
- }
- return nil, fmt.Errorf("failed to decode response: %w", err)
- }
-
- success = true
- }
-
- if !success {
- return nil, fmt.Errorf("failed to fetch anime details after maximum retries")
- }
-
- if animeResponse.Data.MALID == 0 {
- return nil, fmt.Errorf("no data found for MAL ID %d", malID)
- }
-
- return &animeResponse, nil
-}
-
-func getAnimeViaAnilist(anilistID int) (*types.AnilistAnimeResponse, error) {
- graphQLQuery := fmt.Sprintf(`query {
- Media(id: %d) {
- id
- idMal
- title {
- romaji
- english
- native
- userPreferred
- }
- type
- format
- status
- description
- startDate {
- year
- month
- day
- }
- endDate {
- year
- month
- day
- }
- season
- seasonYear
- episodes
- duration
- chapters
- volumes
- countryOfOrigin
- isLicensed
- source
- hashtag
- trailer {
- id
- site
- thumbnail
- }
- updatedAt
- coverImage {
- extraLarge
- large
- medium
- color
- }
- bannerImage
- genres
- synonyms
- averageScore
- meanScore
- popularity
- isLocked
- trending
- favourites
- tags {
- id
- name
- description
- category
- rank
- isGeneralSpoiler
- isMediaSpoiler
- isAdult
- }
- relations {
- edges {
- id
- relationType
- node {
- id
- title {
- romaji
- english
- native
- userPreferred
- }
- format
- type
- status
- coverImage {
- extraLarge
- large
- medium
- color
- }
- bannerImage
- }
- }
- }
- characters {
- edges {
- role
- node {
- id
- name {
- first
- last
- middle
- full
- native
- userPreferred
- }
- image {
- large
- medium
- }
- description
- age
- }
- }
- }
- staff {
- edges {
- role
- node {
- id
- name {
- first
- middle
- last
- full
- native
- userPreferred
- }
- image {
- large
- medium
- }
- description
- primaryOccupations
- gender
- age
- languageV2
- }
- }
- }
- studios {
- edges {
- isMain
- node {
- id
- name
- }
- }
- }
- isAdult
- nextAiringEpisode {
- id
- airingAt
- timeUntilAiring
- episode
- }
- airingSchedule {
- nodes {
- id
- episode
- airingAt
- timeUntilAiring
- }
- }
- trends {
- nodes {
- date
- trending
- popularity
- inProgress
- }
- }
- externalLinks {
- id
- url
- site
- }
- streamingEpisodes {
- title
- thumbnail
- url
- site
- }
- rankings {
- id
- rank
- type
- format
- year
- season
- allTime
- context
- }
- stats {
- scoreDistribution {
- score
- amount
- }
- statusDistribution {
- status
- amount
- }
- }
- siteUrl
- }
- }`, anilistID)
-
- // Remove debug print that can cause issues with large queries
- // fmt.Printf("GraphQL Query: %s\n", graphQLQuery)
-
- apiURL := "https://graphql.anilist.co"
-
- // Escape quotes in the query to make valid JSON
- escapedQuery := strings.Replace(graphQLQuery, `"`, `\"`, -1)
- escapedQuery = strings.Replace(escapedQuery, "\n", " ", -1)
- escapedQuery = strings.Replace(escapedQuery, "\t", "", -1)
-
- // Create the JSON payload
- jsonData := []byte(fmt.Sprintf(`{"query": "%s"}`, escapedQuery))
-
- // Create a request with the proper body
- req, err := http.NewRequest("POST", apiURL, 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")
-
- client := &http.Client{}
- resp, err := client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to execute request: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- // Read error response body for better debugging
- bodyBytes, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("failed to get anime data: %s - %s", resp.Status, string(bodyBytes))
- }
-
- var anilistResponse types.AnilistAnimeResponse
- if err := json.NewDecoder(resp.Body).Decode(&anilistResponse); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
- }
- if anilistResponse.Data.Media.ID == 0 {
- return nil, fmt.Errorf("no data found for Anilist ID %d", anilistID)
- }
- return &anilistResponse, nil
-}
-
-func getAnimeEpisodesViaJikan(malId int) (*types.JikanAnimeEpisodeResponse, error) {
- apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/episodes", malId)
- var allEpisodes []types.JikanAnimeEpisode
- page := 1
- var lastVisiblePage int
-
- maxRetries := 3
- baseBackoff := 1 * time.Second
- maxAttempts := 15 // Maximum number of attempts across all pages to prevent infinite loops
-
- logger.Log(fmt.Sprintf("Fetching episodes for anime %d", malId), types.LogOptions{
- Level: types.Info,
- Prefix: "AnimeAPI",
- })
-
- totalAttempts := 0
- for {
- if totalAttempts >= maxAttempts {
- logger.Log(fmt.Sprintf("Reached maximum total attempts (%d) for anime %d. Returning collected episodes so far.",
- maxAttempts, malId), types.LogOptions{
- Level: types.Warn,
- Prefix: "AnimeAPI",
- })
- break
- }
-
- var pageResponse types.JikanAnimeEpisodeResponse
- success := false
- retries := 0
-
- for !success && retries <= maxRetries {
- totalAttempts++
-
- // Use rate limiter before making the request
- logger.Log(fmt.Sprintf("Waiting for rate limiter before requesting page %d for anime %d", page, malId), types.LogOptions{
- Level: types.Debug,
- Prefix: "AnimeAPI",
- })
- WaitForJikanRequest()
-
- pageURL := fmt.Sprintf("%s?page=%d", apiURL, page)
- req, err := http.NewRequest("GET", pageURL, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- // Add a context with timeout
- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
- req = req.WithContext(ctx)
-
- client := &http.Client{
- Timeout: 15 * time.Second, // Add timeout to prevent hanging requests
- }
-
- resp, err := client.Do(req)
- cancel() // Cancel the context regardless of the outcome
-
- if err != nil {
- if retries < maxRetries {
- retries++
- backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1)))
- logger.Log(fmt.Sprintf("Request error, retrying in %v (retry %d/%d): %v",
- backoffTime, retries, maxRetries, err), types.LogOptions{
- Level: types.Warn,
- Prefix: "AnimeAPI",
- })
- time.Sleep(backoffTime)
- continue
- }
- return nil, fmt.Errorf("failed to execute request after %d retries: %w", maxRetries, err)
- }
-
- defer resp.Body.Close()
-
- // Handle rate limiting with exponential backoff
- if resp.StatusCode == http.StatusTooManyRequests {
- if retries < maxRetries {
- retries++
-
- // Start with a reasonable base backoff
- backoffTime := time.Duration(float64(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
- }
- }
-
- logger.Log(fmt.Sprintf("Rate limited, backing off for %v (retry %d/%d)",
- backoffTime, retries, maxRetries), types.LogOptions{
- Level: types.Warn,
- Prefix: "AnimeAPI",
- })
- time.Sleep(backoffTime)
- continue
- }
-
- // If we've reached maximum retries and still getting rate limited,
- // return what we have so far rather than failing completely
- if len(allEpisodes) > 0 {
- logger.Log(fmt.Sprintf("Rate limited after maximum retries. Returning %d episodes collected so far.",
- len(allEpisodes)), types.LogOptions{
- Level: types.Warn,
- Prefix: "AnimeAPI",
- })
-
- return &types.JikanAnimeEpisodeResponse{
- Pagination: types.JikanPagination{
- LastVisiblePage: lastVisiblePage,
- HasNextPage: false,
- },
- Data: allEpisodes,
- }, nil
- }
-
- return nil, fmt.Errorf("failed to get anime episodes (page %d): rate limited after %d retries", page, maxRetries)
- } else if resp.StatusCode != http.StatusOK {
- if retries < maxRetries {
- retries++
- backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1)))
- logger.Log(fmt.Sprintf("HTTP error %d, retrying in %v (retry %d/%d)",
- resp.StatusCode, backoffTime, retries, maxRetries), types.LogOptions{
- Level: types.Warn,
- Prefix: "AnimeAPI",
- })
- time.Sleep(backoffTime)
- continue
- }
- return nil, fmt.Errorf("failed to get anime episodes (page %d): %s", page, resp.Status)
- }
-
- // 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 < maxRetries {
- retries++
- backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1)))
- logger.Log(fmt.Sprintf("Error reading response body, retrying in %v (retry %d/%d): %v",
- backoffTime, retries, maxRetries, err), types.LogOptions{
- Level: types.Warn,
- Prefix: "AnimeAPI",
- })
- time.Sleep(backoffTime)
- continue
- }
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
-
- if err := json.Unmarshal(bodyBytes, &pageResponse); err != nil {
- if retries < maxRetries {
- retries++
- backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1)))
- logger.Log(fmt.Sprintf("JSON decode error, retrying in %v (retry %d/%d): %v",
- backoffTime, retries, maxRetries, err), types.LogOptions{
- Level: types.Warn,
- Prefix: "AnimeAPI",
- })
- time.Sleep(backoffTime)
- continue
- }
- return nil, fmt.Errorf("failed to decode response: %w", err)
- }
-
- success = true
- }
-
- if !success {
- // If we've collected some episodes, return them instead of completely failing
- if len(allEpisodes) > 0 {
- logger.Log(fmt.Sprintf("Failed to fetch page %d after maximum retries. Returning %d episodes collected so far.",
- page, len(allEpisodes)), types.LogOptions{
- Level: types.Warn,
- Prefix: "AnimeAPI",
- })
-
- return &types.JikanAnimeEpisodeResponse{
- Pagination: types.JikanPagination{
- LastVisiblePage: page - 1,
- HasNextPage: false,
- },
- Data: allEpisodes,
- }, nil
- }
-
- return nil, fmt.Errorf("failed to fetch page %d after maximum retries", page)
- }
-
- // Convert and append episodes from this page
- for _, episode := range pageResponse.Data {
- allEpisodes = append(allEpisodes, types.JikanAnimeEpisode{
- MALID: episode.MALID,
- URL: episode.URL,
- Title: episode.Title,
- TitleJapanese: episode.TitleJapanese,
- TitleRomaji: episode.TitleRomaji,
- Aired: episode.Aired,
- Score: episode.Score,
- Filler: episode.Filler,
- Recap: episode.Recap,
- ForumURL: episode.ForumURL,
- })
- }
-
- // Update pagination info
- lastVisiblePage = pageResponse.Pagination.LastVisiblePage
-
- logger.Log(fmt.Sprintf("Fetched page %d/%d with %d episodes",
- page, lastVisiblePage, len(pageResponse.Data)), types.LogOptions{
- Level: types.Debug,
- Prefix: "AnimeAPI",
- })
-
- // Check if there are more pages
- if !pageResponse.Pagination.HasNextPage || page >= lastVisiblePage {
- break
- }
-
- // Safety check - don't fetch more than a reasonable number of pages
- if page >= 25 {
- logger.Log(fmt.Sprintf("Reached maximum page limit (25) for anime %d. Returning collected episodes so far.",
- malId), types.LogOptions{
- Level: types.Warn,
- Prefix: "AnimeAPI",
- })
- break
- }
-
- // No need for explicit waiting between pages anymore
- // The rate limiter will handle the pacing automatically
- page++
- }
-
- logger.Log(fmt.Sprintf("Completed fetching all %d episodes for anime %d",
- len(allEpisodes), malId), types.LogOptions{
- Level: types.Success,
- Prefix: "AnimeAPI",
- })
-
- // Return the complete response with all collected episodes
- return &types.JikanAnimeEpisodeResponse{
- Pagination: types.JikanPagination{
- LastVisiblePage: lastVisiblePage,
- HasNextPage: false,
- },
- Data: allEpisodes,
- }, nil
-}
-
func getEpisodeCount(malAnime *types.JikanAnimeResponse, anilistAnime *types.AnilistAnimeResponse) int {
streamingScheduleLength := len(anilistAnime.Data.Media.AiringSchedule.Nodes)
episodes := max(malAnime.Data.Episodes, anilistAnime.Data.Media.Episodes)
@@ -695,37 +90,3 @@ func getEpisodeCount(malAnime *types.JikanAnimeResponse, anilistAnime *types.Ani
return episodes
}
-
-func generateEpisodeData(episodes []types.JikanAnimeEpisode) ([]types.AnimeSingleEpisode, error) {
- var AnimeEpisodes []types.AnimeSingleEpisode
-
- for _, episode := range episodes {
- AnimeEpisodes = append(AnimeEpisodes, types.AnimeSingleEpisode{
- Titles: types.EpisodeTitles{
- English: episode.Title,
- Japanese: episode.TitleJapanese,
- Romaji: episode.TitleRomaji,
- },
- Aired: episode.Aired,
- Score: episode.Score,
- Filler: episode.Filler,
- Recap: episode.Recap,
- ForumURL: episode.ForumURL,
- URL: episode.URL,
- Description: "No description available",
- })
- }
- return AnimeEpisodes, nil
-}
-
-func generateEpisodeDataWithDescriptions(episodes []types.JikanAnimeEpisode, title string, alternativeTitle string, tmdbID int) ([]types.AnimeSingleEpisode, error) {
- // First create basic episode data
- basicEpisodes, err := generateEpisodeData(episodes)
- if err != nil {
- return nil, fmt.Errorf("failed to generate basic episode data: %w", err)
- }
-
- // Then enrich with descriptions - this won't fail, just return original episodes if there's an issue
- enrichedEpisodes := AttachEpisodeDescriptions(title, basicEpisodes, alternativeTitle, tmdbID)
- return enrichedEpisodes, nil
-}
diff --git a/utils/anime/crunchyroll.go b/utils/anime/crunchyroll.go
new file mode 100644
index 0000000..dbe9692
--- /dev/null
+++ b/utils/anime/crunchyroll.go
@@ -0,0 +1,201 @@
+package anime
+
+import (
+ "crypto/tls"
+ "fmt"
+ "metachan/types"
+ "metachan/utils/logger"
+ "net/http"
+ "strings"
+ "time"
+)
+
+func extractCrunchyrollSeriesID(crURL string) string {
+ logger.Log(fmt.Sprintf("Attempting to extract series ID from URL: %s", crURL), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+
+ // Direct series URL format
+ if strings.Contains(crURL, "/series/") {
+ parts := strings.Split(crURL, "/series/")
+ if len(parts) < 2 {
+ logger.Log("URL contains /series/ but couldn't extract ID part", types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ return ""
+ }
+
+ idParts := strings.Split(parts[1], "/")
+ if len(idParts) < 1 {
+ logger.Log("Couldn't extract ID from path segments", types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ return ""
+ }
+
+ logger.Log(fmt.Sprintf("Found series ID directly in URL: %s", idParts[0]), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ return idParts[0]
+ }
+
+ // Need to follow redirect to get series ID
+ logger.Log("URL doesn't contain /series/, following redirect...", types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+
+ // Create a transport that uses modern TLS settings
+ transport := &http.Transport{
+ TLSClientConfig: &tls.Config{
+ MinVersion: tls.VersionTLS12,
+ },
+ ForceAttemptHTTP2: true,
+ }
+
+ client := &http.Client{
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ // Don't follow redirects, just capture the Location header
+ return http.ErrUseLastResponse
+ },
+ Timeout: 10 * time.Second,
+ Transport: transport,
+ }
+
+ // Update HTTP to HTTPS for Crunchyroll URLs if needed
+ if strings.HasPrefix(crURL, "http://www.crunchyroll.com") {
+ crURL = strings.Replace(crURL, "http://", "https://", 1)
+ logger.Log(fmt.Sprintf("Updated URL to HTTPS: %s", crURL), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ }
+
+ // Add User-Agent header to mimic a browser
+ req, err := http.NewRequest("GET", crURL, nil)
+ if err != nil {
+ logger.Log(fmt.Sprintf("Failed to create request for Crunchyroll URL: %v", err), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ return ""
+ }
+
+ 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")
+ req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ logger.Log(fmt.Sprintf("Failed to get Crunchyroll redirect: %v", err), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ return ""
+ }
+ defer resp.Body.Close()
+
+ // Log the status code and response headers for debugging
+ logger.Log(fmt.Sprintf("Crunchyroll response status: %d %s", resp.StatusCode, resp.Status), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+
+ for name, values := range resp.Header {
+ logger.Log(fmt.Sprintf("Header %s: %s", name, strings.Join(values, ", ")), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ }
+
+ // Check for specific status codes for redirects
+ if resp.StatusCode != http.StatusMovedPermanently &&
+ resp.StatusCode != http.StatusFound &&
+ resp.StatusCode != http.StatusTemporaryRedirect &&
+ resp.StatusCode != http.StatusPermanentRedirect {
+ logger.Log(fmt.Sprintf("Unexpected status code from Crunchyroll: %d", resp.StatusCode), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+
+ // If we got a 200 OK, maybe Crunchyroll served the page directly
+ // Try to extract the series ID from the URL itself as a fallback
+ if resp.StatusCode == http.StatusOK && strings.Contains(crURL, "crunchyroll.com") {
+ // For URLs like http://www.crunchyroll.com/fullmetal-alchemist-brotherhood
+ // Extract the last part as a potential identifier
+ urlParts := strings.Split(crURL, "/")
+ if len(urlParts) > 0 {
+ potentialId := urlParts[len(urlParts)-1]
+ logger.Log(fmt.Sprintf("Extracted potential series ID from original URL: %s", potentialId), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ return potentialId
+ }
+ }
+ return ""
+ }
+
+ redirectURL := resp.Header.Get("Location")
+ if redirectURL == "" {
+ logger.Log("No redirect URL found in Crunchyroll response", types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ return ""
+ }
+
+ logger.Log(fmt.Sprintf("Found redirect URL: %s", redirectURL), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+
+ // Extract series ID from redirect URL
+ if strings.Contains(redirectURL, "/series/") {
+ parts := strings.Split(redirectURL, "/series/")
+ if len(parts) < 2 {
+ return ""
+ }
+
+ idParts := strings.Split(parts[1], "/")
+ if len(idParts) < 1 {
+ return ""
+ }
+
+ logger.Log(fmt.Sprintf("Successfully extracted series ID from redirect: %s", idParts[0]), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ return idParts[0]
+ }
+
+ // For multi-level redirects, try to follow one more time
+ if strings.Contains(redirectURL, "crunchyroll.com") {
+ logger.Log("Trying to follow one more redirect level...", types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ return extractCrunchyrollSeriesID(redirectURL)
+ }
+
+ // As a fallback for older Crunchyroll URLs like fullmetal-alchemist-brotherhood
+ // Use the last path segment as the ID
+ urlParts := strings.Split(crURL, "/")
+ if len(urlParts) > 0 {
+ potentialId := urlParts[len(urlParts)-1]
+ logger.Log(fmt.Sprintf("Using fallback: extracted ID from original URL: %s", potentialId), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ return potentialId
+ }
+
+ logger.Log("Could not extract series ID from Crunchyroll redirect URL", types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ return ""
+}
diff --git a/utils/anime/jikan.go b/utils/anime/jikan.go
index 0cfe79e..d51a3ca 100644
--- a/utils/anime/jikan.go
+++ b/utils/anime/jikan.go
@@ -1,8 +1,15 @@
package anime
import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math"
"metachan/types"
"metachan/utils/logger"
+ "net/http"
+ "strconv"
"sync"
"time"
)
@@ -143,6 +150,365 @@ func (r *JikanRateLimiter) cleanupOldRequests(now time.Time) {
}
// WaitForJikanRequest is a convenience function to access the global rate limiter
-func WaitForJikanRequest() {
+func waitForJikanRequest() {
jikanLimiter.Wait()
}
+
+func getAnimeViaJikan(malID int) (*types.JikanAnimeResponse, error) {
+ apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/full", malID)
+ maxRetries := 3
+ baseBackoff := 1 * time.Second
+
+ var animeResponse types.JikanAnimeResponse
+ success := false
+ retries := 0
+
+ for !success && retries <= maxRetries {
+ // Use rate limiter before making the request
+ logger.Log(fmt.Sprintf("Waiting for rate limiter before requesting anime %d details", malID), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ waitForJikanRequest()
+
+ req, err := http.NewRequest("GET", apiURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ client := &http.Client{
+ Timeout: 10 * time.Second, // Add timeout to prevent hanging requests
+ }
+ resp, err := client.Do(req)
+
+ if err != nil {
+ if retries < maxRetries {
+ retries++
+ backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1)))
+ logger.Log(fmt.Sprintf("Request error for anime details, retrying in %v (retry %d/%d): %v",
+ backoffTime, retries, maxRetries, err), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "AnimeAPI",
+ })
+ time.Sleep(backoffTime)
+ continue
+ }
+ return nil, fmt.Errorf("failed to execute request after %d retries: %w", maxRetries, err)
+ }
+
+ defer resp.Body.Close()
+
+ // Handle rate limiting with exponential backoff
+ if resp.StatusCode == http.StatusTooManyRequests {
+ if retries < maxRetries {
+ retries++
+ backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1)))
+
+ // If we have a Retry-After header, respect it
+ if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
+ if seconds, err := strconv.Atoi(retryAfter); err == nil {
+ backoffTime = time.Duration(seconds) * time.Second
+ }
+ }
+
+ logger.Log(fmt.Sprintf("Rate limited on anime details, backing off for %v (retry %d/%d)",
+ backoffTime, retries, maxRetries), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "AnimeAPI",
+ })
+ time.Sleep(backoffTime)
+ continue
+ }
+ return nil, fmt.Errorf("failed to get anime data: rate limited after %d retries", maxRetries)
+ } else if resp.StatusCode != http.StatusOK {
+ if retries < maxRetries {
+ retries++
+ backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1)))
+ logger.Log(fmt.Sprintf("HTTP error %d for anime details, retrying in %v (retry %d/%d)",
+ resp.StatusCode, backoffTime, retries, maxRetries), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "AnimeAPI",
+ })
+ time.Sleep(backoffTime)
+ continue
+ }
+ return nil, fmt.Errorf("failed to get anime data: %s", resp.Status)
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&animeResponse); err != nil {
+ if retries < maxRetries {
+ retries++
+ backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1)))
+ logger.Log(fmt.Sprintf("JSON decode error for anime details, retrying in %v (retry %d/%d): %v",
+ backoffTime, retries, maxRetries, err), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "AnimeAPI",
+ })
+ time.Sleep(backoffTime)
+ continue
+ }
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ success = true
+ }
+
+ if !success {
+ return nil, fmt.Errorf("failed to fetch anime details after maximum retries")
+ }
+
+ if animeResponse.Data.MALID == 0 {
+ return nil, fmt.Errorf("no data found for MAL ID %d", malID)
+ }
+
+ return &animeResponse, nil
+}
+
+func getAnimeEpisodesViaJikan(malId int) (*types.JikanAnimeEpisodeResponse, error) {
+ apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/episodes", malId)
+ var allEpisodes []types.JikanAnimeEpisode
+ page := 1
+ var lastVisiblePage int
+
+ maxRetries := 3
+ baseBackoff := 1 * time.Second
+ maxAttempts := 15 // Maximum number of attempts across all pages to prevent infinite loops
+
+ logger.Log(fmt.Sprintf("Fetching episodes for anime %d", malId), types.LogOptions{
+ Level: types.Info,
+ Prefix: "AnimeAPI",
+ })
+
+ totalAttempts := 0
+ for {
+ if totalAttempts >= maxAttempts {
+ logger.Log(fmt.Sprintf("Reached maximum total attempts (%d) for anime %d. Returning collected episodes so far.",
+ maxAttempts, malId), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "AnimeAPI",
+ })
+ break
+ }
+
+ var pageResponse types.JikanAnimeEpisodeResponse
+ success := false
+ retries := 0
+
+ for !success && retries <= maxRetries {
+ totalAttempts++
+
+ // Use rate limiter before making the request
+ logger.Log(fmt.Sprintf("Waiting for rate limiter before requesting page %d for anime %d", page, malId), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ waitForJikanRequest()
+
+ pageURL := fmt.Sprintf("%s?page=%d", apiURL, page)
+ req, err := http.NewRequest("GET", pageURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Add a context with timeout
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ req = req.WithContext(ctx)
+
+ client := &http.Client{
+ Timeout: 15 * time.Second, // Add timeout to prevent hanging requests
+ }
+
+ resp, err := client.Do(req)
+ cancel() // Cancel the context regardless of the outcome
+
+ if err != nil {
+ if retries < maxRetries {
+ retries++
+ backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1)))
+ logger.Log(fmt.Sprintf("Request error, retrying in %v (retry %d/%d): %v",
+ backoffTime, retries, maxRetries, err), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "AnimeAPI",
+ })
+ time.Sleep(backoffTime)
+ continue
+ }
+ return nil, fmt.Errorf("failed to execute request after %d retries: %w", maxRetries, err)
+ }
+
+ defer resp.Body.Close()
+
+ // Handle rate limiting with exponential backoff
+ if resp.StatusCode == http.StatusTooManyRequests {
+ if retries < maxRetries {
+ retries++
+
+ // Start with a reasonable base backoff
+ backoffTime := time.Duration(float64(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
+ }
+ }
+
+ logger.Log(fmt.Sprintf("Rate limited, backing off for %v (retry %d/%d)",
+ backoffTime, retries, maxRetries), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "AnimeAPI",
+ })
+ time.Sleep(backoffTime)
+ continue
+ }
+
+ // If we've reached maximum retries and still getting rate limited,
+ // return what we have so far rather than failing completely
+ if len(allEpisodes) > 0 {
+ logger.Log(fmt.Sprintf("Rate limited after maximum retries. Returning %d episodes collected so far.",
+ len(allEpisodes)), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "AnimeAPI",
+ })
+
+ return &types.JikanAnimeEpisodeResponse{
+ Pagination: types.JikanPagination{
+ LastVisiblePage: lastVisiblePage,
+ HasNextPage: false,
+ },
+ Data: allEpisodes,
+ }, nil
+ }
+
+ return nil, fmt.Errorf("failed to get anime episodes (page %d): rate limited after %d retries", page, maxRetries)
+ } else if resp.StatusCode != http.StatusOK {
+ if retries < maxRetries {
+ retries++
+ backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1)))
+ logger.Log(fmt.Sprintf("HTTP error %d, retrying in %v (retry %d/%d)",
+ resp.StatusCode, backoffTime, retries, maxRetries), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "AnimeAPI",
+ })
+ time.Sleep(backoffTime)
+ continue
+ }
+ return nil, fmt.Errorf("failed to get anime episodes (page %d): %s", page, resp.Status)
+ }
+
+ // 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 < maxRetries {
+ retries++
+ backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1)))
+ logger.Log(fmt.Sprintf("Error reading response body, retrying in %v (retry %d/%d): %v",
+ backoffTime, retries, maxRetries, err), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "AnimeAPI",
+ })
+ time.Sleep(backoffTime)
+ continue
+ }
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ if err := json.Unmarshal(bodyBytes, &pageResponse); err != nil {
+ if retries < maxRetries {
+ retries++
+ backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1)))
+ logger.Log(fmt.Sprintf("JSON decode error, retrying in %v (retry %d/%d): %v",
+ backoffTime, retries, maxRetries, err), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "AnimeAPI",
+ })
+ time.Sleep(backoffTime)
+ continue
+ }
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ success = true
+ }
+
+ if !success {
+ // If we've collected some episodes, return them instead of completely failing
+ if len(allEpisodes) > 0 {
+ logger.Log(fmt.Sprintf("Failed to fetch page %d after maximum retries. Returning %d episodes collected so far.",
+ page, len(allEpisodes)), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "AnimeAPI",
+ })
+
+ return &types.JikanAnimeEpisodeResponse{
+ Pagination: types.JikanPagination{
+ LastVisiblePage: page - 1,
+ HasNextPage: false,
+ },
+ Data: allEpisodes,
+ }, nil
+ }
+
+ return nil, fmt.Errorf("failed to fetch page %d after maximum retries", page)
+ }
+
+ // Convert and append episodes from this page
+ for _, episode := range pageResponse.Data {
+ allEpisodes = append(allEpisodes, types.JikanAnimeEpisode{
+ MALID: episode.MALID,
+ URL: episode.URL,
+ Title: episode.Title,
+ TitleJapanese: episode.TitleJapanese,
+ TitleRomaji: episode.TitleRomaji,
+ Aired: episode.Aired,
+ Score: episode.Score,
+ Filler: episode.Filler,
+ Recap: episode.Recap,
+ ForumURL: episode.ForumURL,
+ })
+ }
+
+ // Update pagination info
+ lastVisiblePage = pageResponse.Pagination.LastVisiblePage
+
+ logger.Log(fmt.Sprintf("Fetched page %d/%d with %d episodes",
+ page, lastVisiblePage, len(pageResponse.Data)), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+
+ // Check if there are more pages
+ if !pageResponse.Pagination.HasNextPage || page >= lastVisiblePage {
+ break
+ }
+
+ // Safety check - don't fetch more than a reasonable number of pages
+ if page >= 25 {
+ logger.Log(fmt.Sprintf("Reached maximum page limit (25) for anime %d. Returning collected episodes so far.",
+ malId), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "AnimeAPI",
+ })
+ break
+ }
+
+ // No need for explicit waiting between pages anymore
+ // The rate limiter will handle the pacing automatically
+ page++
+ }
+
+ logger.Log(fmt.Sprintf("Completed fetching all %d episodes for anime %d",
+ len(allEpisodes), malId), types.LogOptions{
+ Level: types.Success,
+ Prefix: "AnimeAPI",
+ })
+
+ // Return the complete response with all collected episodes
+ return &types.JikanAnimeEpisodeResponse{
+ Pagination: types.JikanPagination{
+ LastVisiblePage: lastVisiblePage,
+ HasNextPage: false,
+ },
+ Data: allEpisodes,
+ }, nil
+}
diff --git a/utils/anime/malsync.go b/utils/anime/malsync.go
new file mode 100644
index 0000000..0d06c25
--- /dev/null
+++ b/utils/anime/malsync.go
@@ -0,0 +1,98 @@
+package anime
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "metachan/types"
+ "metachan/utils/logger"
+ "net/http"
+ "time"
+)
+
+func getAnimeViaMalSync(malID int) (*types.MALSyncAnimeResponse, error) {
+ apiURL := fmt.Sprintf("https://api.malsync.moe/mal/anime/%d", malID)
+ req, err := http.NewRequest("GET", apiURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ client := &http.Client{
+ Timeout: 10 * time.Second, // Add timeout to prevent hanging requests
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to execute request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ bodyBytes, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("failed to get anime data: %s - %s", resp.Status, string(bodyBytes))
+ }
+
+ var malSyncResponse types.MALSyncAnimeResponse
+ if err := json.NewDecoder(resp.Body).Decode(&malSyncResponse); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &malSyncResponse, nil
+}
+
+func extractLogosFromMALSync(malSyncResponse *types.MALSyncAnimeResponse) types.AnimeLogos {
+ logos := types.AnimeLogos{}
+
+ // Check if Crunchyroll data exists in the MALSync response
+ crunchyrollSites, exists := malSyncResponse.Sites["Crunchyroll"]
+ if !exists || len(crunchyrollSites) == 0 {
+ logger.Log("No Crunchyroll data found in MALSync response", types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ return logos
+ }
+
+ // Get the Crunchyroll URL from any of the entries
+ crURL := ""
+ for _, site := range crunchyrollSites {
+ crURL = site.URL
+ break // Take the first URL
+ }
+
+ if crURL == "" {
+ logger.Log("No valid Crunchyroll URL found", types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+ return logos
+ }
+
+ // Extract series ID from URL
+ seriesID := extractCrunchyrollSeriesID(crURL)
+ if seriesID == "" {
+ return logos
+ }
+
+ // Define logo sizes
+ logoSizes := map[string]int{
+ "Small": 320,
+ "Medium": 480,
+ "Large": 600,
+ "XLarge": 800,
+ "Original": 1200,
+ }
+
+ // Generate logo URLs
+ logos.Small = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["Small"], seriesID)
+ logos.Medium = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["Medium"], seriesID)
+ logos.Large = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["Large"], seriesID)
+ logos.XLarge = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["XLarge"], seriesID)
+ logos.Original = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["Original"], seriesID)
+
+ logger.Log(fmt.Sprintf("Successfully generated logo URLs for series ID: %s", seriesID), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AnimeAPI",
+ })
+
+ return logos
+}
diff --git a/utils/anime/tmdb.go b/utils/anime/tmdb.go
index 3f3df24..ae61326 100644
--- a/utils/anime/tmdb.go
+++ b/utils/anime/tmdb.go
@@ -337,7 +337,7 @@ func findBestSeason(shows []types.TMDBShowResult, title string, episodeCount int
return 0, 0, fmt.Errorf("could not find matching season for: %s", title)
}
-// AttachEpisodeDescriptions enriches anime episodes with descriptions from TMDB
+// AttachEpisodeDescriptions enriches anime episodes with descriptions and thumbnails from TMDB
func AttachEpisodeDescriptions(title string, episodes []types.AnimeSingleEpisode, alternativeTitle string, tmdbID int) []types.AnimeSingleEpisode {
if config.Config.TMDB.ReadAccessToken == "" {
logger.Log("TMDB is not configured, skipping episode description enrichment", types.LogOptions{
@@ -454,11 +454,15 @@ func AttachEpisodeDescriptions(title string, episodes []types.AnimeSingleEpisode
return episodes
}
- // Enrich episodes with descriptions
+ // Enrich episodes with descriptions and thumbnails
tmdbEpisodes := seasonDetails.Episodes
enrichedEpisodes := make([]types.AnimeSingleEpisode, len(episodes))
copy(enrichedEpisodes, episodes)
+ // The base URL for TMDB images
+ const tmdbImageBaseURL = "https://image.tmdb.org/t/p/"
+ const thumbnailSize = "w300" // Use w300 size for episode thumbnails
+
for i := range enrichedEpisodes {
if i < len(tmdbEpisodes) {
// Only add description if it's not empty
@@ -467,16 +471,61 @@ func AttachEpisodeDescriptions(title string, episodes []types.AnimeSingleEpisode
} else {
enrichedEpisodes[i].Description = "No description available"
}
+
+ // Add thumbnail URL if available
+ if tmdbEpisodes[i].StillPath != "" {
+ enrichedEpisodes[i].ThumbnailURL = tmdbImageBaseURL + thumbnailSize + tmdbEpisodes[i].StillPath
+ }
} else {
enrichedEpisodes[i].Description = "No description available"
}
}
- logger.Log(fmt.Sprintf("Successfully enriched %d episodes with descriptions for: %s",
- len(enrichedEpisodes), title), types.LogOptions{
+ thumbnailCount := 0
+ for _, ep := range enrichedEpisodes {
+ if ep.ThumbnailURL != "" {
+ thumbnailCount++
+ }
+ }
+
+ logger.Log(fmt.Sprintf("Successfully enriched %d episodes with descriptions and %d with thumbnails for: %s",
+ len(enrichedEpisodes), thumbnailCount, title), types.LogOptions{
Level: types.Success,
Prefix: "TMDB",
})
return enrichedEpisodes
}
+
+func generateEpisodeData(episodes []types.JikanAnimeEpisode) ([]types.AnimeSingleEpisode, error) {
+ var AnimeEpisodes []types.AnimeSingleEpisode
+
+ for _, episode := range episodes {
+ AnimeEpisodes = append(AnimeEpisodes, types.AnimeSingleEpisode{
+ Titles: types.EpisodeTitles{
+ English: episode.Title,
+ Japanese: episode.TitleJapanese,
+ Romaji: episode.TitleRomaji,
+ },
+ Aired: episode.Aired,
+ Score: episode.Score,
+ Filler: episode.Filler,
+ Recap: episode.Recap,
+ ForumURL: episode.ForumURL,
+ URL: episode.URL,
+ Description: "No description available",
+ ThumbnailURL: "",
+ })
+ }
+ return AnimeEpisodes, nil
+}
+
+func generateEpisodeDataWithDescriptions(episodes []types.JikanAnimeEpisode, title string, alternativeTitle string, tmdbID int) ([]types.AnimeSingleEpisode, error) {
+ basicEpisodes, err := generateEpisodeData(episodes)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate basic episode data: %w", err)
+ }
+
+ enrichedEpisodes := AttachEpisodeDescriptions(title, basicEpisodes, alternativeTitle, tmdbID)
+ return enrichedEpisodes, nil
+}