aboutsummaryrefslogtreecommitdiff
path: root/utils/api
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-02-06 15:53:32 +0530
committerBobby <[email protected]>2026-02-06 15:53:32 +0530
commit4300955f9d90dae98c5503b3bab88a5a22dd5bba (patch)
tree46071313463c91c2f3529a2d6b5057bff5aa3066 /utils/api
parent3980ea772e3895c127ac147ec07d69a2ab9b71a7 (diff)
downloadmetachan-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/api')
-rw-r--r--utils/api/anilist/anilist.go20
-rw-r--r--utils/api/aniskip/aniskip.go9
-rw-r--r--utils/api/jikan/jikan.go9
-rw-r--r--utils/api/malsync/malsync.go14
-rw-r--r--utils/api/streaming/streaming.go279
-rw-r--r--utils/api/streaming/types.go45
-rw-r--r--utils/api/tmdb/tmdb.go619
-rw-r--r--utils/api/tmdb/types.go82
-rw-r--r--utils/api/tvdb/tvdb.go240
-rw-r--r--utils/api/tvdb/types.go49
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
}