aboutsummaryrefslogtreecommitdiff
path: root/utils/api
diff options
context:
space:
mode:
authorBobby <[email protected]>2025-05-09 00:54:39 +0530
committerBobby <[email protected]>2025-05-09 00:54:39 +0530
commit5e28d86fb10270b0e1680924d6ac6617f780d814 (patch)
treede80fadd7a1f08df8658acfd23975e8ad14d2791 /utils/api
parentf656ab2350f5bbaa8465278b0842a0c052858a86 (diff)
downloadmetachan-5e28d86fb10270b0e1680924d6ac6617f780d814.tar.xz
metachan-5e28d86fb10270b0e1680924d6ac6617f780d814.zip
move anime to services. refactor. add sub dub streaming counts
Diffstat (limited to 'utils/api')
-rw-r--r--utils/api/anilist.go165
-rw-r--r--utils/api/aniskip.go415
-rw-r--r--utils/api/jikan.go244
-rw-r--r--utils/api/malsync.go99
-rw-r--r--utils/api/streaming.go526
-rw-r--r--utils/api/tmdb.go498
-rw-r--r--utils/api/tvdb.go37
7 files changed, 1984 insertions, 0 deletions
diff --git a/utils/api/anilist.go b/utils/api/anilist.go
new file mode 100644
index 0000000..cda860a
--- /dev/null
+++ b/utils/api/anilist.go
@@ -0,0 +1,165 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "metachan/types"
+ "metachan/utils/logger"
+ "net/http"
+)
+
+// AniListClient provides methods for interacting with the AniList API
+type AniListClient struct {
+ client *http.Client
+ maxRetries int
+}
+
+// NewAniListClient creates a new AniList API client
+func NewAniListClient() *AniListClient {
+ return &AniListClient{
+ client: &http.Client{},
+ maxRetries: 3,
+ }
+}
+
+// GetAnime fetches anime details from AniList by ID using a simpler approach
+func (c *AniListClient) GetAnime(anilistID int) (*types.AnilistAnimeResponse, error) {
+ // Create a much simpler request with minimal formatting that might trigger Cloudflare
+ query := `
+ query ($id: Int) {
+ Media(id: $id, type: ANIME) {
+ 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 }
+ coverImage {
+ extraLarge
+ large
+ medium
+ color
+ }
+ bannerImage
+ genres
+ synonyms
+ averageScore
+ meanScore
+ popularity
+ isLocked
+ trending
+ favourites
+ tags { id name description category rank isGeneralSpoiler isMediaSpoiler isAdult }
+ nextAiringEpisode { id airingAt timeUntilAiring episode }
+ airingSchedule { nodes { id episode airingAt timeUntilAiring } }
+ studios { edges { isMain node { id name } } }
+ isAdult
+ }
+ }
+ `
+
+ // Create a simple JSON structure with variables
+ requestBody := map[string]interface{}{
+ "query": query,
+ "variables": map[string]interface{}{
+ "id": anilistID,
+ },
+ }
+
+ jsonData, err := json.Marshal(requestBody)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request body: %w", err)
+ }
+
+ // Log the request for debugging
+ logger.Log(fmt.Sprintf("Sending request to AniList for ID %d", anilistID), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AniList",
+ })
+
+ var resp *http.Response
+ var lastErr error
+ success := false
+
+ for i := 0; i <= c.maxRetries && !success; i++ {
+ req, err := http.NewRequest("POST", "https://graphql.anilist.co", bytes.NewBuffer(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+
+ // Add User-Agent to make the request look more like a browser
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
+
+ resp, err = c.client.Do(req)
+ if err != nil {
+ lastErr = err
+ logger.Log(fmt.Sprintf("AniList request attempt %d failed: %v", i+1, err), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AniList",
+ })
+ continue
+ }
+
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body := make([]byte, 1024)
+ n, _ := resp.Body.Read(body)
+ lastErr = fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body[:n]))
+ logger.Log(fmt.Sprintf("AniList returned non-200 status on attempt %d: %v", i+1, lastErr), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AniList",
+ })
+ continue
+ }
+
+ var anilistResponse types.AnilistAnimeResponse
+ if err := json.NewDecoder(resp.Body).Decode(&anilistResponse); err != nil {
+ lastErr = fmt.Errorf("failed to decode response: %w", err)
+ continue
+ }
+
+ if anilistResponse.Data.Media.ID == 0 {
+ lastErr = fmt.Errorf("no data found for Anilist ID %d", anilistID)
+ continue
+ }
+
+ // Log cover image data for debugging
+ if anilistResponse.Data.Media.CoverImage.ExtraLarge != "" {
+ logger.Log(fmt.Sprintf("Found cover data - Color: %s, Image: %s",
+ anilistResponse.Data.Media.CoverImage.Color,
+ anilistResponse.Data.Media.CoverImage.ExtraLarge), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AniList",
+ })
+ }
+
+ success = true
+ return &anilistResponse, nil
+ }
+
+ return nil, fmt.Errorf("failed after %d retries: %w", c.maxRetries, lastErr)
+}
diff --git a/utils/api/aniskip.go b/utils/api/aniskip.go
new file mode 100644
index 0000000..c5aa462
--- /dev/null
+++ b/utils/api/aniskip.go
@@ -0,0 +1,415 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "metachan/types"
+ "metachan/utils/logger"
+ "metachan/utils/ratelimit"
+ "net/http"
+ "sync"
+ "time"
+)
+
+const (
+ aniskipBaseURL = "https://api.aniskip.com/v2"
+)
+
+// AniSkipClient provides methods for interacting with the AniSkip API
+type AniSkipClient struct {
+ client *http.Client
+ rateLimiter *ratelimit.RateLimiter
+ maxRetries int
+ cache map[string][]types.AnimeSkipTimes
+ cacheMutex sync.RWMutex
+ cacheTTL time.Duration
+ cacheTime map[string]time.Time
+}
+
+// EpisodeSkipTimesResult contains skip times for a specific episode
+type EpisodeSkipTimesResult struct {
+ EpisodeNumber int
+ SkipTimes []types.AnimeSkipTimes
+}
+
+// NewAniSkipClient creates a new client for the AniSkip API
+func NewAniSkipClient() *AniSkipClient {
+ return &AniSkipClient{
+ client: &http.Client{
+ Timeout: 5 * time.Second, // Reduced timeout for faster failure detection
+ },
+ rateLimiter: ratelimit.NewRateLimiter(10, 10*time.Second), // Conservative rate limit
+ maxRetries: 2,
+ cache: make(map[string][]types.AnimeSkipTimes),
+ cacheTime: make(map[string]time.Time),
+ cacheTTL: 24 * time.Hour, // Cache skip times for 24 hours
+ }
+}
+
+// getCacheKey generates a cache key for skip times
+func (c *AniSkipClient) getCacheKey(malID, episode int) string {
+ return fmt.Sprintf("%d-%d", malID, episode)
+}
+
+// getFromCache tries to get skip times from cache
+func (c *AniSkipClient) getFromCache(malID, episode int) ([]types.AnimeSkipTimes, bool) {
+ key := c.getCacheKey(malID, episode)
+
+ c.cacheMutex.RLock()
+ defer c.cacheMutex.RUnlock()
+
+ // Check if we have a valid cache entry
+ if cacheTime, exists := c.cacheTime[key]; exists {
+ // Check if cache is still valid
+ if time.Since(cacheTime) < c.cacheTTL {
+ return c.cache[key], true
+ }
+ }
+
+ return nil, false
+}
+
+// saveToCache saves skip times to cache
+func (c *AniSkipClient) saveToCache(malID, episode int, skipTimes []types.AnimeSkipTimes) {
+ key := c.getCacheKey(malID, episode)
+
+ c.cacheMutex.Lock()
+ defer c.cacheMutex.Unlock()
+
+ c.cache[key] = skipTimes
+ c.cacheTime[key] = time.Now()
+}
+
+// GetSkipTimesForEpisode fetches skip times for a specific anime episode
+func (c *AniSkipClient) GetSkipTimesForEpisode(malID, episodeNumber int) ([]types.AnimeSkipTimes, error) {
+ // Check cache first
+ if skipTimes, found := c.getFromCache(malID, episodeNumber); found {
+ return skipTimes, nil
+ }
+
+ // Wait for rate limiter before making request
+ c.rateLimiter.Wait()
+
+ // Using v2 API which is more efficient
+ apiURL := fmt.Sprintf("%s/skip-times/%d/%d?types=op&types=ed", aniskipBaseURL, malID, episodeNumber)
+
+ // Create context with timeout
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create request
+ req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Execute request with retries
+ var resp *http.Response
+ var lastErr error
+ success := false
+
+ for i := 0; i <= c.maxRetries && !success; i++ {
+ resp, err = c.client.Do(req)
+ if err != nil {
+ lastErr = err
+ // Backoff with exponential delay
+ time.Sleep(time.Duration((i+1)*300) * time.Millisecond)
+ continue
+ }
+
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusNotFound {
+ // No skip times found, not an error
+ c.saveToCache(malID, episodeNumber, []types.AnimeSkipTimes{})
+ return []types.AnimeSkipTimes{}, nil
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ lastErr = fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
+
+ // Longer backoff for rate limits
+ if resp.StatusCode == http.StatusTooManyRequests {
+ time.Sleep(time.Duration((i+1)*1000) * time.Millisecond)
+ } else {
+ time.Sleep(time.Duration((i+1)*300) * time.Millisecond)
+ }
+ continue
+ }
+
+ success = true
+ }
+
+ if !success {
+ return nil, fmt.Errorf("failed after %d retries: %w", c.maxRetries, lastErr)
+ }
+
+ // Parse response
+ bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) // 1MB limit
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ // The response format from AniSkip API v1 as shown in the prompt example
+ type aniskipResponse struct {
+ Found bool `json:"found"`
+ Results []struct {
+ Interval struct {
+ StartTime float64 `json:"start_time"`
+ EndTime float64 `json:"end_time"`
+ } `json:"interval"`
+ SkipType string `json:"skip_type"`
+ SkipID string `json:"skip_id"`
+ EpisodeLength float64 `json:"episode_length"`
+ } `json:"results"`
+ }
+
+ var skipResp aniskipResponse
+ if err := json.Unmarshal(bodyBytes, &skipResp); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ // If no results found
+ if !skipResp.Found || len(skipResp.Results) == 0 {
+ c.saveToCache(malID, episodeNumber, []types.AnimeSkipTimes{})
+ return []types.AnimeSkipTimes{}, nil
+ }
+
+ // Convert to our skip times format
+ var skipTimes []types.AnimeSkipTimes
+ for _, result := range skipResp.Results {
+ skipTime := types.AnimeSkipTimes{
+ SkipType: result.SkipType,
+ StartTime: result.Interval.StartTime,
+ EndTime: result.Interval.EndTime,
+ EpisodeLength: result.EpisodeLength,
+ }
+ skipTimes = append(skipTimes, skipTime)
+ }
+
+ // Save to cache
+ c.saveToCache(malID, episodeNumber, skipTimes)
+
+ return skipTimes, nil
+}
+
+// GetSkipTimesForEpisodesBatch fetches skip times for episodes in batches
+func (c *AniSkipClient) GetSkipTimesForEpisodesBatch(malID int, episodes []int) (map[int][]types.AnimeSkipTimes, error) {
+ // If we have fewer than 3 episodes, use individual requests instead
+ if len(episodes) < 3 {
+ results := make(map[int][]types.AnimeSkipTimes)
+ for _, ep := range episodes {
+ skipTimes, err := c.GetSkipTimesForEpisode(malID, ep)
+ if err != nil {
+ return nil, err
+ }
+ results[ep] = skipTimes
+ }
+ return results, nil
+ }
+
+ // Check if all episodes are cached and return them
+ allCached := true
+ cachedResults := make(map[int][]types.AnimeSkipTimes)
+
+ for _, ep := range episodes {
+ if skipTimes, found := c.getFromCache(malID, ep); found {
+ cachedResults[ep] = skipTimes
+ } else {
+ allCached = false
+ break
+ }
+ }
+
+ if allCached {
+ return cachedResults, nil
+ }
+
+ // Wait for rate limiter
+ c.rateLimiter.Wait()
+
+ // Construct episode IDs parameter
+ episodeParams := ""
+ for i, ep := range episodes {
+ if i > 0 {
+ episodeParams += ","
+ }
+ episodeParams += fmt.Sprintf("%d", ep)
+ }
+
+ // Batch endpoint URL
+ apiURL := fmt.Sprintf("%s/skip-times-batch?malId=%d&episodeIds=%s&types=op&types=ed",
+ aniskipBaseURL, malID, episodeParams)
+
+ // Create context with timeout
+ ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+ defer cancel()
+
+ // Create request
+ req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create batch request: %w", err)
+ }
+
+ // Execute request with retries
+ var resp *http.Response
+ var lastErr error
+ success := false
+
+ for i := 0; i <= c.maxRetries && !success; i++ {
+ resp, err = c.client.Do(req)
+ if err != nil {
+ lastErr = err
+ time.Sleep(time.Duration((i+1)*300) * time.Millisecond)
+ continue
+ }
+
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ lastErr = fmt.Errorf("batch request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
+
+ if resp.StatusCode == http.StatusTooManyRequests {
+ time.Sleep(time.Duration((i+1)*1000) * time.Millisecond)
+ } else {
+ time.Sleep(time.Duration((i+1)*300) * time.Millisecond)
+ }
+ continue
+ }
+
+ success = true
+ }
+
+ if !success {
+ return nil, fmt.Errorf("batch request failed after %d retries: %w", c.maxRetries, lastErr)
+ }
+
+ // Parse response
+ bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
+ if err != nil {
+ return nil, fmt.Errorf("failed to read batch response: %w", err)
+ }
+
+ // Batch response format
+ type batchSkipTime struct {
+ Interval struct {
+ StartTime float64 `json:"start_time"`
+ EndTime float64 `json:"end_time"`
+ } `json:"interval"`
+ SkipType string `json:"skip_type"`
+ SkipID string `json:"skip_id"`
+ EpisodeLength float64 `json:"episode_length"`
+ }
+
+ type episodeSkipTimes struct {
+ Found bool `json:"found"`
+ Results []batchSkipTime `json:"results"`
+ }
+
+ // Map of episode number to skip times
+ type batchResponse map[string]episodeSkipTimes
+
+ var skipResp batchResponse
+ if err := json.Unmarshal(bodyBytes, &skipResp); err != nil {
+ return nil, fmt.Errorf("failed to decode batch response: %w", err)
+ }
+
+ results := make(map[int][]types.AnimeSkipTimes)
+
+ // Process results
+ for epStr, epData := range skipResp {
+ var epNum int
+ if _, err := fmt.Sscanf(epStr, "%d", &epNum); err != nil {
+ continue // Skip if we can't parse the episode number
+ }
+
+ var skipTimes []types.AnimeSkipTimes
+
+ if epData.Found {
+ for _, result := range epData.Results {
+ skipTimes = append(skipTimes, types.AnimeSkipTimes{
+ SkipType: result.SkipType,
+ StartTime: result.Interval.StartTime,
+ EndTime: result.Interval.EndTime,
+ EpisodeLength: result.EpisodeLength,
+ })
+ }
+ }
+
+ // Save to cache
+ c.saveToCache(malID, epNum, skipTimes)
+ results[epNum] = skipTimes
+ }
+
+ return results, nil
+}
+
+// GetSkipTimesForEpisodes fetches skip times for multiple episodes efficiently
+func (c *AniSkipClient) GetSkipTimesForEpisodes(malID int, episodeCount int, maxConcurrent int) []types.EpisodeSkipResult {
+ startTime := time.Now()
+
+ // If episode count is small, just use single endpoint
+ if episodeCount <= 5 {
+ results := []types.EpisodeSkipResult{}
+ for i := 1; i <= episodeCount; i++ {
+ skipTimes, err := c.GetSkipTimesForEpisode(malID, i)
+ if err == nil && len(skipTimes) > 0 {
+ results = append(results, types.EpisodeSkipResult{
+ EpisodeNumber: i,
+ SkipTimes: skipTimes,
+ })
+ }
+ }
+ return results
+ }
+
+ // Create episode numbers slice
+ allEpisodes := make([]int, episodeCount)
+ for i := 0; i < episodeCount; i++ {
+ allEpisodes[i] = i + 1 // 1-indexed episodes
+ }
+
+ // Batch size - we'll process episodes in batches
+ const batchSize = 25
+ var results []types.EpisodeSkipResult
+
+ // Process in batches
+ for i := 0; i < episodeCount; i += batchSize {
+ end := i + batchSize
+ if end > episodeCount {
+ end = episodeCount
+ }
+
+ batchEpisodes := allEpisodes[i:end]
+ batchResults, err := c.GetSkipTimesForEpisodesBatch(malID, batchEpisodes)
+ if err != nil {
+ logger.Log(fmt.Sprintf("Error fetching skip times batch %d-%d: %v", i+1, end, err), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "AniSkip",
+ })
+ continue
+ }
+
+ // Add results to the final list
+ for epNum, skipTimes := range batchResults {
+ if len(skipTimes) > 0 {
+ results = append(results, types.EpisodeSkipResult{
+ EpisodeNumber: epNum,
+ SkipTimes: skipTimes,
+ })
+ }
+ }
+ }
+
+ logger.Log(fmt.Sprintf("AniSkip: Fetched skip times for %d episodes of %d in %s",
+ len(results), episodeCount, time.Since(startTime)), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "AniSkip",
+ })
+
+ return results
+}
diff --git a/utils/api/jikan.go b/utils/api/jikan.go
new file mode 100644
index 0000000..0f9ff83
--- /dev/null
+++ b/utils/api/jikan.go
@@ -0,0 +1,244 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math"
+ "metachan/types"
+ "metachan/utils/ratelimit"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+var (
+ // Global Jikan rate limiters
+ jikanPerSecLimiter = ratelimit.NewRateLimiter(3, time.Second)
+ jikanPerMinLimiter = ratelimit.NewRateLimiter(60, time.Minute)
+ jikanLimiter = ratelimit.NewMultiLimiter(jikanPerSecLimiter, jikanPerMinLimiter)
+)
+
+// JikanClient provides methods to interact with the Jikan API
+type JikanClient struct {
+ client *http.Client
+ maxRetries int
+ baseBackoff time.Duration
+}
+
+// NewJikanClient creates a new Jikan API client
+func NewJikanClient() *JikanClient {
+ return &JikanClient{
+ client: &http.Client{
+ Timeout: 15 * time.Second,
+ },
+ maxRetries: 3,
+ baseBackoff: 1 * time.Second,
+ }
+}
+
+// WaitForRateLimit waits until a request can be made according to rate limiting rules
+func (c *JikanClient) WaitForRateLimit() {
+ jikanLimiter.Wait()
+}
+
+// makeRequest makes an HTTP request with retries and proper error handling
+func (c *JikanClient) makeRequest(ctx context.Context, url string) ([]byte, error) {
+ var bodyBytes []byte
+ var statusCode int
+
+ retries := 0
+ for retries <= c.maxRetries {
+ // Wait for rate limiter before attempting request
+ c.WaitForRateLimit()
+
+ // Create the request with timeout context
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Execute the request
+ resp, err := c.client.Do(req)
+ if err != nil {
+ if retries < c.maxRetries {
+ retries++
+ backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(2, float64(retries-1)))
+ time.Sleep(backoffTime)
+ continue
+ }
+ return nil, fmt.Errorf("failed to execute request after %d retries: %w", c.maxRetries, err)
+ }
+ defer resp.Body.Close()
+
+ statusCode = resp.StatusCode
+
+ // Handle rate limiting with exponential backoff
+ if statusCode == http.StatusTooManyRequests {
+ if retries < c.maxRetries {
+ retries++
+ backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(1.5, float64(retries-1)))
+
+ // Respect Retry-After header if available
+ if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
+ if seconds, err := strconv.Atoi(retryAfter); err == nil {
+ backoffTime = time.Duration(seconds) * time.Second
+ }
+ }
+
+ time.Sleep(backoffTime)
+ continue
+ }
+ return nil, fmt.Errorf("rate limited after %d retries", c.maxRetries)
+ } else if statusCode != http.StatusOK {
+ if retries < c.maxRetries {
+ retries++
+ backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(2, float64(retries-1)))
+ time.Sleep(backoffTime)
+ continue
+ }
+ return nil, fmt.Errorf("request failed with status: %d", statusCode)
+ }
+
+ // Limit response body size to prevent memory issues
+ bodyBytes, err = io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit
+ if err != nil {
+ if retries < c.maxRetries {
+ retries++
+ backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(2, float64(retries-1)))
+ time.Sleep(backoffTime)
+ continue
+ }
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ // Success, break the retry loop
+ return bodyBytes, nil
+ }
+
+ return nil, fmt.Errorf("exhausted all retries with status code: %d", statusCode)
+}
+
+// GetAnime fetches basic anime information by MAL ID
+func (c *JikanClient) GetAnime(malID int) (*types.JikanAnimeResponse, error) {
+ apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d", malID)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ bodyBytes, err := c.makeRequest(ctx, apiURL)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get anime data: %w", err)
+ }
+
+ var animeResponse types.JikanAnimeResponse
+ if err := json.Unmarshal(bodyBytes, &animeResponse); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ if animeResponse.Data.MALID == 0 {
+ return nil, fmt.Errorf("no data found for MAL ID %d", malID)
+ }
+
+ return &animeResponse, nil
+}
+
+// GetFullAnime fetches detailed anime information by MAL ID
+func (c *JikanClient) GetFullAnime(malID int) (*types.JikanAnimeResponse, error) {
+ apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/full", malID)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ bodyBytes, err := c.makeRequest(ctx, apiURL)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get anime full data: %w", err)
+ }
+
+ var animeResponse types.JikanAnimeResponse
+ if err := json.Unmarshal(bodyBytes, &animeResponse); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ if animeResponse.Data.MALID == 0 {
+ return nil, fmt.Errorf("no data found for MAL ID %d", malID)
+ }
+
+ return &animeResponse, nil
+}
+
+// GetAnimeEpisodes fetches all episodes for an anime by MAL ID
+func (c *JikanClient) GetAnimeEpisodes(malID int) (*types.JikanAnimeEpisodeResponse, error) {
+ result := types.JikanAnimeEpisodeResponse{
+ Data: []types.JikanAnimeEpisode{},
+ }
+
+ maxPages := 25 // Safety limit to avoid excessive requests
+ page := 1
+ maxAttempts := 15 // Maximum number of attempts across all pages
+ totalAttempts := 0
+
+ for page <= maxPages && totalAttempts < maxAttempts {
+ apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/episodes?page=%d", malID, page)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+
+ totalAttempts++
+
+ bodyBytes, err := c.makeRequest(ctx, apiURL)
+ cancel()
+
+ if err != nil {
+ // If we have some episodes already, return them rather than failing
+ if len(result.Data) > 0 {
+ result.Pagination.HasNextPage = false
+ break
+ }
+ return nil, fmt.Errorf("failed to get anime episodes page %d: %w", page, err)
+ }
+
+ var pageResponse types.JikanAnimeEpisodeResponse
+ if err := json.Unmarshal(bodyBytes, &pageResponse); err != nil {
+ // Return what we have if we got some pages successfully
+ if len(result.Data) > 0 {
+ result.Pagination.HasNextPage = false
+ break
+ }
+ return nil, fmt.Errorf("failed to decode episodes response: %w", err)
+ }
+
+ // Append episodes from this page
+ result.Data = append(result.Data, pageResponse.Data...)
+ result.Pagination = pageResponse.Pagination
+
+ // Check if we need to fetch more pages
+ if !pageResponse.Pagination.HasNextPage {
+ break
+ }
+
+ page++
+ }
+
+ return &result, nil
+}
+
+// GetAnimeCharacters fetches all characters for an anime by MAL ID
+func (c *JikanClient) GetAnimeCharacters(malID int) (*types.JikanAnimeCharacterResponse, error) {
+ apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/characters", malID)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ bodyBytes, err := c.makeRequest(ctx, apiURL)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get anime characters: %w", err)
+ }
+
+ var characterResponse types.JikanAnimeCharacterResponse
+ if err := json.Unmarshal(bodyBytes, &characterResponse); err != nil {
+ return nil, fmt.Errorf("failed to decode characters response: %w", err)
+ }
+
+ return &characterResponse, nil
+}
diff --git a/utils/api/malsync.go b/utils/api/malsync.go
new file mode 100644
index 0000000..d323841
--- /dev/null
+++ b/utils/api/malsync.go
@@ -0,0 +1,99 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "metachan/types"
+ "net/http"
+ "time"
+)
+
+const (
+ malsyncAPIBaseURL = "https://api.malsync.moe/mal"
+)
+
+// MALSyncClient provides methods for interacting with the MALSync API
+type MALSyncClient struct {
+ client *http.Client
+ maxRetries int
+}
+
+// NewMALSyncClient creates a new client for the MALSync API
+func NewMALSyncClient() *MALSyncClient {
+ return &MALSyncClient{
+ client: &http.Client{
+ Timeout: 8 * time.Second, // Shorter timeout since this is a less critical API
+ },
+ maxRetries: 2,
+ }
+}
+
+// GetAnimeByMALID fetches anime metadata from MALSync by MAL ID
+func (c *MALSyncClient) GetAnimeByMALID(malID int) (*types.MALSyncAnimeResponse, error) {
+ apiURL := fmt.Sprintf("%s/anime/%d", malsyncAPIBaseURL, malID)
+
+ // Create context with timeout
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ // Create request
+ req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ // Execute request with retries
+ var resp *http.Response
+ var lastErr error
+ success := false
+
+ for i := 0; i <= c.maxRetries && !success; i++ {
+ resp, err = c.client.Do(req)
+ if err != nil {
+ lastErr = err
+ time.Sleep(time.Duration((i+1)*300) * time.Millisecond) // Short backoff on error
+ continue
+ }
+
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusNotFound {
+ return nil, nil // Not found is not an error, just return nil
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ lastErr = fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
+ time.Sleep(time.Duration((i+1)*300) * time.Millisecond)
+ continue
+ }
+
+ success = true
+ }
+
+ if !success {
+ return nil, fmt.Errorf("failed after %d retries: %w", c.maxRetries, lastErr)
+ }
+
+ // Parse response
+ bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) // 1MB limit
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ var malSyncResponse types.MALSyncAnimeResponse
+ if err := json.Unmarshal(bodyBytes, &malSyncResponse); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ // Simple validation
+ if malSyncResponse.ID == 0 {
+ return nil, fmt.Errorf("received empty response for MAL ID %d", malID)
+ }
+
+ return &malSyncResponse, nil
+}
diff --git a/utils/api/streaming.go b/utils/api/streaming.go
new file mode 100644
index 0000000..cd9a54a
--- /dev/null
+++ b/utils/api/streaming.go
@@ -0,0 +1,526 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "metachan/types"
+ "net/http"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ allanimeBaseURL = "https://api.allanime.day/api"
+)
+
+// AllAnimeClient provides methods for interacting with the AllAnime API
+type AllAnimeClient struct {
+ client *http.Client
+ headers http.Header
+}
+
+// StreamingSearchResult represents a search result from AllAnime
+type StreamingSearchResult struct {
+ ID string `json:"_id"`
+ Name string `json:"name"`
+ SubEpisodes int `json:"sub_episodes"`
+ DubEpisodes int `json:"dub_episodes"`
+ Similarity float64 `json:"similarity"`
+}
+
+// NewAllAnimeClient creates a new AllAnime client
+func NewAllAnimeClient() *AllAnimeClient {
+ headers := http.Header{
+ "User-Agent": {"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0"},
+ "Referer": {"https://allmanga.to"},
+ }
+
+ return &AllAnimeClient{
+ client: &http.Client{
+ Timeout: 10 * time.Second,
+ },
+ headers: headers,
+ }
+}
+
+// calculateSimilarity determines how closely a title matches a query
+func (c *AllAnimeClient) calculateSimilarity(query, title string) float64 {
+ queryLower := strings.ToLower(query)
+ titleLower := strings.ToLower(title)
+
+ // Exact match
+ if queryLower == titleLower {
+ return 1.0
+ }
+
+ // Title contains query
+ if strings.Contains(titleLower, queryLower) {
+ return 0.9
+ }
+
+ // Calculate word match score
+ queryWords := strings.Fields(queryLower)
+ titleWords := strings.Fields(titleLower)
+
+ matchCount := 0
+ for _, qw := range queryWords {
+ for _, tw := range titleWords {
+ if qw == tw || strings.Contains(tw, qw) || strings.Contains(qw, tw) {
+ matchCount++
+ break
+ }
+ }
+ }
+
+ if len(queryWords) == 0 {
+ return 0
+ }
+
+ return float64(matchCount) / float64(len(queryWords))
+}
+
+// decodeURL decodes an encoded URL from AllAnime
+func (c *AllAnimeClient) decodeURL(encodedString string) string {
+ if !strings.HasPrefix(encodedString, "--") {
+ return encodedString
+ }
+
+ encodedString = encodedString[2:]
+ decodeMap := map[string]string{
+ "01": "9", "08": "0", "05": "=", "0a": "2",
+ "0b": "3", "0c": "4", "07": "?", "00": "8",
+ "5c": "d", "0f": "7", "5e": "f", "17": "/",
+ "54": "l", "09": "1", "48": "p", "4f": "w",
+ "0e": "6", "5b": "c", "5d": "e", "0d": "5",
+ "53": "k", "1e": "&", "5a": "b", "59": "a",
+ "4a": "r", "4c": "t", "4e": "v", "57": "o",
+ "51": "i",
+ }
+
+ var decoded strings.Builder
+ for i := 0; i < len(encodedString); i += 2 {
+ if i+2 <= len(encodedString) {
+ pair := encodedString[i : i+2]
+ if val, ok := decodeMap[pair]; ok {
+ decoded.WriteString(val)
+ }
+ }
+ }
+
+ return decoded.String()
+}
+
+// processProviderURL processes provider URLs from AllAnime
+func (c *AllAnimeClient) processProviderURL(urlStr string) string {
+ baseURL := "https://allanime.day"
+
+ if strings.HasPrefix(urlStr, "/") {
+ urlStr = strings.Replace(urlStr, "/apivtwo/clock", "/apivtwo/clock.json", 1)
+ return baseURL + urlStr
+ }
+
+ return urlStr
+}
+
+// getClockLink fetches a direct streaming link from a clock endpoint
+func (c *AllAnimeClient) getClockLink(urlStr string) (string, error) {
+ if strings.HasPrefix(urlStr, "/") {
+ urlStr = "https://allanime.day" + urlStr
+ }
+
+ req, err := http.NewRequest("GET", urlStr, nil)
+ if err != nil {
+ return "", err
+ }
+
+ for key, values := range c.headers {
+ req.Header[key] = values
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ var data map[string]interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ return "", err
+ }
+
+ if links, ok := data["links"].([]interface{}); ok && len(links) > 0 {
+ if link, ok := links[0].(map[string]interface{}); ok {
+ if linkStr, ok := link["link"].(string); ok {
+ return linkStr, nil
+ }
+ }
+ }
+
+ return "", fmt.Errorf("no valid link found")
+}
+
+// processSourceURL processes a streaming source URL from AllAnime
+func (c *AllAnimeClient) processSourceURL(sourceURL, sourceType string) *types.AnimeStreamingSource {
+ var decodedURL string
+ if strings.HasPrefix(sourceURL, "--") {
+ decodedURL = c.decodeURL(sourceURL)
+ } else {
+ decodedURL = strings.ReplaceAll(sourceURL, "\\u002F", "/")
+ }
+
+ processedURL := c.processProviderURL(decodedURL)
+
+ // Check if it's a clock link
+ if strings.Contains(processedURL, "/apivtwo/clock") {
+ if directURL, err := c.getClockLink(processedURL); err == nil {
+ return &types.AnimeStreamingSource{
+ URL: directURL,
+ Server: getServerName(sourceType),
+ Type: "direct",
+ }
+ }
+ }
+
+ // Check if it's a direct stream link
+ directPatterns := []string{"fast4speed.rsvp", "sharepoint.com", ".m3u8", ".mp4"}
+ for _, pattern := range directPatterns {
+ if strings.Contains(processedURL, pattern) {
+ return &types.AnimeStreamingSource{
+ URL: processedURL,
+ Server: getServerName(sourceType),
+ Type: "direct",
+ }
+ }
+ }
+
+ // Return as regular source if not direct
+ return &types.AnimeStreamingSource{
+ URL: processedURL,
+ Server: getServerName(sourceType),
+ Type: "embed",
+ }
+}
+
+// getServerName maps AllAnime source types to readable server names
+func getServerName(sourceType string) string {
+ switch strings.ToLower(sourceType) {
+ case "default":
+ return "Maria"
+ case "luf-mp4":
+ return "Rose"
+ case "s-mp4":
+ return "Sina"
+ default:
+ return sourceType
+ }
+}
+
+// SearchAnime searches for anime by title on AllAnime
+func (c *AllAnimeClient) SearchAnime(query string) ([]StreamingSearchResult, error) {
+ searchQuery := `
+ query(
+ $search: SearchInput
+ $limit: Int
+ $page: Int
+ $countryOrigin: VaildCountryOriginEnumType
+ ) {
+ shows(
+ search: $search
+ limit: $limit
+ page: $page
+ countryOrigin: $countryOrigin
+ ) {
+ edges {
+ _id
+ name
+ availableEpisodes
+ __typename
+ }
+ }
+ }
+ `
+
+ variables := map[string]interface{}{
+ "search": map[string]interface{}{
+ "allowAdult": false,
+ "allowUnknown": false,
+ "query": query,
+ },
+ "limit": 40,
+ "page": 1,
+ "countryOrigin": "ALL",
+ }
+
+ params := url.Values{}
+ variablesJSON, _ := json.Marshal(variables)
+ params.Set("variables", string(variablesJSON))
+ params.Set("query", searchQuery)
+
+ req, err := http.NewRequest("GET", allanimeBaseURL+"?"+params.Encode(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ for key, values := range c.headers {
+ req.Header[key] = values
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var data map[string]interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ return nil, err
+ }
+
+ shows := data["data"].(map[string]interface{})["shows"].(map[string]interface{})["edges"].([]interface{})
+ results := make([]StreamingSearchResult, 0, len(shows))
+
+ for _, show := range shows {
+ showMap := show.(map[string]interface{})
+ episodes := showMap["availableEpisodes"].(map[string]interface{})
+ result := StreamingSearchResult{
+ ID: showMap["_id"].(string),
+ Name: showMap["name"].(string),
+ SubEpisodes: int(episodes["sub"].(float64)),
+ DubEpisodes: int(episodes["dub"].(float64)),
+ }
+ result.Similarity = c.calculateSimilarity(query, result.Name)
+ results = append(results, result)
+ }
+
+ // Sort by similarity
+ sort.Slice(results, func(i, j int) bool {
+ return results[i].Similarity > results[j].Similarity
+ })
+
+ return results, nil
+}
+
+// GetEpisodesList gets the list of available episodes for an anime
+func (c *AllAnimeClient) GetEpisodesList(showID string, mode string) ([]string, error) {
+ episodesQuery := `
+ query ($showId: String!) {
+ show(
+ _id: $showId
+ ) {
+ _id
+ availableEpisodesDetail
+ }
+ }
+ `
+
+ variables := map[string]interface{}{
+ "showId": showID,
+ }
+
+ params := url.Values{}
+ variablesJSON, _ := json.Marshal(variables)
+ params.Set("variables", string(variablesJSON))
+ params.Set("query", episodesQuery)
+
+ req, err := http.NewRequest("GET", allanimeBaseURL+"?"+params.Encode(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ for key, values := range c.headers {
+ req.Header[key] = values
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var data map[string]interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ return nil, err
+ }
+
+ showData := data["data"].(map[string]interface{})["show"].(map[string]interface{})
+ episodesDetail := showData["availableEpisodesDetail"].(map[string]interface{})
+ episodesList := episodesDetail[mode].([]interface{})
+
+ result := make([]string, 0, len(episodesList))
+ for _, ep := range episodesList {
+ switch v := ep.(type) {
+ case float64:
+ result = append(result, fmt.Sprintf("%.0f", v))
+ case string:
+ result = append(result, v)
+ default:
+ result = append(result, fmt.Sprintf("%v", v))
+ }
+ }
+
+ sort.Slice(result, func(i, j int) bool {
+ ni, _ := strconv.Atoi(result[i])
+ nj, _ := strconv.Atoi(result[j])
+ return ni < nj
+ })
+
+ return result, nil
+}
+
+// GetEpisodeLinks gets streaming links for a specific episode
+func (c *AllAnimeClient) GetEpisodeLinks(showID, episode, mode string) ([]types.AnimeStreamingSource, error) {
+ episodeQuery := `
+ query ($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
+ episode(
+ showId: $showId
+ translationType: $translationType
+ episodeString: $episodeString
+ ) {
+ episodeString
+ sourceUrls
+ }
+ }
+ `
+
+ variables := map[string]interface{}{
+ "showId": showID,
+ "translationType": mode,
+ "episodeString": episode,
+ }
+
+ params := url.Values{}
+ variablesJSON, _ := json.Marshal(variables)
+ params.Set("variables", string(variablesJSON))
+ params.Set("query", episodeQuery)
+
+ req, err := http.NewRequest("GET", allanimeBaseURL+"?"+params.Encode(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ for key, values := range c.headers {
+ req.Header[key] = values
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var data map[string]interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ return nil, err
+ }
+
+ episodeData := data["data"].(map[string]interface{})["episode"].(map[string]interface{})
+ sourceUrls := episodeData["sourceUrls"].([]interface{})
+
+ var links []types.AnimeStreamingSource
+ for _, source := range sourceUrls {
+ sourceMap := source.(map[string]interface{})
+ if sourceURL, ok := sourceMap["sourceUrl"].(string); ok {
+ sourceName := sourceMap["sourceName"].(string)
+ sourceInfo := c.processSourceURL(sourceURL, sourceName)
+
+ // Only add direct sources
+ if sourceInfo.Type == "direct" {
+ links = append(links, *sourceInfo)
+ }
+ }
+ }
+
+ return links, nil
+}
+
+// GetStreamingSources fetches both sub and dub streaming sources for an anime episode
+func (c *AllAnimeClient) GetStreamingSources(title string, episodeNumber int) (*types.AnimeStreaming, error) {
+ // Search for the anime
+ searchResults, err := c.SearchAnime(title)
+ if err != nil {
+ return nil, fmt.Errorf("failed to search for anime: %w", err)
+ }
+
+ if len(searchResults) == 0 {
+ return nil, fmt.Errorf("no streaming sources found for '%s'", title)
+ }
+
+ // Use the best match (first result)
+ bestMatch := searchResults[0]
+
+ streaming := &types.AnimeStreaming{
+ Sub: []types.AnimeStreamingSource{},
+ Dub: []types.AnimeStreamingSource{},
+ }
+
+ // Get sub episodes if available
+ if bestMatch.SubEpisodes > 0 {
+ episodes, err := c.GetEpisodesList(bestMatch.ID, "sub")
+ if err == nil && len(episodes) > 0 {
+ // Find the closest episode
+ episodeStr := fmt.Sprintf("%d", episodeNumber)
+ var closestEpisode string
+
+ for _, ep := range episodes {
+ if ep == episodeStr {
+ closestEpisode = ep
+ break
+ }
+ }
+
+ if closestEpisode != "" {
+ subSources, err := c.GetEpisodeLinks(bestMatch.ID, closestEpisode, "sub")
+ if err == nil {
+ streaming.Sub = subSources
+ }
+ }
+ }
+ }
+
+ // Get dub episodes if available
+ if bestMatch.DubEpisodes > 0 {
+ episodes, err := c.GetEpisodesList(bestMatch.ID, "dub")
+ if err == nil && len(episodes) > 0 {
+ // Find the closest episode
+ episodeStr := fmt.Sprintf("%d", episodeNumber)
+ var closestEpisode string
+
+ for _, ep := range episodes {
+ if ep == episodeStr {
+ closestEpisode = ep
+ break
+ }
+ }
+
+ if closestEpisode != "" {
+ dubSources, err := c.GetEpisodeLinks(bestMatch.ID, closestEpisode, "dub")
+ if err == nil {
+ streaming.Dub = dubSources
+ }
+ }
+ }
+ }
+
+ return streaming, nil
+}
+
+// GetStreamingCounts fetches the total count of subbed and dubbed episodes for an anime without fetching individual episode data
+func (c *AllAnimeClient) GetStreamingCounts(title string) (int, int, error) {
+ // Search for the anime
+ searchResults, err := c.SearchAnime(title)
+ if err != nil {
+ return 0, 0, fmt.Errorf("failed to search for anime: %w", err)
+ }
+
+ if len(searchResults) == 0 {
+ return 0, 0, fmt.Errorf("no results found for '%s'", title)
+ }
+
+ // Use the best match (first result)
+ bestMatch := searchResults[0]
+
+ return bestMatch.SubEpisodes, bestMatch.DubEpisodes, nil
+}
diff --git a/utils/api/tmdb.go b/utils/api/tmdb.go
new file mode 100644
index 0000000..5af7599
--- /dev/null
+++ b/utils/api/tmdb.go
@@ -0,0 +1,498 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "math"
+ "math/rand"
+ "metachan/config"
+ "metachan/types"
+ "metachan/utils/logger"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// makeRequestWithRetries executes an HTTP request with retries for handling temporary network failures
+func makeRequestWithRetries(req *http.Request, maxRetries int) (*http.Response, error) {
+ client := &http.Client{
+ Timeout: 10 * time.Second,
+ }
+
+ var lastErr error
+ for attempt := 0; attempt <= maxRetries; attempt++ {
+ if attempt > 0 {
+ // Exponential backoff with jitter for retries
+ backoffTime := time.Duration(math.Pow(1.5, float64(attempt))) * time.Second
+
+ // Updated jitter calculation without using deprecated rand.Seed
+ jitter := time.Duration(rand.Int31n(500)) * time.Millisecond
+
+ sleepTime := backoffTime + jitter
+
+ logger.Log(fmt.Sprintf("TMDB request retry %d/%d after %v due to: %v",
+ attempt, maxRetries, sleepTime, lastErr), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "TMDB",
+ })
+
+ time.Sleep(sleepTime)
+
+ // Create a fresh request to avoid any issues with reusing the same request
+ newReq, err := http.NewRequest(req.Method, req.URL.String(), nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create new request for retry: %w", err)
+ }
+
+ // Copy all headers from the original request
+ for key, values := range req.Header {
+ for _, value := range values {
+ newReq.Header.Add(key, value)
+ }
+ }
+
+ // Set the new retry request as our active request
+ req = newReq
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ lastErr = err
+ // Check if this is a network error that might be temporary
+ if strings.Contains(err.Error(), "connection reset by peer") ||
+ strings.Contains(err.Error(), "EOF") ||
+ strings.Contains(err.Error(), "connection refused") ||
+ strings.Contains(err.Error(), "timeout") {
+ // These are retryable errors
+ continue
+ }
+ // Other errors are not retryable
+ return nil, err
+ }
+
+ // If we got a server error (5xx), retry
+ if resp.StatusCode >= 500 && resp.StatusCode < 600 {
+ lastErr = fmt.Errorf("server error: %s", resp.Status)
+ resp.Body.Close() // Make sure we close the body before we retry
+ continue
+ }
+
+ return resp, nil
+ }
+
+ return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, lastErr)
+}
+
+// normalizeTitle cleans up the anime title for better matching with TMDB
+func normalizeTitle(title string) string {
+ // Handle empty titles
+ if title == "" {
+ return ""
+ }
+
+ // Remove common suffixes and prefixes
+ normalized := title
+ normalized = strings.Replace(normalized, "TV Animation", "", -1)
+ normalized = strings.Replace(normalized, ": Season", "", -1)
+ normalized = strings.Replace(normalized, "Season", "", -1)
+ normalized = strings.Replace(normalized, "Part", "", -1)
+ normalized = strings.Replace(normalized, "Cour", "", -1)
+
+ // Handle patterns like "Dr. Stone: Stone Wars" -> "Dr. Stone"
+ if colonIndex := strings.Index(normalized, ":"); colonIndex > 0 {
+ normalized = normalized[:colonIndex]
+ }
+
+ // Remove parentheses and text inside them
+ for {
+ openParen := strings.Index(normalized, "(")
+ if openParen == -1 {
+ break
+ }
+ closeParen := strings.Index(normalized, ")")
+ if closeParen == -1 || closeParen < openParen {
+ break
+ }
+ normalized = normalized[:openParen] + normalized[closeParen+1:]
+ }
+
+ return strings.TrimSpace(normalized)
+}
+
+// searchTVShowsByTitle searches for TV shows on TMDB by title
+func searchTVShowsByTitle(title string, alternativeTitle string, isAdult bool, countryPriority string) ([]types.TMDBShowResult, error) {
+ if config.Config.TMDB.ReadAccessToken == "" {
+ return nil, fmt.Errorf("TMDB is not initialized")
+ }
+
+ // Normalize the title
+ query := normalizeTitle(title)
+ if query == "" && alternativeTitle != "" {
+ query = normalizeTitle(alternativeTitle)
+ }
+
+ logger.Log(fmt.Sprintf("Searching TMDB for TV show: %s", query), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "TMDB",
+ })
+
+ apiURL := "https://api.themoviedb.org/3/search/tv"
+ req, err := http.NewRequest("GET", apiURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Add query parameters
+ q := req.URL.Query()
+ q.Add("query", query)
+ req.URL.RawQuery = q.Encode()
+
+ // Add headers
+ req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.Config.TMDB.ReadAccessToken))
+ req.Header.Add("Accept", "application/json")
+
+ // Use our retry mechanism (3 retries)
+ resp, err := makeRequestWithRetries(req, 3)
+ if err != nil {
+ return nil, fmt.Errorf("failed to search TV shows: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to search TV shows: %s", resp.Status)
+ }
+
+ // Parse response
+ var searchResponse types.TMDBSearchResponse
+ if err := json.NewDecoder(resp.Body).Decode(&searchResponse); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ results := searchResponse.Results
+
+ // Filter results if needed
+ var filteredResults []types.TMDBShowResult
+ for _, show := range results {
+ if (isAdult && show.Adult) || (!isAdult && !show.Adult) {
+ filteredResults = append(filteredResults, show)
+ }
+ }
+
+ // Sort by country priority if specified
+ if countryPriority != "" && len(filteredResults) > 0 {
+ var prioritizedResults []types.TMDBShowResult
+ var otherResults []types.TMDBShowResult
+
+ for _, show := range filteredResults {
+ hasPriority := false
+ for _, country := range show.OriginCountry {
+ if country == countryPriority {
+ hasPriority = true
+ break
+ }
+ }
+
+ if hasPriority {
+ prioritizedResults = append(prioritizedResults, show)
+ } else {
+ otherResults = append(otherResults, show)
+ }
+ }
+
+ // Combine the results with prioritized ones first
+ filteredResults = append(prioritizedResults, otherResults...)
+ }
+
+ if len(filteredResults) == 0 {
+ logger.Log(fmt.Sprintf("No TMDB shows found for: %s", query), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "TMDB",
+ })
+ } else {
+ logger.Log(fmt.Sprintf("Found %d TMDB shows for: %s", len(filteredResults), query), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "TMDB",
+ })
+ }
+
+ return filteredResults, nil
+}
+
+// getTVShowDetails gets details for a TV show from TMDB
+func getTVShowDetails(showID int) (*types.TMDBShowDetails, error) {
+ if config.Config.TMDB.ReadAccessToken == "" {
+ return nil, fmt.Errorf("TMDB is not initialized")
+ }
+
+ apiURL := fmt.Sprintf("https://api.themoviedb.org/3/tv/%d", showID)
+ req, err := http.NewRequest("GET", apiURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Add headers
+ req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.Config.TMDB.ReadAccessToken))
+ req.Header.Add("Accept", "application/json")
+
+ // Use our retry mechanism (3 retries)
+ resp, err := makeRequestWithRetries(req, 10)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get TV show details: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get TV show details: %s", resp.Status)
+ }
+
+ // Parse response
+ var showDetails types.TMDBShowDetails
+ if err := json.NewDecoder(resp.Body).Decode(&showDetails); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &showDetails, nil
+}
+
+// getSeasonDetails gets details for a TV season from TMDB
+func getSeasonDetails(showID, seasonNumber int) (*types.TMDBSeasonDetails, error) {
+ if config.Config.TMDB.ReadAccessToken == "" {
+ return nil, fmt.Errorf("TMDB is not initialized")
+ }
+
+ apiURL := fmt.Sprintf("https://api.themoviedb.org/3/tv/%d/season/%d", showID, seasonNumber)
+ req, err := http.NewRequest("GET", apiURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Add headers
+ req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.Config.TMDB.ReadAccessToken))
+ req.Header.Add("Accept", "application/json")
+
+ // Use our retry mechanism (3 retries)
+ resp, err := makeRequestWithRetries(req, 3)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get season details: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get season details: %s", resp.Status)
+ }
+
+ // Parse response
+ var seasonDetails types.TMDBSeasonDetails
+ if err := json.NewDecoder(resp.Body).Decode(&seasonDetails); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &seasonDetails, nil
+}
+
+// findBestSeason finds the best matching season for an anime
+func findBestSeason(shows []types.TMDBShowResult, title string, episodeCount int, airDate string) (int, int, error) {
+ for _, show := range shows {
+ showDetails, err := getTVShowDetails(show.ID)
+ if err != nil {
+ logger.Log(fmt.Sprintf("Failed to get details for show %d: %v", show.ID, err), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "TMDB",
+ })
+ continue
+ }
+
+ for _, season := range showDetails.Seasons {
+ // Skip season 0 (usually specials)
+ if season.SeasonNumber == 0 {
+ continue
+ }
+
+ // Check if episode count matches (with some flexibility)
+ episodeCountMatches := season.EpisodeCount == episodeCount ||
+ (episodeCount > 0 && season.EpisodeCount >= episodeCount-2 &&
+ season.EpisodeCount <= episodeCount+2)
+
+ // Check if air dates are close
+ airDateMatches := false
+ if airDate != "" && season.AirDate != "" {
+ // Simple year comparison
+ animeYear := airDate[:4]
+ seasonYear := season.AirDate[:4]
+ airDateMatches = animeYear == seasonYear
+ }
+
+ // If either count or air date matches, consider it a potential match
+ if episodeCountMatches || airDateMatches {
+ logger.Log(fmt.Sprintf("Found matching season for \"%s\": Show ID %d, Season %d",
+ title, show.ID, season.SeasonNumber), types.LogOptions{
+ Level: types.Info,
+ Prefix: "TMDB",
+ })
+ return show.ID, season.SeasonNumber, nil
+ }
+ }
+ }
+
+ return 0, 0, fmt.Errorf("could not find matching season for: %s", title)
+}
+
+// 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{
+ Level: types.Warn,
+ Prefix: "TMDB",
+ })
+ return episodes
+ }
+
+ if len(episodes) == 0 {
+ return episodes
+ }
+
+ logger.Log(fmt.Sprintf("Enriching episodes for: %s", title), types.LogOptions{
+ Level: types.Info,
+ Prefix: "TMDB",
+ })
+
+ var showID int
+ var seasonNumber int
+ var err error
+
+ // If we have a TMDB ID, use it directly
+ if tmdbID > 0 {
+ showID = tmdbID
+
+ // Try to get show details and find the best season
+ showDetails, err := getTVShowDetails(showID)
+ if err != nil {
+ logger.Log(fmt.Sprintf("Failed to get TMDB show details for ID %d: %v", tmdbID, err), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "TMDB",
+ })
+ return episodes
+ }
+
+ // Find the best matching season - prefer the first season if we can't determine
+ seasonNumber = 1
+ bestMatchScore := 0
+
+ for _, season := range showDetails.Seasons {
+ if season.SeasonNumber == 0 {
+ continue // Skip specials
+ }
+
+ matchScore := 0
+
+ // Check episode count similarity
+ if math.Abs(float64(season.EpisodeCount-len(episodes))) <= 2 {
+ matchScore += 2
+ }
+
+ // Check air date if available
+ if len(episodes) > 0 && episodes[0].Aired != "" && season.AirDate != "" {
+ animeYear := episodes[0].Aired[:4]
+ seasonYear := season.AirDate[:4]
+ if animeYear == seasonYear {
+ matchScore += 1
+ }
+ }
+
+ if matchScore > bestMatchScore {
+ bestMatchScore = matchScore
+ seasonNumber = season.SeasonNumber
+ }
+ }
+
+ logger.Log(fmt.Sprintf("Using TMDB ID %d with season %d", showID, seasonNumber), types.LogOptions{
+ Level: types.Info,
+ Prefix: "TMDB",
+ })
+ } else {
+ // Search for the TV show on TMDB if we don't have a direct ID
+ shows, err := searchTVShowsByTitle(title, alternativeTitle, false, "JP")
+ if err != nil {
+ logger.Log(fmt.Sprintf("Failed to search TV shows: %v", err), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "TMDB",
+ })
+ return episodes
+ }
+
+ if len(shows) == 0 {
+ logger.Log(fmt.Sprintf("No TV shows found for: %s", title), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "TMDB",
+ })
+ return episodes
+ }
+
+ // Find the best matching season
+ airDate := ""
+ if len(episodes) > 0 && episodes[0].Aired != "" {
+ airDate = episodes[0].Aired
+ }
+
+ showID, seasonNumber, err = findBestSeason(shows, title, len(episodes), airDate)
+ if err != nil {
+ logger.Log(fmt.Sprintf("Failed to find best season: %v", err), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "TMDB",
+ })
+ return episodes
+ }
+ }
+
+ // Get season details with episode information
+ seasonDetails, err := getSeasonDetails(showID, seasonNumber)
+ if err != nil {
+ logger.Log(fmt.Sprintf("Failed to get season details: %v", err), types.LogOptions{
+ Level: types.Warn,
+ Prefix: "TMDB",
+ })
+ return episodes
+ }
+
+ // 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
+ if tmdbEpisodes[i].Overview != "" {
+ enrichedEpisodes[i].Description = tmdbEpisodes[i].Overview
+ } 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"
+ }
+ }
+
+ 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
+}
diff --git a/utils/api/tvdb.go b/utils/api/tvdb.go
new file mode 100644
index 0000000..cee3de2
--- /dev/null
+++ b/utils/api/tvdb.go
@@ -0,0 +1,37 @@
+package api
+
+import (
+ "fmt"
+ "metachan/database"
+ "metachan/entities"
+ "metachan/types"
+ "metachan/utils/logger"
+)
+
+// FindSeasonMappings finds all anime mappings that belong to the same series based on TVDB ID
+func FindSeasonMappings(tvdbID int) ([]entities.AnimeMapping, error) {
+ logger.Log(fmt.Sprintf("Finding season mappings for TVDB ID %d", tvdbID), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "TVDB",
+ })
+
+ // Use our database function to find all mappings with the same TVDB ID
+ mappings, err := database.GetAnimeMappingsByTVDBID(tvdbID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get season mappings: %w", err)
+ }
+
+ if len(mappings) == 0 {
+ logger.Log(fmt.Sprintf("No season mappings found for TVDB ID %d", tvdbID), types.LogOptions{
+ Level: types.Debug,
+ Prefix: "TVDB",
+ })
+ } else {
+ logger.Log(fmt.Sprintf("Found %d season mappings for TVDB ID %d", len(mappings), tvdbID), types.LogOptions{
+ Level: types.Info,
+ Prefix: "TVDB",
+ })
+ }
+
+ return mappings, nil
+}