diff options
| author | Bobby <[email protected]> | 2025-05-09 00:54:39 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2025-05-09 00:54:39 +0530 |
| commit | 5e28d86fb10270b0e1680924d6ac6617f780d814 (patch) | |
| tree | de80fadd7a1f08df8658acfd23975e8ad14d2791 /utils | |
| parent | f656ab2350f5bbaa8465278b0842a0c052858a86 (diff) | |
| download | metachan-5e28d86fb10270b0e1680924d6ac6617f780d814.tar.xz metachan-5e28d86fb10270b0e1680924d6ac6617f780d814.zip | |
move anime to services. refactor. add sub dub streaming counts
Diffstat (limited to 'utils')
| -rw-r--r-- | utils/anime/anilist.go | 261 | ||||
| -rw-r--r-- | utils/anime/anime.go | 290 | ||||
| -rw-r--r-- | utils/anime/aniskip.go | 68 | ||||
| -rw-r--r-- | utils/anime/crunchyroll.go | 201 | ||||
| -rw-r--r-- | utils/anime/jikan.go | 722 | ||||
| -rw-r--r-- | utils/anime/malsync.go | 98 | ||||
| -rw-r--r-- | utils/anime/tvdb.go | 163 | ||||
| -rw-r--r-- | utils/api/anilist.go | 165 | ||||
| -rw-r--r-- | utils/api/aniskip.go | 415 | ||||
| -rw-r--r-- | utils/api/jikan.go | 244 | ||||
| -rw-r--r-- | utils/api/malsync.go | 99 | ||||
| -rw-r--r-- | utils/api/streaming.go (renamed from utils/anime/stream.go) | 126 | ||||
| -rw-r--r-- | utils/api/tmdb.go (renamed from utils/anime/tmdb.go) | 197 | ||||
| -rw-r--r-- | utils/api/tvdb.go | 37 | ||||
| -rw-r--r-- | utils/concurrency/fetch.go | 94 | ||||
| -rw-r--r-- | utils/ratelimit/limiter.go | 107 |
16 files changed, 1223 insertions, 2064 deletions
diff --git a/utils/anime/anilist.go b/utils/anime/anilist.go deleted file mode 100644 index 7a5842e..0000000 --- a/utils/anime/anilist.go +++ /dev/null @@ -1,261 +0,0 @@ -package anime - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "metachan/types" - "net/http" - "strings" -) - -func getAnimeViaAnilist(anilistID int) (*types.AnilistAnimeResponse, error) { - graphQLQuery := fmt.Sprintf(`query { - Media(id: %d) { - id - idMal - title { - romaji - english - native - userPreferred - } - type - format - status - description - startDate { - year - month - day - } - endDate { - year - month - day - } - season - seasonYear - episodes - duration - chapters - volumes - countryOfOrigin - isLicensed - source - hashtag - trailer { - id - site - thumbnail - } - updatedAt - coverImage { - extraLarge - large - medium - color - } - bannerImage - genres - synonyms - averageScore - meanScore - popularity - isLocked - trending - favourites - tags { - id - name - description - category - rank - isGeneralSpoiler - isMediaSpoiler - isAdult - } - relations { - edges { - id - relationType - node { - id - title { - romaji - english - native - userPreferred - } - format - type - status - coverImage { - extraLarge - large - medium - color - } - bannerImage - } - } - } - characters { - edges { - role - node { - id - name { - first - last - middle - full - native - userPreferred - } - image { - large - medium - } - description - age - } - } - } - staff { - edges { - role - node { - id - name { - first - middle - last - full - native - userPreferred - } - image { - large - medium - } - description - primaryOccupations - gender - age - languageV2 - } - } - } - studios { - edges { - isMain - node { - id - name - } - } - } - isAdult - nextAiringEpisode { - id - airingAt - timeUntilAiring - episode - } - airingSchedule { - nodes { - id - episode - airingAt - timeUntilAiring - } - } - trends { - nodes { - date - trending - popularity - inProgress - } - } - externalLinks { - id - url - site - } - streamingEpisodes { - title - thumbnail - url - site - } - rankings { - id - rank - type - format - year - season - allTime - context - } - stats { - scoreDistribution { - score - amount - } - statusDistribution { - status - amount - } - } - siteUrl - } - }`, anilistID) - - // Remove debug print that can cause issues with large queries - // fmt.Printf("GraphQL Query: %s\n", graphQLQuery) - - apiURL := "https://graphql.anilist.co" - - // Escape quotes in the query to make valid JSON - escapedQuery := strings.Replace(graphQLQuery, `"`, `\"`, -1) - escapedQuery = strings.Replace(escapedQuery, "\n", " ", -1) - escapedQuery = strings.Replace(escapedQuery, "\t", "", -1) - - // Create the JSON payload - jsonData := []byte(fmt.Sprintf(`{"query": "%s"}`, escapedQuery)) - - // Create a request with the proper body - req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - // Read error response body for better debugging - bodyBytes, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("failed to get anime data: %s - %s", resp.Status, string(bodyBytes)) - } - - var anilistResponse types.AnilistAnimeResponse - if err := json.NewDecoder(resp.Body).Decode(&anilistResponse); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - if anilistResponse.Data.Media.ID == 0 { - return nil, fmt.Errorf("no data found for Anilist ID %d", anilistID) - } - return &anilistResponse, nil -} diff --git a/utils/anime/anime.go b/utils/anime/anime.go deleted file mode 100644 index e5b7186..0000000 --- a/utils/anime/anime.go +++ /dev/null @@ -1,290 +0,0 @@ -package anime - -import ( - "fmt" - "metachan/entities" - "metachan/types" - "metachan/utils/logger" -) - -func GetAnimeDetails(animeMapping *entities.AnimeMapping) (*types.Anime, error) { - malID := animeMapping.MAL - - anime, err := getFullAnimeViaJikan(malID) - if err != nil { - return nil, fmt.Errorf("failed to get anime details: %w", err) - } - var anilistAnime *types.AnilistAnimeResponse - if animeMapping.Anilist != 0 { - anilistAnime, err = getAnimeViaAnilist(animeMapping.Anilist) - if err != nil { - return nil, fmt.Errorf("failed to get anime details from Anilist: %w", err) - } - } - - episodes, err := getAnimeEpisodesViaJikan(malID) - if err != nil { - return nil, fmt.Errorf("failed to get anime episodes: %w", err) - } - - episodeData, _ := generateEpisodeDataWithDescriptions( - episodes.Data, - anime.Data.Title, - anime.Data.TitleEnglish, - animeMapping.TMDB, - ) - - var logos types.AnimeLogos - malSyncData, err := getAnimeViaMalSync(malID) - if err == nil { - logos = extractLogosFromMALSync(malSyncData) - } else { - logger.Log(fmt.Sprintf("Failed to get MALSync data for logos: %v", err), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - logos = types.AnimeLogos{} - } - - // Get seasons information if TVDB ID is available - var seasons []types.AnimeSeason - if animeMapping.TVDB != 0 { - // Get all mappings for this TVDB ID (representing different seasons) - seasonMappings, err := FindSeasonMappings(animeMapping.TVDB) - if err != nil { - logger.Log(fmt.Sprintf("Failed to find season mappings: %v", err), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - } else if len(seasonMappings) > 0 { - // Process the season mappings to get season details - seasons, err = GetAnimeSeason(&seasonMappings, malID) - if err != nil { - logger.Log(fmt.Sprintf("Failed to get anime seasons: %v", err), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - } - } - } - - characterResponse, err := getAnimeCharactersViaJikan(malID) - if err != nil { - return nil, fmt.Errorf("failed to get anime characters: %w", err) - } - - characters := getAnimeCharacters(characterResponse) - - nextAiringEpisode := getNextAiringEpisode(anilistAnime) - schedule := getAnimeSchedule(anilistAnime) - - animeDetails := &types.Anime{ - MALID: malID, - Titles: types.AnimeTitles{ - Romaji: anime.Data.Title, - English: anime.Data.TitleEnglish, - Japanese: anime.Data.TitleJapanese, - Synonyms: anime.Data.TitleSynonyms, - }, - Synopsis: anime.Data.Synopsis, - Type: types.AniSyncType(animeMapping.Type), - Source: anime.Data.Source, - Airing: anime.Data.Airing, - Status: anime.Data.Status, - AiringStatus: types.AiringStatus{ - From: types.AiringStatusDates{ - Day: anime.Data.Aired.Prop.From.Day, - Month: anime.Data.Aired.Prop.From.Month, - Year: anime.Data.Aired.Prop.From.Year, - String: anime.Data.Aired.From, - }, - To: types.AiringStatusDates{ - Day: anime.Data.Aired.Prop.To.Day, - Month: anime.Data.Aired.Prop.To.Month, - Year: anime.Data.Aired.Prop.To.Year, - String: anime.Data.Aired.To, - }, - String: anime.Data.Aired.String, - }, - Duration: anime.Data.Duration, - Images: types.AnimeImages{ - Small: anime.Data.Images.JPG.SmallImageURL, - Large: anime.Data.Images.JPG.LargeImageURL, - Original: anime.Data.Images.JPG.ImageURL, - }, - Logos: logos, - Covers: types.AnimeImages{ - Small: anilistAnime.Data.Media.CoverImage.Medium, - Large: anilistAnime.Data.Media.CoverImage.Large, - Original: anilistAnime.Data.Media.CoverImage.ExtraLarge, - }, - Color: anilistAnime.Data.Media.CoverImage.Color, - Genres: generateGenres(anime.Data.Genres, anime.Data.ExplicitGenres), - Scores: types.AnimeScores{ - Score: anime.Data.Score, - ScoredBy: anime.Data.ScoredBy, - Rank: anime.Data.Rank, - Popularity: anime.Data.Popularity, - Members: anime.Data.Members, - Favorites: anime.Data.Favorites, - }, - Season: anime.Data.Season, - Year: anime.Data.Year, - Broadcast: types.AnimeBroadcast{ - Day: anime.Data.Broadcast.Day, - Time: anime.Data.Broadcast.Time, - Timezone: anime.Data.Broadcast.Timezone, - String: anime.Data.Broadcast.String, - }, - Producers: generateProducers(anime.Data.Producers), - Studios: generateStudios(anime.Data.Studios), - Licensors: generateLicensors(anime.Data.Licensors), - Seasons: seasons, // Add seasons information - Episodes: types.AnimeEpisodes{ - Total: getEpisodeCount(anime, anilistAnime), - Aired: len(episodes.Data), - Episodes: episodeData, - }, - NextAiringEpisode: nextAiringEpisode, - AiringSchedule: schedule, - Characters: characters, - Mappings: types.AnimeMappings{ - AniDB: animeMapping.AniDB, - Anilist: animeMapping.Anilist, - AnimeCountdown: animeMapping.AnimeCountdown, - AnimePlanet: animeMapping.AnimePlanet, - AniSearch: animeMapping.AniSearch, - IMDB: animeMapping.IMDB, - Kitsu: animeMapping.Kitsu, - LiveChart: animeMapping.LiveChart, - NotifyMoe: animeMapping.NotifyMoe, - Simkl: animeMapping.Simkl, - TMDB: animeMapping.TMDB, - TVDB: animeMapping.TVDB, - }, - } - return animeDetails, nil -} - -func getEpisodeCount(malAnime *types.JikanAnimeResponse, anilistAnime *types.AnilistAnimeResponse) int { - streamingScheduleLength := len(anilistAnime.Data.Media.AiringSchedule.Nodes) - episodes := max(malAnime.Data.Episodes, anilistAnime.Data.Media.Episodes) - episodes = max(episodes, streamingScheduleLength) - - return episodes -} - -func getAnimeCharacters(characterResponse *types.JikanAnimeCharacterResponse) []types.AnimeCharacter { - characters := make([]types.AnimeCharacter, len(characterResponse.Data)) - for i, character := range characterResponse.Data { - characters[i] = types.AnimeCharacter{ - MALID: character.Character.MALID, - URL: character.Character.URL, - ImageURL: character.Character.Images.JPG.ImageURL, - Name: character.Character.Name, - Role: character.Role, - VoiceActors: make([]types.AnimeVoiceActor, len(character.VoiceActors)), - } - - for j, voiceActor := range character.VoiceActors { - characters[i].VoiceActors[j] = types.AnimeVoiceActor{ - MALID: voiceActor.Person.MALID, - URL: voiceActor.Person.URL, - Image: voiceActor.Person.Images.JPG.ImageURL, - Name: voiceActor.Person.Name, - Language: voiceActor.Language, - } - } - } - return characters -} - -func generateGenres(genres, explicitGenres []types.JikanGenericMALStructure) []types.AnimeGenres { - animeGenres := make([]types.AnimeGenres, len(genres)+len(explicitGenres)) - counter := 0 - - for _, genre := range genres { - animeGenres[counter] = types.AnimeGenres{ - Name: genre.Name, - GenreID: genre.MALID, - URL: genre.URL, - } - counter++ - } - - for _, genre := range explicitGenres { - animeGenres[counter] = types.AnimeGenres{ - Name: genre.Name, - GenreID: genre.MALID, - URL: genre.URL, - } - counter++ - } - - return animeGenres -} - -func generateStudios(genericPLS []types.JikanGenericMALStructure) []types.AnimeStudio { - studios := make([]types.AnimeStudio, len(genericPLS)) - for i, studio := range genericPLS { - studios[i] = types.AnimeStudio{ - Name: studio.Name, - StudioID: studio.MALID, - URL: studio.URL, - } - } - return studios -} - -func generateProducers(genericPLS []types.JikanGenericMALStructure) []types.AnimeProducer { - producers := make([]types.AnimeProducer, len(genericPLS)) - for i, producer := range genericPLS { - producers[i] = types.AnimeProducer{ - Name: producer.Name, - ProducerID: producer.MALID, - URL: producer.URL, - } - } - return producers -} - -func generateLicensors(genericPLS []types.JikanGenericMALStructure) []types.AnimeLicensor { - licensors := make([]types.AnimeLicensor, len(genericPLS)) - for i, licensor := range genericPLS { - licensors[i] = types.AnimeLicensor{ - Name: licensor.Name, - ProducerID: licensor.MALID, - URL: licensor.URL, - } - } - return licensors -} - -func getNextAiringEpisode(anilistAnime *types.AnilistAnimeResponse) types.AnimeAiringEpisode { - if anilistAnime.Data.Media.NextAiringEpisode.ID == 0 { - return types.AnimeAiringEpisode{} - } - - return types.AnimeAiringEpisode{ - TimeUntilAiring: anilistAnime.Data.Media.NextAiringEpisode.TimeUntilAiring, - Episode: anilistAnime.Data.Media.NextAiringEpisode.Episode, - AiringAt: anilistAnime.Data.Media.NextAiringEpisode.AiringAt, - } -} - -func getAnimeSchedule(anilistAnime *types.AnilistAnimeResponse) []types.AnimeAiringEpisode { - if anilistAnime.Data.Media.AiringSchedule.Nodes == nil { - return nil - } - - schedule := make([]types.AnimeAiringEpisode, len(anilistAnime.Data.Media.AiringSchedule.Nodes)) - for i, episode := range anilistAnime.Data.Media.AiringSchedule.Nodes { - schedule[i] = types.AnimeAiringEpisode{ - TimeUntilAiring: episode.TimeUntilAiring, - Episode: episode.Episode, - AiringAt: episode.AiringAt, - } - } - - return schedule -} diff --git a/utils/anime/aniskip.go b/utils/anime/aniskip.go deleted file mode 100644 index 7a4ad22..0000000 --- a/utils/anime/aniskip.go +++ /dev/null @@ -1,68 +0,0 @@ -package anime - -import ( - "encoding/json" - "fmt" - "metachan/types" - "net/http" - "time" -) - -type AniSkipInterval struct { - StartTime float64 `json:"start_time"` - EndTime float64 `json:"end_time"` -} - -type AniSkipResult 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 AniSkipResponse struct { - Found bool `json:"found"` - Results []AniSkipResult `json:"results"` -} - -func getAnimeEpisodeSkipTimes(malID int, episodeNumber int) ([]types.AnimeSkipTimes, error) { - client := http.Client{ - Timeout: 10 * time.Second, - } - - url := fmt.Sprintf("https://api.aniskip.com/v1/skip-times/%d/%d?types=op&types=ed", malID, episodeNumber) - - resp, err := client.Get(url) - if err != nil { - return nil, fmt.Errorf("failed to request skip times: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to get skip times: status code %d", resp.StatusCode) - } - - var skipResp AniSkipResponse - if err := json.NewDecoder(resp.Body).Decode(&skipResp); err != nil { - return nil, fmt.Errorf("failed to parse skip times: %w", err) - } - - if !skipResp.Found || len(skipResp.Results) == 0 { - return nil, nil - } - - skipTimes := make([]types.AnimeSkipTimes, len(skipResp.Results)) - for i, result := range skipResp.Results { - skipTimes[i] = types.AnimeSkipTimes{ - SkipType: result.SkipType, - StartTime: result.Interval.StartTime, - EndTime: result.Interval.EndTime, - EpisodeLength: result.EpisodeLength, - } - } - - return skipTimes, nil -} diff --git a/utils/anime/crunchyroll.go b/utils/anime/crunchyroll.go deleted file mode 100644 index dbe9692..0000000 --- a/utils/anime/crunchyroll.go +++ /dev/null @@ -1,201 +0,0 @@ -package anime - -import ( - "crypto/tls" - "fmt" - "metachan/types" - "metachan/utils/logger" - "net/http" - "strings" - "time" -) - -func extractCrunchyrollSeriesID(crURL string) string { - logger.Log(fmt.Sprintf("Attempting to extract series ID from URL: %s", crURL), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - - // Direct series URL format - if strings.Contains(crURL, "/series/") { - parts := strings.Split(crURL, "/series/") - if len(parts) < 2 { - logger.Log("URL contains /series/ but couldn't extract ID part", types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - return "" - } - - idParts := strings.Split(parts[1], "/") - if len(idParts) < 1 { - logger.Log("Couldn't extract ID from path segments", types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - return "" - } - - logger.Log(fmt.Sprintf("Found series ID directly in URL: %s", idParts[0]), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - return idParts[0] - } - - // Need to follow redirect to get series ID - logger.Log("URL doesn't contain /series/, following redirect...", types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - - // Create a transport that uses modern TLS settings - transport := &http.Transport{ - TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - }, - ForceAttemptHTTP2: true, - } - - client := &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { - // Don't follow redirects, just capture the Location header - return http.ErrUseLastResponse - }, - Timeout: 10 * time.Second, - Transport: transport, - } - - // Update HTTP to HTTPS for Crunchyroll URLs if needed - if strings.HasPrefix(crURL, "http://www.crunchyroll.com") { - crURL = strings.Replace(crURL, "http://", "https://", 1) - logger.Log(fmt.Sprintf("Updated URL to HTTPS: %s", crURL), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - } - - // Add User-Agent header to mimic a browser - req, err := http.NewRequest("GET", crURL, nil) - if err != nil { - logger.Log(fmt.Sprintf("Failed to create request for Crunchyroll URL: %v", err), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - return "" - } - - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") - req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml") - - resp, err := client.Do(req) - if err != nil { - logger.Log(fmt.Sprintf("Failed to get Crunchyroll redirect: %v", err), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - return "" - } - defer resp.Body.Close() - - // Log the status code and response headers for debugging - logger.Log(fmt.Sprintf("Crunchyroll response status: %d %s", resp.StatusCode, resp.Status), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - - for name, values := range resp.Header { - logger.Log(fmt.Sprintf("Header %s: %s", name, strings.Join(values, ", ")), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - } - - // Check for specific status codes for redirects - if resp.StatusCode != http.StatusMovedPermanently && - resp.StatusCode != http.StatusFound && - resp.StatusCode != http.StatusTemporaryRedirect && - resp.StatusCode != http.StatusPermanentRedirect { - logger.Log(fmt.Sprintf("Unexpected status code from Crunchyroll: %d", resp.StatusCode), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - - // If we got a 200 OK, maybe Crunchyroll served the page directly - // Try to extract the series ID from the URL itself as a fallback - if resp.StatusCode == http.StatusOK && strings.Contains(crURL, "crunchyroll.com") { - // For URLs like http://www.crunchyroll.com/fullmetal-alchemist-brotherhood - // Extract the last part as a potential identifier - urlParts := strings.Split(crURL, "/") - if len(urlParts) > 0 { - potentialId := urlParts[len(urlParts)-1] - logger.Log(fmt.Sprintf("Extracted potential series ID from original URL: %s", potentialId), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - return potentialId - } - } - return "" - } - - redirectURL := resp.Header.Get("Location") - if redirectURL == "" { - logger.Log("No redirect URL found in Crunchyroll response", types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - return "" - } - - logger.Log(fmt.Sprintf("Found redirect URL: %s", redirectURL), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - - // Extract series ID from redirect URL - if strings.Contains(redirectURL, "/series/") { - parts := strings.Split(redirectURL, "/series/") - if len(parts) < 2 { - return "" - } - - idParts := strings.Split(parts[1], "/") - if len(idParts) < 1 { - return "" - } - - logger.Log(fmt.Sprintf("Successfully extracted series ID from redirect: %s", idParts[0]), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - return idParts[0] - } - - // For multi-level redirects, try to follow one more time - if strings.Contains(redirectURL, "crunchyroll.com") { - logger.Log("Trying to follow one more redirect level...", types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - return extractCrunchyrollSeriesID(redirectURL) - } - - // As a fallback for older Crunchyroll URLs like fullmetal-alchemist-brotherhood - // Use the last path segment as the ID - urlParts := strings.Split(crURL, "/") - if len(urlParts) > 0 { - potentialId := urlParts[len(urlParts)-1] - logger.Log(fmt.Sprintf("Using fallback: extracted ID from original URL: %s", potentialId), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - return potentialId - } - - logger.Log("Could not extract series ID from Crunchyroll redirect URL", types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - return "" -} diff --git a/utils/anime/jikan.go b/utils/anime/jikan.go deleted file mode 100644 index 22e9810..0000000 --- a/utils/anime/jikan.go +++ /dev/null @@ -1,722 +0,0 @@ -package anime - -import ( - "context" - "encoding/json" - "fmt" - "io" - "math" - "metachan/types" - "metachan/utils/logger" - "net/http" - "strconv" - "sync" - "time" -) - -// JikanRateLimiter manages request rate limiting for the Jikan API -// Jikan allows maximum 3 requests per second, 60 requests per minute -type JikanRateLimiter struct { - mu sync.Mutex - lastRequests []time.Time - perSecRequests int // Max requests per second - perSecWindow time.Duration - perMinRequests int // Max requests per minute - perMinWindow time.Duration -} - -var ( - // Global Jikan rate limiter instance with conservative settings - jikanLimiter = &JikanRateLimiter{ - lastRequests: make([]time.Time, 0, 60), - perSecRequests: 3, // More conservative than the stated 3/sec - perSecWindow: time.Second, - perMinRequests: 60, // More conservative than the stated 60/min - perMinWindow: time.Minute, - } -) - -// Wait blocks until a request can be made according to rate limiting rules -func (r *JikanRateLimiter) Wait() { - r.mu.Lock() - defer r.mu.Unlock() - - now := time.Now() - - // Clean up old requests - r.cleanupOldRequests(now) - - // Count requests in the current windows - secWindowRequests := 0 - minWindowRequests := 0 - - for _, t := range r.lastRequests { - if now.Sub(t) < r.perSecWindow { - secWindowRequests++ - } - if now.Sub(t) < r.perMinWindow { - minWindowRequests++ - } - } - - logger.Log( - "Rate limit check - Second: "+ - time.Duration(secWindowRequests).String()+"/"+time.Duration(r.perSecRequests).String()+ - " - Minutes: "+time.Duration(minWindowRequests).String()+"/"+time.Duration(r.perMinRequests).String(), - types.LogOptions{ - Level: types.Debug, - Prefix: "JikanAPI", - }, - ) - - // Calculate necessary delay - var delay time.Duration - - // Check per-second limit - if secWindowRequests >= r.perSecRequests && len(r.lastRequests) > 0 { - // Find the oldest request within the second window - var oldestInSecWindow time.Time - foundInSecWindow := false - - for _, t := range r.lastRequests { - if now.Sub(t) < r.perSecWindow { - if !foundInSecWindow || t.Before(oldestInSecWindow) { - oldestInSecWindow = t - foundInSecWindow = true - } - } - } - - if foundInSecWindow { - // Calculate when we can make the next request - secDelay := r.perSecWindow - now.Sub(oldestInSecWindow) + 200*time.Millisecond // Add buffer - if secDelay > 0 { - delay = secDelay - } - } - } - - // Check per-minute limit - if minWindowRequests >= r.perMinRequests && len(r.lastRequests) > 0 { - // Find the oldest request within the minute window - var oldestInMinWindow time.Time - foundInMinWindow := false - - for _, t := range r.lastRequests { - if now.Sub(t) < r.perMinWindow { - if !foundInMinWindow || t.Before(oldestInMinWindow) { - oldestInMinWindow = t - foundInMinWindow = true - } - } - } - - if foundInMinWindow { - // Calculate when we can make the next request - minDelay := r.perMinWindow - now.Sub(oldestInMinWindow) + 200*time.Millisecond // Add buffer - if minDelay > delay { - delay = minDelay - } - } - } - - // If we need to wait, do so - if delay > 0 { - // Log and sleep - r.mu.Unlock() // Unlock while sleeping - logger.Log("Rate limiting Jikan API request - waiting "+delay.String(), types.LogOptions{ - Level: types.Info, - Prefix: "JikanAPI", - }) - time.Sleep(delay) - r.mu.Lock() // Lock again before modifying state - now = time.Now() // Update current time - } - - // Record this request with current time - r.lastRequests = append(r.lastRequests, now) -} - -// cleanupOldRequests removes requests older than the longest window -func (r *JikanRateLimiter) cleanupOldRequests(now time.Time) { - validRequests := make([]time.Time, 0, len(r.lastRequests)) - for _, t := range r.lastRequests { - // Keep requests that are within our longest time window (per minute) - if now.Sub(t) < r.perMinWindow { - validRequests = append(validRequests, t) - } - } - r.lastRequests = validRequests -} - -// WaitForJikanRequest is a convenience function to access the global rate limiter -func waitForJikanRequest() { - jikanLimiter.Wait() -} - -func getAnimeViaJikan(malID int) (*types.JikanAnimeResponse, error) { - apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d", malID) - maxRetries := 3 - baseBackoff := 1 * time.Second - - var animeResponse types.JikanAnimeResponse - success := false - retries := 0 - - for !success && retries <= maxRetries { - // Use rate limiter before making the request - logger.Log(fmt.Sprintf("Waiting for rate limiter before requesting anime %d details", malID), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - waitForJikanRequest() - - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - client := &http.Client{ - Timeout: 10 * time.Second, // Add timeout to prevent hanging requests - } - resp, err := client.Do(req) - - if err != nil { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - logger.Log(fmt.Sprintf("Request error for anime details, retrying in %v (retry %d/%d): %v", - backoffTime, retries, maxRetries, err), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to execute request after %d retries: %w", maxRetries, err) - } - - defer resp.Body.Close() - - if resp.StatusCode == http.StatusTooManyRequests { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - logger.Log(fmt.Sprintf("Rate limited on anime details, backing off for %v (retry %d/%d)", - backoffTime, retries, maxRetries), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to get anime data: rate limited after %d retries", maxRetries) - } else if resp.StatusCode != http.StatusOK { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - logger.Log(fmt.Sprintf("HTTP error %d for anime details, retrying in %v (retry %d/%d)", - resp.StatusCode, backoffTime, retries, maxRetries), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to get anime data: %s", resp.Status) - } - // Limit response body size to prevent memory issues - bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit - if err != nil { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - logger.Log(fmt.Sprintf("Error reading response body, retrying in %v (retry %d/%d): %v", - backoffTime, retries, maxRetries, err), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to read response body: %w", err) - } - if err := json.Unmarshal(bodyBytes, &animeResponse); err != nil { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - logger.Log(fmt.Sprintf("JSON decode error for anime details, retrying in %v (retry %d/%d): %v", - backoffTime, retries, maxRetries, err), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to decode response: %w", err) - } - success = true - } - - if !success { - return nil, fmt.Errorf("failed to fetch anime details after maximum retries") - } - - if animeResponse.Data.MALID == 0 { - return nil, fmt.Errorf("no data found for MAL ID %d", malID) - } - - return &animeResponse, nil -} - -func getFullAnimeViaJikan(malID int) (*types.JikanAnimeResponse, error) { - apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/full", malID) - maxRetries := 3 - baseBackoff := 1 * time.Second - - var animeResponse types.JikanAnimeResponse - success := false - retries := 0 - - for !success && retries <= maxRetries { - // Use rate limiter before making the request - logger.Log(fmt.Sprintf("Waiting for rate limiter before requesting anime %d details", malID), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - waitForJikanRequest() - - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - client := &http.Client{ - Timeout: 10 * time.Second, // Add timeout to prevent hanging requests - } - resp, err := client.Do(req) - - if err != nil { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - logger.Log(fmt.Sprintf("Request error for anime details, retrying in %v (retry %d/%d): %v", - backoffTime, retries, maxRetries, err), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to execute request after %d retries: %w", maxRetries, err) - } - - defer resp.Body.Close() - - // Handle rate limiting with exponential backoff - if resp.StatusCode == http.StatusTooManyRequests { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - - // If we have a Retry-After header, respect it - if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { - if seconds, err := strconv.Atoi(retryAfter); err == nil { - backoffTime = time.Duration(seconds) * time.Second - } - } - - logger.Log(fmt.Sprintf("Rate limited on anime details, backing off for %v (retry %d/%d)", - backoffTime, retries, maxRetries), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to get anime data: rate limited after %d retries", maxRetries) - } else if resp.StatusCode != http.StatusOK { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - logger.Log(fmt.Sprintf("HTTP error %d for anime details, retrying in %v (retry %d/%d)", - resp.StatusCode, backoffTime, retries, maxRetries), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to get anime data: %s", resp.Status) - } - - if err := json.NewDecoder(resp.Body).Decode(&animeResponse); err != nil { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - logger.Log(fmt.Sprintf("JSON decode error for anime details, retrying in %v (retry %d/%d): %v", - backoffTime, retries, maxRetries, err), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - success = true - } - - if !success { - return nil, fmt.Errorf("failed to fetch anime details after maximum retries") - } - - if animeResponse.Data.MALID == 0 { - return nil, fmt.Errorf("no data found for MAL ID %d", malID) - } - - return &animeResponse, nil -} - -func getAnimeEpisodesViaJikan(malId int) (*types.JikanAnimeEpisodeResponse, error) { - apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/episodes", malId) - var allEpisodes []types.JikanAnimeEpisode - page := 1 - var lastVisiblePage int - - maxRetries := 3 - baseBackoff := 1 * time.Second - maxAttempts := 15 // Maximum number of attempts across all pages to prevent infinite loops - - logger.Log(fmt.Sprintf("Fetching episodes for anime %d", malId), types.LogOptions{ - Level: types.Info, - Prefix: "AnimeAPI", - }) - - totalAttempts := 0 - for { - if totalAttempts >= maxAttempts { - logger.Log(fmt.Sprintf("Reached maximum total attempts (%d) for anime %d. Returning collected episodes so far.", - maxAttempts, malId), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - break - } - - var pageResponse types.JikanAnimeEpisodeResponse - success := false - retries := 0 - - for !success && retries <= maxRetries { - totalAttempts++ - - // Use rate limiter before making the request - logger.Log(fmt.Sprintf("Waiting for rate limiter before requesting page %d for anime %d", page, malId), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - waitForJikanRequest() - - pageURL := fmt.Sprintf("%s?page=%d", apiURL, page) - req, err := http.NewRequest("GET", pageURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Add a context with timeout - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - req = req.WithContext(ctx) - - client := &http.Client{ - Timeout: 15 * time.Second, // Add timeout to prevent hanging requests - } - - resp, err := client.Do(req) - cancel() // Cancel the context regardless of the outcome - - if err != nil { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - logger.Log(fmt.Sprintf("Request error, retrying in %v (retry %d/%d): %v", - backoffTime, retries, maxRetries, err), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to execute request after %d retries: %w", maxRetries, err) - } - - defer resp.Body.Close() - - // Handle rate limiting with exponential backoff - if resp.StatusCode == http.StatusTooManyRequests { - if retries < maxRetries { - retries++ - - // Start with a reasonable base backoff - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(1.5, float64(retries-1))) - - // Respect Retry-After header if available - if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { - if seconds, err := strconv.Atoi(retryAfter); err == nil { - backoffTime = time.Duration(seconds) * time.Second - } - } - - logger.Log(fmt.Sprintf("Rate limited, backing off for %v (retry %d/%d)", - backoffTime, retries, maxRetries), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - - // If we've reached maximum retries and still getting rate limited, - // return what we have so far rather than failing completely - if len(allEpisodes) > 0 { - logger.Log(fmt.Sprintf("Rate limited after maximum retries. Returning %d episodes collected so far.", - len(allEpisodes)), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - - return &types.JikanAnimeEpisodeResponse{ - Pagination: types.JikanPagination{ - LastVisiblePage: lastVisiblePage, - HasNextPage: false, - }, - Data: allEpisodes, - }, nil - } - - return nil, fmt.Errorf("failed to get anime episodes (page %d): rate limited after %d retries", page, maxRetries) - } else if resp.StatusCode != http.StatusOK { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - logger.Log(fmt.Sprintf("HTTP error %d, retrying in %v (retry %d/%d)", - resp.StatusCode, backoffTime, retries, maxRetries), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to get anime episodes (page %d): %s", page, resp.Status) - } - - // Limit response body size to prevent memory issues - bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit - if err != nil { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - logger.Log(fmt.Sprintf("Error reading response body, retrying in %v (retry %d/%d): %v", - backoffTime, retries, maxRetries, err), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - if err := json.Unmarshal(bodyBytes, &pageResponse); err != nil { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - logger.Log(fmt.Sprintf("JSON decode error, retrying in %v (retry %d/%d): %v", - backoffTime, retries, maxRetries, err), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - success = true - } - - if !success { - // If we've collected some episodes, return them instead of completely failing - if len(allEpisodes) > 0 { - logger.Log(fmt.Sprintf("Failed to fetch page %d after maximum retries. Returning %d episodes collected so far.", - page, len(allEpisodes)), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - - return &types.JikanAnimeEpisodeResponse{ - Pagination: types.JikanPagination{ - LastVisiblePage: page - 1, - HasNextPage: false, - }, - Data: allEpisodes, - }, nil - } - - return nil, fmt.Errorf("failed to fetch page %d after maximum retries", page) - } - - // Convert and append episodes from this page - for _, episode := range pageResponse.Data { - allEpisodes = append(allEpisodes, types.JikanAnimeEpisode{ - MALID: episode.MALID, - URL: episode.URL, - Title: episode.Title, - TitleJapanese: episode.TitleJapanese, - TitleRomaji: episode.TitleRomaji, - Aired: episode.Aired, - Score: episode.Score, - Filler: episode.Filler, - Recap: episode.Recap, - ForumURL: episode.ForumURL, - }) - } - - // Update pagination info - lastVisiblePage = pageResponse.Pagination.LastVisiblePage - - logger.Log(fmt.Sprintf("Fetched page %d/%d with %d episodes", - page, lastVisiblePage, len(pageResponse.Data)), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - - // Check if there are more pages - if !pageResponse.Pagination.HasNextPage || page >= lastVisiblePage { - break - } - - // Safety check - don't fetch more than a reasonable number of pages - if page >= 25 { - logger.Log(fmt.Sprintf("Reached maximum page limit (25) for anime %d. Returning collected episodes so far.", - malId), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - break - } - - // No need for explicit waiting between pages anymore - // The rate limiter will handle the pacing automatically - page++ - } - - logger.Log(fmt.Sprintf("Completed fetching all %d episodes for anime %d", - len(allEpisodes), malId), types.LogOptions{ - Level: types.Success, - Prefix: "AnimeAPI", - }) - - // Return the complete response with all collected episodes - return &types.JikanAnimeEpisodeResponse{ - Pagination: types.JikanPagination{ - LastVisiblePage: lastVisiblePage, - HasNextPage: false, - }, - Data: allEpisodes, - }, nil -} - -func getAnimeCharactersViaJikan(malID int) (*types.JikanAnimeCharacterResponse, error) { - apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/characters", malID) - maxRetries := 3 - baseBackoff := 1 * time.Second - - var characterResponse types.JikanAnimeCharacterResponse - success := false - retries := 0 - - for !success && retries <= maxRetries { - logger.Log(fmt.Sprintf("Waiting for rate limiter before requesting anime %d characters", malID), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - waitForJikanRequest() - - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - client := &http.Client{ - Timeout: 10 * time.Second, // Add timeout to prevent hanging requests - } - resp, err := client.Do(req) - if err != nil { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - logger.Log(fmt.Sprintf("Request error for anime characters, retrying in %v (retry %d/%d): %v", - backoffTime, retries, maxRetries, err), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to execute request after %d retries: %w", maxRetries, err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusTooManyRequests { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - logger.Log(fmt.Sprintf("Rate limited on anime characters, backing off for %v (retry %d/%d)", - backoffTime, retries, maxRetries), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to get anime characters: rate limited after %d retries", maxRetries) - } else if resp.StatusCode != http.StatusOK { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - logger.Log(fmt.Sprintf("HTTP error %d for anime characters, retrying in %v (retry %d/%d)", - resp.StatusCode, backoffTime, retries, maxRetries), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to get anime characters: %s", resp.Status) - } - - if err := json.NewDecoder(resp.Body).Decode(&characterResponse); err != nil { - if retries < maxRetries { - retries++ - backoffTime := time.Duration(float64(baseBackoff) * math.Pow(2, float64(retries-1))) - logger.Log(fmt.Sprintf("JSON decode error for anime characters, retrying in %v (retry %d/%d): %v", - backoffTime, retries, maxRetries, err), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeAPI", - }) - time.Sleep(backoffTime) - continue - } - return nil, fmt.Errorf("failed to decode response: %w", err) - } - success = true - } - - if !success { - return nil, fmt.Errorf("failed to fetch anime characters after maximum retries") - } - - return &characterResponse, nil -} diff --git a/utils/anime/malsync.go b/utils/anime/malsync.go deleted file mode 100644 index 0d06c25..0000000 --- a/utils/anime/malsync.go +++ /dev/null @@ -1,98 +0,0 @@ -package anime - -import ( - "encoding/json" - "fmt" - "io" - "metachan/types" - "metachan/utils/logger" - "net/http" - "time" -) - -func getAnimeViaMalSync(malID int) (*types.MALSyncAnimeResponse, error) { - apiURL := fmt.Sprintf("https://api.malsync.moe/mal/anime/%d", malID) - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - client := &http.Client{ - Timeout: 10 * time.Second, // Add timeout to prevent hanging requests - } - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("failed to get anime data: %s - %s", resp.Status, string(bodyBytes)) - } - - var malSyncResponse types.MALSyncAnimeResponse - if err := json.NewDecoder(resp.Body).Decode(&malSyncResponse); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - return &malSyncResponse, nil -} - -func extractLogosFromMALSync(malSyncResponse *types.MALSyncAnimeResponse) types.AnimeLogos { - logos := types.AnimeLogos{} - - // Check if Crunchyroll data exists in the MALSync response - crunchyrollSites, exists := malSyncResponse.Sites["Crunchyroll"] - if !exists || len(crunchyrollSites) == 0 { - logger.Log("No Crunchyroll data found in MALSync response", types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - return logos - } - - // Get the Crunchyroll URL from any of the entries - crURL := "" - for _, site := range crunchyrollSites { - crURL = site.URL - break // Take the first URL - } - - if crURL == "" { - logger.Log("No valid Crunchyroll URL found", types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - return logos - } - - // Extract series ID from URL - seriesID := extractCrunchyrollSeriesID(crURL) - if seriesID == "" { - return logos - } - - // Define logo sizes - logoSizes := map[string]int{ - "Small": 320, - "Medium": 480, - "Large": 600, - "XLarge": 800, - "Original": 1200, - } - - // Generate logo URLs - logos.Small = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["Small"], seriesID) - logos.Medium = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["Medium"], seriesID) - logos.Large = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["Large"], seriesID) - logos.XLarge = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["XLarge"], seriesID) - logos.Original = fmt.Sprintf("https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=%d/keyart/%s-title_logo-en-us", logoSizes["Original"], seriesID) - - logger.Log(fmt.Sprintf("Successfully generated logo URLs for series ID: %s", seriesID), types.LogOptions{ - Level: types.Debug, - Prefix: "AnimeAPI", - }) - - return logos -} diff --git a/utils/anime/tvdb.go b/utils/anime/tvdb.go deleted file mode 100644 index 4206c24..0000000 --- a/utils/anime/tvdb.go +++ /dev/null @@ -1,163 +0,0 @@ -package anime - -import ( - "fmt" - "metachan/database" - "metachan/entities" - "metachan/types" - "metachan/utils/logger" - "sort" - "strings" -) - -// GetAnimeSeason prepares season information for an anime -func GetAnimeSeason(mappings *[]entities.AnimeMapping, currentMALID int) ([]types.AnimeSeason, error) { - var seasons []types.AnimeSeason - - for _, mapping := range *mappings { - // Fetch basic anime info from Jikan API - animeDetails, err := getAnimeViaJikan(mapping.MAL) - if err != nil { - logger.Log(fmt.Sprintf("Failed to get anime details for MAL ID %d: %v", mapping.MAL, err), types.LogOptions{ - Level: types.Warn, - Prefix: "AnimeSeason", - }) - continue - } - - // Build the season object - season := types.AnimeSeason{ - MALID: mapping.MAL, - Titles: types.AnimeTitles{ - English: animeDetails.Data.TitleEnglish, - Japanese: animeDetails.Data.TitleJapanese, - Romaji: animeDetails.Data.Title, - Synonyms: animeDetails.Data.TitleSynonyms, - }, - Synopsis: animeDetails.Data.Synopsis, - Type: types.AniSyncType(mapping.Type), - Source: animeDetails.Data.Source, - Airing: animeDetails.Data.Airing, - Status: animeDetails.Data.Status, - AiringStatus: types.AiringStatus{ - From: types.AiringStatusDates{ - Day: animeDetails.Data.Aired.Prop.From.Day, - Month: animeDetails.Data.Aired.Prop.From.Month, - Year: animeDetails.Data.Aired.Prop.From.Year, - String: animeDetails.Data.Aired.From, - }, - To: types.AiringStatusDates{ - Day: animeDetails.Data.Aired.Prop.To.Day, - Month: animeDetails.Data.Aired.Prop.To.Month, - Year: animeDetails.Data.Aired.Prop.To.Year, - String: animeDetails.Data.Aired.To, - }, - String: animeDetails.Data.Aired.String, - }, - Duration: animeDetails.Data.Duration, - Images: types.AnimeImages{ - Small: animeDetails.Data.Images.JPG.SmallImageURL, - Large: animeDetails.Data.Images.JPG.LargeImageURL, - Original: animeDetails.Data.Images.JPG.ImageURL, - }, - Scores: types.AnimeScores{ - Score: animeDetails.Data.Score, - ScoredBy: animeDetails.Data.ScoredBy, - Rank: animeDetails.Data.Rank, - Popularity: animeDetails.Data.Popularity, - Members: animeDetails.Data.Members, - Favorites: animeDetails.Data.Favorites, - }, - Season: animeDetails.Data.Season, - Year: animeDetails.Data.Year, - Current: mapping.MAL == currentMALID, // Mark if this is the current season - } - - seasons = append(seasons, season) - } - - // Sort seasons chronologically by air date - if len(seasons) > 1 { - sortSeasonsByAirDate(&seasons) - logger.Log(fmt.Sprintf("Found and sorted %d seasons for anime", len(seasons)), types.LogOptions{ - Level: types.Info, - Prefix: "AnimeSeason", - }) - } - - return seasons, nil -} - -// sortSeasonsByAirDate sorts the seasons array chronologically by air date -func sortSeasonsByAirDate(seasons *[]types.AnimeSeason) { - // Use a slice instead of a pointer to slice to make the code cleaner - s := *seasons - - // Sort by air date using the structured fields (year, month, day) - sort.Slice(s, func(i, j int) bool { - // Compare years first - if s[i].AiringStatus.From.Year != s[j].AiringStatus.From.Year { - return s[i].AiringStatus.From.Year < s[j].AiringStatus.From.Year - } - - // If years are equal, compare months - if s[i].AiringStatus.From.Month != s[j].AiringStatus.From.Month { - return s[i].AiringStatus.From.Month < s[j].AiringStatus.From.Month - } - - // If months are equal, compare days - if s[i].AiringStatus.From.Day != s[j].AiringStatus.From.Day { - return s[i].AiringStatus.From.Day < s[j].AiringStatus.From.Day - } - - // If all date fields are equal, use season as a tiebreaker - if s[i].Season != s[j].Season { - seasonOrder := map[string]int{ - "winter": 0, - "spring": 1, - "summer": 2, - "fall": 3, - "": 4, // Unknown season comes last - } - - seasonI := strings.ToLower(s[i].Season) - seasonJ := strings.ToLower(s[j].Season) - - return seasonOrder[seasonI] < seasonOrder[seasonJ] - } - - // If everything is equal, preserve original order (stable sort) - return i < j - }) - - // Update the original slice - *seasons = s -} - -// 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/anilist.go b/utils/api/anilist.go new file mode 100644 index 0000000..cda860a --- /dev/null +++ b/utils/api/anilist.go @@ -0,0 +1,165 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "metachan/types" + "metachan/utils/logger" + "net/http" +) + +// AniListClient provides methods for interacting with the AniList API +type AniListClient struct { + client *http.Client + maxRetries int +} + +// NewAniListClient creates a new AniList API client +func NewAniListClient() *AniListClient { + return &AniListClient{ + client: &http.Client{}, + maxRetries: 3, + } +} + +// GetAnime fetches anime details from AniList by ID using a simpler approach +func (c *AniListClient) GetAnime(anilistID int) (*types.AnilistAnimeResponse, error) { + // Create a much simpler request with minimal formatting that might trigger Cloudflare + query := ` + query ($id: Int) { + Media(id: $id, type: ANIME) { + id + idMal + title { + romaji + english + native + userPreferred + } + type + format + status + description + startDate { year month day } + endDate { year month day } + season + seasonYear + episodes + duration + chapters + volumes + countryOfOrigin + isLicensed + source + hashtag + trailer { id site thumbnail } + coverImage { + extraLarge + large + medium + color + } + bannerImage + genres + synonyms + averageScore + meanScore + popularity + isLocked + trending + favourites + tags { id name description category rank isGeneralSpoiler isMediaSpoiler isAdult } + nextAiringEpisode { id airingAt timeUntilAiring episode } + airingSchedule { nodes { id episode airingAt timeUntilAiring } } + studios { edges { isMain node { id name } } } + isAdult + } + } + ` + + // Create a simple JSON structure with variables + requestBody := map[string]interface{}{ + "query": query, + "variables": map[string]interface{}{ + "id": anilistID, + }, + } + + jsonData, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + + // Log the request for debugging + logger.Log(fmt.Sprintf("Sending request to AniList for ID %d", anilistID), types.LogOptions{ + Level: types.Debug, + Prefix: "AniList", + }) + + var resp *http.Response + var lastErr error + success := false + + for i := 0; i <= c.maxRetries && !success; i++ { + req, err := http.NewRequest("POST", "https://graphql.anilist.co", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + // Add User-Agent to make the request look more like a browser + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") + + resp, err = c.client.Do(req) + if err != nil { + lastErr = err + logger.Log(fmt.Sprintf("AniList request attempt %d failed: %v", i+1, err), types.LogOptions{ + Level: types.Debug, + Prefix: "AniList", + }) + continue + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body := make([]byte, 1024) + n, _ := resp.Body.Read(body) + lastErr = fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body[:n])) + logger.Log(fmt.Sprintf("AniList returned non-200 status on attempt %d: %v", i+1, lastErr), types.LogOptions{ + Level: types.Debug, + Prefix: "AniList", + }) + continue + } + + var anilistResponse types.AnilistAnimeResponse + if err := json.NewDecoder(resp.Body).Decode(&anilistResponse); err != nil { + lastErr = fmt.Errorf("failed to decode response: %w", err) + continue + } + + if anilistResponse.Data.Media.ID == 0 { + lastErr = fmt.Errorf("no data found for Anilist ID %d", anilistID) + continue + } + + // Log cover image data for debugging + if anilistResponse.Data.Media.CoverImage.ExtraLarge != "" { + logger.Log(fmt.Sprintf("Found cover data - Color: %s, Image: %s", + anilistResponse.Data.Media.CoverImage.Color, + anilistResponse.Data.Media.CoverImage.ExtraLarge), types.LogOptions{ + Level: types.Debug, + Prefix: "AniList", + }) + } + + success = true + return &anilistResponse, nil + } + + return nil, fmt.Errorf("failed after %d retries: %w", c.maxRetries, lastErr) +} diff --git a/utils/api/aniskip.go b/utils/api/aniskip.go new file mode 100644 index 0000000..c5aa462 --- /dev/null +++ b/utils/api/aniskip.go @@ -0,0 +1,415 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "metachan/types" + "metachan/utils/logger" + "metachan/utils/ratelimit" + "net/http" + "sync" + "time" +) + +const ( + aniskipBaseURL = "https://api.aniskip.com/v2" +) + +// AniSkipClient provides methods for interacting with the AniSkip API +type AniSkipClient struct { + client *http.Client + rateLimiter *ratelimit.RateLimiter + maxRetries int + cache map[string][]types.AnimeSkipTimes + cacheMutex sync.RWMutex + cacheTTL time.Duration + cacheTime map[string]time.Time +} + +// EpisodeSkipTimesResult contains skip times for a specific episode +type EpisodeSkipTimesResult struct { + EpisodeNumber int + SkipTimes []types.AnimeSkipTimes +} + +// NewAniSkipClient creates a new client for the AniSkip API +func NewAniSkipClient() *AniSkipClient { + return &AniSkipClient{ + client: &http.Client{ + Timeout: 5 * time.Second, // Reduced timeout for faster failure detection + }, + rateLimiter: ratelimit.NewRateLimiter(10, 10*time.Second), // Conservative rate limit + maxRetries: 2, + cache: make(map[string][]types.AnimeSkipTimes), + cacheTime: make(map[string]time.Time), + cacheTTL: 24 * time.Hour, // Cache skip times for 24 hours + } +} + +// getCacheKey generates a cache key for skip times +func (c *AniSkipClient) getCacheKey(malID, episode int) string { + return fmt.Sprintf("%d-%d", malID, episode) +} + +// getFromCache tries to get skip times from cache +func (c *AniSkipClient) getFromCache(malID, episode int) ([]types.AnimeSkipTimes, bool) { + key := c.getCacheKey(malID, episode) + + c.cacheMutex.RLock() + defer c.cacheMutex.RUnlock() + + // Check if we have a valid cache entry + if cacheTime, exists := c.cacheTime[key]; exists { + // Check if cache is still valid + if time.Since(cacheTime) < c.cacheTTL { + return c.cache[key], true + } + } + + return nil, false +} + +// saveToCache saves skip times to cache +func (c *AniSkipClient) saveToCache(malID, episode int, skipTimes []types.AnimeSkipTimes) { + key := c.getCacheKey(malID, episode) + + c.cacheMutex.Lock() + defer c.cacheMutex.Unlock() + + c.cache[key] = skipTimes + c.cacheTime[key] = time.Now() +} + +// GetSkipTimesForEpisode fetches skip times for a specific anime episode +func (c *AniSkipClient) GetSkipTimesForEpisode(malID, episodeNumber int) ([]types.AnimeSkipTimes, error) { + // Check cache first + if skipTimes, found := c.getFromCache(malID, episodeNumber); found { + return skipTimes, nil + } + + // Wait for rate limiter before making request + c.rateLimiter.Wait() + + // Using v2 API which is more efficient + apiURL := fmt.Sprintf("%s/skip-times/%d/%d?types=op&types=ed", aniskipBaseURL, malID, episodeNumber) + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Create request + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Execute request with retries + var resp *http.Response + var lastErr error + success := false + + for i := 0; i <= c.maxRetries && !success; i++ { + resp, err = c.client.Do(req) + if err != nil { + lastErr = err + // Backoff with exponential delay + time.Sleep(time.Duration((i+1)*300) * time.Millisecond) + continue + } + + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + // No skip times found, not an error + c.saveToCache(malID, episodeNumber, []types.AnimeSkipTimes{}) + return []types.AnimeSkipTimes{}, nil + } + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + lastErr = fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + + // Longer backoff for rate limits + if resp.StatusCode == http.StatusTooManyRequests { + time.Sleep(time.Duration((i+1)*1000) * time.Millisecond) + } else { + time.Sleep(time.Duration((i+1)*300) * time.Millisecond) + } + continue + } + + success = true + } + + if !success { + return nil, fmt.Errorf("failed after %d retries: %w", c.maxRetries, lastErr) + } + + // Parse response + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) // 1MB limit + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // The response format from AniSkip API v1 as shown in the prompt example + type aniskipResponse struct { + Found bool `json:"found"` + Results []struct { + Interval struct { + StartTime float64 `json:"start_time"` + EndTime float64 `json:"end_time"` + } `json:"interval"` + SkipType string `json:"skip_type"` + SkipID string `json:"skip_id"` + EpisodeLength float64 `json:"episode_length"` + } `json:"results"` + } + + var skipResp aniskipResponse + if err := json.Unmarshal(bodyBytes, &skipResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + // If no results found + if !skipResp.Found || len(skipResp.Results) == 0 { + c.saveToCache(malID, episodeNumber, []types.AnimeSkipTimes{}) + return []types.AnimeSkipTimes{}, nil + } + + // Convert to our skip times format + var skipTimes []types.AnimeSkipTimes + for _, result := range skipResp.Results { + skipTime := types.AnimeSkipTimes{ + SkipType: result.SkipType, + StartTime: result.Interval.StartTime, + EndTime: result.Interval.EndTime, + EpisodeLength: result.EpisodeLength, + } + skipTimes = append(skipTimes, skipTime) + } + + // Save to cache + c.saveToCache(malID, episodeNumber, skipTimes) + + return skipTimes, nil +} + +// GetSkipTimesForEpisodesBatch fetches skip times for episodes in batches +func (c *AniSkipClient) GetSkipTimesForEpisodesBatch(malID int, episodes []int) (map[int][]types.AnimeSkipTimes, error) { + // If we have fewer than 3 episodes, use individual requests instead + if len(episodes) < 3 { + results := make(map[int][]types.AnimeSkipTimes) + for _, ep := range episodes { + skipTimes, err := c.GetSkipTimesForEpisode(malID, ep) + if err != nil { + return nil, err + } + results[ep] = skipTimes + } + return results, nil + } + + // Check if all episodes are cached and return them + allCached := true + cachedResults := make(map[int][]types.AnimeSkipTimes) + + for _, ep := range episodes { + if skipTimes, found := c.getFromCache(malID, ep); found { + cachedResults[ep] = skipTimes + } else { + allCached = false + break + } + } + + if allCached { + return cachedResults, nil + } + + // Wait for rate limiter + c.rateLimiter.Wait() + + // Construct episode IDs parameter + episodeParams := "" + for i, ep := range episodes { + if i > 0 { + episodeParams += "," + } + episodeParams += fmt.Sprintf("%d", ep) + } + + // Batch endpoint URL + apiURL := fmt.Sprintf("%s/skip-times-batch?malId=%d&episodeIds=%s&types=op&types=ed", + aniskipBaseURL, malID, episodeParams) + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + + // Create request + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create batch request: %w", err) + } + + // Execute request with retries + var resp *http.Response + var lastErr error + success := false + + for i := 0; i <= c.maxRetries && !success; i++ { + resp, err = c.client.Do(req) + if err != nil { + lastErr = err + time.Sleep(time.Duration((i+1)*300) * time.Millisecond) + continue + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + lastErr = fmt.Errorf("batch request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + + if resp.StatusCode == http.StatusTooManyRequests { + time.Sleep(time.Duration((i+1)*1000) * time.Millisecond) + } else { + time.Sleep(time.Duration((i+1)*300) * time.Millisecond) + } + continue + } + + success = true + } + + if !success { + return nil, fmt.Errorf("batch request failed after %d retries: %w", c.maxRetries, lastErr) + } + + // Parse response + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) + if err != nil { + return nil, fmt.Errorf("failed to read batch response: %w", err) + } + + // Batch response format + type batchSkipTime struct { + Interval struct { + StartTime float64 `json:"start_time"` + EndTime float64 `json:"end_time"` + } `json:"interval"` + SkipType string `json:"skip_type"` + SkipID string `json:"skip_id"` + EpisodeLength float64 `json:"episode_length"` + } + + type episodeSkipTimes struct { + Found bool `json:"found"` + Results []batchSkipTime `json:"results"` + } + + // Map of episode number to skip times + type batchResponse map[string]episodeSkipTimes + + var skipResp batchResponse + if err := json.Unmarshal(bodyBytes, &skipResp); err != nil { + return nil, fmt.Errorf("failed to decode batch response: %w", err) + } + + results := make(map[int][]types.AnimeSkipTimes) + + // Process results + for epStr, epData := range skipResp { + var epNum int + if _, err := fmt.Sscanf(epStr, "%d", &epNum); err != nil { + continue // Skip if we can't parse the episode number + } + + var skipTimes []types.AnimeSkipTimes + + if epData.Found { + for _, result := range epData.Results { + skipTimes = append(skipTimes, types.AnimeSkipTimes{ + SkipType: result.SkipType, + StartTime: result.Interval.StartTime, + EndTime: result.Interval.EndTime, + EpisodeLength: result.EpisodeLength, + }) + } + } + + // Save to cache + c.saveToCache(malID, epNum, skipTimes) + results[epNum] = skipTimes + } + + return results, nil +} + +// GetSkipTimesForEpisodes fetches skip times for multiple episodes efficiently +func (c *AniSkipClient) GetSkipTimesForEpisodes(malID int, episodeCount int, maxConcurrent int) []types.EpisodeSkipResult { + startTime := time.Now() + + // If episode count is small, just use single endpoint + if episodeCount <= 5 { + results := []types.EpisodeSkipResult{} + for i := 1; i <= episodeCount; i++ { + skipTimes, err := c.GetSkipTimesForEpisode(malID, i) + if err == nil && len(skipTimes) > 0 { + results = append(results, types.EpisodeSkipResult{ + EpisodeNumber: i, + SkipTimes: skipTimes, + }) + } + } + return results + } + + // Create episode numbers slice + allEpisodes := make([]int, episodeCount) + for i := 0; i < episodeCount; i++ { + allEpisodes[i] = i + 1 // 1-indexed episodes + } + + // Batch size - we'll process episodes in batches + const batchSize = 25 + var results []types.EpisodeSkipResult + + // Process in batches + for i := 0; i < episodeCount; i += batchSize { + end := i + batchSize + if end > episodeCount { + end = episodeCount + } + + batchEpisodes := allEpisodes[i:end] + batchResults, err := c.GetSkipTimesForEpisodesBatch(malID, batchEpisodes) + if err != nil { + logger.Log(fmt.Sprintf("Error fetching skip times batch %d-%d: %v", i+1, end, err), types.LogOptions{ + Level: types.Warn, + Prefix: "AniSkip", + }) + continue + } + + // Add results to the final list + for epNum, skipTimes := range batchResults { + if len(skipTimes) > 0 { + results = append(results, types.EpisodeSkipResult{ + EpisodeNumber: epNum, + SkipTimes: skipTimes, + }) + } + } + } + + logger.Log(fmt.Sprintf("AniSkip: Fetched skip times for %d episodes of %d in %s", + len(results), episodeCount, time.Since(startTime)), types.LogOptions{ + Level: types.Debug, + Prefix: "AniSkip", + }) + + return results +} diff --git a/utils/api/jikan.go b/utils/api/jikan.go new file mode 100644 index 0000000..0f9ff83 --- /dev/null +++ b/utils/api/jikan.go @@ -0,0 +1,244 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "metachan/types" + "metachan/utils/ratelimit" + "net/http" + "strconv" + "time" +) + +var ( + // Global Jikan rate limiters + jikanPerSecLimiter = ratelimit.NewRateLimiter(3, time.Second) + jikanPerMinLimiter = ratelimit.NewRateLimiter(60, time.Minute) + jikanLimiter = ratelimit.NewMultiLimiter(jikanPerSecLimiter, jikanPerMinLimiter) +) + +// JikanClient provides methods to interact with the Jikan API +type JikanClient struct { + client *http.Client + maxRetries int + baseBackoff time.Duration +} + +// NewJikanClient creates a new Jikan API client +func NewJikanClient() *JikanClient { + return &JikanClient{ + client: &http.Client{ + Timeout: 15 * time.Second, + }, + maxRetries: 3, + baseBackoff: 1 * time.Second, + } +} + +// WaitForRateLimit waits until a request can be made according to rate limiting rules +func (c *JikanClient) WaitForRateLimit() { + jikanLimiter.Wait() +} + +// makeRequest makes an HTTP request with retries and proper error handling +func (c *JikanClient) makeRequest(ctx context.Context, url string) ([]byte, error) { + var bodyBytes []byte + var statusCode int + + retries := 0 + for retries <= c.maxRetries { + // Wait for rate limiter before attempting request + c.WaitForRateLimit() + + // Create the request with timeout context + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Execute the request + resp, err := c.client.Do(req) + if err != nil { + if retries < c.maxRetries { + retries++ + backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(2, float64(retries-1))) + time.Sleep(backoffTime) + continue + } + return nil, fmt.Errorf("failed to execute request after %d retries: %w", c.maxRetries, err) + } + defer resp.Body.Close() + + statusCode = resp.StatusCode + + // Handle rate limiting with exponential backoff + if statusCode == http.StatusTooManyRequests { + if retries < c.maxRetries { + retries++ + backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(1.5, float64(retries-1))) + + // Respect Retry-After header if available + if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { + if seconds, err := strconv.Atoi(retryAfter); err == nil { + backoffTime = time.Duration(seconds) * time.Second + } + } + + time.Sleep(backoffTime) + continue + } + return nil, fmt.Errorf("rate limited after %d retries", c.maxRetries) + } else if statusCode != http.StatusOK { + if retries < c.maxRetries { + retries++ + backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(2, float64(retries-1))) + time.Sleep(backoffTime) + continue + } + return nil, fmt.Errorf("request failed with status: %d", statusCode) + } + + // Limit response body size to prevent memory issues + bodyBytes, err = io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit + if err != nil { + if retries < c.maxRetries { + retries++ + backoffTime := time.Duration(float64(c.baseBackoff) * math.Pow(2, float64(retries-1))) + time.Sleep(backoffTime) + continue + } + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Success, break the retry loop + return bodyBytes, nil + } + + return nil, fmt.Errorf("exhausted all retries with status code: %d", statusCode) +} + +// GetAnime fetches basic anime information by MAL ID +func (c *JikanClient) GetAnime(malID int) (*types.JikanAnimeResponse, error) { + apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d", malID) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + bodyBytes, err := c.makeRequest(ctx, apiURL) + if err != nil { + return nil, fmt.Errorf("failed to get anime data: %w", err) + } + + var animeResponse types.JikanAnimeResponse + if err := json.Unmarshal(bodyBytes, &animeResponse); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if animeResponse.Data.MALID == 0 { + return nil, fmt.Errorf("no data found for MAL ID %d", malID) + } + + return &animeResponse, nil +} + +// GetFullAnime fetches detailed anime information by MAL ID +func (c *JikanClient) GetFullAnime(malID int) (*types.JikanAnimeResponse, error) { + apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/full", malID) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + bodyBytes, err := c.makeRequest(ctx, apiURL) + if err != nil { + return nil, fmt.Errorf("failed to get anime full data: %w", err) + } + + var animeResponse types.JikanAnimeResponse + if err := json.Unmarshal(bodyBytes, &animeResponse); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if animeResponse.Data.MALID == 0 { + return nil, fmt.Errorf("no data found for MAL ID %d", malID) + } + + return &animeResponse, nil +} + +// GetAnimeEpisodes fetches all episodes for an anime by MAL ID +func (c *JikanClient) GetAnimeEpisodes(malID int) (*types.JikanAnimeEpisodeResponse, error) { + result := types.JikanAnimeEpisodeResponse{ + Data: []types.JikanAnimeEpisode{}, + } + + maxPages := 25 // Safety limit to avoid excessive requests + page := 1 + maxAttempts := 15 // Maximum number of attempts across all pages + totalAttempts := 0 + + for page <= maxPages && totalAttempts < maxAttempts { + apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/episodes?page=%d", malID, page) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + + totalAttempts++ + + bodyBytes, err := c.makeRequest(ctx, apiURL) + cancel() + + if err != nil { + // If we have some episodes already, return them rather than failing + if len(result.Data) > 0 { + result.Pagination.HasNextPage = false + break + } + return nil, fmt.Errorf("failed to get anime episodes page %d: %w", page, err) + } + + var pageResponse types.JikanAnimeEpisodeResponse + if err := json.Unmarshal(bodyBytes, &pageResponse); err != nil { + // Return what we have if we got some pages successfully + if len(result.Data) > 0 { + result.Pagination.HasNextPage = false + break + } + return nil, fmt.Errorf("failed to decode episodes response: %w", err) + } + + // Append episodes from this page + result.Data = append(result.Data, pageResponse.Data...) + result.Pagination = pageResponse.Pagination + + // Check if we need to fetch more pages + if !pageResponse.Pagination.HasNextPage { + break + } + + page++ + } + + return &result, nil +} + +// GetAnimeCharacters fetches all characters for an anime by MAL ID +func (c *JikanClient) GetAnimeCharacters(malID int) (*types.JikanAnimeCharacterResponse, error) { + apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/characters", malID) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + bodyBytes, err := c.makeRequest(ctx, apiURL) + if err != nil { + return nil, fmt.Errorf("failed to get anime characters: %w", err) + } + + var characterResponse types.JikanAnimeCharacterResponse + if err := json.Unmarshal(bodyBytes, &characterResponse); err != nil { + return nil, fmt.Errorf("failed to decode characters response: %w", err) + } + + return &characterResponse, nil +} diff --git a/utils/api/malsync.go b/utils/api/malsync.go new file mode 100644 index 0000000..d323841 --- /dev/null +++ b/utils/api/malsync.go @@ -0,0 +1,99 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "metachan/types" + "net/http" + "time" +) + +const ( + malsyncAPIBaseURL = "https://api.malsync.moe/mal" +) + +// MALSyncClient provides methods for interacting with the MALSync API +type MALSyncClient struct { + client *http.Client + maxRetries int +} + +// NewMALSyncClient creates a new client for the MALSync API +func NewMALSyncClient() *MALSyncClient { + return &MALSyncClient{ + client: &http.Client{ + Timeout: 8 * time.Second, // Shorter timeout since this is a less critical API + }, + maxRetries: 2, + } +} + +// GetAnimeByMALID fetches anime metadata from MALSync by MAL ID +func (c *MALSyncClient) GetAnimeByMALID(malID int) (*types.MALSyncAnimeResponse, error) { + apiURL := fmt.Sprintf("%s/anime/%d", malsyncAPIBaseURL, malID) + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Create request + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + // Execute request with retries + var resp *http.Response + var lastErr error + success := false + + for i := 0; i <= c.maxRetries && !success; i++ { + resp, err = c.client.Do(req) + if err != nil { + lastErr = err + time.Sleep(time.Duration((i+1)*300) * time.Millisecond) // Short backoff on error + continue + } + + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil // Not found is not an error, just return nil + } + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + lastErr = fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + time.Sleep(time.Duration((i+1)*300) * time.Millisecond) + continue + } + + success = true + } + + if !success { + return nil, fmt.Errorf("failed after %d retries: %w", c.maxRetries, lastErr) + } + + // Parse response + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) // 1MB limit + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var malSyncResponse types.MALSyncAnimeResponse + if err := json.Unmarshal(bodyBytes, &malSyncResponse); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + // Simple validation + if malSyncResponse.ID == 0 { + return nil, fmt.Errorf("received empty response for MAL ID %d", malID) + } + + return &malSyncResponse, nil +} diff --git a/utils/anime/stream.go b/utils/api/streaming.go index f5f295c..cd9a54a 100644 --- a/utils/anime/stream.go +++ b/utils/api/streaming.go @@ -1,10 +1,9 @@ -package anime +package api import ( "encoding/json" "fmt" "metachan/types" - "metachan/utils/logger" "net/http" "net/url" "sort" @@ -17,13 +16,13 @@ const ( allanimeBaseURL = "https://api.allanime.day/api" ) -// AllAnimeClient handles communication with the AllAnime API +// AllAnimeClient provides methods for interacting with the AllAnime API type AllAnimeClient struct { client *http.Client headers http.Header } -// StreamingSearchResult represents an anime search result from AllAnime +// StreamingSearchResult represents a search result from AllAnime type StreamingSearchResult struct { ID string `json:"_id"` Name string `json:"name"` @@ -32,7 +31,7 @@ type StreamingSearchResult struct { Similarity float64 `json:"similarity"` } -// NewAllAnimeClient creates a new client for accessing the AllAnime 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"}, @@ -49,31 +48,38 @@ func NewAllAnimeClient() *AllAnimeClient { // calculateSimilarity determines how closely a title matches a query func (c *AllAnimeClient) calculateSimilarity(query, title string) float64 { - query = strings.ToLower(strings.TrimSpace(query)) - title = strings.ToLower(strings.TrimSpace(title)) + queryLower := strings.ToLower(query) + titleLower := strings.ToLower(title) - if query == title { + // Exact match + if queryLower == titleLower { return 1.0 } - if strings.Contains(title, query) { + // Title contains query + if strings.Contains(titleLower, queryLower) { return 0.9 } - matches := 0 - queryRunes := []rune(query) - titleRunes := []rune(title) + // Calculate word match score + queryWords := strings.Fields(queryLower) + titleWords := strings.Fields(titleLower) - for i := 0; i < len(queryRunes); i++ { - for j := 0; j < len(titleRunes); j++ { - if queryRunes[i] == titleRunes[j] { - matches++ + matchCount := 0 + for _, qw := range queryWords { + for _, tw := range titleWords { + if qw == tw || strings.Contains(tw, qw) || strings.Contains(qw, tw) { + matchCount++ break } } } - return float64(matches) / float64(len(query)) + if len(queryWords) == 0 { + return 0 + } + + return float64(matchCount) / float64(len(queryWords)) } // decodeURL decodes an encoded URL from AllAnime @@ -212,8 +218,8 @@ func getServerName(sourceType string) string { } } -// searchAnime searches for anime by title on AllAnime -func (c *AllAnimeClient) searchAnime(query string) ([]StreamingSearchResult, error) { +// SearchAnime searches for anime by title on AllAnime +func (c *AllAnimeClient) SearchAnime(query string) ([]StreamingSearchResult, error) { searchQuery := ` query( $search: SearchInput @@ -274,12 +280,11 @@ func (c *AllAnimeClient) searchAnime(query string) ([]StreamingSearchResult, err } shows := data["data"].(map[string]interface{})["shows"].(map[string]interface{})["edges"].([]interface{}) - results := make([]StreamingSearchResult, 0) + results := make([]StreamingSearchResult, 0, len(shows)) for _, show := range shows { showMap := show.(map[string]interface{}) episodes := showMap["availableEpisodes"].(map[string]interface{}) - result := StreamingSearchResult{ ID: showMap["_id"].(string), Name: showMap["name"].(string), @@ -290,6 +295,7 @@ func (c *AllAnimeClient) searchAnime(query string) ([]StreamingSearchResult, err results = append(results, result) } + // Sort by similarity sort.Slice(results, func(i, j int) bool { return results[i].Similarity > results[j].Similarity }) @@ -297,8 +303,8 @@ func (c *AllAnimeClient) searchAnime(query string) ([]StreamingSearchResult, err return results, nil } -// getEpisodesList gets the list of available episodes for an anime -func (c *AllAnimeClient) getEpisodesList(showID string, mode string) ([]string, error) { +// 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( @@ -364,8 +370,8 @@ func (c *AllAnimeClient) getEpisodesList(showID string, mode string) ([]string, return result, nil } -// getEpisodeLinks gets streaming links for a specific episode -func (c *AllAnimeClient) getEpisodeLinks(showID, episode, mode string) ([]types.AnimeStreamingSource, error) { +// 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( @@ -420,7 +426,7 @@ func (c *AllAnimeClient) getEpisodeLinks(showID, episode, mode string) ([]types. sourceName := sourceMap["sourceName"].(string) sourceInfo := c.processSourceURL(sourceURL, sourceName) - // Only add direct sources, matching the BubbleTea implementation + // Only add direct sources if sourceInfo.Type == "direct" { links = append(links, *sourceInfo) } @@ -431,16 +437,9 @@ func (c *AllAnimeClient) getEpisodeLinks(showID, episode, mode string) ([]types. } // GetStreamingSources fetches both sub and dub streaming sources for an anime episode -func GetStreamingSources(title string, episodeNumber int) (*types.AnimeStreaming, error) { - client := NewAllAnimeClient() - - logger.Log(fmt.Sprintf("Searching for streaming sources for '%s' episode %d", title, episodeNumber), types.LogOptions{ - Level: types.Info, - Prefix: "Streaming", - }) - +func (c *AllAnimeClient) GetStreamingSources(title string, episodeNumber int) (*types.AnimeStreaming, error) { // Search for the anime - searchResults, err := client.searchAnime(title) + searchResults, err := c.SearchAnime(title) if err != nil { return nil, fmt.Errorf("failed to search for anime: %w", err) } @@ -451,26 +450,20 @@ func GetStreamingSources(title string, episodeNumber int) (*types.AnimeStreaming // Use the best match (first result) bestMatch := searchResults[0] - logger.Log(fmt.Sprintf("Found anime '%s' with similarity %.2f", bestMatch.Name, bestMatch.Similarity), types.LogOptions{ - Level: types.Debug, - Prefix: "Streaming", - }) streaming := &types.AnimeStreaming{ - SkipTimes: []types.AnimeSkipTimes{}, - Sub: []types.AnimeStreamingSource{}, - Dub: []types.AnimeStreamingSource{}, + Sub: []types.AnimeStreamingSource{}, + Dub: []types.AnimeStreamingSource{}, } - // Convert episode number to string - episodeStr := strconv.Itoa(episodeNumber) - // Get sub episodes if available if bestMatch.SubEpisodes > 0 { - episodes, err := client.getEpisodesList(bestMatch.ID, "sub") + 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 @@ -479,13 +472,9 @@ func GetStreamingSources(title string, episodeNumber int) (*types.AnimeStreaming } if closestEpisode != "" { - subSources, err := client.getEpisodeLinks(bestMatch.ID, closestEpisode, "sub") + subSources, err := c.GetEpisodeLinks(bestMatch.ID, closestEpisode, "sub") if err == nil { streaming.Sub = subSources - logger.Log(fmt.Sprintf("Found %d sub streaming sources for episode %s", len(subSources), closestEpisode), types.LogOptions{ - Level: types.Debug, - Prefix: "Streaming", - }) } } } @@ -493,10 +482,12 @@ func GetStreamingSources(title string, episodeNumber int) (*types.AnimeStreaming // Get dub episodes if available if bestMatch.DubEpisodes > 0 { - episodes, err := client.getEpisodesList(bestMatch.ID, "dub") + 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 @@ -505,28 +496,31 @@ func GetStreamingSources(title string, episodeNumber int) (*types.AnimeStreaming } if closestEpisode != "" { - dubSources, err := client.getEpisodeLinks(bestMatch.ID, closestEpisode, "dub") + dubSources, err := c.GetEpisodeLinks(bestMatch.ID, closestEpisode, "dub") if err == nil { streaming.Dub = dubSources - logger.Log(fmt.Sprintf("Found %d dub streaming sources for episode %s", len(dubSources), closestEpisode), types.LogOptions{ - Level: types.Debug, - Prefix: "Streaming", - }) } } } } - // Check if we found any sources - if len(streaming.Sub) == 0 && len(streaming.Dub) == 0 { - return nil, fmt.Errorf("no streaming sources found for '%s' episode %d", title, episodeNumber) + 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) } - logger.Log(fmt.Sprintf("Successfully retrieved streaming sources for '%s' episode %d: %d sub, %d dub", - title, episodeNumber, len(streaming.Sub), len(streaming.Dub)), types.LogOptions{ - Level: types.Success, - Prefix: "Streaming", - }) + // Use the best match (first result) + bestMatch := searchResults[0] - return streaming, nil + return bestMatch.SubEpisodes, bestMatch.DubEpisodes, nil } diff --git a/utils/anime/tmdb.go b/utils/api/tmdb.go index 99696b7..5af7599 100644 --- a/utils/anime/tmdb.go +++ b/utils/api/tmdb.go @@ -1,4 +1,4 @@ -package anime +package api import ( "encoding/json" @@ -9,7 +9,6 @@ import ( "metachan/types" "metachan/utils/logger" "net/http" - "strconv" "strings" "time" ) @@ -236,7 +235,7 @@ func getTVShowDetails(showID int) (*types.TMDBShowDetails, error) { req.Header.Add("Accept", "application/json") // Use our retry mechanism (3 retries) - resp, err := makeRequestWithRetries(req, 3) + resp, err := makeRequestWithRetries(req, 10) if err != nil { return nil, fmt.Errorf("failed to get TV show details: %w", err) } @@ -497,195 +496,3 @@ func AttachEpisodeDescriptions(title string, episodes []types.AnimeSingleEpisode return enrichedEpisodes } - -func generateEpisodeData(episodes []types.JikanAnimeEpisode) ([]types.AnimeSingleEpisode, error) { - var AnimeEpisodes []types.AnimeSingleEpisode - - for _, episode := range episodes { - AnimeEpisodes = append(AnimeEpisodes, types.AnimeSingleEpisode{ - Titles: types.EpisodeTitles{ - English: episode.Title, - Japanese: episode.TitleJapanese, - Romaji: episode.TitleRomaji, - }, - Aired: episode.Aired, - Score: episode.Score, - Filler: episode.Filler, - Recap: episode.Recap, - ForumURL: episode.ForumURL, - URL: episode.URL, - Description: "No description available", - ThumbnailURL: "", - Stream: types.AnimeStreaming{ - SkipTimes: []types.AnimeSkipTimes{}, - }, - }) - } - return AnimeEpisodes, nil -} - -func generateEpisodeDataWithDescriptions(episodes []types.JikanAnimeEpisode, title string, alternativeTitle string, tmdbID int) ([]types.AnimeSingleEpisode, error) { - basicEpisodes, err := generateEpisodeData(episodes) - if err != nil { - return nil, fmt.Errorf("failed to generate basic episode data: %w", err) - } - - enrichedEpisodes := AttachEpisodeDescriptions(title, basicEpisodes, alternativeTitle, tmdbID) - - // Get the MAL ID from the first episode URL if available - var malID int - if len(episodes) > 0 && episodes[0].URL != "" { - // Extract MAL ID from URL like "https://myanimelist.net/anime/1735/Naruto__Shippuuden/episode/1" - parts := strings.Split(episodes[0].URL, "/") - for i, part := range parts { - if part == "anime" && i+1 < len(parts) { - // Try to parse the next part as an integer - id, err := strconv.Atoi(parts[i+1]) - if err == nil { - malID = id - break - } - } - } - } - - if malID > 0 { - logger.Log(fmt.Sprintf("Fetching skip times for anime with MAL ID %d", malID), types.LogOptions{ - Level: types.Info, - Prefix: "AniSkip", - }) - - // Process each episode to add skip times - for i := range enrichedEpisodes { - // Episode numbers in the API are 1-indexed - episodeNumber := i + 1 - skipTimes, err := getAnimeEpisodeSkipTimes(malID, episodeNumber) - - if err != nil { - logger.Log(fmt.Sprintf("Failed to get skip times for episode %d: %v", episodeNumber, err), types.LogOptions{ - Level: types.Debug, - Prefix: "AniSkip", - }) - continue - } - - if len(skipTimes) > 0 { - enrichedEpisodes[i].Stream.SkipTimes = skipTimes - logger.Log(fmt.Sprintf("Added %d skip times for episode %d", len(skipTimes), episodeNumber), types.LogOptions{ - Level: types.Debug, - Prefix: "AniSkip", - }) - } - } - - // Count how many episodes have skip times - skipTimeCount := 0 - for _, ep := range enrichedEpisodes { - if len(ep.Stream.SkipTimes) > 0 { - skipTimeCount++ - } - } - - if skipTimeCount > 0 { - logger.Log(fmt.Sprintf("Successfully added skip times to %d/%d episodes for: %s", - skipTimeCount, len(enrichedEpisodes), title), types.LogOptions{ - Level: types.Success, - Prefix: "AniSkip", - }) - } else { - logger.Log(fmt.Sprintf("No skip times found for any episodes of: %s", title), types.LogOptions{ - Level: types.Warn, - Prefix: "AniSkip", - }) - } - } else { - logger.Log(fmt.Sprintf("Could not determine MAL ID for skip times for: %s", title), types.LogOptions{ - Level: types.Warn, - Prefix: "AniSkip", - }) - } - - // Add streaming sources to episodes - logger.Log(fmt.Sprintf("Fetching streaming sources for anime: %s", title), types.LogOptions{ - Level: types.Info, - Prefix: "Streaming", - }) - - // Prioritize original titles for searching - first romaji, then Japanese, then English - // This better matches how anime streaming sites catalog their content - searchTitle := title // Default to romaji title - - // Process all episodes to add streaming sources - for i := range enrichedEpisodes { - episodeNumber := i + 1 - - streaming, err := GetStreamingSources(searchTitle, episodeNumber) - if err != nil { - // If search fails with romaji title, try with Japanese title if available - if enrichedEpisodes[i].Titles.Japanese != "" { - logger.Log(fmt.Sprintf("Retrying search with Japanese title for episode %d", episodeNumber), types.LogOptions{ - Level: types.Debug, - Prefix: "Streaming", - }) - streaming, err = GetStreamingSources(enrichedEpisodes[i].Titles.Japanese, episodeNumber) - } - - // If both fail and English title is available, try with that - if err != nil && alternativeTitle != "" { - logger.Log(fmt.Sprintf("Retrying search with English title for episode %d", episodeNumber), types.LogOptions{ - Level: types.Debug, - Prefix: "Streaming", - }) - - englishTitle := strings.TrimPrefix(alternativeTitle, "English: ") - - streaming, err = GetStreamingSources(englishTitle, episodeNumber) - } - - if err != nil { - logger.Log(fmt.Sprintf("Failed to get streaming sources for episode %d: %v", episodeNumber, err), types.LogOptions{ - Level: types.Debug, - Prefix: "Streaming", - }) - continue - } - } - - // Keep the skip times which were already added - streaming.SkipTimes = enrichedEpisodes[i].Stream.SkipTimes - - // Update the streaming sources - enrichedEpisodes[i].Stream = *streaming - - // Add a small delay to avoid rate limiting - time.Sleep(200 * time.Millisecond) - } - - // Count how many episodes have streaming sources - streamingSubCount := 0 - streamingDubCount := 0 - - for _, ep := range enrichedEpisodes { - if len(ep.Stream.Sub) > 0 { - streamingSubCount++ - } - if len(ep.Stream.Dub) > 0 { - streamingDubCount++ - } - } - - if streamingSubCount > 0 || streamingDubCount > 0 { - logger.Log(fmt.Sprintf("Successfully added streaming sources to episodes for: %s (SUB: %d/%d, DUB: %d/%d)", - title, streamingSubCount, len(enrichedEpisodes), streamingDubCount, len(enrichedEpisodes)), types.LogOptions{ - Level: types.Success, - Prefix: "Streaming", - }) - } else { - logger.Log(fmt.Sprintf("No streaming sources found for any episodes of: %s", title), types.LogOptions{ - Level: types.Warn, - Prefix: "Streaming", - }) - } - - return enrichedEpisodes, nil -} diff --git a/utils/api/tvdb.go b/utils/api/tvdb.go new file mode 100644 index 0000000..cee3de2 --- /dev/null +++ b/utils/api/tvdb.go @@ -0,0 +1,37 @@ +package api + +import ( + "fmt" + "metachan/database" + "metachan/entities" + "metachan/types" + "metachan/utils/logger" +) + +// FindSeasonMappings finds all anime mappings that belong to the same series based on TVDB ID +func FindSeasonMappings(tvdbID int) ([]entities.AnimeMapping, error) { + logger.Log(fmt.Sprintf("Finding season mappings for TVDB ID %d", tvdbID), types.LogOptions{ + Level: types.Debug, + Prefix: "TVDB", + }) + + // Use our database function to find all mappings with the same TVDB ID + mappings, err := database.GetAnimeMappingsByTVDBID(tvdbID) + if err != nil { + return nil, fmt.Errorf("failed to get season mappings: %w", err) + } + + if len(mappings) == 0 { + logger.Log(fmt.Sprintf("No season mappings found for TVDB ID %d", tvdbID), types.LogOptions{ + Level: types.Debug, + Prefix: "TVDB", + }) + } else { + logger.Log(fmt.Sprintf("Found %d season mappings for TVDB ID %d", len(mappings), tvdbID), types.LogOptions{ + Level: types.Info, + Prefix: "TVDB", + }) + } + + return mappings, nil +} diff --git a/utils/concurrency/fetch.go b/utils/concurrency/fetch.go new file mode 100644 index 0000000..f2e827f --- /dev/null +++ b/utils/concurrency/fetch.go @@ -0,0 +1,94 @@ +package concurrency + +import ( + "sync" +) + +// ParallelResult represents a result or error from a parallel computation +type ParallelResult[T any] struct { + Value T + Error error +} + +// Parallel executes multiple functions concurrently and returns their results +// This is a powerful utility that allows us to fetch data from multiple APIs in parallel +func Parallel[T any](funcs ...func() (T, error)) []ParallelResult[T] { + results := make([]ParallelResult[T], len(funcs)) + var wg sync.WaitGroup + + for i, f := range funcs { + wg.Add(1) + go func(index int, function func() (T, error)) { + defer wg.Done() + value, err := function() + results[index] = ParallelResult[T]{ + Value: value, + Error: err, + } + }(i, f) + } + + wg.Wait() + return results +} + +// ParallelMapResult represents a result from a map operation that may contain an error +type ParallelMapResult[T any] struct { + Value T + Error error +} + +// ParallelMap applies a function to each item in a slice concurrently +// This is useful for operations like fetching skip times for multiple episodes at once +func ParallelMap[T any, R any](items []T, f func(T) (R, error)) []ParallelMapResult[R] { + results := make([]ParallelMapResult[R], len(items)) + var wg sync.WaitGroup + + for i, item := range items { + wg.Add(1) + go func(index int, element T) { + defer wg.Done() + value, err := f(element) + results[index] = ParallelMapResult[R]{ + Value: value, + Error: err, + } + }(i, item) + } + + wg.Wait() + return results +} + +// ParallelMapWithLimit applies a function to each item in a slice concurrently +// with a maximum number of concurrent operations +// This is crucial for rate-limited APIs like AniSkip +func ParallelMapWithLimit[T any, R any](items []T, limit int, f func(T) (R, error)) []ParallelMapResult[R] { + results := make([]ParallelMapResult[R], len(items)) + var wg sync.WaitGroup + + // Create a semaphore channel with the specified limit + semaphore := make(chan struct{}, limit) + + for i, item := range items { + wg.Add(1) + go func(index int, element T) { + // Acquire semaphore + semaphore <- struct{}{} + defer func() { + // Release semaphore when done + <-semaphore + wg.Done() + }() + + value, err := f(element) + results[index] = ParallelMapResult[R]{ + Value: value, + Error: err, + } + }(i, item) + } + + wg.Wait() + return results +} diff --git a/utils/ratelimit/limiter.go b/utils/ratelimit/limiter.go new file mode 100644 index 0000000..5ef1792 --- /dev/null +++ b/utils/ratelimit/limiter.go @@ -0,0 +1,107 @@ +package ratelimit + +import ( + "sync" + "time" +) + +// RateLimiter manages request rate limiting for APIs +type RateLimiter struct { + mu sync.Mutex + lastRequests []time.Time + maxRequests int // Maximum requests per time window + window time.Duration // Time window duration +} + +// NewRateLimiter creates a new rate limiter +// maxRequests is the maximum number of requests allowed in the specified time window +func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter { + return &RateLimiter{ + lastRequests: make([]time.Time, 0, maxRequests), + maxRequests: maxRequests, + window: window, + } +} + +// Wait blocks until a request can be made according to rate limiting rules +func (r *RateLimiter) Wait() { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now() + + // Clean up old requests + cutoff := now.Add(-r.window) + i := 0 + for i < len(r.lastRequests) && r.lastRequests[i].Before(cutoff) { + i++ + } + if i > 0 { + r.lastRequests = r.lastRequests[i:] + } + + // If we've reached max requests in the window, wait until we can make another + if len(r.lastRequests) >= r.maxRequests { + // Calculate wait time based on the oldest request in the window + oldestInWindow := r.lastRequests[0] + waitDuration := r.window - now.Sub(oldestInWindow) + + // Release lock while waiting + r.mu.Unlock() + time.Sleep(waitDuration + time.Millisecond) // Add 1ms to be safe + r.mu.Lock() // Re-acquire lock + + // Refresh current time and clean up again after waiting + now = time.Now() + cutoff = now.Add(-r.window) + i = 0 + for i < len(r.lastRequests) && r.lastRequests[i].Before(cutoff) { + i++ + } + if i > 0 { + r.lastRequests = r.lastRequests[i:] + } + } + + // Add current request timestamp + r.lastRequests = append(r.lastRequests, now) +} + +// RemainingRequests returns the number of requests that can still be made in the current window +func (r *RateLimiter) RemainingRequests() int { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now() + cutoff := now.Add(-r.window) + + // Clean up old requests + i := 0 + for i < len(r.lastRequests) && r.lastRequests[i].Before(cutoff) { + i++ + } + if i > 0 { + r.lastRequests = r.lastRequests[i:] + } + + return r.maxRequests - len(r.lastRequests) +} + +// MultiLimiter combines multiple rate limiters +type MultiLimiter struct { + limiters []*RateLimiter +} + +// NewMultiLimiter creates a new multi-limiter from the given limiters +func NewMultiLimiter(limiters ...*RateLimiter) *MultiLimiter { + return &MultiLimiter{ + limiters: limiters, + } +} + +// Wait waits for all underlying limiters +func (m *MultiLimiter) Wait() { + for _, limiter := range m.limiters { + limiter.Wait() + } +} |
