diff options
| author | Bobby <[email protected]> | 2026-02-06 15:53:32 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-02-06 15:53:32 +0530 |
| commit | 4300955f9d90dae98c5503b3bab88a5a22dd5bba (patch) | |
| tree | 46071313463c91c2f3529a2d6b5057bff5aa3066 /utils | |
| parent | 3980ea772e3895c127ac147ec07d69a2ab9b71a7 (diff) | |
| download | metachan-4300955f9d90dae98c5503b3bab88a5a22dd5bba.tar.xz metachan-4300955f9d90dae98c5503b3bab88a5a22dd5bba.zip | |
Refactor TMDB and TVDB API integration
- Removed redundant struct definitions in TMDB types.go for cleaner code.
- Introduced a client struct in both TMDB and TVDB to manage HTTP client and authentication tokens.
- Updated TVDB authentication logic to use a single client instance with improved error handling.
- Refactored episode fetching and processing functions in TVDB to enhance readability and maintainability.
- Simplified episode ID generation logic by consolidating it into a single function.
- Improved logging for better debugging and tracking of API interactions.
Diffstat (limited to 'utils')
| -rw-r--r-- | utils/api/anilist/anilist.go | 20 | ||||
| -rw-r--r-- | utils/api/aniskip/aniskip.go | 9 | ||||
| -rw-r--r-- | utils/api/jikan/jikan.go | 9 | ||||
| -rw-r--r-- | utils/api/malsync/malsync.go | 14 | ||||
| -rw-r--r-- | utils/api/streaming/streaming.go | 279 | ||||
| -rw-r--r-- | utils/api/streaming/types.go | 45 | ||||
| -rw-r--r-- | utils/api/tmdb/tmdb.go | 619 | ||||
| -rw-r--r-- | utils/api/tmdb/types.go | 82 | ||||
| -rw-r--r-- | utils/api/tvdb/tvdb.go | 240 | ||||
| -rw-r--r-- | utils/api/tvdb/types.go | 49 |
10 files changed, 569 insertions, 797 deletions
diff --git a/utils/api/anilist/anilist.go b/utils/api/anilist/anilist.go index 7d8036b..fcf5e7f 100644 --- a/utils/api/anilist/anilist.go +++ b/utils/api/anilist/anilist.go @@ -18,15 +18,21 @@ import ( const ( anilistAPIBaseURL = "https://graphql.anilist.co" contextTimeout = 60 * time.Second + timeout = 15 * time.Second + maxRetries = 3 + backoffDuration = 1 * time.Second + contentType = "application/json" + acceptHeader = "application/json" + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" ) var ( clientInstance = &client{ httpClient: &http.Client{ - Timeout: 15 * time.Second, + Timeout: timeout, }, - maxRetries: 3, - backoff: 1 * time.Second, + maxRetries: maxRetries, + backoff: backoffDuration, } ) @@ -81,9 +87,9 @@ func (c *client) makeRequest(ctx context.Context, query string, variables map[st return nil, errors.New("failed to create request to Anilist API") } - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Accept", "application/json") - request.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") + request.Header.Set("Content-Type", contentType) + request.Header.Set("Accept", acceptHeader) + request.Header.Set("User-Agent", userAgent) response, err = c.httpClient.Do(request) if err != nil { @@ -310,7 +316,7 @@ func GetAnimeByAnilistID(id int) (*types.AnilistAnimeResponse, error) { if response.Data.Media.ID == 0 { logger.Errorf("AnilistClient", "No data found for Anilist ID %d", id) - return nil, fmt.Errorf("no data found for Anilist ID %d", id) + return nil, errors.New("no data found") } return &response, nil diff --git a/utils/api/aniskip/aniskip.go b/utils/api/aniskip/aniskip.go index cdccdf7..eda78f0 100644 --- a/utils/api/aniskip/aniskip.go +++ b/utils/api/aniskip/aniskip.go @@ -20,6 +20,9 @@ const ( rateLimitPerSec = 10 rateLimitPer10Sec = 100 contextTimeout = 10 * time.Second + timeout = 10 * time.Second + maxRetries = 3 + backoffDuration = 1 * time.Second ) var ( @@ -29,10 +32,10 @@ var ( ) clientInstance = &client{ httpClient: &http.Client{ - Timeout: 10 * time.Second, + Timeout: timeout, }, - maxRetries: 3, - backoff: 1 * time.Second, + maxRetries: maxRetries, + backoff: backoffDuration, } ) diff --git a/utils/api/jikan/jikan.go b/utils/api/jikan/jikan.go index 0121659..920f6dc 100644 --- a/utils/api/jikan/jikan.go +++ b/utils/api/jikan/jikan.go @@ -20,6 +20,9 @@ const ( rateLimitPerSec = 3 rateLimitPerMin = 60 contextTimeout = 60 * time.Second + timeout = 15 * time.Second + maxRetries = 3 + backoffDuration = 1 * time.Second ) var ( @@ -29,10 +32,10 @@ var ( ) clientInstance = &client{ httpClient: &http.Client{ - Timeout: 15 * time.Second, + Timeout: timeout, }, - maxRetries: 3, - backoff: 1 * time.Second, + maxRetries: maxRetries, + backoff: backoffDuration, } ) diff --git a/utils/api/malsync/malsync.go b/utils/api/malsync/malsync.go index 5fcc01e..1ee479c 100644 --- a/utils/api/malsync/malsync.go +++ b/utils/api/malsync/malsync.go @@ -17,15 +17,19 @@ import ( const ( malsyncAPIBaseURL = "https://api.malsync.moe/mal" contextTimeout = 10 * time.Second + timeout = 10 * time.Second + maxRetries = 3 + backoffDuration = 1 * time.Second + acceptHeader = "application/json" ) var ( clientInstance = &client{ httpClient: &http.Client{ - Timeout: 10 * time.Second, + Timeout: timeout, }, - maxRetries: 3, - backoff: 1 * time.Second, + maxRetries: maxRetries, + backoff: backoffDuration, } ) @@ -69,7 +73,7 @@ func (c *client) makeRequest(ctx context.Context, url string) ([]byte, error) { return nil, errors.New("failed to create request to Malsync API") } - request.Header.Set("Accept", "application/json") + request.Header.Set("Accept", acceptHeader) response, err = c.httpClient.Do(request) if err != nil { @@ -140,7 +144,7 @@ func GetAnimeByMALID(malID int) (*types.MalsyncAnimeResponse, error) { if response.ID == 0 { logger.Errorf("MalsyncClient", "Received empty response for MAL ID %d", malID) - return nil, fmt.Errorf("received empty response for MAL ID %d", malID) + return nil, errors.New("received empty response") } return &response, nil diff --git a/utils/api/streaming/streaming.go b/utils/api/streaming/streaming.go index 570a744..f08a2d3 100644 --- a/utils/api/streaming/streaming.go +++ b/utils/api/streaming/streaming.go @@ -2,8 +2,10 @@ package streaming import ( "encoding/json" + "errors" "fmt" "maps" + "metachan/types" "metachan/utils/logger" "metachan/utils/mappers" "net/http" @@ -15,40 +17,64 @@ import ( ) const ( - allanimeBaseURL = "https://api.allanime.day/api" + allanimeBaseURL = "https://api.allanime.day/api" + allanimeDay = "https://allanime.day" + allanimeReferer = "https://allmanga.to" + clockPath = "/apivtwo/clock" + clockJSONPath = "/apivtwo/clock.json" + urlPrefix = "--" + unicodeSlash = "\\u002F" + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0" + timeout = 10 * time.Second + maxRetries = 3 + backoffDuration = 1 * time.Second + perfectMatch = 1.0 + partialMatch = 0.9 + specialMatch = 2.0 + serverMaria = "Maria" + serverSina = "Sina" + serverRose = "Rose" + serverTypeMP4 = "s-mp4" + serverTypeLufMP4 = "luf-mp4" + serverTypeDefault = "default" + sourceTypeDirect = "direct" + sourceTypeEmbed = "embed" + sourceTypeHLS = "HLS" + sourceTypeMP4 = "MP4" + patternSharepoint = "sharepoint.com" + patternM3U8 = ".m3u8" + patternMP4 = ".mp4" + searchLimit = 40 + searchPage = 1 + countryOrigin = "ALL" ) -// NewAllAnimeClient creates a new AllAnime client -func NewAllAnimeClient() *AllAnimeClient { - headers := http.Header{ - "User-Agent": {"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0"}, - "Referer": {"https://allmanga.to"}, - } - - return &AllAnimeClient{ - client: &http.Client{ - Timeout: 10 * time.Second, +var ( + clientInstance = &client{ + httpClient: &http.Client{ + Timeout: timeout, + }, + headers: http.Header{ + "User-Agent": {userAgent}, + "Referer": {allanimeReferer}, }, - headers: headers, + maxRetries: maxRetries, + backoff: backoffDuration, } -} +) -// calculateSimilarity determines how closely a title matches a query -func (c *AllAnimeClient) calculateSimilarity(query, title string) float64 { +func calculateSimilarity(query, title string) float64 { queryLower := strings.ToLower(query) titleLower := strings.ToLower(title) - // Exact match if queryLower == titleLower { - return 1.0 + return perfectMatch } - // Title contains query if strings.Contains(titleLower, queryLower) { - return 0.9 + return partialMatch } - // Calculate word match score queryWords := strings.Fields(queryLower) titleWords := strings.Fields(titleLower) @@ -69,13 +95,12 @@ func (c *AllAnimeClient) calculateSimilarity(query, title string) float64 { return float64(matchCount) / float64(len(queryWords)) } -// decodeURL decodes an encoded URL from AllAnime -func (c *AllAnimeClient) decodeURL(encodedString string) string { - if !strings.HasPrefix(encodedString, "--") { +func decodeURL(encodedString string) string { + if !strings.HasPrefix(encodedString, urlPrefix) { return encodedString } - encodedString = encodedString[2:] + encodedString = encodedString[len(urlPrefix):] decodeMap := map[string]string{ "01": "9", "08": "0", "05": "=", "0a": "2", "0b": "3", "0c": "4", "07": "?", "00": "8", @@ -100,22 +125,18 @@ func (c *AllAnimeClient) decodeURL(encodedString string) string { return decoded.String() } -// processProviderURL processes provider URLs from AllAnime -func (c *AllAnimeClient) processProviderURL(urlStr string) string { - baseURL := "https://allanime.day" - +func processProviderURL(urlStr string) string { if strings.HasPrefix(urlStr, "/") { - urlStr = strings.Replace(urlStr, "/apivtwo/clock", "/apivtwo/clock.json", 1) - return baseURL + urlStr + urlStr = strings.Replace(urlStr, clockPath, clockJSONPath, 1) + return allanimeDay + urlStr } return urlStr } -// getClockLink fetches a direct streaming link from a clock endpoint -func (c *AllAnimeClient) getClockLink(urlStr string) (string, error) { +func getClockLink(urlStr string) (string, error) { if strings.HasPrefix(urlStr, "/") { - urlStr = "https://allanime.day" + urlStr + urlStr = allanimeDay + urlStr } req, err := http.NewRequest("GET", urlStr, nil) @@ -123,9 +144,9 @@ func (c *AllAnimeClient) getClockLink(urlStr string) (string, error) { return "", err } - maps.Copy(req.Header, c.headers) + maps.Copy(req.Header, clientInstance.headers) - resp, err := c.client.Do(req) + resp, err := clientInstance.httpClient.Do(req) if err != nil { return "", err } @@ -144,68 +165,61 @@ func (c *AllAnimeClient) getClockLink(urlStr string) (string, error) { } } - return "", fmt.Errorf("no valid link found") + return "", errors.New("no valid link found") } -// processSourceURL processes a streaming source URL from AllAnime -func (c *AllAnimeClient) processSourceURL(sourceURL, sourceType string) *AnimeStreamingSource { +func processSourceURL(sourceURL, sourceType string) *types.StreamAnimeStreamingSource { var decodedURL string - if strings.HasPrefix(sourceURL, "--") { - decodedURL = c.decodeURL(sourceURL) + if strings.HasPrefix(sourceURL, urlPrefix) { + decodedURL = decodeURL(sourceURL) } else { - decodedURL = strings.ReplaceAll(sourceURL, "\\u002F", "/") + decodedURL = strings.ReplaceAll(sourceURL, unicodeSlash, "/") } - processedURL := c.processProviderURL(decodedURL) + processedURL := processProviderURL(decodedURL) - // Check if it's a clock link - if strings.Contains(processedURL, "/apivtwo/clock") { - if directURL, err := c.getClockLink(processedURL); err == nil { - return &AnimeStreamingSource{ + if strings.Contains(processedURL, clockPath) { + if directURL, err := getClockLink(processedURL); err == nil { + return &types.StreamAnimeStreamingSource{ URL: directURL, Server: getServerName(sourceType), - Type: "direct", + Type: sourceTypeDirect, } } } - // Check if it's a direct stream link - directPatterns := []string{"sharepoint.com", ".m3u8", ".mp4"} + directPatterns := []string{patternSharepoint, patternM3U8, patternMP4} for _, pattern := range directPatterns { if strings.Contains(processedURL, pattern) { - return &AnimeStreamingSource{ + return &types.StreamAnimeStreamingSource{ URL: processedURL, Server: getServerName(sourceType), - Type: "direct", + Type: sourceTypeDirect, } } } - // Return as regular source if not direct - return &AnimeStreamingSource{ + return &types.StreamAnimeStreamingSource{ URL: processedURL, Server: getServerName(sourceType), - Type: "embed", + Type: sourceTypeEmbed, } } -// getServerName maps AllAnime source types to readable server names func getServerName(sourceType string) string { switch strings.ToLower(sourceType) { - case "s-mp4": - return "Maria" - case "luf-mp4": - return "Sina" - case "default": - return "Rose" + case serverTypeMP4: + return serverMaria + case serverTypeLufMP4: + return serverSina + case serverTypeDefault: + return serverRose default: return sourceType } } -// SearchAnime searches for anime by title on AllAnime -func (c *AllAnimeClient) SearchAnime(query string) ([]StreamingSearchResult, error) { - // Check for special anime ID mapping +func SearchAnime(query string) ([]types.StreamSearchResult, error) { specialID, hasSpecialMapping := mappers.GetSpecialAnimeID(query) searchQuery := ` @@ -237,9 +251,9 @@ func (c *AllAnimeClient) SearchAnime(query string) ([]StreamingSearchResult, err "allowUnknown": false, "query": query, }, - "limit": 40, - "page": 1, - "countryOrigin": "ALL", + "limit": searchLimit, + "page": searchPage, + "countryOrigin": countryOrigin, } params := url.Values{} @@ -252,9 +266,9 @@ func (c *AllAnimeClient) SearchAnime(query string) ([]StreamingSearchResult, err return nil, err } - maps.Copy(req.Header, c.headers) + maps.Copy(req.Header, clientInstance.headers) - resp, err := c.client.Do(req) + resp, err := clientInstance.httpClient.Do(req) if err != nil { return nil, err } @@ -266,28 +280,26 @@ func (c *AllAnimeClient) SearchAnime(query string) ([]StreamingSearchResult, err } shows := data["data"].(map[string]any)["shows"].(map[string]any)["edges"].([]any) - results := make([]StreamingSearchResult, 0, len(shows)) + results := make([]types.StreamSearchResult, 0, len(shows)) for _, show := range shows { showMap := show.(map[string]any) episodes := showMap["availableEpisodes"].(map[string]any) - result := StreamingSearchResult{ + result := types.StreamSearchResult{ ID: showMap["_id"].(string), Name: showMap["name"].(string), SubEpisodes: int(episodes["sub"].(float64)), DubEpisodes: int(episodes["dub"].(float64)), - Similarity: c.calculateSimilarity(query, showMap["name"].(string)), + Similarity: calculateSimilarity(query, showMap["name"].(string)), } - // If this is the special anime we're looking for, boost its similarity if hasSpecialMapping && result.ID == specialID { - result.Similarity = 2.0 // Forcing special ID to be the best match + result.Similarity = specialMatch } results = append(results, result) } - // Sort only once by similarity sort.Slice(results, func(i, j int) bool { return results[i].Similarity > results[j].Similarity }) @@ -295,8 +307,7 @@ 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) { +func GetEpisodesList(showID string, mode string) ([]string, error) { episodesQuery := ` query ($showId: String!) { show( @@ -322,11 +333,9 @@ func (c *AllAnimeClient) GetEpisodesList(showID string, mode string) ([]string, return nil, err } - for key, values := range c.headers { - req.Header[key] = values - } + maps.Copy(req.Header, clientInstance.headers) - resp, err := c.client.Do(req) + resp, err := clientInstance.httpClient.Do(req) if err != nil { return nil, err } @@ -362,8 +371,7 @@ 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) ([]AnimeStreamingSource, error) { +func GetEpisodeLinks(showID, episode, mode string) ([]types.StreamAnimeStreamingSource, error) { episodeQuery := ` query ($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) { episode( @@ -393,9 +401,9 @@ func (c *AllAnimeClient) GetEpisodeLinks(showID, episode, mode string) ([]AnimeS return nil, err } - maps.Copy(req.Header, c.headers) + maps.Copy(req.Header, clientInstance.headers) - resp, err := c.client.Do(req) + resp, err := clientInstance.httpClient.Do(req) if err != nil { return nil, err } @@ -409,20 +417,18 @@ func (c *AllAnimeClient) GetEpisodeLinks(showID, episode, mode string) ([]AnimeS episodeData := data["data"].(map[string]any)["episode"].(map[string]any) sourceUrls := episodeData["sourceUrls"].([]any) - var links []AnimeStreamingSource + var links []types.StreamAnimeStreamingSource for _, source := range sourceUrls { sourceMap := source.(map[string]any) if sourceURL, ok := sourceMap["sourceUrl"].(string); ok { sourceName := sourceMap["sourceName"].(string) - sourceInfo := c.processSourceURL(sourceURL, sourceName) + sourceInfo := processSourceURL(sourceURL, sourceName) - // Only add direct sources - if sourceInfo.Type == "direct" { - // Transform type to M3U8 or MP4 based on URL - if strings.HasSuffix(sourceInfo.URL, ".m3u8") { - sourceInfo.Type = "HLS" + if sourceInfo.Type == sourceTypeDirect { + if strings.HasSuffix(sourceInfo.URL, patternM3U8) { + sourceInfo.Type = sourceTypeHLS } else { - sourceInfo.Type = "MP4" + sourceInfo.Type = sourceTypeMP4 } links = append(links, *sourceInfo) } @@ -432,48 +438,31 @@ func (c *AllAnimeClient) GetEpisodeLinks(showID, episode, mode string) ([]AnimeS return links, nil } -// GetStreamingSources fetches both sub and dub streaming sources for an anime episode -func (c *AllAnimeClient) GetStreamingSources(title string, episodeNumber int) (*AnimeStreaming, error) { - logger.Log(fmt.Sprintf("Fetching streaming sources for '%s' episode %d", title, episodeNumber), logger.LogOptions{ - Level: logger.Debug, - Prefix: "Streaming", - }) +func GetStreamingSources(title string, episodeNumber int) (*types.StreamAnimeStreaming, error) { + logger.Debugf("Streaming", "Fetching streaming sources for '%s' episode %d", title, episodeNumber) - // Search for the anime - searchResults, err := c.SearchAnime(title) + searchResults, err := SearchAnime(title) if err != nil { - logger.Log(fmt.Sprintf("Failed to search anime '%s': %v", title, err), logger.LogOptions{ - Level: logger.Error, - Prefix: "Streaming", - }) - return nil, fmt.Errorf("failed to search for anime: %w", err) + logger.Errorf("Streaming", "Failed to search anime '%s': %v", title, err) + return nil, errors.New("failed to search for anime") } if len(searchResults) == 0 { - logger.Log(fmt.Sprintf("No streaming sources found for '%s'", title), logger.LogOptions{ - Level: logger.Warn, - Prefix: "Streaming", - }) - return nil, fmt.Errorf("no streaming sources found for '%s'", title) + logger.Warnf("Streaming", "No streaming sources found for '%s'", title) + return nil, errors.New("no streaming sources found") } - // Use the best match (first result) bestMatch := searchResults[0] - logger.Log(fmt.Sprintf("Best match: '%s' (ID: %s, Sub: %d, Dub: %d)", bestMatch.Name, bestMatch.ID, bestMatch.SubEpisodes, bestMatch.DubEpisodes), logger.LogOptions{ - Level: logger.Debug, - Prefix: "Streaming", - }) + logger.Debugf("Streaming", "Best match: '%s' (ID: %s, Sub: %d, Dub: %d)", bestMatch.Name, bestMatch.ID, bestMatch.SubEpisodes, bestMatch.DubEpisodes) - streaming := &AnimeStreaming{ - Sub: []AnimeStreamingSource{}, - Dub: []AnimeStreamingSource{}, + streaming := &types.StreamAnimeStreaming{ + Sub: []types.StreamAnimeStreamingSource{}, + Dub: []types.StreamAnimeStreamingSource{}, } - // Get sub episodes if available if bestMatch.SubEpisodes > 0 { - episodes, err := c.GetEpisodesList(bestMatch.ID, "sub") + episodes, err := GetEpisodesList(bestMatch.ID, "sub") if err == nil && len(episodes) > 0 { - // Find the closest episode episodeStr := fmt.Sprintf("%d", episodeNumber) var closestEpisode string @@ -485,28 +474,20 @@ func (c *AllAnimeClient) GetStreamingSources(title string, episodeNumber int) (* } if closestEpisode != "" { - subSources, err := c.GetEpisodeLinks(bestMatch.ID, closestEpisode, "sub") + subSources, err := GetEpisodeLinks(bestMatch.ID, closestEpisode, "sub") if err == nil { streaming.Sub = subSources - logger.Log(fmt.Sprintf("Found %d sub sources for episode %d", len(subSources), episodeNumber), logger.LogOptions{ - Level: logger.Debug, - Prefix: "Streaming", - }) + logger.Debugf("Streaming", "Found %d sub sources for episode %d", len(subSources), episodeNumber) } else { - logger.Log(fmt.Sprintf("Failed to get sub sources: %v", err), logger.LogOptions{ - Level: logger.Warn, - Prefix: "Streaming", - }) + logger.Warnf("Streaming", "Failed to get sub sources: %v", err) } } } } - // Get dub episodes if available if bestMatch.DubEpisodes > 0 { - episodes, err := c.GetEpisodesList(bestMatch.ID, "dub") + episodes, err := GetEpisodesList(bestMatch.ID, "dub") if err == nil && len(episodes) > 0 { - // Find the closest episode episodeStr := fmt.Sprintf("%d", episodeNumber) var closestEpisode string @@ -518,43 +499,33 @@ func (c *AllAnimeClient) GetStreamingSources(title string, episodeNumber int) (* } if closestEpisode != "" { - dubSources, err := c.GetEpisodeLinks(bestMatch.ID, closestEpisode, "dub") + dubSources, err := GetEpisodeLinks(bestMatch.ID, closestEpisode, "dub") if err == nil { streaming.Dub = dubSources - logger.Log(fmt.Sprintf("Found %d dub sources for episode %d", len(dubSources), episodeNumber), logger.LogOptions{ - Level: logger.Debug, - Prefix: "Streaming", - }) + logger.Debugf("Streaming", "Found %d dub sources for episode %d", len(dubSources), episodeNumber) } else { - logger.Log(fmt.Sprintf("Failed to get dub sources: %v", err), logger.LogOptions{ - Level: logger.Warn, - Prefix: "Streaming", - }) + logger.Warnf("Streaming", "Failed to get dub sources: %v", err) } } } } - logger.Log(fmt.Sprintf("Successfully fetched streaming sources for episode %d (Sub: %d, Dub: %d)", episodeNumber, len(streaming.Sub), len(streaming.Dub)), logger.LogOptions{ - Level: logger.Info, - Prefix: "Streaming", - }) + logger.Infof("Streaming", "Successfully fetched streaming sources for episode %d (Sub: %d, Dub: %d)", episodeNumber, len(streaming.Sub), len(streaming.Dub)) 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) +func GetStreamingCounts(title string) (int, int, error) { + searchResults, err := SearchAnime(title) if err != nil { - return 0, 0, fmt.Errorf("failed to search for anime: %w", err) + logger.Errorf("Streaming", "Failed to search anime '%s': %v", title, err) + return 0, 0, errors.New("failed to search for anime") } if len(searchResults) == 0 { - return 0, 0, fmt.Errorf("no results found for '%s'", title) + logger.Warnf("Streaming", "No results found for '%s'", title) + return 0, 0, errors.New("no results found") } - // Use the best match (first result) bestMatch := searchResults[0] return bestMatch.SubEpisodes, bestMatch.DubEpisodes, nil diff --git a/utils/api/streaming/types.go b/utils/api/streaming/types.go index 1b12da0..2310a76 100644 --- a/utils/api/streaming/types.go +++ b/utils/api/streaming/types.go @@ -1,38 +1,13 @@ package streaming -import "net/http" - -// AllAnimeClient provides methods for interacting with the AllAnime API -type AllAnimeClient struct { - client *http.Client - headers http.Header -} - -// AnimeStreamingSource represents a single streaming source for an episode -type AnimeStreamingSource struct { - URL string `json:"url"` - Server string `json:"server"` - Type string `json:"type"` // direct or embed -} - -// AnimeStreaming represents all available streaming sources for an episode -type AnimeStreaming struct { - Sub []AnimeStreamingSource `json:"sub"` - Dub []AnimeStreamingSource `json:"dub"` -} - -// StreamingSearchResult represents a search result from streaming providers -type StreamingSearchResult struct { - ID string `json:"_id"` - Name string `json:"name"` - SubEpisodes int `json:"sub_episodes"` - DubEpisodes int `json:"dub_episodes"` - Similarity float64 `json:"similarity"` -} - -// EpisodeStreamingResult contains streaming sources for a specific episode -// Used for parallel streaming source fetching -type EpisodeStreamingResult struct { - EpisodeNumber int - Streaming *AnimeStreaming +import ( + "net/http" + "time" +) + +type client struct { + httpClient *http.Client + headers http.Header + maxRetries int + backoff time.Duration } diff --git a/utils/api/tmdb/tmdb.go b/utils/api/tmdb/tmdb.go index d4a490c..99f6670 100644 --- a/utils/api/tmdb/tmdb.go +++ b/utils/api/tmdb/tmdb.go @@ -3,9 +3,11 @@ package tmdb import ( "crypto/md5" "encoding/json" + "errors" "fmt" "math" "metachan/config" + "metachan/entities" "metachan/types" "metachan/utils/logger" "net/http" @@ -14,86 +16,84 @@ import ( ) const ( - MAX_RETRIES = 10 + tmdbAPIBaseURL = "https://api.themoviedb.org/3" + tmdbImageBaseURL = "https://image.tmdb.org/t/p/" + searchTVEndpoint = "/search/tv" + searchMovieEndpoint = "/search/movie" + tvDetailsEndpoint = "/tv/%d" + seasonDetailsEndpoint = "/tv/%d/season/%d" + movieDetailsEndpoint = "/movie/%d" + timeout = 5 * time.Second + rateLimitWait = 5 * time.Second + maxRetries = 10 + maxEnrichmentDuration = 10 * time.Second + thumbnailSize = "w300" + backdropSize = "w780" + acceptHeader = "application/json" + connectionResetError = "connection reset" + noDescription = "No description available" + tvAnimation = "TV Animation" + seasonSuffix = ": Season" + seasonWord = "Season" + partWord = "Part" + courWord = "Cour" + countryPriorityJP = "JP" + episodeCountFlexibility = 2 ) -// makeSimpleRequest executes a simple HTTP request with retries for both connection and rate limit errors -func makeSimpleRequest(req *http.Request) (*http.Response, error) { - // Create a simple HTTP client with a short timeout - client := &http.Client{ - Timeout: 5 * time.Second, // Reduced timeout for faster failure +var ( + clientInstance = &client{ + httpClient: &http.Client{ + Timeout: timeout, + }, } +) - // Do retries for up to MAX_RETRIES attempts +func makeRequest(req *http.Request) (*http.Response, error) { var lastErr error var resp *http.Response - for attempt := 0; attempt < MAX_RETRIES; attempt++ { - // Log the attempt - // Execute the request - resp, lastErr = client.Do(req) + for attempt := 0; attempt < maxRetries; attempt++ { + resp, lastErr = clientInstance.httpClient.Do(req) - // If successful, check for rate limiting if lastErr == nil { - // If we got rate limited (429), wait and retry if resp.StatusCode == http.StatusTooManyRequests { resp.Body.Close() - - logger.Log(fmt.Sprintf("TMDB rate limited (attempt %d/%d): waiting 5 seconds", attempt+1, MAX_RETRIES), logger.LogOptions{ - Level: logger.Warn, - Prefix: "TMDB", - }) - - // Wait for 5 seconds before retrying for rate limits - time.Sleep(5 * time.Second) + logger.Warnf("TMDB", "TMDB rate limited (attempt %d/%d): waiting %d seconds", attempt+1, maxRetries, int(rateLimitWait.Seconds())) + time.Sleep(rateLimitWait) continue } - - // Any other status code (including success) should be returned return resp, nil } - // Check if this is a connection reset error for immediate retry - if strings.Contains(lastErr.Error(), "connection reset") { - logger.Log(fmt.Sprintf("TMDB connection reset (attempt %d/%d): retrying immediately", attempt+1, MAX_RETRIES), logger.LogOptions{ - Level: logger.Debug, - Prefix: "TMDB", - }) + if strings.Contains(lastErr.Error(), connectionResetError) { + logger.Debugf("TMDB", "TMDB connection reset (attempt %d/%d): retrying immediately", attempt+1, maxRetries) continue } - // Log the error - logger.Log(fmt.Sprintf("TMDB request error (attempt %d/%d): %v", attempt+1, MAX_RETRIES, lastErr), logger.LogOptions{ - Level: logger.Debug, - Prefix: "TMDB", - }) + logger.Debugf("TMDB", "TMDB request error (attempt %d/%d): %v", attempt+1, maxRetries, lastErr) } - // All attempts failed, return the last error - return nil, fmt.Errorf("failed after %d retry attempts: %w", MAX_RETRIES, lastErr) + logger.Errorf("TMDB", "Failed after %d retry attempts: %v", maxRetries, lastErr) + return nil, errors.New("failed after max retry attempts") } -// normalizeTitle cleans up the anime title for better matching with TMDB func normalizeTitle(title string) string { - // Handle empty titles if title == "" { return "" } - // Remove common suffixes and prefixes normalized := title - normalized = strings.Replace(normalized, "TV Animation", "", -1) - normalized = strings.Replace(normalized, ": Season", "", -1) - normalized = strings.Replace(normalized, "Season", "", -1) - normalized = strings.Replace(normalized, "Part", "", -1) - normalized = strings.Replace(normalized, "Cour", "", -1) + normalized = strings.Replace(normalized, tvAnimation, "", -1) + normalized = strings.Replace(normalized, seasonSuffix, "", -1) + normalized = strings.Replace(normalized, seasonWord, "", -1) + normalized = strings.Replace(normalized, partWord, "", -1) + normalized = strings.Replace(normalized, courWord, "", -1) - // Handle patterns like "Dr. Stone: Stone Wars" -> "Dr. Stone" if colonIndex := strings.Index(normalized, ":"); colonIndex > 0 { normalized = normalized[:colonIndex] } - // Remove parentheses and text inside them for { openParen := strings.Index(normalized, "(") if openParen == -1 { @@ -109,68 +109,61 @@ func normalizeTitle(title string) string { return strings.TrimSpace(normalized) } -// searchTVShowsByTitle searches for TV shows on TMDB by title -func searchTVShowsByTitle(title string, alternativeTitle string, isAdult bool, countryPriority string) ([]TMDBShowResult, error) { - if config.Config.TMDB.ReadAccessToken == "" { - return nil, fmt.Errorf("TMDB is not initialized") +func searchTVShowsByTitle(title string, alternativeTitle string, isAdult bool, countryPriority string) ([]types.TMDBShowResult, error) { + if config.API.TMDBReadToken == "" { + logger.Errorf("TMDB", "TMDB is not initialized") + return nil, errors.New("TMDB is not initialized") } - // Normalize the title query := normalizeTitle(title) if query == "" && alternativeTitle != "" { query = normalizeTitle(alternativeTitle) } - logger.Log(fmt.Sprintf("Searching TMDB for TV show: %s", query), logger.LogOptions{ - Level: logger.Debug, - Prefix: "TMDB", - }) + logger.Debugf("TMDB", "Searching TMDB for TV show: %s", query) - // Create request - apiURL := "https://api.themoviedb.org/3/search/tv" + apiURL := tmdbAPIBaseURL + searchTVEndpoint req, err := http.NewRequest("GET", apiURL, nil) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + logger.Errorf("TMDB", "Failed to create request: %v", err) + return nil, errors.New("failed to create request") } - // Add query parameters q := req.URL.Query() q.Add("query", query) req.URL.RawQuery = q.Encode() - // Add headers - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.Config.TMDB.ReadAccessToken)) - req.Header.Add("Accept", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.API.TMDBReadToken)) + req.Header.Add("Accept", acceptHeader) - // Make the simple request - resp, err := makeSimpleRequest(req) + resp, err := makeRequest(req) if err != nil { - return nil, fmt.Errorf("failed to search TV shows: %w", err) + logger.Errorf("TMDB", "Failed to search TV shows: %v", err) + return nil, errors.New("failed to search TV shows") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to search TV shows: %s", resp.Status) + logger.Errorf("TMDB", "Failed to search TV shows: %s", resp.Status) + return nil, errors.New("failed to search TV shows") } - // Parse response - var searchResponse TMDBSearchResponse + var searchResponse types.TMDBSearchResponse if err := json.NewDecoder(resp.Body).Decode(&searchResponse); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + logger.Errorf("TMDB", "Failed to decode response: %v", err) + return nil, errors.New("failed to decode response") } - // Filter results if needed - var filteredResults []TMDBShowResult + var filteredResults []types.TMDBShowResult for _, show := range searchResponse.Results { if (isAdult && show.Adult) || (!isAdult && !show.Adult) { filteredResults = append(filteredResults, show) } } - // Sort by country priority if specified if countryPriority != "" && len(filteredResults) > 0 { - var prioritizedResults []TMDBShowResult - var otherResults []TMDBShowResult + var prioritizedResults []types.TMDBShowResult + var otherResults []types.TMDBShowResult for _, show := range filteredResults { hasPriority := false @@ -188,211 +181,191 @@ func searchTVShowsByTitle(title string, alternativeTitle string, isAdult bool, c } } - // Combine the results with prioritized ones first filteredResults = append(prioritizedResults, otherResults...) } if len(filteredResults) == 0 { - logger.Log(fmt.Sprintf("No TMDB shows found for: %s", query), logger.LogOptions{ - Level: logger.Warn, - Prefix: "TMDB", - }) + logger.Warnf("TMDB", "No TMDB shows found for: %s", query) } else { - logger.Log(fmt.Sprintf("Found %d TMDB shows for: %s", len(filteredResults), query), logger.LogOptions{ - Level: logger.Debug, - Prefix: "TMDB", - }) + logger.Debugf("TMDB", "Found %d TMDB shows for: %s", len(filteredResults), query) } return filteredResults, nil } -// getTVShowDetails gets details for a TV show from TMDB -func getTVShowDetails(showID int) (*TMDBShowDetails, error) { - if config.Config.TMDB.ReadAccessToken == "" { - return nil, fmt.Errorf("TMDB is not initialized") +func getTVShowDetails(showID int) (*types.TMDBShowDetails, error) { + if config.API.TMDBReadToken == "" { + logger.Errorf("TMDB", "TMDB is not initialized") + return nil, errors.New("TMDB is not initialized") } - apiURL := fmt.Sprintf("https://api.themoviedb.org/3/tv/%d", showID) + apiURL := fmt.Sprintf(tmdbAPIBaseURL+tvDetailsEndpoint, showID) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + logger.Errorf("TMDB", "Failed to create request: %v", err) + return nil, errors.New("failed to create request") } - // Add headers - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.Config.TMDB.ReadAccessToken)) - req.Header.Add("Accept", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.API.TMDBReadToken)) + req.Header.Add("Accept", acceptHeader) - // Make the simple request - resp, err := makeSimpleRequest(req) + resp, err := makeRequest(req) if err != nil { - return nil, fmt.Errorf("failed to get TV show details: %w", err) + logger.Errorf("TMDB", "Failed to get TV show details: %v", err) + return nil, errors.New("failed to get TV show details") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to get TV show details: %s", resp.Status) + logger.Errorf("TMDB", "Failed to get TV show details: %s", resp.Status) + return nil, errors.New("failed to get TV show details") } - // Parse response - details := &TMDBShowDetails{} + details := &types.TMDBShowDetails{} if err := json.NewDecoder(resp.Body).Decode(details); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + logger.Errorf("TMDB", "Failed to decode response: %v", err) + return nil, errors.New("failed to decode response") } return details, nil } -// getSeasonDetails gets details for a TV season from TMDB -func getSeasonDetails(showID, seasonNumber int) (*TMDBSeasonDetails, error) { - if config.Config.TMDB.ReadAccessToken == "" { - return nil, fmt.Errorf("TMDB is not initialized") +func getSeasonDetails(showID, seasonNumber int) (*types.TMDBSeasonDetails, error) { + if config.API.TMDBReadToken == "" { + logger.Errorf("TMDB", "TMDB is not initialized") + return nil, errors.New("TMDB is not initialized") } - apiURL := fmt.Sprintf("https://api.themoviedb.org/3/tv/%d/season/%d", showID, seasonNumber) + apiURL := fmt.Sprintf(tmdbAPIBaseURL+seasonDetailsEndpoint, showID, seasonNumber) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + logger.Errorf("TMDB", "Failed to create request: %v", err) + return nil, errors.New("failed to create request") } - // Add headers - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.Config.TMDB.ReadAccessToken)) - req.Header.Add("Accept", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.API.TMDBReadToken)) + req.Header.Add("Accept", acceptHeader) - // Make the simple request - resp, err := makeSimpleRequest(req) + resp, err := makeRequest(req) if err != nil { - return nil, fmt.Errorf("failed to get season details: %w", err) + logger.Errorf("TMDB", "Failed to get season details: %v", err) + return nil, errors.New("failed to get season details") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to get season details: %s", resp.Status) + logger.Errorf("TMDB", "Failed to get season details: %s", resp.Status) + return nil, errors.New("failed to get season details") } - // Parse response - details := &TMDBSeasonDetails{} + details := &types.TMDBSeasonDetails{} if err := json.NewDecoder(resp.Body).Decode(details); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + logger.Errorf("TMDB", "Failed to decode response: %v", err) + return nil, errors.New("failed to decode response") } return details, nil } -// findBestSeason finds the best matching season for an anime -func findBestSeason(shows []TMDBShowResult, title string, episodeCount int, airDate string) (int, int, error) { +func findBestSeason(shows []types.TMDBShowResult, title string, episodeCount int, airDate string) (int, int, error) { for _, show := range shows { showDetails, err := getTVShowDetails(show.ID) if err != nil { - logger.Log(fmt.Sprintf("Failed to get details for show %d: %v", show.ID, err), logger.LogOptions{ - Level: logger.Warn, - Prefix: "TMDB", - }) + logger.Warnf("TMDB", "Failed to get details for show %d: %v", show.ID, err) continue } for _, season := range showDetails.Seasons { - // Skip season 0 (usually specials) if season.SeasonNumber == 0 { continue } - // Check if episode count matches (with some flexibility) episodeCountMatches := season.EpisodeCount == episodeCount || - (episodeCount > 0 && season.EpisodeCount >= episodeCount-2 && - season.EpisodeCount <= episodeCount+2) + (episodeCount > 0 && season.EpisodeCount >= episodeCount-episodeCountFlexibility && + season.EpisodeCount <= episodeCount+episodeCountFlexibility) - // Check if air dates are close airDateMatches := false if airDate != "" && season.AirDate != "" { - // Simple year comparison animeYear := airDate[:4] seasonYear := season.AirDate[:4] airDateMatches = animeYear == seasonYear } - // If either count or air date matches, consider it a potential match if episodeCountMatches || airDateMatches { - logger.Log(fmt.Sprintf("Found matching season for \"%s\": Show ID %d, Season %d", - title, show.ID, season.SeasonNumber), logger.LogOptions{ - Level: logger.Info, - Prefix: "TMDB", - }) + logger.Infof("TMDB", "Found matching season for \"%s\": Show ID %d, Season %d", title, show.ID, season.SeasonNumber) return show.ID, season.SeasonNumber, nil } } } - return 0, 0, fmt.Errorf("could not find matching season for: %s", title) + logger.Warnf("TMDB", "Could not find matching season for: %s", title) + return 0, 0, errors.New("could not find matching season") } -// AttachEpisodeDescriptions enriches anime episodes with descriptions and thumbnails from TMDB -func AttachEpisodeDescriptions(title string, episodes []types.AnimeSingleEpisode, alternativeTitle string, tmdbID int) ([]types.AnimeSingleEpisode, error) { - if config.Config.TMDB.ReadAccessToken == "" { - logger.Log("TMDB is not configured, skipping episode description enrichment", logger.LogOptions{ - Level: logger.Warn, - Prefix: "TMDB", - }) - return episodes, fmt.Errorf("TMDB is not configured") +func AttachEpisodeDescriptions(anime *entities.Anime) error { + if config.API.TMDBReadToken == "" { + logger.Warnf("TMDB", "TMDB is not configured, skipping episode description enrichment") + return errors.New("TMDB is not configured") + } + + if anime == nil || len(anime.Episodes) == 0 { + return nil + } + + title := "" + alternativeTitle := "" + if anime.Title != nil { + title = anime.Title.Romaji + alternativeTitle = anime.Title.English } - if len(episodes) == 0 { - return episodes, nil + tmdbID := 0 + malID := anime.MALID + if anime.Mapping != nil { + tmdbID = anime.Mapping.TMDB } - logger.Log(fmt.Sprintf("Enriching episodes for: %s", title), logger.LogOptions{ - Level: logger.Info, - Prefix: "TMDB", - }) + episodes := make([]*entities.Episode, len(anime.Episodes)) + for i := range anime.Episodes { + episodes[i] = &anime.Episodes[i] + } + + logger.Infof("TMDB", "Enriching episodes for: %s", title) var showID int var seasonNumber int var err error - // Use a short timeout for the entire operation startTime := time.Now() - maxDuration := 10 * time.Second - // If we have a TMDB ID, use it directly if tmdbID > 0 { showID = tmdbID - // Check if we've exceeded the timeout - if time.Since(startTime) > maxDuration { - logger.Log("TMDB enrichment timed out", logger.LogOptions{ - Level: logger.Warn, - Prefix: "TMDB", - }) - return episodes, fmt.Errorf("TMDB enrichment timed out") + if time.Since(startTime) > maxEnrichmentDuration { + logger.Warnf("TMDB", "TMDB enrichment timed out") + return errors.New("TMDB enrichment timed out") } - // Try to get show details and find the best season showDetails, err := getTVShowDetails(showID) if err != nil { - logger.Log(fmt.Sprintf("Failed to get TMDB show details for ID %d: %v", tmdbID, err), logger.LogOptions{ - Level: logger.Warn, - Prefix: "TMDB", - }) - return episodes, fmt.Errorf("failed to get TMDB show details: %w", err) + logger.Warnf("TMDB", "Failed to get TMDB show details for ID %d: %v", tmdbID, err) + return errors.New("failed to get TMDB show details") } - // Find the best matching season - prefer the first season if we can't determine seasonNumber = 1 bestMatchScore := 0 for _, season := range showDetails.Seasons { if season.SeasonNumber == 0 { - continue // Skip specials + continue } matchScore := 0 - // Check episode count similarity - if math.Abs(float64(season.EpisodeCount-len(episodes))) <= 2 { + if math.Abs(float64(season.EpisodeCount-len(episodes))) <= episodeCountFlexibility { matchScore += 2 } - // Check air date if available if len(episodes) > 0 && episodes[0].Aired != "" && season.AirDate != "" { animeYear := episodes[0].Aired[:4] seasonYear := season.AirDate[:4] @@ -407,129 +380,101 @@ func AttachEpisodeDescriptions(title string, episodes []types.AnimeSingleEpisode } } - logger.Log(fmt.Sprintf("Using TMDB ID %d with season %d", showID, seasonNumber), logger.LogOptions{ - Level: logger.Info, - Prefix: "TMDB", - }) + logger.Infof("TMDB", "Using TMDB ID %d with season %d", showID, seasonNumber) } else { - // Check if we've exceeded the timeout - if time.Since(startTime) > maxDuration { - logger.Log("TMDB enrichment timed out", logger.LogOptions{ - Level: logger.Warn, - Prefix: "TMDB", - }) - return episodes, fmt.Errorf("TMDB enrichment timed out") + if time.Since(startTime) > maxEnrichmentDuration { + logger.Warnf("TMDB", "TMDB enrichment timed out") + return errors.New("TMDB enrichment timed out") } - // Search for the TV show on TMDB if we don't have a direct ID - shows, err := searchTVShowsByTitle(title, alternativeTitle, false, "JP") + shows, err := searchTVShowsByTitle(title, alternativeTitle, false, countryPriorityJP) if err != nil { - logger.Log(fmt.Sprintf("Failed to search TV shows: %v", err), logger.LogOptions{ - Level: logger.Warn, - Prefix: "TMDB", - }) - return episodes, fmt.Errorf("failed to search TMDB shows: %w", err) + logger.Warnf("TMDB", "Failed to search TV shows: %v", err) + return errors.New("failed to search TMDB shows") } if len(shows) == 0 { - logger.Log(fmt.Sprintf("No TV shows found for: %s", title), logger.LogOptions{ - Level: logger.Warn, - Prefix: "TMDB", - }) - return episodes, fmt.Errorf("no TMDB shows found for: %s", title) + logger.Warnf("TMDB", "No TV shows found for: %s", title) + return errors.New("no TMDB shows found") } - // Find the best matching season airDate := "" if len(episodes) > 0 && episodes[0].Aired != "" { airDate = episodes[0].Aired } - // Check if we've exceeded the timeout - if time.Since(startTime) > maxDuration { - logger.Log("TMDB enrichment timed out", logger.LogOptions{ - Level: logger.Warn, - Prefix: "TMDB", - }) - return episodes, fmt.Errorf("TMDB enrichment timed out") + if time.Since(startTime) > maxEnrichmentDuration { + logger.Warnf("TMDB", "TMDB enrichment timed out") + return errors.New("TMDB enrichment timed out") } showID, seasonNumber, err = findBestSeason(shows, title, len(episodes), airDate) if err != nil { - logger.Log(fmt.Sprintf("Failed to find best season: %v", err), logger.LogOptions{ - Level: logger.Warn, - Prefix: "TMDB", - }) - return episodes, fmt.Errorf("failed to find best season: %w", err) + logger.Warnf("TMDB", "Failed to find best season: %v", err) + return errors.New("failed to find best season") } } - // Check if we've exceeded the timeout - if time.Since(startTime) > maxDuration { - logger.Log("TMDB enrichment timed out", logger.LogOptions{ - Level: logger.Warn, - Prefix: "TMDB", - }) - return episodes, fmt.Errorf("TMDB enrichment timed out") + if time.Since(startTime) > maxEnrichmentDuration { + logger.Warnf("TMDB", "TMDB enrichment timed out") + return errors.New("TMDB enrichment timed out") } - // Get season details with episode information seasonDetails, err := getSeasonDetails(showID, seasonNumber) if err != nil { - logger.Log(fmt.Sprintf("Failed to get season details: %v", err), logger.LogOptions{ - Level: logger.Warn, - Prefix: "TMDB", - }) - return episodes, fmt.Errorf("failed to get season details: %w", err) + logger.Warnf("TMDB", "Failed to get season details: %v", err) + return errors.New("failed to get season details") } - // Enrich episodes with descriptions and thumbnails tmdbEpisodes := seasonDetails.Episodes - enrichedEpisodes := make([]types.AnimeSingleEpisode, len(episodes)) - copy(enrichedEpisodes, episodes) - - // The base URL for TMDB images - const tmdbImageBaseURL = "https://image.tmdb.org/t/p/" - const thumbnailSize = "w300" // Use w300 size for episode thumbnails - for i := range enrichedEpisodes { + for i, episode := range episodes { if i < len(tmdbEpisodes) { - // Only add description if it's not empty if tmdbEpisodes[i].Overview != "" { - enrichedEpisodes[i].Description = tmdbEpisodes[i].Overview + episode.Description = tmdbEpisodes[i].Overview } else { - enrichedEpisodes[i].Description = "No description available" + episode.Description = noDescription } - // Add thumbnail URL if available if tmdbEpisodes[i].StillPath != "" { - enrichedEpisodes[i].ThumbnailURL = tmdbImageBaseURL + thumbnailSize + tmdbEpisodes[i].StillPath + episode.ThumbnailURL = tmdbImageBaseURL + thumbnailSize + tmdbEpisodes[i].StillPath + } + + episode.EpisodeNumber = tmdbEpisodes[i].EpisodeNumber + + titleForID := "" + if episode.Title != nil { + if episode.Title.English != "" { + titleForID = episode.Title.English + } else if episode.Title.Romaji != "" { + titleForID = episode.Title.Romaji + } } + if titleForID == "" && tmdbEpisodes[i].Name != "" { + titleForID = tmdbEpisodes[i].Name + } + episode.EpisodeID = generateEpisodeID(malID, episode.EpisodeNumber, titleForID) } else { - enrichedEpisodes[i].Description = "No description available" + episode.Description = noDescription } } thumbnailCount := 0 - for _, ep := range enrichedEpisodes { + for _, ep := range episodes { if ep.ThumbnailURL != "" { thumbnailCount++ } } - logger.Log(fmt.Sprintf("Successfully enriched %d episodes with descriptions and %d with thumbnails for: %s", - len(enrichedEpisodes), thumbnailCount, title), logger.LogOptions{ - Level: logger.Success, - Prefix: "TMDB", - }) + logger.Successf("TMDB", "Successfully enriched %d episodes with descriptions and %d with thumbnails for: %s", len(episodes), thumbnailCount, title) - return enrichedEpisodes, nil + return nil } -// searchMoviesByTitle searches for movies on TMDB by title -func searchMoviesByTitle(title string, alternativeTitle string) ([]TMDBMovieResult, error) { - if config.Config.TMDB.ReadAccessToken == "" { - return nil, fmt.Errorf("TMDB is not initialized") +func searchMoviesByTitle(title string, alternativeTitle string) ([]types.TMDBMovieResult, error) { + if config.API.TMDBReadToken == "" { + logger.Errorf("TMDB", "TMDB is not initialized") + return nil, errors.New("TMDB is not initialized") } query := normalizeTitle(title) @@ -537,130 +482,134 @@ func searchMoviesByTitle(title string, alternativeTitle string) ([]TMDBMovieResu query = normalizeTitle(alternativeTitle) } - logger.Log(fmt.Sprintf("Searching TMDB for movie: %s", query), logger.LogOptions{ - Level: logger.Debug, - Prefix: "TMDB", - }) + logger.Debugf("TMDB", "Searching TMDB for movie: %s", query) - apiURL := "https://api.themoviedb.org/3/search/movie" + apiURL := tmdbAPIBaseURL + searchMovieEndpoint req, err := http.NewRequest("GET", apiURL, nil) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + logger.Errorf("TMDB", "Failed to create request: %v", err) + return nil, errors.New("failed to create request") } q := req.URL.Query() q.Add("query", query) req.URL.RawQuery = q.Encode() - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.Config.TMDB.ReadAccessToken)) - req.Header.Add("Accept", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.API.TMDBReadToken)) + req.Header.Add("Accept", acceptHeader) - resp, err := makeSimpleRequest(req) + resp, err := makeRequest(req) if err != nil { - return nil, fmt.Errorf("failed to search movies: %w", err) + logger.Errorf("TMDB", "Failed to search movies: %v", err) + return nil, errors.New("failed to search movies") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("search failed with status: %d", resp.StatusCode) + logger.Errorf("TMDB", "Search failed with status: %d", resp.StatusCode) + return nil, errors.New("search failed") } - var searchResp TMDBMovieSearchResponse + var searchResp types.TMDBMovieSearchResponse if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + logger.Errorf("TMDB", "Failed to decode response: %v", err) + return nil, errors.New("failed to decode response") } - logger.Log(fmt.Sprintf("Found %d movie results for: %s", len(searchResp.Results), query), logger.LogOptions{ - Level: logger.Debug, - Prefix: "TMDB", - }) + logger.Debugf("TMDB", "Found %d movie results for: %s", len(searchResp.Results), query) return searchResp.Results, nil } -// getMovieDetails fetches details for a specific movie -func getMovieDetails(movieID int) (*TMDBMovieDetails, error) { - if config.Config.TMDB.ReadAccessToken == "" { - return nil, fmt.Errorf("TMDB is not initialized") +func getMovieDetails(movieID int) (*types.TMDBMovieDetails, error) { + if config.API.TMDBReadToken == "" { + logger.Errorf("TMDB", "TMDB is not initialized") + return nil, errors.New("TMDB is not initialized") } - apiURL := fmt.Sprintf("https://api.themoviedb.org/3/movie/%d", movieID) + apiURL := fmt.Sprintf(tmdbAPIBaseURL+movieDetailsEndpoint, movieID) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + logger.Errorf("TMDB", "Failed to create request: %v", err) + return nil, errors.New("failed to create request") } - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.Config.TMDB.ReadAccessToken)) - req.Header.Add("Accept", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.API.TMDBReadToken)) + req.Header.Add("Accept", acceptHeader) - resp, err := makeSimpleRequest(req) + resp, err := makeRequest(req) if err != nil { - return nil, fmt.Errorf("failed to fetch movie details: %w", err) + logger.Errorf("TMDB", "Failed to fetch movie details: %v", err) + return nil, errors.New("failed to fetch movie details") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("request failed with status: %d", resp.StatusCode) + logger.Errorf("TMDB", "Request failed with status: %d", resp.StatusCode) + return nil, errors.New("request failed") } - var movieDetails TMDBMovieDetails + var movieDetails types.TMDBMovieDetails if err := json.NewDecoder(resp.Body).Decode(&movieDetails); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + logger.Errorf("TMDB", "Failed to decode response: %v", err) + return nil, errors.New("failed to decode response") } return &movieDetails, nil } -// GetMovieAsEpisode fetches movie details and returns it as a single episode -func GetMovieAsEpisode(title string, alternativeTitle string, tmdbID int, malID int, japaneseTitle string, animeScore float64) ([]types.AnimeSingleEpisode, error) { - logger.Log(fmt.Sprintf("Fetching movie episode data for: %s", title), logger.LogOptions{ - Level: logger.Debug, - Prefix: "TMDB", - }) +func EnrichEpisodeFromMovie(anime *entities.Anime) error { + if anime == nil || len(anime.Episodes) == 0 { + return nil + } + + episode := &anime.Episodes[0] + + title := "" + alternativeTitle := "" + japaneseTitle := "" + if anime.Title != nil { + title = anime.Title.Romaji + alternativeTitle = anime.Title.English + japaneseTitle = anime.Title.Japanese + } + + tmdbID := 0 + malID := anime.MALID + if anime.Mapping != nil { + tmdbID = anime.Mapping.TMDB + } + + animeScore := 0.0 + if anime.Scores != nil { + animeScore = anime.Scores.Score + } + + logger.Debugf("TMDB", "Fetching movie episode data for: %s", title) var movieID int var err error - // If TMDB ID is provided, use it directly if tmdbID > 0 { movieID = tmdbID - logger.Log(fmt.Sprintf("Using provided TMDB movie ID: %d", movieID), logger.LogOptions{ - Level: logger.Debug, - Prefix: "TMDB", - }) + logger.Debugf("TMDB", "Using provided TMDB movie ID: %d", movieID) } else { - // Search for the movie movies, err := searchMoviesByTitle(title, alternativeTitle) if err != nil || len(movies) == 0 { - logger.Log(fmt.Sprintf("Failed to find movie on TMDB: %v", err), logger.LogOptions{ - Level: logger.Warn, - Prefix: "TMDB", - }) - return nil, fmt.Errorf("movie not found on TMDB") + logger.Warnf("TMDB", "Failed to find movie on TMDB: %v", err) + return errors.New("movie not found on TMDB") } - // Use the first result movieID = movies[0].ID - logger.Log(fmt.Sprintf("Found TMDB movie ID: %d for title: %s", movieID, title), logger.LogOptions{ - Level: logger.Debug, - Prefix: "TMDB", - }) + logger.Debugf("TMDB", "Found TMDB movie ID: %d for title: %s", movieID, title) } - // Get movie details movieDetails, err := getMovieDetails(movieID) if err != nil { - logger.Log(fmt.Sprintf("Failed to fetch movie details: %v", err), logger.LogOptions{ - Level: logger.Warn, - Prefix: "TMDB", - }) - return nil, err + logger.Warnf("TMDB", "Failed to fetch movie details: %v", err) + return err } - // Convert movie to a single episode format - const tmdbImageBaseURL = "https://image.tmdb.org/t/p/" - const backdropSize = "w780" - backdropURL := "" if movieDetails.BackdropPath != "" { backdropURL = tmdbImageBaseURL + backdropSize + movieDetails.BackdropPath @@ -670,20 +619,18 @@ func GetMovieAsEpisode(title string, alternativeTitle string, tmdbID int, malID description := movieDetails.Overview if description == "" { - description = "No description available" + description = noDescription } - // Create titles structure with English title from TMDB and Japanese/Romaji from MAL - titles := types.EpisodeTitles{ - English: movieDetails.Title, - Japanese: japaneseTitle, - Romaji: title, + if episode.Title == nil { + episode.Title = &entities.Title{} } + episode.Title.English = movieDetails.Title + episode.Title.Japanese = japaneseTitle + episode.Title.Romaji = title - // Calculate score out of 5 (half of MAL score out of 10), rounded to 2 decimal points movieScore := float64(int((animeScore/2.0)*100)) / 100 - // Generate MAL URLs malURL := "" forumURL := "" if malID > 0 { @@ -691,39 +638,25 @@ func GetMovieAsEpisode(title string, alternativeTitle string, tmdbID int, malID forumURL = fmt.Sprintf("https://myanimelist.net/anime/%d/forum", malID) } - episode := types.AnimeSingleEpisode{ - ID: generateEpisodeID(titles), - Titles: titles, - Description: description, - ThumbnailURL: backdropURL, - Aired: movieDetails.ReleaseDate, - Score: movieScore, - Filler: false, - Recap: false, - ForumURL: forumURL, - URL: malURL, - } + episode.EpisodeID = generateEpisodeID(malID, 1, movieDetails.Title) + episode.Description = description + episode.ThumbnailURL = backdropURL + episode.Aired = movieDetails.ReleaseDate + episode.Score = movieScore + episode.Filler = false + episode.Recap = false + episode.ForumURL = forumURL + episode.URL = malURL + episode.EpisodeNumber = 1 + episode.EpisodeLength = float64(movieDetails.Runtime) - logger.Log(fmt.Sprintf("Successfully created episode from movie: %s", title), logger.LogOptions{ - Level: logger.Success, - Prefix: "TMDB", - }) + logger.Successf("TMDB", "Successfully created episode from movie: %s", title) - return []types.AnimeSingleEpisode{episode}, nil + return nil } -// generateEpisodeID creates a unique episode ID from titles -func generateEpisodeID(titles types.EpisodeTitles) string { - var title string - if titles.English != "" { - title = titles.English - } else if titles.Romaji != "" { - title = titles.Romaji - } else { - title = titles.Japanese - } - - // MD5 hash for ID generation to match Jikan episode IDs - hash := md5.Sum([]byte(title)) +func generateEpisodeID(malID int, episodeNumber int, title string) string { + uniqueString := fmt.Sprintf("%d-%d-%s", malID, episodeNumber, title) + hash := md5.Sum([]byte(uniqueString)) return fmt.Sprintf("%x", hash) } diff --git a/utils/api/tmdb/types.go b/utils/api/tmdb/types.go index 1ef8a27..0ade27a 100644 --- a/utils/api/tmdb/types.go +++ b/utils/api/tmdb/types.go @@ -1,81 +1,9 @@ package tmdb -// TMDBShowResult represents a TV show result from TMDB search -type TMDBShowResult struct { - ID int `json:"id"` - Name string `json:"name"` - FirstAirDate string `json:"first_air_date"` - OriginCountry []string `json:"origin_country"` - Adult bool `json:"adult"` -} - -// TMDBSearchResponse represents the response from TMDB search API -type TMDBSearchResponse struct { - Page int `json:"page"` - Results []TMDBShowResult `json:"results"` - TotalPages int `json:"total_pages"` - TotalResults int `json:"total_results"` -} - -// TMDBEpisode represents a TV episode from TMDB -type TMDBEpisode struct { - ID int `json:"id"` - Name string `json:"name"` - Overview string `json:"overview"` - StillPath string `json:"still_path"` - AirDate string `json:"air_date"` - EpisodeNumber int `json:"episode_number"` - SeasonNumber int `json:"season_number"` -} - -// TMDBSeasonDetails represents a TV season from TMDB -type TMDBSeasonDetails struct { - ID int `json:"id"` - AirDate string `json:"air_date"` - EpisodeCount int `json:"episode_count"` - Name string `json:"name"` - Overview string `json:"overview"` - SeasonNumber int `json:"season_number"` - Episodes []TMDBEpisode `json:"episodes"` -} - -// TMDBShowDetails represents a TV show from TMDB -type TMDBShowDetails struct { - ID int `json:"id"` - Name string `json:"name"` - Overview string `json:"overview"` - Seasons []struct { - ID int `json:"id"` - Name string `json:"name"` - SeasonNumber int `json:"season_number"` - EpisodeCount int `json:"episode_count"` - AirDate string `json:"air_date"` - } `json:"seasons"` -} - -// TMDBMovieResult represents a movie result from TMDB search -type TMDBMovieResult struct { - ID int `json:"id"` - Title string `json:"title"` - ReleaseDate string `json:"release_date"` - Adult bool `json:"adult"` -} - -// TMDBMovieSearchResponse represents the response from TMDB movie search API -type TMDBMovieSearchResponse struct { - Page int `json:"page"` - Results []TMDBMovieResult `json:"results"` - TotalPages int `json:"total_pages"` - TotalResults int `json:"total_results"` -} +import ( + "net/http" +) -// TMDBMovieDetails represents a movie from TMDB -type TMDBMovieDetails struct { - ID int `json:"id"` - Title string `json:"title"` - Overview string `json:"overview"` - PosterPath string `json:"poster_path"` - BackdropPath string `json:"backdrop_path"` - ReleaseDate string `json:"release_date"` - Runtime int `json:"runtime"` +type client struct { + httpClient *http.Client } diff --git a/utils/api/tvdb/tvdb.go b/utils/api/tvdb/tvdb.go index dd83643..5d6f78d 100644 --- a/utils/api/tvdb/tvdb.go +++ b/utils/api/tvdb/tvdb.go @@ -4,9 +4,9 @@ import ( "bytes" "crypto/md5" "encoding/json" + "errors" "fmt" "metachan/config" - "metachan/database" "metachan/entities" "metachan/types" "metachan/utils/logger" @@ -14,206 +14,186 @@ import ( "time" ) -var tvdbToken string -var tvdbTokenExpiry time.Time +const ( + tvdbAPIBaseURL = "https://api4.thetvdb.com/v4" + tvdbLoginEndpoint = "/login" + tvdbImageBaseURL = "https://artworks.thetvdb.com" + timeout = 10 * time.Second + episodesTimeout = 15 * time.Second + tokenExpiry = 24 * time.Hour + contentType = "application/json" + acceptHeader = "application/json" + noDescription = "No description available" + recapType = "recap" +) -// authenticateTVDB authenticates with TVDB API and returns a token -func authenticateTVDB() (string, error) { - // Check if we have a valid token - if tvdbToken != "" && time.Now().Before(tvdbTokenExpiry) { - return tvdbToken, nil +var ( + clientInstance = &client{ + httpClient: &http.Client{ + Timeout: timeout, + }, } +) - if config.Config.TVDB.APIKey == "" { - return "", fmt.Errorf("TVDB API key is not set") +func authenticate() (string, error) { + if clientInstance.token != "" && time.Now().Before(clientInstance.tokenExpiry) { + return clientInstance.token, nil } - logger.Log("Authenticating with TVDB API", logger.LogOptions{ - Level: logger.Debug, - Prefix: "TVDB", - }) + if config.API.TVDBKey == "" { + logger.Errorf("TVDB", "TVDB API key is not set") + return "", errors.New("TVDB API key is not set") + } - client := &http.Client{Timeout: 10 * time.Second} + logger.Debugf("TVDB", "Authenticating with TVDB API") - // Create request body with apikey - authBody := map[string]string{"apikey": config.Config.TVDB.APIKey} + authBody := map[string]string{"apikey": config.API.TVDBKey} jsonBody, err := json.Marshal(authBody) if err != nil { - return "", fmt.Errorf("failed to marshal auth body: %w", err) + logger.Errorf("TVDB", "Failed to marshal auth body: %v", err) + return "", errors.New("failed to marshal auth body") } - req, err := http.NewRequest("POST", "https://api4.thetvdb.com/v4/login", bytes.NewBuffer(jsonBody)) + req, err := http.NewRequest("POST", tvdbAPIBaseURL+tvdbLoginEndpoint, bytes.NewBuffer(jsonBody)) if err != nil { - return "", fmt.Errorf("failed to create auth request: %w", err) + logger.Errorf("TVDB", "Failed to create auth request: %v", err) + return "", errors.New("failed to create auth request") } - req.Header.Add("Content-Type", "application/json") + req.Header.Add("Content-Type", contentType) - resp, err := client.Do(req) + resp, err := clientInstance.httpClient.Do(req) if err != nil { - return "", fmt.Errorf("failed to authenticate: %w", err) + logger.Errorf("TVDB", "Failed to authenticate: %v", err) + return "", errors.New("failed to authenticate") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("authentication failed with status: %d", resp.StatusCode) + logger.Errorf("TVDB", "Authentication failed with status: %d", resp.StatusCode) + return "", errors.New("authentication failed") } - var authResp TVDBAuthResponse + var authResp types.TVDBAuthResponse if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { - return "", fmt.Errorf("failed to decode auth response: %w", err) + logger.Errorf("TVDB", "Failed to decode auth response: %v", err) + return "", errors.New("failed to decode auth response") } if authResp.Data.Token == "" { - return "", fmt.Errorf("no token received from TVDB") + logger.Errorf("TVDB", "No token received from TVDB") + return "", errors.New("no token received from TVDB") } - // Store token and set expiry (TVDB tokens typically last 30 days, but we'll refresh after 24 hours to be safe) - tvdbToken = authResp.Data.Token - tvdbTokenExpiry = time.Now().Add(24 * time.Hour) + clientInstance.token = authResp.Data.Token + clientInstance.tokenExpiry = time.Now().Add(tokenExpiry) - logger.Log("Successfully authenticated with TVDB", logger.LogOptions{ - Level: logger.Success, - Prefix: "TVDB", - }) + logger.Successf("TVDB", "Successfully authenticated with TVDB") - return tvdbToken, nil + return clientInstance.token, nil } -// GetSeriesEpisodes fetches all episodes for a TVDB series -func GetSeriesEpisodes(tvdbID int) ([]TVDBEpisode, error) { - token, err := authenticateTVDB() +func GetSeriesEpisodes(tvdbID int) ([]types.TVDBEpisode, error) { + token, err := authenticate() if err != nil { - return nil, fmt.Errorf("failed to authenticate with TVDB: %w", err) + logger.Errorf("TVDB", "Failed to authenticate with TVDB for series %d: %v", tvdbID, err) + return nil, errors.New("failed to authenticate with TVDB") } - logger.Log(fmt.Sprintf("Fetching episodes for TVDB series %d", tvdbID), logger.LogOptions{ - Level: logger.Debug, - Prefix: "TVDB", - }) + logger.Debugf("TVDB", "Fetching episodes for TVDB series %d", tvdbID) - client := &http.Client{Timeout: 15 * time.Second} + tempClient := &http.Client{Timeout: episodesTimeout} - // TVDB v4 API endpoint for episodes - url := fmt.Sprintf("https://api4.thetvdb.com/v4/series/%d/episodes/default", tvdbID) + url := fmt.Sprintf("%s/series/%d/episodes/default", tvdbAPIBaseURL, tvdbID) req, err := http.NewRequest("GET", url, nil) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + logger.Errorf("TVDB", "Failed to create request for series %d: %v", tvdbID, err) + return nil, errors.New("failed to create request") } req.Header.Add("Authorization", "Bearer "+token) - req.Header.Add("Accept", "application/json") + req.Header.Add("Accept", acceptHeader) - resp, err := client.Do(req) + resp, err := tempClient.Do(req) if err != nil { - return nil, fmt.Errorf("failed to fetch episodes: %w", err) + logger.Errorf("TVDB", "Failed to fetch episodes for series %d: %v", tvdbID, err) + return nil, errors.New("failed to fetch episodes") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch episodes with status: %d", resp.StatusCode) + logger.Errorf("TVDB", "Failed to fetch episodes with status: %d", resp.StatusCode) + return nil, errors.New("failed to fetch episodes") } - var episodesResp TVDBEpisodesResponse + var episodesResp types.TVDBEpisodesResponse if err := json.NewDecoder(resp.Body).Decode(&episodesResp); err != nil { - return nil, fmt.Errorf("failed to decode episodes response: %w", err) + logger.Errorf("TVDB", "Failed to decode episodes response for series %d: %v", tvdbID, err) + return nil, errors.New("failed to decode episodes response") } - logger.Log(fmt.Sprintf("Successfully fetched %d episodes from TVDB for series %d", len(episodesResp.Data.Episodes), tvdbID), logger.LogOptions{ - Level: logger.Success, - Prefix: "TVDB", - }) + logger.Successf("TVDB", "Successfully fetched %d episodes from TVDB for series %d", len(episodesResp.Data.Episodes), tvdbID) return episodesResp.Data.Episodes, nil } -// ConvertTVDBEpisodesToAnimeEpisodes converts TVDB episodes to anime episode format -func ConvertTVDBEpisodesToAnimeEpisodes(tvdbEpisodes []TVDBEpisode) []types.AnimeSingleEpisode { - var animeEpisodes []types.AnimeSingleEpisode +func EnrichEpisodesFromTVDB(anime *entities.Anime, tvdbEpisodes []types.TVDBEpisode) { + if anime == nil || len(anime.Episodes) == 0 { + return + } - const tvdbImageBaseURL = "https://artworks.thetvdb.com" + malID := anime.MALID - for _, ep := range tvdbEpisodes { - // Generate episode ID from name - titles := types.EpisodeTitles{ - English: ep.Name, - Japanese: "", - Romaji: "", + for i, ep := range tvdbEpisodes { + if i >= len(anime.Episodes) { + break } - thumbnailURL := "" - if ep.Image != "" { - thumbnailURL = ep.Image - } + episode := &anime.Episodes[i] - description := ep.Overview - if description == "" { - description = "No description available" + if episode.Title == nil { + episode.Title = &entities.Title{} } - - isRecap := false - if ep.FinaleType != nil && *ep.FinaleType == "recap" { - isRecap = true + if ep.Name != "" { + episode.Title.English = ep.Name } - animeEpisodes = append(animeEpisodes, types.AnimeSingleEpisode{ - ID: generateEpisodeID(titles), - Titles: titles, - Description: description, - Aired: ep.Aired, - ThumbnailURL: thumbnailURL, - Score: 0, - Filler: false, - Recap: isRecap, - ForumURL: "", - URL: "", - }) - } - - return animeEpisodes -} + if ep.Image != "" { + episode.ThumbnailURL = ep.Image + } -// generateEpisodeID creates a unique episode ID from titles -func generateEpisodeID(titles types.EpisodeTitles) string { - var title string - if titles.English != "" { - title = titles.English - } else if titles.Romaji != "" { - title = titles.Romaji - } else { - title = titles.Japanese - } + if ep.Overview != "" { + episode.Description = ep.Overview + } else { + episode.Description = noDescription + } - // MD5 hash for ID generation to match Jikan episode IDs - hash := md5.Sum([]byte(title)) - return fmt.Sprintf("%x", hash) -} + if ep.Aired != "" { + episode.Aired = ep.Aired + } -// FindSeasonMappings finds all anime mappings that belong to the same series based on TVDB ID -func FindSeasonMappings(tvdbID int) ([]entities.AnimeMapping, error) { - logger.Log(fmt.Sprintf("Finding season mappings for TVDB ID %d", tvdbID), logger.LogOptions{ - Level: logger.Debug, - Prefix: "TVDB", - }) + if ep.FinaleType != nil && *ep.FinaleType == recapType { + episode.Recap = true + } - // 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) - } + episode.EpisodeNumber = ep.Number + episode.EpisodeLength = float64(ep.Runtime) - if len(mappings) == 0 { - logger.Log(fmt.Sprintf("No season mappings found for TVDB ID %d", tvdbID), logger.LogOptions{ - Level: logger.Debug, - Prefix: "TVDB", - }) - } else { - logger.Log(fmt.Sprintf("Found %d season mappings for TVDB ID %d", len(mappings), tvdbID), logger.LogOptions{ - Level: logger.Info, - Prefix: "TVDB", - }) + titleForID := ep.Name + if titleForID == "" && episode.Title != nil { + if episode.Title.English != "" { + titleForID = episode.Title.English + } else if episode.Title.Romaji != "" { + titleForID = episode.Title.Romaji + } + } + episode.EpisodeID = generateEpisodeID(malID, ep.Number, titleForID) } +} - return mappings, nil +func generateEpisodeID(malID int, episodeNumber int, title string) string { + uniqueString := fmt.Sprintf("%d-%d-%s", malID, episodeNumber, title) + hash := md5.Sum([]byte(uniqueString)) + return fmt.Sprintf("%x", hash) } diff --git a/utils/api/tvdb/types.go b/utils/api/tvdb/types.go index 4922041..151dbbc 100644 --- a/utils/api/tvdb/types.go +++ b/utils/api/tvdb/types.go @@ -1,43 +1,12 @@ package tvdb -// TVDBAuthResponse represents the authentication response from TVDB -type TVDBAuthResponse struct { - Status string `json:"status"` - Data struct { - Token string `json:"token"` - } `json:"data"` -} - -// TVDBEpisode represents an episode from TVDB API v4 -type TVDBEpisode struct { - ID int `json:"id"` - SeriesID int `json:"seriesId"` - Name string `json:"name"` - Aired string `json:"aired"` - Runtime int `json:"runtime"` - NameTranslations []string `json:"nameTranslations"` - Overview string `json:"overview"` - OverviewTranslations []string `json:"overviewTranslations"` - Image string `json:"image"` - ImageType int `json:"imageType"` - IsMovie int `json:"isMovie"` - Number int `json:"number"` - AbsoluteNumber int `json:"absoluteNumber"` - SeasonNumber int `json:"seasonNumber"` - LastUpdated string `json:"lastUpdated"` - FinaleType *string `json:"finaleType"` - AirsBeforeSeason int `json:"airsBeforeSeason"` - AirsBeforeEpisode int `json:"airsBeforeEpisode"` - Year string `json:"year"` -} - -// TVDBEpisodesData represents the data container for episodes -type TVDBEpisodesData struct { - Episodes []TVDBEpisode `json:"episodes"` -} - -// TVDBEpisodesResponse represents the episodes response from TVDB API v4 -type TVDBEpisodesResponse struct { - Status string `json:"status"` - Data TVDBEpisodesData `json:"data"` +import ( + "net/http" + "time" +) + +type client struct { + httpClient *http.Client + token string + tokenExpiry time.Time } |
