using Jellyfin.Plugin.MetaShark.Configuration; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net; using System.Text; using System.Threading; using System.Threading.Tasks; using TMDbLib.Client; using TMDbLib.Objects.Collections; using TMDbLib.Objects.Find; using TMDbLib.Objects.General; using TMDbLib.Objects.Movies; using TMDbLib.Objects.People; using TMDbLib.Objects.Search; using TMDbLib.Objects.TvShows; namespace Jellyfin.Plugin.MetaShark.Api { public class TmdbApi : IDisposable { public const string DEFAULT_API_KEY = "4219e299c89411838049ab0dab19ebd5"; public const string DEFAULT_API_HOST = "api.tmdb.org"; private const int CacheDurationInHours = 1; private readonly ILogger _logger; private readonly IMemoryCache _memoryCache; private readonly TMDbClient _tmDbClient; /// /// Initializes a new instance of the class. /// public TmdbApi(ILoggerFactory loggerFactory) { _logger = loggerFactory.CreateLogger(); _memoryCache = new MemoryCache(new MemoryCacheOptions()); var config = Plugin.Instance?.Configuration; var apiKey = string.IsNullOrEmpty(config?.TmdbApiKey) ? DEFAULT_API_KEY : config.TmdbApiKey; var host = string.IsNullOrEmpty(config?.TmdbHost) ? DEFAULT_API_HOST : config.TmdbHost; _tmDbClient = new TMDbClient(apiKey, true, host); _tmDbClient.Timeout = TimeSpan.FromSeconds(10); // Not really interested in NotFoundException _tmDbClient.ThrowApiExceptions = false; } /// /// Gets a movie from the TMDb API based on its TMDb id. /// /// The movie's TMDb id. /// The movie's language. /// A comma-separated list of image languages. /// The cancellation token. /// The TMDb movie or null if not found. public async Task GetMovieAsync(int tmdbId, string language, string imageLanguages, CancellationToken cancellationToken) { if (!this.IsEnable()) { return null; } var key = $"movie-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out Movie movie)) { return movie; } try { await EnsureClientConfigAsync().ConfigureAwait(false); movie = await _tmDbClient.GetMovieAsync( tmdbId, NormalizeLanguage(language), GetImageLanguagesParam(imageLanguages), MovieMethods.Credits | MovieMethods.Releases | MovieMethods.Images | MovieMethods.Keywords | MovieMethods.Videos, cancellationToken).ConfigureAwait(false); if (movie != null) { _memoryCache.Set(key, movie, TimeSpan.FromHours(CacheDurationInHours)); } return movie; } catch (Exception ex) { this._logger.LogError(ex, ex.Message); return null; } } /// /// Gets a collection from the TMDb API based on its TMDb id. /// /// The collection's TMDb id. /// The collection's language. /// A comma-separated list of image languages. /// The cancellation token. /// The TMDb collection or null if not found. public async Task GetCollectionAsync(int tmdbId, string language, string imageLanguages, CancellationToken cancellationToken) { var key = $"collection-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out Collection collection)) { return collection; } await EnsureClientConfigAsync().ConfigureAwait(false); collection = await _tmDbClient.GetCollectionAsync( tmdbId, NormalizeLanguage(language), imageLanguages, CollectionMethods.Images, cancellationToken).ConfigureAwait(false); if (collection != null) { _memoryCache.Set(key, collection, TimeSpan.FromHours(CacheDurationInHours)); } return collection; } /// /// Gets a tv show from the TMDb API based on its TMDb id. /// /// The tv show's TMDb id. /// The tv show's language. /// A comma-separated list of image languages. /// The cancellation token. /// The TMDb tv show information or null if not found. public async Task GetSeriesAsync(int tmdbId, string language, string imageLanguages, CancellationToken cancellationToken) { if (!this.IsEnable()) { return null; } var key = $"series-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out TvShow series)) { return series; } try { await EnsureClientConfigAsync().ConfigureAwait(false); series = await _tmDbClient.GetTvShowAsync( tmdbId, language: NormalizeLanguage(language), includeImageLanguage: GetImageLanguagesParam(imageLanguages), extraMethods: TvShowMethods.Credits | TvShowMethods.Images | TvShowMethods.Keywords | TvShowMethods.ExternalIds | TvShowMethods.Videos | TvShowMethods.ContentRatings, cancellationToken: cancellationToken).ConfigureAwait(false); if (series != null) { _memoryCache.Set(key, series, TimeSpan.FromHours(CacheDurationInHours)); } return series; } catch (Exception ex) { this._logger.LogError(ex, ex.Message); return null; } } /// /// Gets a tv season from the TMDb API based on the tv show's TMDb id. /// /// The tv season's TMDb id. /// The season number. /// The tv season's language. /// A comma-separated list of image languages. /// The cancellation token. /// The TMDb tv season information or null if not found. public async Task GetSeasonAsync(int tvShowId, int seasonNumber, string language, string imageLanguages, CancellationToken cancellationToken) { if (!this.IsEnable()) { return null; } var key = $"season-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out TvSeason season)) { return season; } try { await EnsureClientConfigAsync().ConfigureAwait(false); season = await _tmDbClient.GetTvSeasonAsync( tvShowId, seasonNumber, language: NormalizeLanguage(language), includeImageLanguage: GetImageLanguagesParam(imageLanguages), extraMethods: TvSeasonMethods.Credits | TvSeasonMethods.Images | TvSeasonMethods.ExternalIds | TvSeasonMethods.Videos, cancellationToken: cancellationToken).ConfigureAwait(false); _memoryCache.Set(key, season, TimeSpan.FromHours(CacheDurationInHours)); return season; } catch (Exception ex) { // 可能网络有问题,缓存一下避免频繁请求 _memoryCache.Set(key, season, TimeSpan.FromSeconds(30)); this._logger.LogError(ex, ex.Message); return null; } } /// /// Gets a movie from the TMDb API based on the tv show's TMDb id. /// /// The tv show's TMDb id. /// The season number. /// The episode number. /// The episode's language. /// A comma-separated list of image languages. /// The cancellation token. /// The TMDb tv episode information or null if not found. public async Task GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string language, string imageLanguages, CancellationToken cancellationToken) { if (!this.IsEnable()) { return null; } var key = $"episode-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}e{episodeNumber.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out TvEpisode episode)) { return episode; } try { await EnsureClientConfigAsync().ConfigureAwait(false); episode = await _tmDbClient.GetTvEpisodeAsync( tvShowId, seasonNumber, episodeNumber, language: NormalizeLanguage(language), includeImageLanguage: GetImageLanguagesParam(imageLanguages), extraMethods: TvEpisodeMethods.Credits | TvEpisodeMethods.Images | TvEpisodeMethods.ExternalIds | TvEpisodeMethods.Videos, cancellationToken: cancellationToken).ConfigureAwait(false); if (episode != null) { _memoryCache.Set(key, episode, TimeSpan.FromHours(CacheDurationInHours)); } return episode; } catch (Exception ex) { this._logger.LogError(ex, ex.Message); return null; } } /// /// Gets a person eg. cast or crew member from the TMDb API based on its TMDb id. /// /// The person's TMDb id. /// The cancellation token. /// The TMDb person information or null if not found. public async Task GetPersonAsync(int personTmdbId, CancellationToken cancellationToken) { if (!this.IsEnable()) { return null; } var key = $"person-{personTmdbId.ToString(CultureInfo.InvariantCulture)}"; if (_memoryCache.TryGetValue(key, out Person person)) { return person; } try { await EnsureClientConfigAsync().ConfigureAwait(false); person = await _tmDbClient.GetPersonAsync( personTmdbId, PersonMethods.TvCredits | PersonMethods.MovieCredits | PersonMethods.Images | PersonMethods.ExternalIds, cancellationToken).ConfigureAwait(false); if (person != null) { _memoryCache.Set(key, person, TimeSpan.FromHours(CacheDurationInHours)); } return person; } catch (Exception ex) { this._logger.LogError(ex, ex.Message); return null; } } /// /// Gets an item from the TMDb API based on its id from an external service eg. IMDb id, TvDb id. /// /// The item's external id. /// The source of the id eg. IMDb. /// The item's language. /// The cancellation token. /// The TMDb item or null if not found. public async Task FindByExternalIdAsync( string externalId, FindExternalSource source, string language, CancellationToken cancellationToken) { if (!this.IsEnable()) { return null; } var key = $"find-{source.ToString()}-{externalId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out FindContainer result)) { return result; } try { await EnsureClientConfigAsync().ConfigureAwait(false); result = await _tmDbClient.FindAsync( source, externalId, NormalizeLanguage(language), cancellationToken).ConfigureAwait(false); if (result != null) { _memoryCache.Set(key, result, TimeSpan.FromHours(CacheDurationInHours)); } return result; } catch (Exception ex) { this._logger.LogError(ex, ex.Message); return null; } } /// /// Searches for a tv show using the TMDb API based on its name. /// /// The name of the tv show. /// The tv show's language. /// The cancellation token. /// The TMDb tv show information. public async Task> SearchSeriesAsync(string name, string language, CancellationToken cancellationToken) { if (!this.IsEnable()) { return new List(); } var key = $"searchseries-{name}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer series)) { return series.Results; } try { await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient .SearchTvShowAsync(name, NormalizeLanguage(language), cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) { _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); } return searchResults.Results; } catch (Exception ex) { return new List(); } } /// /// Searches for a person based on their name using the TMDb API. /// /// The name of the person. /// The cancellation token. /// The TMDb person information. public async Task> SearchPersonAsync(string name, CancellationToken cancellationToken) { if (!this.IsEnable()) { return new List(); } var key = $"searchperson-{name}"; if (_memoryCache.TryGetValue(key, out SearchContainer person)) { return person.Results; } try { await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient .SearchPersonAsync(name, cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) { _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); } return searchResults.Results; } catch (Exception ex) { this._logger.LogError(ex, ex.Message); return new List(); } } /// /// Searches for a movie based on its name using the TMDb API. /// /// The name of the movie. /// The movie's language. /// The cancellation token. /// The TMDb movie information. public Task> SearchMovieAsync(string name, string language, CancellationToken cancellationToken) { return SearchMovieAsync(name, 0, language, cancellationToken); } /// /// Searches for a movie based on its name using the TMDb API. /// /// The name of the movie. /// The release year of the movie. /// The movie's language. /// The cancellation token. /// The TMDb movie information. public async Task> SearchMovieAsync(string name, int year, string language, CancellationToken cancellationToken) { if (!this.IsEnable()) { return new List(); } var key = $"moviesearch-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer movies)) { return movies.Results; } try { await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient .SearchMovieAsync(name, NormalizeLanguage(language), year: year, cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) { _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); } return searchResults.Results; } catch (Exception ex) { this._logger.LogError(ex, ex.Message); return new List(); } } /// /// Searches for a collection based on its name using the TMDb API. /// /// The name of the collection. /// The collection's language. /// The cancellation token. /// The TMDb collection information. public async Task> SearchCollectionAsync(string name, string language, CancellationToken cancellationToken) { if (!this.IsEnable()) { return new List(); } var key = $"collectionsearch-{name}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer collections)) { return collections.Results; } try { await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient .SearchCollectionAsync(name, NormalizeLanguage(language), cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) { _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); } return searchResults.Results; } catch (Exception ex) { this._logger.LogError(ex, ex.Message); return new List(); } } /// /// Gets the absolute URL of the poster. /// /// The relative URL of the poster. /// The absolute URL. public string? GetPosterUrl(string posterPath) { if (string.IsNullOrEmpty(posterPath)) { return null; } return _tmDbClient.GetImageUrl(_tmDbClient.Config.Images.PosterSizes[^1], posterPath).ToString(); } /// /// Gets the absolute URL of the backdrop image. /// /// The relative URL of the backdrop image. /// The absolute URL. public string? GetBackdropUrl(string posterPath) { if (string.IsNullOrEmpty(posterPath)) { return null; } return _tmDbClient.GetImageUrl(_tmDbClient.Config.Images.BackdropSizes[^1], posterPath).ToString(); } /// /// Gets the absolute URL of the profile image. /// /// The relative URL of the profile image. /// The absolute URL. public string? GetProfileUrl(string actorProfilePath) { if (string.IsNullOrEmpty(actorProfilePath)) { return null; } return _tmDbClient.GetImageUrl(_tmDbClient.Config.Images.ProfileSizes[^1], actorProfilePath).ToString(); } /// /// Gets the absolute URL of the still image. /// /// The relative URL of the still image. /// The absolute URL. public string? GetStillUrl(string filePath) { if (string.IsNullOrEmpty(filePath)) { return null; } return _tmDbClient.GetImageUrl(_tmDbClient.Config.Images.StillSizes[^1], filePath).ToString(); } /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Dispose. /// /// Dispose all members. protected virtual void Dispose(bool disposing) { if (disposing) { _memoryCache.Dispose(); _tmDbClient.Dispose(); } } private Task EnsureClientConfigAsync() { return !_tmDbClient.HasConfig ? _tmDbClient.GetConfigAsync() : Task.CompletedTask; } /// /// Normalizes a language string for use with TMDb's language parameter. /// /// The language code. /// The normalized language code. public string NormalizeLanguage(string language) { if (string.IsNullOrEmpty(language)) { return language; } // They require this to be uppercase // Everything after the hyphen must be written in uppercase due to a way TMDB wrote their api. // See here: https://www.themoviedb.org/talk/5119221d760ee36c642af4ad?page=3#56e372a0c3a3685a9e0019ab var parts = language.Split('-'); if (parts.Length == 2) { language = parts[0] + "-" + parts[1].ToUpperInvariant(); } return language; } public string GetImageLanguagesParam(string preferredLanguage) { if (string.IsNullOrEmpty(preferredLanguage)) { return null; } var languages = new List(); if (!string.IsNullOrEmpty(preferredLanguage)) { preferredLanguage = NormalizeLanguage(preferredLanguage); languages.Add(preferredLanguage); if (preferredLanguage.Length == 5) // like en-US { // Currently, TMDB supports 2-letter language codes only // They are planning to change this in the future, thus we're // supplying both codes if we're having a 5-letter code. languages.Add(preferredLanguage.Substring(0, 2)); } } languages.Add("null"); if (!string.Equals(preferredLanguage, "en", StringComparison.OrdinalIgnoreCase)) { languages.Add("en"); } return string.Join(',', languages); } private bool IsEnable() { return Plugin.Instance?.Configuration.EnableTmdb ?? true; } } }