aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2025-05-07 00:46:21 +0530
committerBobby <[email protected]>2025-05-07 00:46:21 +0530
commit328fce93d5d7607576cf1735df3032ecdfe9c2e8 (patch)
tree6d0d6b36d7f46112bbaa4e2788f7624301649390
parent8cc063b4f65c2181fde4c567f2cdc82a75cca9bf (diff)
downloadmetachan-328fce93d5d7607576cf1735df3032ecdfe9c2e8.tar.xz
metachan-328fce93d5d7607576cf1735df3032ecdfe9c2e8.zip
Added characters and streaming to anime
-rw-r--r--types/anime.go140
-rw-r--r--utils/anime/anime.go35
-rw-r--r--utils/anime/aniskip.go68
-rw-r--r--utils/anime/jikan.go93
-rw-r--r--utils/anime/stream.go532
-rw-r--r--utils/anime/tmdb.go160
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
}