aboutsummaryrefslogtreecommitdiff
path: root/utils
diff options
context:
space:
mode:
authorBobby <[email protected]>2025-05-09 00:54:39 +0530
committerBobby <[email protected]>2025-05-09 00:54:39 +0530
commit5e28d86fb10270b0e1680924d6ac6617f780d814 (patch)
treede80fadd7a1f08df8658acfd23975e8ad14d2791 /utils
parentf656ab2350f5bbaa8465278b0842a0c052858a86 (diff)
downloadmetachan-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.go261
-rw-r--r--utils/anime/anime.go290
-rw-r--r--utils/anime/aniskip.go68
-rw-r--r--utils/anime/crunchyroll.go201
-rw-r--r--utils/anime/jikan.go722
-rw-r--r--utils/anime/malsync.go98
-rw-r--r--utils/anime/tvdb.go163
-rw-r--r--utils/api/anilist.go165
-rw-r--r--utils/api/aniskip.go415
-rw-r--r--utils/api/jikan.go244
-rw-r--r--utils/api/malsync.go99
-rw-r--r--utils/api/streaming.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.go37
-rw-r--r--utils/concurrency/fetch.go94
-rw-r--r--utils/ratelimit/limiter.go107
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()
+ }
+}