diff options
| author | Bobby <[email protected]> | 2025-05-09 00:54:39 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2025-05-09 00:54:39 +0530 |
| commit | 5e28d86fb10270b0e1680924d6ac6617f780d814 (patch) | |
| tree | de80fadd7a1f08df8658acfd23975e8ad14d2791 /utils/api | |
| parent | f656ab2350f5bbaa8465278b0842a0c052858a86 (diff) | |
| download | metachan-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.go | 165 | ||||
| -rw-r--r-- | utils/api/aniskip.go | 415 | ||||
| -rw-r--r-- | utils/api/jikan.go | 244 | ||||
| -rw-r--r-- | utils/api/malsync.go | 99 | ||||
| -rw-r--r-- | utils/api/streaming.go | 526 | ||||
| -rw-r--r-- | utils/api/tmdb.go | 498 | ||||
| -rw-r--r-- | utils/api/tvdb.go | 37 |
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 +} |
