aboutsummaryrefslogtreecommitdiff
path: root/utils
diff options
context:
space:
mode:
Diffstat (limited to 'utils')
-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
4 files changed, 457 insertions, 1 deletions
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"`
+}