aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--config/config.go7
-rw-r--r--services/anime/helpers.go4
-rw-r--r--services/anime/service.go105
-rw-r--r--types/server.go5
-rw-r--r--utils/api/tmdb/tmdb.go203
-rw-r--r--utils/api/tmdb/types.go27
-rw-r--r--utils/api/tvdb/tvdb.go185
-rw-r--r--utils/api/tvdb/types.go43
8 files changed, 558 insertions, 21 deletions
diff --git a/config/config.go b/config/config.go
index c70560d..b0e9eee 100644
--- a/config/config.go
+++ b/config/config.go
@@ -29,6 +29,9 @@ func init() {
APIKey: getEnv("TMDB_API_KEY"),
ReadAccessToken: getEnv("TMDB_READ_ACCESS_TOKEN"),
},
+ TVDB: types.TVDBConfig{
+ APIKey: getEnv("TVDB_API_KEY"),
+ },
}
switch Config.DatabaseDriver {
@@ -53,6 +56,10 @@ func init() {
logger.Log("Invalid TMDB read access token or TMDB read access token not set", logOptions)
}
+ if Config.TVDB.APIKey == "" {
+ logger.Log("Invalid TVDB API key or TVDB API key not set", logOptions)
+ }
+
logOptions.Level = logger.Success
logOptions.Fatal = false
logger.Log("Config initialized successfully", logOptions)
diff --git a/services/anime/helpers.go b/services/anime/helpers.go
index 932dbc2..2c81116 100644
--- a/services/anime/helpers.go
+++ b/services/anime/helpers.go
@@ -9,7 +9,7 @@ import (
"metachan/utils/api/jikan"
"metachan/utils/api/malsync"
"metachan/utils/api/tmdb"
- api "metachan/utils/api/tvdb"
+ "metachan/utils/api/tvdb"
"metachan/utils/logger"
"net/http"
"strings"
@@ -551,4 +551,4 @@ func extractCrunchyrollSeriesID(crURL string) string {
}
// FindSeasonMappings finds all anime mappings that belong to the same series based on TVDB ID
-var FindSeasonMappings = api.FindSeasonMappings
+var FindSeasonMappings = tvdb.FindSeasonMappings
diff --git a/services/anime/service.go b/services/anime/service.go
index 34a32df..553c6fd 100644
--- a/services/anime/service.go
+++ b/services/anime/service.go
@@ -9,6 +9,8 @@ import (
"metachan/utils/api/jikan"
"metachan/utils/api/malsync"
"metachan/utils/api/streaming"
+ "metachan/utils/api/tmdb"
+ "metachan/utils/api/tvdb"
"metachan/utils/concurrency"
"metachan/utils/logger"
"strings"
@@ -198,20 +200,92 @@ func (s *Service) GetAnimeDetailsWithSource(mapping *entities.AnimeMapping, sour
defer close(dubbedCountChan)
defer close(tmdbErrorChan)
- basicEpisodes := generateBasicEpisodes(episodes.Data)
- logger.Log(fmt.Sprintf("Generated basic episodes: %d", len(basicEpisodes)), logger.LogOptions{
- Level: logger.Debug,
- Prefix: "AnimeAPI",
- })
+ var enrichedEpisodes []types.AnimeSingleEpisode
+ var tmdbErr error
- // Enrich episodes with TMDB data
- logger.Log(fmt.Sprintf("Starting enrichEpisodes for %d episodes", len(basicEpisodes)), logger.LogOptions{
- Level: logger.Debug,
- Prefix: "AnimeAPI",
- })
- enrichStart := time.Now()
+ // Check anime type - use different sources for movies vs TV shows
+ animeType := string(mapping.Type)
+
+ if (animeType == "MOVIE" || animeType == "Movie") && mapping.TMDB != 0 {
+ // For movies with TMDB mapping, use TMDB to get movie details as a single episode
+ logger.Log(fmt.Sprintf("Detected movie type with TMDB ID %d, fetching from TMDB for: %s", mapping.TMDB, anime.Data.Title), logger.LogOptions{
+ Level: logger.Debug,
+ Prefix: "AnimeAPI",
+ })
+
+ enrichedEpisodes, tmdbErr = tmdb.GetMovieAsEpisode(
+ anime.Data.Title,
+ anime.Data.TitleEnglish,
+ mapping.TMDB,
+ anime.Data.MALID,
+ anime.Data.TitleJapanese,
+ anime.Data.Score,
+ )
+ if tmdbErr != nil {
+ logger.Log(fmt.Sprintf("Failed to get movie from TMDB: %v, falling back to basic episode", tmdbErr), logger.LogOptions{
+ Level: logger.Warn,
+ Prefix: "AnimeAPI",
+ })
+ // Fallback to basic episode generation
+ basicEpisodes := generateBasicEpisodes(episodes.Data)
+ enrichedEpisodes = basicEpisodes
+ }
+ } else {
+ // For TV shows, prefer TVDB over TMDB
+ var usedfallback bool
+
+ if mapping.TVDB != 0 {
+ // Try TVDB first for TV shows
+ logger.Log(fmt.Sprintf("Using TVDB for TV show episodes (TVDB ID: %d)", mapping.TVDB), logger.LogOptions{
+ Level: logger.Debug,
+ Prefix: "AnimeAPI",
+ })
+
+ tvdbEpisodes, tvdbErr := tvdb.GetSeriesEpisodes(mapping.TVDB)
+ if tvdbErr == nil && len(tvdbEpisodes) > 0 {
+ enrichedEpisodes = tvdb.ConvertTVDBEpisodesToAnimeEpisodes(tvdbEpisodes)
+ logger.Log(fmt.Sprintf("Successfully fetched %d episodes from TVDB", len(enrichedEpisodes)), logger.LogOptions{
+ Level: logger.Success,
+ Prefix: "TVDB",
+ })
+ } else {
+ logger.Log(fmt.Sprintf("TVDB fetch failed or returned no episodes: %v, falling back to TMDB", tvdbErr), logger.LogOptions{
+ Level: logger.Warn,
+ Prefix: "TVDB",
+ })
+ usedfallback = true
+ }
+ } else {
+ logger.Log("No TVDB ID available, using TMDB for episodes", logger.LogOptions{
+ Level: logger.Debug,
+ Prefix: "AnimeAPI",
+ })
+ usedfallback = true
+ }
+
+ // Fallback to TMDB if TVDB failed or wasn't available
+ if usedfallback {
+ basicEpisodes := generateBasicEpisodes(episodes.Data)
+ logger.Log(fmt.Sprintf("Generated basic episodes: %d", len(basicEpisodes)), logger.LogOptions{
+ Level: logger.Debug,
+ Prefix: "AnimeAPI",
+ })
+
+ logger.Log(fmt.Sprintf("Starting TMDB enrichment for %d episodes", len(basicEpisodes)), logger.LogOptions{
+ Level: logger.Debug,
+ Prefix: "AnimeAPI",
+ })
+ enrichStart := time.Now()
+
+ enrichedEpisodes, tmdbErr = AttachEpisodeDescriptions(anime.Data.Title, basicEpisodes, anime.Data.TitleEnglish, mapping.TMDB)
+
+ logger.Log(fmt.Sprintf("TMDB enrichment execution time: %s", time.Since(enrichStart)), logger.LogOptions{
+ Level: logger.Debug,
+ Prefix: "AnimeAPI",
+ })
+ }
+ }
- enrichedEpisodes, tmdbErr := AttachEpisodeDescriptions(anime.Data.Title, basicEpisodes, anime.Data.TitleEnglish, mapping.TMDB)
tmdbErrorChan <- tmdbErr
// Get subbed and dubbed episode counts in bulk with a single API call (much faster)
@@ -252,11 +326,6 @@ func (s *Service) GetAnimeDetailsWithSource(mapping *entities.AnimeMapping, sour
Prefix: "AnimeAPI",
})
- logger.Log(fmt.Sprintf("enrichEpisodes execution time: %s", time.Since(enrichStart)), logger.LogOptions{
- Level: logger.Debug,
- Prefix: "AnimeAPI",
- })
-
episodeDataChan <- enrichedEpisodes
subbedCountChan <- subCount
dubbedCountChan <- dubCount
@@ -270,7 +339,7 @@ func (s *Service) GetAnimeDetailsWithSource(mapping *entities.AnimeMapping, sour
Level: logger.Debug,
Prefix: "TVDB",
})
- seasonMappings, err := FindSeasonMappings(mapping.TVDB)
+ seasonMappings, err := tvdb.FindSeasonMappings(mapping.TVDB)
if err == nil && len(seasonMappings) > 0 {
logger.Log(fmt.Sprintf("Found %d season mappings for TVDB ID %d", len(seasonMappings), mapping.TVDB), logger.LogOptions{
Level: logger.Debug,
diff --git a/types/server.go b/types/server.go
index 1c7af2b..5bb7065 100644
--- a/types/server.go
+++ b/types/server.go
@@ -14,9 +14,14 @@ type TMDBConfig struct {
ReadAccessToken string
}
+type TVDBConfig struct {
+ APIKey string
+}
+
type ServerConfig struct {
DatabaseDriver DatabaseDriver
DataSourceName string
Port int
TMDB TMDBConfig
+ TVDB TVDBConfig
}
diff --git a/utils/api/tmdb/tmdb.go b/utils/api/tmdb/tmdb.go
index c162fa6..d4a490c 100644
--- a/utils/api/tmdb/tmdb.go
+++ b/utils/api/tmdb/tmdb.go
@@ -1,6 +1,7 @@
package tmdb
import (
+ "crypto/md5"
"encoding/json"
"fmt"
"math"
@@ -524,3 +525,205 @@ func AttachEpisodeDescriptions(title string, episodes []types.AnimeSingleEpisode
return enrichedEpisodes, 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")
+ }
+
+ query := normalizeTitle(title)
+ if query == "" && alternativeTitle != "" {
+ query = normalizeTitle(alternativeTitle)
+ }
+
+ logger.Log(fmt.Sprintf("Searching TMDB for movie: %s", query), logger.LogOptions{
+ Level: logger.Debug,
+ Prefix: "TMDB",
+ })
+
+ apiURL := "https://api.themoviedb.org/3/search/movie"
+ req, err := http.NewRequest("GET", apiURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ 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")
+
+ resp, err := makeSimpleRequest(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to search movies: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("search failed with status: %d", resp.StatusCode)
+ }
+
+ var searchResp TMDBMovieSearchResponse
+ if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ logger.Log(fmt.Sprintf("Found %d movie results for: %s", len(searchResp.Results), query), logger.LogOptions{
+ Level: logger.Debug,
+ Prefix: "TMDB",
+ })
+
+ 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")
+ }
+
+ apiURL := fmt.Sprintf("https://api.themoviedb.org/3/movie/%d", movieID)
+ req, err := http.NewRequest("GET", apiURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.Config.TMDB.ReadAccessToken))
+ req.Header.Add("Accept", "application/json")
+
+ resp, err := makeSimpleRequest(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch movie details: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("request failed with status: %d", resp.StatusCode)
+ }
+
+ var movieDetails TMDBMovieDetails
+ if err := json.NewDecoder(resp.Body).Decode(&movieDetails); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ 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",
+ })
+
+ 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",
+ })
+ } 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")
+ }
+
+ // 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",
+ })
+ }
+
+ // 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
+ }
+
+ // 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
+ } else if movieDetails.PosterPath != "" {
+ backdropURL = tmdbImageBaseURL + backdropSize + movieDetails.PosterPath
+ }
+
+ description := movieDetails.Overview
+ if description == "" {
+ description = "No description available"
+ }
+
+ // Create titles structure with English title from TMDB and Japanese/Romaji from MAL
+ titles := types.EpisodeTitles{
+ English: movieDetails.Title,
+ Japanese: japaneseTitle,
+ 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 {
+ malURL = fmt.Sprintf("https://myanimelist.net/anime/%d", 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,
+ }
+
+ logger.Log(fmt.Sprintf("Successfully created episode from movie: %s", title), logger.LogOptions{
+ Level: logger.Success,
+ Prefix: "TMDB",
+ })
+
+ return []types.AnimeSingleEpisode{episode}, 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))
+ return fmt.Sprintf("%x", hash)
+}
diff --git a/utils/api/tmdb/types.go b/utils/api/tmdb/types.go
index 8743573..1ef8a27 100644
--- a/utils/api/tmdb/types.go
+++ b/utils/api/tmdb/types.go
@@ -52,3 +52,30 @@ type TMDBShowDetails struct {
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"`
+}
+
+// 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"`
+}
diff --git a/utils/api/tvdb/tvdb.go b/utils/api/tvdb/tvdb.go
index 230be54..dd83643 100644
--- a/utils/api/tvdb/tvdb.go
+++ b/utils/api/tvdb/tvdb.go
@@ -1,12 +1,195 @@
-package api
+package tvdb
import (
+ "bytes"
+ "crypto/md5"
+ "encoding/json"
"fmt"
+ "metachan/config"
"metachan/database"
"metachan/entities"
+ "metachan/types"
"metachan/utils/logger"
+ "net/http"
+ "time"
)
+var tvdbToken string
+var tvdbTokenExpiry time.Time
+
+// 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
+ }
+
+ if config.Config.TVDB.APIKey == "" {
+ return "", fmt.Errorf("TVDB API key is not set")
+ }
+
+ logger.Log("Authenticating with TVDB API", logger.LogOptions{
+ Level: logger.Debug,
+ Prefix: "TVDB",
+ })
+
+ client := &http.Client{Timeout: 10 * time.Second}
+
+ // Create request body with apikey
+ authBody := map[string]string{"apikey": config.Config.TVDB.APIKey}
+ jsonBody, err := json.Marshal(authBody)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal auth body: %w", err)
+ }
+
+ req, err := http.NewRequest("POST", "https://api4.thetvdb.com/v4/login", bytes.NewBuffer(jsonBody))
+ if err != nil {
+ return "", fmt.Errorf("failed to create auth request: %w", err)
+ }
+
+ req.Header.Add("Content-Type", "application/json")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("failed to authenticate: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("authentication failed with status: %d", resp.StatusCode)
+ }
+
+ var authResp TVDBAuthResponse
+ if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
+ return "", fmt.Errorf("failed to decode auth response: %w", err)
+ }
+
+ if authResp.Data.Token == "" {
+ return "", fmt.Errorf("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)
+
+ logger.Log("Successfully authenticated with TVDB", logger.LogOptions{
+ Level: logger.Success,
+ Prefix: "TVDB",
+ })
+
+ return tvdbToken, nil
+}
+
+// GetSeriesEpisodes fetches all episodes for a TVDB series
+func GetSeriesEpisodes(tvdbID int) ([]TVDBEpisode, error) {
+ token, err := authenticateTVDB()
+ if err != nil {
+ return nil, fmt.Errorf("failed to authenticate with TVDB: %w", err)
+ }
+
+ logger.Log(fmt.Sprintf("Fetching episodes for TVDB series %d", tvdbID), logger.LogOptions{
+ Level: logger.Debug,
+ Prefix: "TVDB",
+ })
+
+ client := &http.Client{Timeout: 15 * time.Second}
+
+ // TVDB v4 API endpoint for episodes
+ url := fmt.Sprintf("https://api4.thetvdb.com/v4/series/%d/episodes/default", tvdbID)
+
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Add("Authorization", "Bearer "+token)
+ req.Header.Add("Accept", "application/json")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch episodes: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to fetch episodes with status: %d", resp.StatusCode)
+ }
+
+ var episodesResp TVDBEpisodesResponse
+ if err := json.NewDecoder(resp.Body).Decode(&episodesResp); err != nil {
+ return nil, fmt.Errorf("failed to decode episodes response: %w", err)
+ }
+
+ 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",
+ })
+
+ return episodesResp.Data.Episodes, nil
+}
+
+// ConvertTVDBEpisodesToAnimeEpisodes converts TVDB episodes to anime episode format
+func ConvertTVDBEpisodesToAnimeEpisodes(tvdbEpisodes []TVDBEpisode) []types.AnimeSingleEpisode {
+ var animeEpisodes []types.AnimeSingleEpisode
+
+ const tvdbImageBaseURL = "https://artworks.thetvdb.com"
+
+ for _, ep := range tvdbEpisodes {
+ // Generate episode ID from name
+ titles := types.EpisodeTitles{
+ English: ep.Name,
+ Japanese: "",
+ Romaji: "",
+ }
+
+ thumbnailURL := ""
+ if ep.Image != "" {
+ thumbnailURL = ep.Image
+ }
+
+ description := ep.Overview
+ if description == "" {
+ description = "No description available"
+ }
+
+ isRecap := false
+ if ep.FinaleType != nil && *ep.FinaleType == "recap" {
+ isRecap = true
+ }
+
+ 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
+}
+
+// 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))
+ return fmt.Sprintf("%x", hash)
+}
+
// 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{
diff --git a/utils/api/tvdb/types.go b/utils/api/tvdb/types.go
new file mode 100644
index 0000000..4922041
--- /dev/null
+++ b/utils/api/tvdb/types.go
@@ -0,0 +1,43 @@
+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"`
+}