diff options
| -rw-r--r-- | types/anime.go | 66 | ||||
| -rw-r--r-- | utils/anime/anilist.go | 261 | ||||
| -rw-r--r-- | utils/anime/anime.go | 665 | ||||
| -rw-r--r-- | utils/anime/crunchyroll.go | 201 | ||||
| -rw-r--r-- | utils/anime/jikan.go | 368 | ||||
| -rw-r--r-- | utils/anime/malsync.go | 98 | ||||
| -rw-r--r-- | utils/anime/tmdb.go | 57 |
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 +} |
