From cb41c834529f696c2f83adbb41e6b592c89495f3 Mon Sep 17 00:00:00 2001 From: Bobby Date: Fri, 9 May 2025 02:25:54 +0530 Subject: refactored types --- utils/api/anilist.go | 165 ------------ utils/api/anilist/anilist.go | 164 ++++++++++++ utils/api/anilist/types.go | 207 +++++++++++++++ utils/api/aniskip.go | 415 ------------------------------ utils/api/aniskip/aniskip.go | 414 ++++++++++++++++++++++++++++++ utils/api/aniskip/types.go | 15 ++ utils/api/jikan.go | 244 ------------------ utils/api/jikan/jikan.go | 243 ++++++++++++++++++ utils/api/jikan/types.go | 177 +++++++++++++ utils/api/malsync.go | 99 -------- utils/api/malsync/malsync.go | 98 ++++++++ utils/api/malsync/types.go | 31 +++ utils/api/streaming.go | 531 --------------------------------------- utils/api/streaming/streaming.go | 515 +++++++++++++++++++++++++++++++++++++ utils/api/streaming/types.go | 38 +++ utils/api/tmdb.go | 498 ------------------------------------ utils/api/tmdb/tmdb.go | 498 ++++++++++++++++++++++++++++++++++++ utils/api/tmdb/types.go | 54 ++++ utils/api/tvdb.go | 37 --- utils/api/tvdb/tvdb.go | 36 +++ 20 files changed, 2490 insertions(+), 1989 deletions(-) delete mode 100644 utils/api/anilist.go create mode 100644 utils/api/anilist/anilist.go create mode 100644 utils/api/anilist/types.go delete mode 100644 utils/api/aniskip.go create mode 100644 utils/api/aniskip/aniskip.go create mode 100644 utils/api/aniskip/types.go delete mode 100644 utils/api/jikan.go create mode 100644 utils/api/jikan/jikan.go create mode 100644 utils/api/jikan/types.go delete mode 100644 utils/api/malsync.go create mode 100644 utils/api/malsync/malsync.go create mode 100644 utils/api/malsync/types.go delete mode 100644 utils/api/streaming.go create mode 100644 utils/api/streaming/streaming.go create mode 100644 utils/api/streaming/types.go delete mode 100644 utils/api/tmdb.go create mode 100644 utils/api/tmdb/tmdb.go create mode 100644 utils/api/tmdb/types.go delete mode 100644 utils/api/tvdb.go create mode 100644 utils/api/tvdb/tvdb.go (limited to 'utils/api') diff --git a/utils/api/anilist.go b/utils/api/anilist.go deleted file mode 100644 index cda860a..0000000 --- a/utils/api/anilist.go +++ /dev/null @@ -1,165 +0,0 @@ -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/anilist/anilist.go b/utils/api/anilist/anilist.go new file mode 100644 index 0000000..afb88e4 --- /dev/null +++ b/utils/api/anilist/anilist.go @@ -0,0 +1,164 @@ +package anilist + +import ( + "bytes" + "encoding/json" + "fmt" + "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) (*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), logger.LogOptions{ + Level: logger.Debug, + Prefix: "AniList", + }) + + var resp *http.Response + var lastErr error + success := false + + for i := 0; i <= c.maxRetries && !success; i++ { + req, err := http.NewRequest("POST", "https://graphql.anilist.co", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + // Add User-Agent to make the request look more like a browser + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") + + resp, err = c.client.Do(req) + if err != nil { + lastErr = err + logger.Log(fmt.Sprintf("AniList request attempt %d failed: %v", i+1, err), logger.LogOptions{ + Level: logger.Debug, + Prefix: "AniList", + }) + continue + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body := make([]byte, 1024) + n, _ := resp.Body.Read(body) + lastErr = fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body[:n])) + logger.Log(fmt.Sprintf("AniList returned non-200 status on attempt %d: %v", i+1, lastErr), logger.LogOptions{ + Level: logger.Debug, + Prefix: "AniList", + }) + continue + } + + var anilistResponse AnilistAnimeResponse + if err := json.NewDecoder(resp.Body).Decode(&anilistResponse); err != nil { + lastErr = fmt.Errorf("failed to decode response: %w", err) + continue + } + + if anilistResponse.Data.Media.ID == 0 { + lastErr = fmt.Errorf("no data found for Anilist ID %d", anilistID) + continue + } + + // Log cover image data for debugging + if anilistResponse.Data.Media.CoverImage.ExtraLarge != "" { + logger.Log(fmt.Sprintf("Found cover data - Color: %s, Image: %s", + anilistResponse.Data.Media.CoverImage.Color, + anilistResponse.Data.Media.CoverImage.ExtraLarge), logger.LogOptions{ + Level: logger.Debug, + Prefix: "AniList", + }) + } + + success = true + return &anilistResponse, nil + } + + return nil, fmt.Errorf("failed after %d retries: %w", c.maxRetries, lastErr) +} diff --git a/utils/api/anilist/types.go b/utils/api/anilist/types.go new file mode 100644 index 0000000..367719b --- /dev/null +++ b/utils/api/anilist/types.go @@ -0,0 +1,207 @@ +package anilist + +// AnilistAnimeResponse represents the response from AniList API +type AnilistAnimeResponse struct { + Data struct { + Media struct { + ID int `json:"id"` + MALID int `json:"idMal"` + Title struct { + Romaji string `json:"romaji"` + English string `json:"english"` + Native string `json:"native"` + UserPreferred string `json:"userPreferred"` + } `json:"title"` + Type string `json:"type"` + Format string `json:"format"` + Status string `json:"status"` + Description string `json:"description"` + StartDate struct { + Year int `json:"year"` + Month int `json:"month"` + Day int `json:"day"` + } `json:"startDate"` + EndDate struct { + Year int `json:"year"` + Month int `json:"month"` + Day int `json:"day"` + } `json:"endDate"` + Season string `json:"season"` + SeasonYear int `json:"seasonYear"` + Episodes int `json:"episodes"` + Duration int `json:"duration"` + Chapters int `json:"chapters"` + Volumes int `json:"volumes"` + CountryOfOrigin string `json:"countryOfOrigin"` + IsLicensed bool `json:"isLicensed"` + Source string `json:"source"` + Hashtag string `json:"hashtag"` + Trailer struct { + ID string `json:"id"` + Site string `json:"site"` + Thumbnail string `json:"thumbnail"` + } `json:"trailer"` + CoverImage struct { + ExtraLarge string `json:"extraLarge"` + Large string `json:"large"` + Medium string `json:"medium"` + Color string `json:"color"` + } `json:"coverImage"` + BannerImage string `json:"bannerImage"` + Genres []string `json:"genres"` + Synonyms []string `json:"synonyms"` + AverageScore int `json:"averageScore"` + MeanScore int `json:"meanScore"` + Popularity int `json:"popularity"` + IsLocked bool `json:"isLocked"` + Trending int `json:"trending"` + Favorites int `json:"favorites"` + Tags []struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + Rank int `json:"rank"` + IsGeneralSpoiler bool `json:"isGeneralSpoiler"` + IsMediaSpoiler bool `json:"isMediaSpoiler"` + IsAdult bool `json:"isAdult"` + } `json:"tags"` + Relations struct { + Edges []struct { + ID int `json:"id"` + RelationType string `json:"relationType"` + Node struct { + ID int `json:"id"` + Title struct { + Romaji string `json:"romaji"` + English string `json:"english"` + Native string `json:"native"` + UserPreferred string `json:"userPreferred"` + } `json:"title"` + Format string `json:"format"` + Type string `json:"type"` + Status string `json:"status"` + CoverImage struct { + ExtraLarge string `json:"extraLarge"` + Large string `json:"large"` + Medium string `json:"medium"` + Color string `json:"color"` + } `json:"coverImage"` + BannerImage string `json:"bannerImage"` + } `json:"node"` + } `json:"edges"` + } `json:"relations"` + Characters struct { + Edges []struct { + Role string `json:"role"` + Node struct { + ID int `json:"id"` + Name struct { + First string `json:"first"` + Last string `json:"last"` + Middle string `json:"middle"` + Full string `json:"full"` + Native string `json:"native"` + UserPreferred string `json:"userPreferred"` + } `json:"name"` + Image struct { + Large string `json:"large"` + Medium string `json:"medium"` + } `json:"image"` + Description string `json:"description"` + Age string `json:"age"` + } `json:"node"` + } `json:"edges"` + } `json:"characters"` + Staff struct { + Edges []struct { + Role string `json:"role"` + Node struct { + ID int `json:"id"` + Name struct { + First string `json:"first"` + Last string `json:"last"` + Middle string `json:"middle"` + Full string `json:"full"` + Native string `json:"native"` + UserPreferred string `json:"userPreferred"` + } `json:"name"` + Image struct { + Large string `json:"large"` + Medium string `json:"medium"` + } `json:"image"` + Description string `json:"description"` + PrimaryOccupations []string `json:"primaryOccupations"` + Gender string `json:"gender"` + Age int `json:"age"` + LanguageV2 string `json:"languageV2"` + } `json:"node"` + } `json:"edges"` + } `json:"staff"` + Studios struct { + Edges []struct { + IsMain bool `json:"isMain"` + Node struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"node"` + } `json:"edges"` + } `json:"studios"` + IsAdult bool `json:"isAdult"` + NextAiringEpisode struct { + ID int `json:"id"` + AiringAt int `json:"airingAt"` + TimeUntilAiring int `json:"timeUntilAiring"` + Episode int `json:"episode"` + } `json:"nextAiringEpisode"` + AiringSchedule struct { + Nodes []struct { + ID int `json:"id"` + Episode int `json:"episode"` + AiringAt int `json:"airingAt"` + TimeUntilAiring int `json:"timeUntilAiring"` + } `json:"nodes"` + } `json:"airingSchedule"` + Trends struct { + Nodes []struct { + Date int `json:"date"` + Trending int `json:"trending"` + Popularity int `json:"popularity"` + InProgress int `json:"inProgress"` + } `json:"nodes"` + } `json:"trends"` + ExternalLinks []struct { + ID int `json:"id"` + URL string `json:"url"` + Site string `json:"site"` + } `json:"externalLinks"` + StreamingEpisodes []struct { + Title string `json:"title"` + Thumbnail string `json:"thumbnail"` + URL string `json:"url"` + Site string `json:"site"` + } `json:"streamingEpisodes"` + Rankings []struct { + ID int `json:"id"` + Rank int `json:"rank"` + Type string `json:"type"` + Format string `json:"format"` + Year int `json:"year"` + Season string `json:"season"` + AllTime bool `json:"allTime"` + Context string `json:"context"` + } `json:"rankings"` + Stats struct { + ScoreDistribution []struct { + Score int `json:"score"` + Amount int `json:"amount"` + } `json:"scoreDistribution"` + StatusDistribution []struct { + Status string `json:"status"` + Amount int `json:"amount"` + } `json:"statusDistribution"` + } `json:"stats"` + SiteURL string `json:"siteUrl"` + } `json:"media"` + } `json:"data"` +} diff --git a/utils/api/aniskip.go b/utils/api/aniskip.go deleted file mode 100644 index c5aa462..0000000 --- a/utils/api/aniskip.go +++ /dev/null @@ -1,415 +0,0 @@ -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/aniskip/aniskip.go b/utils/api/aniskip/aniskip.go new file mode 100644 index 0000000..fb95f47 --- /dev/null +++ b/utils/api/aniskip/aniskip.go @@ -0,0 +1,414 @@ +package aniskip + +import ( + "context" + "encoding/json" + "fmt" + "io" + "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][]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 []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][]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) ([]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 []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) ([]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, []AnimeSkipTimes{}) + return []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, []AnimeSkipTimes{}) + return []AnimeSkipTimes{}, nil + } + + // Convert to our skip times format + var skipTimes []AnimeSkipTimes + for _, result := range skipResp.Results { + skipTime := 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][]AnimeSkipTimes, error) { + // If we have fewer than 3 episodes, use individual requests instead + if len(episodes) < 3 { + results := make(map[int][]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][]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][]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 []AnimeSkipTimes + + if epData.Found { + for _, result := range epData.Results { + skipTimes = append(skipTimes, 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) []EpisodeSkipResult { + startTime := time.Now() + + // If episode count is small, just use single endpoint + if episodeCount <= 5 { + results := []EpisodeSkipResult{} + for i := 1; i <= episodeCount; i++ { + skipTimes, err := c.GetSkipTimesForEpisode(malID, i) + if err == nil && len(skipTimes) > 0 { + results = append(results, 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 []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), logger.LogOptions{ + Level: logger.Warn, + Prefix: "AniSkip", + }) + continue + } + + // Add results to the final list + for epNum, skipTimes := range batchResults { + if len(skipTimes) > 0 { + results = append(results, 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)), logger.LogOptions{ + Level: logger.Debug, + Prefix: "AniSkip", + }) + + return results +} diff --git a/utils/api/aniskip/types.go b/utils/api/aniskip/types.go new file mode 100644 index 0000000..24c9618 --- /dev/null +++ b/utils/api/aniskip/types.go @@ -0,0 +1,15 @@ +package aniskip + +// AnimeSkipTimes represents skip time intervals for anime episodes +type AnimeSkipTimes struct { + SkipType string `json:"skip_type"` + StartTime float64 `json:"start_time"` + EndTime float64 `json:"end_time"` + EpisodeLength float64 `json:"episode_length"` +} + +// EpisodeSkipResult contains skip times for a specific episode +type EpisodeSkipResult struct { + EpisodeNumber int + SkipTimes []AnimeSkipTimes +} diff --git a/utils/api/jikan.go b/utils/api/jikan.go deleted file mode 100644 index 0f9ff83..0000000 --- a/utils/api/jikan.go +++ /dev/null @@ -1,244 +0,0 @@ -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/jikan/jikan.go b/utils/api/jikan/jikan.go new file mode 100644 index 0000000..9dcf00a --- /dev/null +++ b/utils/api/jikan/jikan.go @@ -0,0 +1,243 @@ +package jikan + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "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) (*JikanAnimeResponse, error) { + apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d", malID) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + bodyBytes, err := c.makeRequest(ctx, apiURL) + if err != nil { + return nil, fmt.Errorf("failed to get anime data: %w", err) + } + + var animeResponse JikanAnimeResponse + if err := json.Unmarshal(bodyBytes, &animeResponse); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if animeResponse.Data.MALID == 0 { + return nil, fmt.Errorf("no data found for MAL ID %d", malID) + } + + return &animeResponse, nil +} + +// GetFullAnime fetches detailed anime information by MAL ID +func (c *JikanClient) GetFullAnime(malID int) (*JikanAnimeResponse, error) { + apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/full", malID) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + bodyBytes, err := c.makeRequest(ctx, apiURL) + if err != nil { + return nil, fmt.Errorf("failed to get anime full data: %w", err) + } + + var animeResponse JikanAnimeResponse + if err := json.Unmarshal(bodyBytes, &animeResponse); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if animeResponse.Data.MALID == 0 { + return nil, fmt.Errorf("no data found for MAL ID %d", malID) + } + + return &animeResponse, nil +} + +// GetAnimeEpisodes fetches all episodes for an anime by MAL ID +func (c *JikanClient) GetAnimeEpisodes(malID int) (*JikanAnimeEpisodeResponse, error) { + result := JikanAnimeEpisodeResponse{ + Data: []JikanAnimeEpisode{}, + } + + maxPages := 25 // Safety limit to avoid excessive requests + page := 1 + maxAttempts := 15 // Maximum number of attempts across all pages + totalAttempts := 0 + + for page <= maxPages && totalAttempts < maxAttempts { + apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/episodes?page=%d", malID, page) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + + totalAttempts++ + + bodyBytes, err := c.makeRequest(ctx, apiURL) + cancel() + + if err != nil { + // If we have some episodes already, return them rather than failing + if len(result.Data) > 0 { + result.Pagination.HasNextPage = false + break + } + return nil, fmt.Errorf("failed to get anime episodes page %d: %w", page, err) + } + + var pageResponse JikanAnimeEpisodeResponse + if err := json.Unmarshal(bodyBytes, &pageResponse); err != nil { + // Return what we have if we got some pages successfully + if len(result.Data) > 0 { + result.Pagination.HasNextPage = false + break + } + return nil, fmt.Errorf("failed to decode episodes response: %w", err) + } + + // Append episodes from this page + result.Data = append(result.Data, pageResponse.Data...) + result.Pagination = pageResponse.Pagination + + // Check if we need to fetch more pages + if !pageResponse.Pagination.HasNextPage { + break + } + + page++ + } + + return &result, nil +} + +// GetAnimeCharacters fetches all characters for an anime by MAL ID +func (c *JikanClient) GetAnimeCharacters(malID int) (*JikanAnimeCharacterResponse, error) { + apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/characters", malID) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + bodyBytes, err := c.makeRequest(ctx, apiURL) + if err != nil { + return nil, fmt.Errorf("failed to get anime characters: %w", err) + } + + var characterResponse JikanAnimeCharacterResponse + if err := json.Unmarshal(bodyBytes, &characterResponse); err != nil { + return nil, fmt.Errorf("failed to decode characters response: %w", err) + } + + return &characterResponse, nil +} diff --git a/utils/api/jikan/types.go b/utils/api/jikan/types.go new file mode 100644 index 0000000..cbcb543 --- /dev/null +++ b/utils/api/jikan/types.go @@ -0,0 +1,177 @@ +package jikan + +// JikanPagination represents the pagination data in Jikan API responses +type JikanPagination struct { + LastVisiblePage int `json:"last_visible_page"` + HasNextPage bool `json:"has_next_page"` +} + +// JikanGenericMALStructure represents a common structure for various MAL entities +type JikanGenericMALStructure struct { + MALID int `json:"mal_id"` + Type string `json:"type"` + URL string `json:"url"` + Name string `json:"name"` +} + +// JikanAnimeResponse represents the main anime response from Jikan API +type JikanAnimeResponse struct { + Data struct { + MALID int `json:"mal_id"` + URL string `json:"url"` + Images struct { + JPG struct { + ImageURL string `json:"image_url"` + SmallImageURL string `json:"small_image_url"` + LargeImageURL string `json:"large_image_url"` + } `json:"jpg"` + WebP struct { + ImageURL string `json:"image_url"` + SmallImageURL string `json:"small_image_url"` + LargeImageURL string `json:"large_image_url"` + } `json:"webp"` + } `json:"images"` + Trailer struct { + YoutubeID string `json:"youtube_id"` + URL string `json:"url"` + EmbedURL string `json:"embed_url"` + Images struct { + ImageURL string `json:"image_url"` + SmallImageURL string `json:"small_image_url"` + MediumImageURL string `json:"medium_image_url"` + LargeImageURL string `json:"large_image_url"` + MaximumImageURL string `json:"maximum_image_url"` + } `json:"images"` + } `json:"trailer"` + Approved bool `json:"approved"` + Titles []struct { + Type string `json:"type"` + Title string `json:"title"` + } `json:"titles"` + Title string `json:"title"` + TitleEnglish string `json:"title_english"` + TitleJapanese string `json:"title_japanese"` + TitleSynonyms []string `json:"title_synonyms"` + Type string `json:"type"` + Source string `json:"source"` + Episodes int `json:"episodes"` + Status string `json:"status"` + Airing bool `json:"airing"` + Aired struct { + From string `json:"from"` + To string `json:"to"` + Prop struct { + From struct { + Day int `json:"day"` + Month int `json:"month"` + Year int `json:"year"` + } `json:"from"` + To struct { + Day int `json:"day"` + Month int `json:"month"` + Year int `json:"year"` + } `json:"to"` + } `json:"prop"` + String string `json:"string"` + } `json:"aired"` + Duration string `json:"duration"` + Rating string `json:"rating"` + Score float64 `json:"score"` + ScoredBy int `json:"scored_by"` + Rank int `json:"rank"` + Popularity int `json:"popularity"` + Members int `json:"members"` + Favorites int `json:"favorites"` + Synopsis string `json:"synopsis"` + Background string `json:"background"` + Season string `json:"season"` + Year int `json:"year"` + Broadcast struct { + Day string `json:"day"` + Time string `json:"time"` + Timezone string `json:"timezone"` + String string `json:"string"` + } `json:"broadcast"` + Producers []JikanGenericMALStructure `json:"producers"` + Licensors []JikanGenericMALStructure `json:"licensors"` + Studios []JikanGenericMALStructure `json:"studios"` + Genres []JikanGenericMALStructure `json:"genres"` + ExplicitGenres []JikanGenericMALStructure `json:"explicit_genres"` + Themes []JikanGenericMALStructure `json:"themes"` + Demographics []JikanGenericMALStructure `json:"demographics"` + Relations []struct { + Relation string `json:"relation"` + Entry []JikanGenericMALStructure `json:"entry"` + } `json:"relations"` + Theme struct { + Openings []string `json:"openings"` + Endings []string `json:"endings"` + } `json:"theme"` + External []struct { + Name string `json:"name"` + URL string `json:"url"` + } `json:"external"` + Streaming []struct { + Name string `json:"name"` + URL string `json:"url"` + } `json:"streaming"` + } `json:"data"` +} + +// JikanAnimeEpisode represents an episode from Jikan API +type JikanAnimeEpisode struct { + MALID int `json:"mal_id"` + URL string `json:"url"` + Title string `json:"title"` + TitleJapanese string `json:"title_japanese"` + TitleRomaji string `json:"title_romaji"` + Aired string `json:"aired"` + Score float64 `json:"score"` + Filler bool `json:"filler"` + Recap bool `json:"recap"` + ForumURL string `json:"forum_url"` +} + +// JikanAnimeEpisodeResponse represents the episodes response from Jikan API +type JikanAnimeEpisodeResponse struct { + Pagination JikanPagination `json:"pagination"` + Data []JikanAnimeEpisode `json:"data"` +} + +// JikanAnimeCharacterResponse represents the characters response from Jikan API +type JikanAnimeCharacterResponse struct { + Data []struct { + Character struct { + MALID int `json:"mal_id"` + URL string `json:"url"` + Images struct { + JPG struct { + ImageURL string `json:"image_url"` + SmallImageURL string `json:"small_image_url"` + } `json:"jpg"` + WebP struct { + ImageURL string `json:"image_url"` + SmallImageURL string `json:"small_image_url"` + } `json:"webp"` + } `json:"images"` + Name string `json:"name"` + } `json:"character"` + Role string `json:"role"` + VoiceActors []struct { + Person struct { + MALID int `json:"mal_id"` + URL string `json:"url"` + Images struct { + JPG struct { + ImageURL string `json:"image_url"` + } `json:"jpg"` + WebP struct { + ImageURL string `json:"image_url"` + } `json:"webp"` + } `json:"images"` + Name string `json:"name"` + } `json:"person"` + Language string `json:"language"` + } `json:"voice_actors"` + } `json:"data"` +} diff --git a/utils/api/malsync.go b/utils/api/malsync.go deleted file mode 100644 index d323841..0000000 --- a/utils/api/malsync.go +++ /dev/null @@ -1,99 +0,0 @@ -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/malsync/malsync.go b/utils/api/malsync/malsync.go new file mode 100644 index 0000000..379c6ad --- /dev/null +++ b/utils/api/malsync/malsync.go @@ -0,0 +1,98 @@ +package malsync + +import ( + "context" + "encoding/json" + "fmt" + "io" + "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) (*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 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/malsync/types.go b/utils/api/malsync/types.go new file mode 100644 index 0000000..9944166 --- /dev/null +++ b/utils/api/malsync/types.go @@ -0,0 +1,31 @@ +package malsync + +// MALSyncStreamingSite represents a single streaming site entry in the MALSync API +type MALSyncStreamingSite struct { + ID int `json:"id,omitempty"` + Identifier any `json:"identifier"` + Image string `json:"image,omitempty"` + MalID int `json:"malId,omitempty"` + AniID int `json:"aniId,omitempty"` + Page string `json:"page"` + Title string `json:"title"` + Type string `json:"type"` + URL string `json:"url"` + External bool `json:"external,omitempty"` +} + +// MALSyncSitesCollection represents the nested structure of streaming sites +// Format: map[platformName]map[identifier]siteObject +type MALSyncSitesCollection map[string]map[string]MALSyncStreamingSite + +// MALSyncAnimeResponse is the top-level response from the MALSync API +type MALSyncAnimeResponse struct { + ID int `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + URL string `json:"url"` + Total int `json:"total"` + Image string `json:"image"` + AnidbID int `json:"anidbId,omitempty"` + Sites MALSyncSitesCollection `json:"Sites"` +} diff --git a/utils/api/streaming.go b/utils/api/streaming.go deleted file mode 100644 index 067704e..0000000 --- a/utils/api/streaming.go +++ /dev/null @@ -1,531 +0,0 @@ -package api - -import ( - "encoding/json" - "fmt" - "maps" - "metachan/types" - "metachan/utils/mappers" - "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 - } - - maps.Copy(req.Header, c.headers) - - resp, err := c.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - var data map[string]any - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return "", err - } - - if links, ok := data["links"].([]any); ok && len(links) > 0 { - if link, ok := links[0].(map[string]any); 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) { - // Check for special anime ID mapping - specialID, hasSpecialMapping := mappers.GetSpecialAnimeID(query) - - 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]any{ - "search": map[string]any{ - "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 - } - - maps.Copy(req.Header, c.headers) - - resp, err := c.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var data map[string]any - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return nil, err - } - - shows := data["data"].(map[string]any)["shows"].(map[string]any)["edges"].([]any) - results := make([]StreamingSearchResult, 0, len(shows)) - - for _, show := range shows { - showMap := show.(map[string]any) - episodes := showMap["availableEpisodes"].(map[string]any) - result := StreamingSearchResult{ - ID: showMap["_id"].(string), - Name: showMap["name"].(string), - SubEpisodes: int(episodes["sub"].(float64)), - DubEpisodes: int(episodes["dub"].(float64)), - Similarity: c.calculateSimilarity(query, showMap["name"].(string)), - } - - // If this is the special anime we're looking for, boost its similarity - if hasSpecialMapping && result.ID == specialID { - result.Similarity = 2.0 // Forcing special ID to be the best match - } - - results = append(results, result) - } - - // Sort only once 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]any{ - "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]any - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return nil, err - } - - showData := data["data"].(map[string]any)["show"].(map[string]any) - episodesDetail := showData["availableEpisodesDetail"].(map[string]any) - episodesList := episodesDetail[mode].([]any) - - 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]any{ - "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 - } - - maps.Copy(req.Header, c.headers) - - resp, err := c.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var data map[string]any - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return nil, err - } - - episodeData := data["data"].(map[string]any)["episode"].(map[string]any) - sourceUrls := episodeData["sourceUrls"].([]any) - - var links []types.AnimeStreamingSource - for _, source := range sourceUrls { - sourceMap := source.(map[string]any) - 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/streaming/streaming.go b/utils/api/streaming/streaming.go new file mode 100644 index 0000000..4d0f625 --- /dev/null +++ b/utils/api/streaming/streaming.go @@ -0,0 +1,515 @@ +package streaming + +import ( + "encoding/json" + "fmt" + "maps" + "metachan/utils/mappers" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +const ( + allanimeBaseURL = "https://api.allanime.day/api" +) + +// 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 + } + + maps.Copy(req.Header, c.headers) + + resp, err := c.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var data map[string]any + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", err + } + + if links, ok := data["links"].([]any); ok && len(links) > 0 { + if link, ok := links[0].(map[string]any); 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) *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 &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 &AnimeStreamingSource{ + URL: processedURL, + Server: getServerName(sourceType), + Type: "direct", + } + } + } + + // Return as regular source if not direct + return &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) { + // Check for special anime ID mapping + specialID, hasSpecialMapping := mappers.GetSpecialAnimeID(query) + + 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]any{ + "search": map[string]any{ + "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 + } + + maps.Copy(req.Header, c.headers) + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var data map[string]any + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + shows := data["data"].(map[string]any)["shows"].(map[string]any)["edges"].([]any) + results := make([]StreamingSearchResult, 0, len(shows)) + + for _, show := range shows { + showMap := show.(map[string]any) + episodes := showMap["availableEpisodes"].(map[string]any) + result := StreamingSearchResult{ + ID: showMap["_id"].(string), + Name: showMap["name"].(string), + SubEpisodes: int(episodes["sub"].(float64)), + DubEpisodes: int(episodes["dub"].(float64)), + Similarity: c.calculateSimilarity(query, showMap["name"].(string)), + } + + // If this is the special anime we're looking for, boost its similarity + if hasSpecialMapping && result.ID == specialID { + result.Similarity = 2.0 // Forcing special ID to be the best match + } + + results = append(results, result) + } + + // Sort only once 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]any{ + "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]any + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + showData := data["data"].(map[string]any)["show"].(map[string]any) + episodesDetail := showData["availableEpisodesDetail"].(map[string]any) + episodesList := episodesDetail[mode].([]any) + + 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) ([]AnimeStreamingSource, error) { + episodeQuery := ` + query ($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) { + episode( + showId: $showId + translationType: $translationType + episodeString: $episodeString + ) { + episodeString + sourceUrls + } + } + ` + + variables := map[string]any{ + "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 + } + + maps.Copy(req.Header, c.headers) + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var data map[string]any + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + episodeData := data["data"].(map[string]any)["episode"].(map[string]any) + sourceUrls := episodeData["sourceUrls"].([]any) + + var links []AnimeStreamingSource + for _, source := range sourceUrls { + sourceMap := source.(map[string]any) + 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) (*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 := &AnimeStreaming{ + Sub: []AnimeStreamingSource{}, + Dub: []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/streaming/types.go b/utils/api/streaming/types.go new file mode 100644 index 0000000..1b12da0 --- /dev/null +++ b/utils/api/streaming/types.go @@ -0,0 +1,38 @@ +package streaming + +import "net/http" + +// AllAnimeClient provides methods for interacting with the AllAnime API +type AllAnimeClient struct { + client *http.Client + headers http.Header +} + +// AnimeStreamingSource represents a single streaming source for an episode +type AnimeStreamingSource struct { + URL string `json:"url"` + Server string `json:"server"` + Type string `json:"type"` // direct or embed +} + +// AnimeStreaming represents all available streaming sources for an episode +type AnimeStreaming struct { + Sub []AnimeStreamingSource `json:"sub"` + Dub []AnimeStreamingSource `json:"dub"` +} + +// StreamingSearchResult represents a search result from streaming providers +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"` +} + +// EpisodeStreamingResult contains streaming sources for a specific episode +// Used for parallel streaming source fetching +type EpisodeStreamingResult struct { + EpisodeNumber int + Streaming *AnimeStreaming +} diff --git a/utils/api/tmdb.go b/utils/api/tmdb.go deleted file mode 100644 index 1344080..0000000 --- a/utils/api/tmdb.go +++ /dev/null @@ -1,498 +0,0 @@ -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, 5) - 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/tmdb/tmdb.go b/utils/api/tmdb/tmdb.go new file mode 100644 index 0000000..1c5a87b --- /dev/null +++ b/utils/api/tmdb/tmdb.go @@ -0,0 +1,498 @@ +package tmdb + +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), logger.LogOptions{ + Level: logger.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) ([]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), logger.LogOptions{ + Level: logger.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 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 []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 []TMDBShowResult + var otherResults []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), logger.LogOptions{ + Level: logger.Warn, + Prefix: "TMDB", + }) + } else { + logger.Log(fmt.Sprintf("Found %d TMDB shows for: %s", len(filteredResults), query), logger.LogOptions{ + Level: logger.Debug, + Prefix: "TMDB", + }) + } + + return filteredResults, nil +} + +// getTVShowDetails gets details for a TV show from TMDB +func getTVShowDetails(showID int) (*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, 5) + 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 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) (*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 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 []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), logger.LogOptions{ + Level: logger.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), logger.LogOptions{ + Level: logger.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", logger.LogOptions{ + Level: logger.Warn, + Prefix: "TMDB", + }) + return episodes + } + + if len(episodes) == 0 { + return episodes + } + + logger.Log(fmt.Sprintf("Enriching episodes for: %s", title), logger.LogOptions{ + Level: logger.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), logger.LogOptions{ + Level: logger.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), logger.LogOptions{ + Level: logger.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), logger.LogOptions{ + Level: logger.Warn, + Prefix: "TMDB", + }) + return episodes + } + + if len(shows) == 0 { + logger.Log(fmt.Sprintf("No TV shows found for: %s", title), logger.LogOptions{ + Level: logger.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), logger.LogOptions{ + Level: logger.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), logger.LogOptions{ + Level: logger.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), logger.LogOptions{ + Level: logger.Success, + Prefix: "TMDB", + }) + + return enrichedEpisodes +} diff --git a/utils/api/tmdb/types.go b/utils/api/tmdb/types.go new file mode 100644 index 0000000..8743573 --- /dev/null +++ b/utils/api/tmdb/types.go @@ -0,0 +1,54 @@ +package tmdb + +// TMDBShowResult represents a TV show result from TMDB search +type TMDBShowResult struct { + ID int `json:"id"` + Name string `json:"name"` + FirstAirDate string `json:"first_air_date"` + OriginCountry []string `json:"origin_country"` + Adult bool `json:"adult"` +} + +// TMDBSearchResponse represents the response from TMDB search API +type TMDBSearchResponse struct { + Page int `json:"page"` + Results []TMDBShowResult `json:"results"` + TotalPages int `json:"total_pages"` + TotalResults int `json:"total_results"` +} + +// TMDBEpisode represents a TV episode from TMDB +type TMDBEpisode struct { + ID int `json:"id"` + Name string `json:"name"` + Overview string `json:"overview"` + StillPath string `json:"still_path"` + AirDate string `json:"air_date"` + EpisodeNumber int `json:"episode_number"` + SeasonNumber int `json:"season_number"` +} + +// TMDBSeasonDetails represents a TV season from TMDB +type TMDBSeasonDetails struct { + ID int `json:"id"` + AirDate string `json:"air_date"` + EpisodeCount int `json:"episode_count"` + Name string `json:"name"` + Overview string `json:"overview"` + SeasonNumber int `json:"season_number"` + Episodes []TMDBEpisode `json:"episodes"` +} + +// TMDBShowDetails represents a TV show from TMDB +type TMDBShowDetails struct { + ID int `json:"id"` + Name string `json:"name"` + Overview string `json:"overview"` + Seasons []struct { + ID int `json:"id"` + Name string `json:"name"` + SeasonNumber int `json:"season_number"` + EpisodeCount int `json:"episode_count"` + AirDate string `json:"air_date"` + } `json:"seasons"` +} diff --git a/utils/api/tvdb.go b/utils/api/tvdb.go deleted file mode 100644 index cee3de2..0000000 --- a/utils/api/tvdb.go +++ /dev/null @@ -1,37 +0,0 @@ -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 -} diff --git a/utils/api/tvdb/tvdb.go b/utils/api/tvdb/tvdb.go new file mode 100644 index 0000000..230be54 --- /dev/null +++ b/utils/api/tvdb/tvdb.go @@ -0,0 +1,36 @@ +package api + +import ( + "fmt" + "metachan/database" + "metachan/entities" + "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), logger.LogOptions{ + Level: logger.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), logger.LogOptions{ + Level: logger.Debug, + Prefix: "TVDB", + }) + } else { + logger.Log(fmt.Sprintf("Found %d season mappings for TVDB ID %d", len(mappings), tvdbID), logger.LogOptions{ + Level: logger.Info, + Prefix: "TVDB", + }) + } + + return mappings, nil +} -- cgit v1.2.3