diff options
| -rw-r--r-- | types/anime.go | 140 | ||||
| -rw-r--r-- | utils/anime/anime.go | 35 | ||||
| -rw-r--r-- | utils/anime/aniskip.go | 68 | ||||
| -rw-r--r-- | utils/anime/jikan.go | 93 | ||||
| -rw-r--r-- | utils/anime/stream.go | 532 | ||||
| -rw-r--r-- | utils/anime/tmdb.go | 160 |
6 files changed, 994 insertions, 34 deletions
diff --git a/types/anime.go b/types/anime.go index 2a54131..fdb2dd9 100644 --- a/types/anime.go +++ b/types/anime.go @@ -28,16 +28,36 @@ type EpisodeTitles struct { Romaji string `json:"romaji"` } +type AnimeSkipTimes struct { + SkipType string `json:"skip_type"` + StartTime float64 `json:"start_time"` + EndTime float64 `json:"end_time"` + EpisodeLength float64 `json:"episode_length"` +} + +type AnimeStreamingSource struct { + URL string `json:"url"` + Server string `json:"server"` + Type string `json:"type"` // MP4 or M3U8 +} + +type AnimeStreaming struct { + SkipTimes []AnimeSkipTimes `json:"skip_times"` + Sub []AnimeStreamingSource `json:"sub"` + Dub []AnimeStreamingSource `json:"dub"` +} + type AnimeSingleEpisode struct { - Titles EpisodeTitles `json:"titles"` - Description string `json:"description"` - Aired string `json:"aired"` - Score float64 `json:"score"` - Filler bool `json:"filler"` - Recap bool `json:"recap"` - ForumURL string `json:"forum_url"` - URL string `json:"url"` - ThumbnailURL string `json:"thumbnail_url"` + Titles EpisodeTitles `json:"titles"` + Description string `json:"description"` + Aired string `json:"aired"` + Score float64 `json:"score"` + Filler bool `json:"filler"` + Recap bool `json:"recap"` + ForumURL string `json:"forum_url"` + URL string `json:"url"` + ThumbnailURL string `json:"thumbnail_url"` + Stream AnimeStreaming `json:"stream"` } type AnimeEpisodes struct { @@ -130,31 +150,49 @@ type AnimeSeason struct { Current bool `json:"current"` } +type AnimeVoiceActor struct { + MALID int `json:"mal_id"` + URL string `json:"url"` + Image string `json:"image_url"` + Name string `json:"name"` + Language string `json:"language"` +} + +type AnimeCharacter struct { + MALID int `json:"mal_id"` + URL string `json:"url"` + ImageURL string `json:"image_url"` + Name string `json:"name"` + Role string `json:"role"` + VoiceActors []AnimeVoiceActor `json:"voice_actors"` +} + type Anime struct { - MALID int `json:"id"` - Titles AnimeTitles `json:"titles"` - Synopsis string `json:"synopsis"` - Type AniSyncType `json:"type"` - Source string `json:"source"` - Airing bool `json:"airing"` - Status string `json:"status"` - AiringStatus AiringStatus `json:"airing_status"` - Duration string `json:"duration"` - Images AnimeImages `json:"images"` - Logos AnimeLogos `json:"logos"` - Covers AnimeImages `json:"covers"` - Color string `json:"color"` - Genres []AnimeGenres `json:"genres"` - Scores AnimeScores `json:"scores"` - Season string `json:"season"` - Year int `json:"year"` - Broadcast AnimeBroadcast `json:"broadcast"` - Producers []AnimeProducer `json:"producers"` - Studios []AnimeStudio `json:"studios"` - Licensors []AnimeLicensor `json:"licensors"` - Seasons []AnimeSeason `json:"seasons"` - Episodes AnimeEpisodes `json:"episodes"` - Mappings AnimeMappings `json:"mappings"` + MALID int `json:"id"` + Titles AnimeTitles `json:"titles"` + Synopsis string `json:"synopsis"` + Type AniSyncType `json:"type"` + Source string `json:"source"` + Airing bool `json:"airing"` + Status string `json:"status"` + AiringStatus AiringStatus `json:"airing_status"` + Duration string `json:"duration"` + Images AnimeImages `json:"images"` + Logos AnimeLogos `json:"logos"` + Covers AnimeImages `json:"covers"` + Color string `json:"color"` + Genres []AnimeGenres `json:"genres"` + Scores AnimeScores `json:"scores"` + Season string `json:"season"` + Year int `json:"year"` + Broadcast AnimeBroadcast `json:"broadcast"` + Producers []AnimeProducer `json:"producers"` + Studios []AnimeStudio `json:"studios"` + Licensors []AnimeLicensor `json:"licensors"` + Seasons []AnimeSeason `json:"seasons"` + Episodes AnimeEpisodes `json:"episodes"` + Characters []AnimeCharacter `json:"characters"` + Mappings AnimeMappings `json:"mappings"` } type JikanPagination struct { @@ -290,6 +328,42 @@ type JikanAnimeEpisodeResponse struct { Data []JikanAnimeEpisode `json:"data"` } +type JikanAnimeCharacterResponse struct { + Data []struct { + Character struct { + MALID int `json:"mal_id"` + URL string `json:"url"` + Images struct { + JPG struct { + ImageURL string `json:"image_url"` + SmallImageURL string `json:"small_image_url"` + } `json:"jpg"` + WebP struct { + ImageURL string `json:"image_url"` + SmallImageURL string `json:"small_image_url"` + } `json:"webp"` + } `json:"images"` + Name string `json:"name"` + } `json:"character"` + Role string `json:"role"` + VoiceActors []struct { + Person struct { + MALID int `json:"mal_id"` + URL string `json:"url"` + Images struct { + JPG struct { + ImageURL string `json:"image_url"` + } `json:"jpg"` + WebP struct { + ImageURL string `json:"image_url"` + } `json:"webp"` + } `json:"images"` + Name string `json:"name"` + } `json:"person"` + Language string `json:"language"` + } `json:"voice_actors"` + } `json:"data"` +} type AnilistAnimeResponse struct { Data struct { Media struct { diff --git a/utils/anime/anime.go b/utils/anime/anime.go index ab10323..997ff39 100644 --- a/utils/anime/anime.go +++ b/utils/anime/anime.go @@ -27,7 +27,7 @@ func GetAnimeDetails(animeMapping *entities.AnimeMapping) (*types.Anime, error) return nil, fmt.Errorf("failed to get anime episodes: %w", err) } - episodeData, err := generateEpisodeDataWithDescriptions( + episodeData, _ := generateEpisodeDataWithDescriptions( episodes.Data, anime.Data.Title, anime.Data.TitleEnglish, @@ -68,6 +68,13 @@ func GetAnimeDetails(animeMapping *entities.AnimeMapping) (*types.Anime, error) } } + characterResponse, err := getAnimeCharactersViaJikan(malID) + if err != nil { + return nil, fmt.Errorf("failed to get anime characters: %w", err) + } + + characters := getAnimeCharacters(characterResponse) + animeDetails := &types.Anime{ MALID: malID, Titles: types.AnimeTitles{ @@ -135,6 +142,7 @@ func GetAnimeDetails(animeMapping *entities.AnimeMapping) (*types.Anime, error) Aired: len(episodes.Data), Episodes: episodeData, }, + Characters: characters, Mappings: types.AnimeMappings{ AniDB: animeMapping.AniDB, Anilist: animeMapping.Anilist, @@ -161,6 +169,31 @@ func getEpisodeCount(malAnime *types.JikanAnimeResponse, anilistAnime *types.Ani 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 diff --git a/utils/anime/aniskip.go b/utils/anime/aniskip.go new file mode 100644 index 0000000..7a4ad22 --- /dev/null +++ b/utils/anime/aniskip.go @@ -0,0 +1,68 @@ +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/jikan.go b/utils/anime/jikan.go index c30a871..22e9810 100644 --- a/utils/anime/jikan.go +++ b/utils/anime/jikan.go @@ -627,3 +627,96 @@ func getAnimeEpisodesViaJikan(malId int) (*types.JikanAnimeEpisodeResponse, erro 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/stream.go b/utils/anime/stream.go new file mode 100644 index 0000000..f5f295c --- /dev/null +++ b/utils/anime/stream.go @@ -0,0 +1,532 @@ +package anime + +import ( + "encoding/json" + "fmt" + "metachan/types" + "metachan/utils/logger" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +const ( + allanimeBaseURL = "https://api.allanime.day/api" +) + +// AllAnimeClient handles communication with the AllAnime API +type AllAnimeClient struct { + client *http.Client + headers http.Header +} + +// StreamingSearchResult represents an anime search result from AllAnime +type StreamingSearchResult struct { + ID string `json:"_id"` + Name string `json:"name"` + SubEpisodes int `json:"sub_episodes"` + DubEpisodes int `json:"dub_episodes"` + Similarity float64 `json:"similarity"` +} + +// NewAllAnimeClient creates a new client for accessing the AllAnime API +func NewAllAnimeClient() *AllAnimeClient { + headers := http.Header{ + "User-Agent": {"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0"}, + "Referer": {"https://allmanga.to"}, + } + + return &AllAnimeClient{ + client: &http.Client{ + Timeout: 10 * time.Second, + }, + headers: headers, + } +} + +// calculateSimilarity determines how closely a title matches a query +func (c *AllAnimeClient) calculateSimilarity(query, title string) float64 { + query = strings.ToLower(strings.TrimSpace(query)) + title = strings.ToLower(strings.TrimSpace(title)) + + if query == title { + return 1.0 + } + + if strings.Contains(title, query) { + return 0.9 + } + + matches := 0 + queryRunes := []rune(query) + titleRunes := []rune(title) + + for i := 0; i < len(queryRunes); i++ { + for j := 0; j < len(titleRunes); j++ { + if queryRunes[i] == titleRunes[j] { + matches++ + break + } + } + } + + return float64(matches) / float64(len(query)) +} + +// decodeURL decodes an encoded URL from AllAnime +func (c *AllAnimeClient) decodeURL(encodedString string) string { + if !strings.HasPrefix(encodedString, "--") { + return encodedString + } + + encodedString = encodedString[2:] + decodeMap := map[string]string{ + "01": "9", "08": "0", "05": "=", "0a": "2", + "0b": "3", "0c": "4", "07": "?", "00": "8", + "5c": "d", "0f": "7", "5e": "f", "17": "/", + "54": "l", "09": "1", "48": "p", "4f": "w", + "0e": "6", "5b": "c", "5d": "e", "0d": "5", + "53": "k", "1e": "&", "5a": "b", "59": "a", + "4a": "r", "4c": "t", "4e": "v", "57": "o", + "51": "i", + } + + var decoded strings.Builder + for i := 0; i < len(encodedString); i += 2 { + if i+2 <= len(encodedString) { + pair := encodedString[i : i+2] + if val, ok := decodeMap[pair]; ok { + decoded.WriteString(val) + } + } + } + + return decoded.String() +} + +// processProviderURL processes provider URLs from AllAnime +func (c *AllAnimeClient) processProviderURL(urlStr string) string { + baseURL := "https://allanime.day" + + if strings.HasPrefix(urlStr, "/") { + urlStr = strings.Replace(urlStr, "/apivtwo/clock", "/apivtwo/clock.json", 1) + return baseURL + urlStr + } + + return urlStr +} + +// getClockLink fetches a direct streaming link from a clock endpoint +func (c *AllAnimeClient) getClockLink(urlStr string) (string, error) { + if strings.HasPrefix(urlStr, "/") { + urlStr = "https://allanime.day" + urlStr + } + + req, err := http.NewRequest("GET", urlStr, nil) + if err != nil { + return "", err + } + + for key, values := range c.headers { + req.Header[key] = values + } + + resp, err := c.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var data map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", err + } + + if links, ok := data["links"].([]interface{}); ok && len(links) > 0 { + if link, ok := links[0].(map[string]interface{}); ok { + if linkStr, ok := link["link"].(string); ok { + return linkStr, nil + } + } + } + + return "", fmt.Errorf("no valid link found") +} + +// processSourceURL processes a streaming source URL from AllAnime +func (c *AllAnimeClient) processSourceURL(sourceURL, sourceType string) *types.AnimeStreamingSource { + var decodedURL string + if strings.HasPrefix(sourceURL, "--") { + decodedURL = c.decodeURL(sourceURL) + } else { + decodedURL = strings.ReplaceAll(sourceURL, "\\u002F", "/") + } + + processedURL := c.processProviderURL(decodedURL) + + // Check if it's a clock link + if strings.Contains(processedURL, "/apivtwo/clock") { + if directURL, err := c.getClockLink(processedURL); err == nil { + return &types.AnimeStreamingSource{ + URL: directURL, + Server: getServerName(sourceType), + Type: "direct", + } + } + } + + // Check if it's a direct stream link + directPatterns := []string{"fast4speed.rsvp", "sharepoint.com", ".m3u8", ".mp4"} + for _, pattern := range directPatterns { + if strings.Contains(processedURL, pattern) { + return &types.AnimeStreamingSource{ + URL: processedURL, + Server: getServerName(sourceType), + Type: "direct", + } + } + } + + // Return as regular source if not direct + return &types.AnimeStreamingSource{ + URL: processedURL, + Server: getServerName(sourceType), + Type: "embed", + } +} + +// getServerName maps AllAnime source types to readable server names +func getServerName(sourceType string) string { + switch strings.ToLower(sourceType) { + case "default": + return "Maria" + case "luf-mp4": + return "Rose" + case "s-mp4": + return "Sina" + default: + return sourceType + } +} + +// searchAnime searches for anime by title on AllAnime +func (c *AllAnimeClient) searchAnime(query string) ([]StreamingSearchResult, error) { + searchQuery := ` + query( + $search: SearchInput + $limit: Int + $page: Int + $countryOrigin: VaildCountryOriginEnumType + ) { + shows( + search: $search + limit: $limit + page: $page + countryOrigin: $countryOrigin + ) { + edges { + _id + name + availableEpisodes + __typename + } + } + } + ` + + variables := map[string]interface{}{ + "search": map[string]interface{}{ + "allowAdult": false, + "allowUnknown": false, + "query": query, + }, + "limit": 40, + "page": 1, + "countryOrigin": "ALL", + } + + params := url.Values{} + variablesJSON, _ := json.Marshal(variables) + params.Set("variables", string(variablesJSON)) + params.Set("query", searchQuery) + + req, err := http.NewRequest("GET", allanimeBaseURL+"?"+params.Encode(), nil) + if err != nil { + return nil, err + } + + for key, values := range c.headers { + req.Header[key] = values + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var data map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + shows := data["data"].(map[string]interface{})["shows"].(map[string]interface{})["edges"].([]interface{}) + results := make([]StreamingSearchResult, 0) + + for _, show := range shows { + showMap := show.(map[string]interface{}) + episodes := showMap["availableEpisodes"].(map[string]interface{}) + + result := StreamingSearchResult{ + ID: showMap["_id"].(string), + Name: showMap["name"].(string), + SubEpisodes: int(episodes["sub"].(float64)), + DubEpisodes: int(episodes["dub"].(float64)), + } + result.Similarity = c.calculateSimilarity(query, result.Name) + results = append(results, result) + } + + sort.Slice(results, func(i, j int) bool { + return results[i].Similarity > results[j].Similarity + }) + + return results, nil +} + +// getEpisodesList gets the list of available episodes for an anime +func (c *AllAnimeClient) getEpisodesList(showID string, mode string) ([]string, error) { + episodesQuery := ` + query ($showId: String!) { + show( + _id: $showId + ) { + _id + availableEpisodesDetail + } + } + ` + + variables := map[string]interface{}{ + "showId": showID, + } + + params := url.Values{} + variablesJSON, _ := json.Marshal(variables) + params.Set("variables", string(variablesJSON)) + params.Set("query", episodesQuery) + + req, err := http.NewRequest("GET", allanimeBaseURL+"?"+params.Encode(), nil) + if err != nil { + return nil, err + } + + for key, values := range c.headers { + req.Header[key] = values + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var data map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + showData := data["data"].(map[string]interface{})["show"].(map[string]interface{}) + episodesDetail := showData["availableEpisodesDetail"].(map[string]interface{}) + episodesList := episodesDetail[mode].([]interface{}) + + result := make([]string, 0, len(episodesList)) + for _, ep := range episodesList { + switch v := ep.(type) { + case float64: + result = append(result, fmt.Sprintf("%.0f", v)) + case string: + result = append(result, v) + default: + result = append(result, fmt.Sprintf("%v", v)) + } + } + + sort.Slice(result, func(i, j int) bool { + ni, _ := strconv.Atoi(result[i]) + nj, _ := strconv.Atoi(result[j]) + return ni < nj + }) + + return result, nil +} + +// getEpisodeLinks gets streaming links for a specific episode +func (c *AllAnimeClient) getEpisodeLinks(showID, episode, mode string) ([]types.AnimeStreamingSource, error) { + episodeQuery := ` + query ($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) { + episode( + showId: $showId + translationType: $translationType + episodeString: $episodeString + ) { + episodeString + sourceUrls + } + } + ` + + variables := map[string]interface{}{ + "showId": showID, + "translationType": mode, + "episodeString": episode, + } + + params := url.Values{} + variablesJSON, _ := json.Marshal(variables) + params.Set("variables", string(variablesJSON)) + params.Set("query", episodeQuery) + + req, err := http.NewRequest("GET", allanimeBaseURL+"?"+params.Encode(), nil) + if err != nil { + return nil, err + } + + for key, values := range c.headers { + req.Header[key] = values + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var data map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + episodeData := data["data"].(map[string]interface{})["episode"].(map[string]interface{}) + sourceUrls := episodeData["sourceUrls"].([]interface{}) + + var links []types.AnimeStreamingSource + for _, source := range sourceUrls { + sourceMap := source.(map[string]interface{}) + if sourceURL, ok := sourceMap["sourceUrl"].(string); ok { + sourceName := sourceMap["sourceName"].(string) + sourceInfo := c.processSourceURL(sourceURL, sourceName) + + // Only add direct sources, matching the BubbleTea implementation + if sourceInfo.Type == "direct" { + links = append(links, *sourceInfo) + } + } + } + + return links, nil +} + +// 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", + }) + + // Search for the anime + searchResults, err := client.searchAnime(title) + if err != nil { + return nil, fmt.Errorf("failed to search for anime: %w", err) + } + + if len(searchResults) == 0 { + return nil, fmt.Errorf("no streaming sources found for '%s'", title) + } + + // Use the best match (first result) + bestMatch := searchResults[0] + 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{}, + } + + // 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") + if err == nil && len(episodes) > 0 { + // Find the closest episode + var closestEpisode string + for _, ep := range episodes { + if ep == episodeStr { + closestEpisode = ep + break + } + } + + if closestEpisode != "" { + subSources, err := client.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", + }) + } + } + } + } + + // Get dub episodes if available + if bestMatch.DubEpisodes > 0 { + episodes, err := client.getEpisodesList(bestMatch.ID, "dub") + if err == nil && len(episodes) > 0 { + // Find the closest episode + var closestEpisode string + for _, ep := range episodes { + if ep == episodeStr { + closestEpisode = ep + break + } + } + + if closestEpisode != "" { + dubSources, err := client.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) + } + + 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", + }) + + return streaming, nil +} diff --git a/utils/anime/tmdb.go b/utils/anime/tmdb.go index ae61326..99696b7 100644 --- a/utils/anime/tmdb.go +++ b/utils/anime/tmdb.go @@ -9,6 +9,7 @@ import ( "metachan/types" "metachan/utils/logger" "net/http" + "strconv" "strings" "time" ) @@ -515,6 +516,9 @@ func generateEpisodeData(episodes []types.JikanAnimeEpisode) ([]types.AnimeSingl URL: episode.URL, Description: "No description available", ThumbnailURL: "", + Stream: types.AnimeStreaming{ + SkipTimes: []types.AnimeSkipTimes{}, + }, }) } return AnimeEpisodes, nil @@ -527,5 +531,161 @@ func generateEpisodeDataWithDescriptions(episodes []types.JikanAnimeEpisode, tit } 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 } |
