using System; using System.Collections.Generic; using System.Globalization; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Caching.Memory; 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 MediaBrowser.Providers.Plugins.Tmdb { /// /// Manager class for abstracting the TMDb API client library. /// public class TmdbClientManager : IDisposable { private const int CacheDurationInHours = 1; private readonly IMemoryCache _memoryCache; private readonly TMDbClient _tmDbClient; /// /// Initializes a new instance of the class. /// /// An instance of . public TmdbClientManager(IMemoryCache memoryCache) { _memoryCache = memoryCache; _tmDbClient = new TMDbClient(TmdbUtils.ApiKey); // 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) { var key = $"movie-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out Movie? movie)) { return movie; } await EnsureClientConfigAsync().ConfigureAwait(false); var extraMethods = MovieMethods.Credits | MovieMethods.Releases | MovieMethods.Images | MovieMethods.Videos; if (!(Plugin.Instance?.Configuration.ExcludeTagsMovies).GetValueOrDefault()) { extraMethods |= MovieMethods.Keywords; } movie = await _tmDbClient.GetMovieAsync( tmdbId, TmdbUtils.NormalizeLanguage(language), imageLanguages, extraMethods, cancellationToken).ConfigureAwait(false); if (movie is not null) { _memoryCache.Set(key, movie, TimeSpan.FromHours(CacheDurationInHours)); } return movie; } /// /// 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, TmdbUtils.NormalizeLanguage(language), imageLanguages, CollectionMethods.Images, cancellationToken).ConfigureAwait(false); if (collection is not 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) { var key = $"series-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out TvShow? series)) { return series; } await EnsureClientConfigAsync().ConfigureAwait(false); var extraMethods = TvShowMethods.Credits | TvShowMethods.Images | TvShowMethods.ExternalIds | TvShowMethods.Videos | TvShowMethods.ContentRatings | TvShowMethods.EpisodeGroups; if (!(Plugin.Instance?.Configuration.ExcludeTagsSeries).GetValueOrDefault()) { extraMethods |= TvShowMethods.Keywords; } series = await _tmDbClient.GetTvShowAsync( tmdbId, language: TmdbUtils.NormalizeLanguage(language), includeImageLanguage: imageLanguages, extraMethods: extraMethods, cancellationToken: cancellationToken).ConfigureAwait(false); if (series is not null) { _memoryCache.Set(key, series, TimeSpan.FromHours(CacheDurationInHours)); } return series; } /// /// Gets a tv show episode group from the TMDb API based on the show id and the display order. /// /// The tv show's TMDb id. /// The display order. /// The tv show's language. /// A comma-separated list of image languages. /// The cancellation token. /// The TMDb tv show episode group information or null if not found. private async Task GetSeriesGroupAsync(int tvShowId, string displayOrder, string? language, string? imageLanguages, CancellationToken cancellationToken) { TvGroupType? groupType = string.Equals(displayOrder, "originalAirDate", StringComparison.Ordinal) ? TvGroupType.OriginalAirDate : string.Equals(displayOrder, "absolute", StringComparison.Ordinal) ? TvGroupType.Absolute : string.Equals(displayOrder, "dvd", StringComparison.Ordinal) ? TvGroupType.DVD : string.Equals(displayOrder, "digital", StringComparison.Ordinal) ? TvGroupType.Digital : string.Equals(displayOrder, "storyArc", StringComparison.Ordinal) ? TvGroupType.StoryArc : string.Equals(displayOrder, "production", StringComparison.Ordinal) ? TvGroupType.Production : string.Equals(displayOrder, "tv", StringComparison.Ordinal) ? TvGroupType.TV : null; if (groupType is null) { return null; } var key = $"group-{tvShowId.ToString(CultureInfo.InvariantCulture)}-{displayOrder}-{language}"; if (_memoryCache.TryGetValue(key, out TvGroupCollection? group)) { return group; } await EnsureClientConfigAsync().ConfigureAwait(false); var series = await GetSeriesAsync(tvShowId, language, imageLanguages, cancellationToken).ConfigureAwait(false); var episodeGroupId = series?.EpisodeGroups.Results.Find(g => g.Type == groupType)?.Id; if (episodeGroupId is null) { return null; } group = await _tmDbClient.GetTvEpisodeGroupsAsync( episodeGroupId, language: TmdbUtils.NormalizeLanguage(language), cancellationToken: cancellationToken).ConfigureAwait(false); if (group is not null) { _memoryCache.Set(key, group, TimeSpan.FromHours(CacheDurationInHours)); } return group; } /// /// 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) { var key = $"season-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out TvSeason? season)) { return season; } await EnsureClientConfigAsync().ConfigureAwait(false); season = await _tmDbClient.GetTvSeasonAsync( tvShowId, seasonNumber, language: TmdbUtils.NormalizeLanguage(language), includeImageLanguage: imageLanguages, extraMethods: TvSeasonMethods.Credits | TvSeasonMethods.Images | TvSeasonMethods.ExternalIds | TvSeasonMethods.Videos, cancellationToken: cancellationToken).ConfigureAwait(false); if (season is not null) { _memoryCache.Set(key, season, TimeSpan.FromHours(CacheDurationInHours)); } return season; } /// /// 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 display order. /// 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 displayOrder, string? language, string? imageLanguages, CancellationToken cancellationToken) { var key = $"episode-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}e{episodeNumber.ToString(CultureInfo.InvariantCulture)}-{displayOrder}-{language}"; if (_memoryCache.TryGetValue(key, out TvEpisode? episode)) { return episode; } await EnsureClientConfigAsync().ConfigureAwait(false); var group = await GetSeriesGroupAsync(tvShowId, displayOrder, language, imageLanguages, cancellationToken).ConfigureAwait(false); if (group is not null) { var season = group.Groups.Find(s => s.Order == seasonNumber); // Episode order starts at 0 var ep = season?.Episodes.Find(e => e.Order == episodeNumber - 1); if (ep is not null) { seasonNumber = ep.SeasonNumber; episodeNumber = ep.EpisodeNumber; } } episode = await _tmDbClient.GetTvEpisodeAsync( tvShowId, seasonNumber, episodeNumber, language: TmdbUtils.NormalizeLanguage(language), includeImageLanguage: imageLanguages, extraMethods: TvEpisodeMethods.Credits | TvEpisodeMethods.Images | TvEpisodeMethods.ExternalIds | TvEpisodeMethods.Videos, cancellationToken: cancellationToken).ConfigureAwait(false); if (episode is not null) { _memoryCache.Set(key, episode, TimeSpan.FromHours(CacheDurationInHours)); } return episode; } /// /// Gets a person eg. cast or crew member from the TMDb API based on its TMDb id. /// /// The person's TMDb id. /// The episode's language. /// The cancellation token. /// The TMDb person information or null if not found. public async Task GetPersonAsync(int personTmdbId, string language, CancellationToken cancellationToken) { var key = $"person-{personTmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out Person? person)) { return person; } await EnsureClientConfigAsync().ConfigureAwait(false); person = await _tmDbClient.GetPersonAsync( personTmdbId, TmdbUtils.NormalizeLanguage(language), PersonMethods.TvCredits | PersonMethods.MovieCredits | PersonMethods.Images | PersonMethods.ExternalIds, cancellationToken).ConfigureAwait(false); if (person is not null) { _memoryCache.Set(key, person, TimeSpan.FromHours(CacheDurationInHours)); } return person; } /// /// 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) { var key = $"find-{source.ToString()}-{externalId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out FindContainer? result)) { return result; } await EnsureClientConfigAsync().ConfigureAwait(false); result = await _tmDbClient.FindAsync( source, externalId, TmdbUtils.NormalizeLanguage(language), cancellationToken).ConfigureAwait(false); if (result is not null) { _memoryCache.Set(key, result, TimeSpan.FromHours(CacheDurationInHours)); } return result; } /// /// 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 year the tv show first aired. /// The cancellation token. /// The TMDb tv show information. public async Task> SearchSeriesAsync(string name, string language, int year = 0, CancellationToken cancellationToken = default) { var key = $"searchseries-{name}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer? series) && series is not null) { return series.Results; } await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient .SearchTvShowAsync(name, TmdbUtils.NormalizeLanguage(language), includeAdult: Plugin.Instance.Configuration.IncludeAdult, firstAirDateYear: year, cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) { _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); } return searchResults.Results; } /// /// 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) { var key = $"searchperson-{name}"; if (_memoryCache.TryGetValue(key, out SearchContainer? person) && person is not null) { return person.Results; } await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient .SearchPersonAsync(name, includeAdult: Plugin.Instance.Configuration.IncludeAdult, cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) { _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); } return searchResults.Results; } /// /// 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) { var key = $"moviesearch-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer? movies) && movies is not null) { return movies.Results; } await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient .SearchMovieAsync(name, TmdbUtils.NormalizeLanguage(language), includeAdult: Plugin.Instance.Configuration.IncludeAdult, year: year, cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) { _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); } return searchResults.Results; } /// /// 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) { var key = $"collectionsearch-{name}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer? collections) && collections is not null) { return collections.Results; } await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient .SearchCollectionAsync(name, TmdbUtils.NormalizeLanguage(language), cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) { _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); } return searchResults.Results; } /// /// Handles bad path checking and builds the absolute url. /// /// The image size to fetch. /// The relative URL of the image. /// The absolute URL. private string? GetUrl(string? size, string path) { if (string.IsNullOrEmpty(path)) { return null; } return _tmDbClient.GetImageUrl(size, path, true).ToString(); } /// /// Gets the absolute URL of the poster. /// /// The relative URL of the poster. /// The absolute URL. public string? GetPosterUrl(string posterPath) { return GetUrl(Plugin.Instance.Configuration.PosterSize, posterPath); } /// /// Gets the absolute URL of the profile image. /// /// The relative URL of the profile image. /// The absolute URL. public string? GetProfileUrl(string actorProfilePath) { return GetUrl(Plugin.Instance.Configuration.ProfileSize, actorProfilePath); } /// /// Converts poster s into s. /// /// The input images. /// The requested language. /// The remote images. public IEnumerable ConvertPostersToRemoteImageInfo(IReadOnlyList images, string requestLanguage) => ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.PosterSize, ImageType.Primary, requestLanguage); /// /// Converts backdrop s into s. /// /// The input images. /// The requested language. /// The remote images. public IEnumerable ConvertBackdropsToRemoteImageInfo(IReadOnlyList images, string requestLanguage) => ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.BackdropSize, ImageType.Backdrop, requestLanguage); /// /// Converts logo s into s. /// /// The input images. /// The requested language. /// The remote images. public IEnumerable ConvertLogosToRemoteImageInfo(IReadOnlyList images, string requestLanguage) => ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.LogoSize, ImageType.Logo, requestLanguage); /// /// Converts profile s into s. /// /// The input images. /// The requested language. /// The remote images. public IEnumerable ConvertProfilesToRemoteImageInfo(IReadOnlyList images, string requestLanguage) => ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.ProfileSize, ImageType.Primary, requestLanguage); /// /// Converts still s into s. /// /// The input images. /// The requested language. /// The remote images. public IEnumerable ConvertStillsToRemoteImageInfo(IReadOnlyList images, string requestLanguage) => ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.StillSize, ImageType.Primary, requestLanguage); /// /// Converts s into s. /// /// The input images. /// The size of the image to fetch. /// The type of the image. /// The requested language. /// The remote images. private IEnumerable ConvertToRemoteImageInfo(IReadOnlyList images, string? size, ImageType type, string requestLanguage) { // sizes provided are for original resolution, don't store them when downloading scaled images var scaleImage = !string.Equals(size, "original", StringComparison.OrdinalIgnoreCase); for (var i = 0; i < images.Count; i++) { var image = images[i]; yield return new RemoteImageInfo { Url = GetUrl(size, image.FilePath), CommunityRating = image.VoteAverage, VoteCount = image.VoteCount, Width = scaleImage ? null : image.Width, Height = scaleImage ? null : image.Height, Language = TmdbUtils.AdjustImageLanguage(image.Iso_639_1, requestLanguage), ProviderName = TmdbUtils.ProviderName, Type = type, RatingType = RatingType.Score }; } } private async Task EnsureClientConfigAsync() { if (!_tmDbClient.HasConfig) { var config = await _tmDbClient.GetConfigAsync().ConfigureAwait(false); ValidatePreferences(config); } } private static void ValidatePreferences(TMDbConfig config) { var imageConfig = config.Images; var pluginConfig = Plugin.Instance.Configuration; if (!imageConfig.PosterSizes.Contains(pluginConfig.PosterSize)) { pluginConfig.PosterSize = imageConfig.PosterSizes[^1]; } if (!imageConfig.BackdropSizes.Contains(pluginConfig.BackdropSize)) { pluginConfig.BackdropSize = imageConfig.BackdropSizes[^1]; } if (!imageConfig.LogoSizes.Contains(pluginConfig.LogoSize)) { pluginConfig.LogoSize = imageConfig.LogoSizes[^1]; } if (!imageConfig.ProfileSizes.Contains(pluginConfig.ProfileSize)) { pluginConfig.ProfileSize = imageConfig.ProfileSizes[^1]; } if (!imageConfig.StillSizes.Contains(pluginConfig.StillSize)) { pluginConfig.StillSize = imageConfig.StillSizes[^1]; } } /// /// Gets the configuration. /// /// The configuration. public async Task GetClientConfiguration() { await EnsureClientConfigAsync().ConfigureAwait(false); return _tmDbClient.Config; } /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Releases unmanaged and - optionally - managed resources. /// /// true to release both managed and unmanaged resources; false to release only unmanaged resources. protected virtual void Dispose(bool disposing) { if (disposing) { _memoryCache?.Dispose(); _tmDbClient?.Dispose(); } } } }