using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Logging; using MediaBrowser.Model.Providers; using MediaBrowser.Model.Serialization; using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Net; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Model.IO; using MediaBrowser.Common; using MediaBrowser.Common.IO; using MediaBrowser.Controller.IO; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Net; namespace MediaBrowser.Providers.Movies { /// /// Class MovieDbProvider /// public class MovieDbProvider : IRemoteMetadataProvider, IDisposable, IHasOrder { internal static MovieDbProvider Current { get; private set; } private readonly IJsonSerializer _jsonSerializer; private readonly IHttpClient _httpClient; private readonly IFileSystem _fileSystem; private readonly IServerConfigurationManager _configurationManager; private readonly ILogger _logger; private readonly ILocalizationManager _localization; private readonly ILibraryManager _libraryManager; private readonly IApplicationHost _appHost; private readonly CultureInfo _usCulture = new CultureInfo("en-US"); public MovieDbProvider(IJsonSerializer jsonSerializer, IHttpClient httpClient, IFileSystem fileSystem, IServerConfigurationManager configurationManager, ILogger logger, ILocalizationManager localization, ILibraryManager libraryManager, IApplicationHost appHost) { _jsonSerializer = jsonSerializer; _httpClient = httpClient; _fileSystem = fileSystem; _configurationManager = configurationManager; _logger = logger; _localization = localization; _libraryManager = libraryManager; _appHost = appHost; Current = this; } public Task> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) { return GetMovieSearchResults(searchInfo, cancellationToken); } public async Task> GetMovieSearchResults(ItemLookupInfo searchInfo, CancellationToken cancellationToken) { var tmdbId = searchInfo.GetProviderId(MetadataProviders.Tmdb); if (!string.IsNullOrEmpty(tmdbId)) { cancellationToken.ThrowIfCancellationRequested(); await EnsureMovieInfo(tmdbId, searchInfo.MetadataLanguage, cancellationToken).ConfigureAwait(false); var dataFilePath = GetDataFilePath(tmdbId, searchInfo.MetadataLanguage); var obj = _jsonSerializer.DeserializeFromFile(dataFilePath); var tmdbSettings = await GetTmdbSettings(cancellationToken).ConfigureAwait(false); var tmdbImageUrl = tmdbSettings.images.secure_base_url + "original"; var remoteResult = new RemoteSearchResult { Name = obj.GetTitle(), SearchProviderName = Name, ImageUrl = string.IsNullOrWhiteSpace(obj.poster_path) ? null : tmdbImageUrl + obj.poster_path }; if (!string.IsNullOrWhiteSpace(obj.release_date)) { DateTime r; // These dates are always in this exact format if (DateTime.TryParse(obj.release_date, _usCulture, DateTimeStyles.None, out r)) { remoteResult.PremiereDate = r.ToUniversalTime(); remoteResult.ProductionYear = remoteResult.PremiereDate.Value.Year; } } remoteResult.SetProviderId(MetadataProviders.Tmdb, obj.id.ToString(_usCulture)); if (!string.IsNullOrWhiteSpace(obj.imdb_id)) { remoteResult.SetProviderId(MetadataProviders.Imdb, obj.imdb_id); } return new[] { remoteResult }; } return await new MovieDbSearch(_logger, _jsonSerializer, _libraryManager).GetMovieSearchResults(searchInfo, cancellationToken).ConfigureAwait(false); } public Task> GetMetadata(MovieInfo info, CancellationToken cancellationToken) { return GetItemMetadata(info, cancellationToken); } public Task> GetItemMetadata(ItemLookupInfo id, CancellationToken cancellationToken) where T : BaseItem, new() { var movieDb = new GenericMovieDbInfo(_logger, _jsonSerializer, _libraryManager, _fileSystem); return movieDb.GetMetadata(id, cancellationToken); } public string Name { get { return "TheMovieDb"; } } /// /// 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 dispose) { } /// /// The _TMDB settings task /// private TmdbSettingsResult _tmdbSettings; /// /// Gets the TMDB settings. /// /// Task{TmdbSettingsResult}. internal async Task GetTmdbSettings(CancellationToken cancellationToken) { if (_tmdbSettings != null) { return _tmdbSettings; } using (var json = await GetMovieDbResponse(new HttpRequestOptions { Url = string.Format(TmdbConfigUrl, ApiKey), CancellationToken = cancellationToken, AcceptHeader = AcceptHeader }).ConfigureAwait(false)) { _tmdbSettings = _jsonSerializer.DeserializeFromStream(json); return _tmdbSettings; } } private const string TmdbConfigUrl = "https://api.themoviedb.org/3/configuration?api_key={0}"; private const string GetMovieInfo3 = @"https://api.themoviedb.org/3/movie/{0}?api_key={1}&append_to_response=casts,releases,images,keywords,trailers"; internal static string ApiKey = "f6bd687ffa63cd282b6ff2c6877f2669"; internal static string AcceptHeader = "application/json,image/*"; /// /// Gets the movie data path. /// /// The app paths. /// The TMDB id. /// System.String. internal static string GetMovieDataPath(IApplicationPaths appPaths, string tmdbId) { var dataPath = GetMoviesDataPath(appPaths); return Path.Combine(dataPath, tmdbId); } internal static string GetMoviesDataPath(IApplicationPaths appPaths) { var dataPath = Path.Combine(appPaths.CachePath, "tmdb-movies2"); return dataPath; } /// /// Downloads the movie info. /// /// The id. /// The preferred metadata language. /// The cancellation token. /// Task. internal async Task DownloadMovieInfo(string id, string preferredMetadataLanguage, CancellationToken cancellationToken) { var mainResult = await FetchMainResult(id, true, preferredMetadataLanguage, cancellationToken).ConfigureAwait(false); if (mainResult == null) return; var dataFilePath = GetDataFilePath(id, preferredMetadataLanguage); _fileSystem.CreateDirectory(Path.GetDirectoryName(dataFilePath)); _jsonSerializer.SerializeToFile(mainResult, dataFilePath); } private readonly Task _cachedTask = Task.FromResult(true); internal Task EnsureMovieInfo(string tmdbId, string language, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(tmdbId)) { throw new ArgumentNullException("tmdbId"); } var path = GetDataFilePath(tmdbId, language); var fileInfo = _fileSystem.GetFileSystemInfo(path); if (fileInfo.Exists) { // If it's recent or automatic updates are enabled, don't re-download if ((DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 3) { return _cachedTask; } } return DownloadMovieInfo(tmdbId, language, cancellationToken); } internal string GetDataFilePath(string tmdbId, string preferredLanguage) { if (string.IsNullOrEmpty(tmdbId)) { throw new ArgumentNullException("tmdbId"); } var path = GetMovieDataPath(_configurationManager.ApplicationPaths, tmdbId); if (string.IsNullOrWhiteSpace(preferredLanguage)) { preferredLanguage = "alllang"; } var filename = string.Format("all-{0}.json", preferredLanguage); return Path.Combine(path, filename); } public static string GetImageLanguagesParam(string preferredLanguage) { var languages = new List(); if (!string.IsNullOrEmpty(preferredLanguage)) { preferredLanguage = NormalizeLanguage(preferredLanguage); languages.Add(preferredLanguage); if (preferredLanguage.Length == 5) // like en-US { // Currenty, 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.ToArray()); } public static string NormalizeLanguage(string language) { if (!string.IsNullOrEmpty(language)) { // They require this to be uppercase // https://emby.media/community/index.php?/topic/32454-fr-follow-tmdbs-new-language-api-update/?p=311148 var parts = language.Split('-'); if (parts.Length == 2) { language = parts[0] + "-" + parts[1].ToUpper(); } } return language; } public static string AdjustImageLanguage(string imageLanguage, string requestLanguage) { if (!string.IsNullOrEmpty(imageLanguage) && !string.IsNullOrEmpty(requestLanguage) && requestLanguage.Length > 2 && imageLanguage.Length == 2 && requestLanguage.StartsWith(imageLanguage, StringComparison.OrdinalIgnoreCase)) { return requestLanguage; } return imageLanguage; } /// /// Fetches the main result. /// /// The id. /// if set to true [is TMDB identifier]. /// The language. /// The cancellation token /// Task{CompleteMovieData}. internal async Task FetchMainResult(string id, bool isTmdbId, string language, CancellationToken cancellationToken) { var url = string.Format(GetMovieInfo3, id, ApiKey); if (!string.IsNullOrEmpty(language)) { url += string.Format("&language={0}", NormalizeLanguage(language)); // Get images in english and with no language url += "&include_image_language=" + GetImageLanguagesParam(language); } CompleteMovieData mainResult; cancellationToken.ThrowIfCancellationRequested(); // Cache if not using a tmdbId because we won't have the tmdb cache directory structure. So use the lower level cache. var cacheMode = isTmdbId ? CacheMode.None : CacheMode.Unconditional; var cacheLength = TimeSpan.FromDays(3); try { using (var json = await GetMovieDbResponse(new HttpRequestOptions { Url = url, CancellationToken = cancellationToken, AcceptHeader = AcceptHeader, CacheMode = cacheMode, CacheLength = cacheLength }).ConfigureAwait(false)) { mainResult = _jsonSerializer.DeserializeFromStream(json); } } catch (HttpException ex) { // Return null so that callers know there is no metadata for this id if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.NotFound) { return null; } throw; } cancellationToken.ThrowIfCancellationRequested(); // If the language preference isn't english, then have the overview fallback to english if it's blank if (mainResult != null && string.IsNullOrEmpty(mainResult.overview) && !string.IsNullOrEmpty(language) && !string.Equals(language, "en", StringComparison.OrdinalIgnoreCase)) { _logger.Info("MovieDbProvider couldn't find meta for language " + language + ". Trying English..."); url = string.Format(GetMovieInfo3, id, ApiKey) + "&language=en"; if (!string.IsNullOrEmpty(language)) { // Get images in english and with no language url += "&include_image_language=" + GetImageLanguagesParam(language); } using (var json = await GetMovieDbResponse(new HttpRequestOptions { Url = url, CancellationToken = cancellationToken, AcceptHeader = AcceptHeader, CacheMode = cacheMode, CacheLength = cacheLength }).ConfigureAwait(false)) { var englishResult = _jsonSerializer.DeserializeFromStream(json); mainResult.overview = englishResult.overview; } } return mainResult; } private static long _lastRequestTicks; // The limit is 40 requests per 10 seconds private static int requestIntervalMs = 300; /// /// Gets the movie db response. /// internal async Task GetMovieDbResponse(HttpRequestOptions options) { var delayTicks = (requestIntervalMs * 10000) - (DateTime.UtcNow.Ticks - _lastRequestTicks); var delayMs = Math.Min(delayTicks / 10000, requestIntervalMs); if (delayMs > 0) { _logger.Debug("Throttling Tmdb by {0} ms", delayMs); await Task.Delay(Convert.ToInt32(delayMs)).ConfigureAwait(false); } _lastRequestTicks = DateTime.UtcNow.Ticks; options.BufferContent = true; options.UserAgent = "Emby/" + _appHost.ApplicationVersion; return await _httpClient.Get(options).ConfigureAwait(false); } public void Dispose() { Dispose(true); } /// /// Class TmdbTitle /// internal class TmdbTitle { /// /// Gets or sets the iso_3166_1. /// /// The iso_3166_1. public string iso_3166_1 { get; set; } /// /// Gets or sets the title. /// /// The title. public string title { get; set; } } /// /// Class TmdbAltTitleResults /// internal class TmdbAltTitleResults { /// /// Gets or sets the id. /// /// The id. public int id { get; set; } /// /// Gets or sets the titles. /// /// The titles. public List titles { get; set; } } internal class BelongsToCollection { public int id { get; set; } public string name { get; set; } public string poster_path { get; set; } public string backdrop_path { get; set; } } internal class GenreItem { public int id { get; set; } public string name { get; set; } } internal class ProductionCompany { public string name { get; set; } public int id { get; set; } } internal class ProductionCountry { public string iso_3166_1 { get; set; } public string name { get; set; } } internal class SpokenLanguage { public string iso_639_1 { get; set; } public string name { get; set; } } internal class Cast { public int id { get; set; } public string name { get; set; } public string character { get; set; } public int order { get; set; } public int cast_id { get; set; } public string profile_path { get; set; } } internal class Crew { public int id { get; set; } public string name { get; set; } public string department { get; set; } public string job { get; set; } public string profile_path { get; set; } } internal class Casts { public List cast { get; set; } public List crew { get; set; } } internal class Country { public string iso_3166_1 { get; set; } public string certification { get; set; } public DateTime release_date { get; set; } } internal class Releases { public List countries { get; set; } } internal class Backdrop { public string file_path { get; set; } public int width { get; set; } public int height { get; set; } public object iso_639_1 { get; set; } public double aspect_ratio { get; set; } public double vote_average { get; set; } public int vote_count { get; set; } } internal class Poster { public string file_path { get; set; } public int width { get; set; } public int height { get; set; } public string iso_639_1 { get; set; } public double aspect_ratio { get; set; } public double vote_average { get; set; } public int vote_count { get; set; } } internal class Images { public List backdrops { get; set; } public List posters { get; set; } } internal class Keyword { public int id { get; set; } public string name { get; set; } } internal class Keywords { public List keywords { get; set; } } internal class Youtube { public string name { get; set; } public string size { get; set; } public string source { get; set; } } internal class Trailers { public List quicktime { get; set; } public List youtube { get; set; } } internal class CompleteMovieData { public bool adult { get; set; } public string backdrop_path { get; set; } public BelongsToCollection belongs_to_collection { get; set; } public int budget { get; set; } public List genres { get; set; } public string homepage { get; set; } public int id { get; set; } public string imdb_id { get; set; } public string original_title { get; set; } public string original_name { get; set; } public string overview { get; set; } public double popularity { get; set; } public string poster_path { get; set; } public List production_companies { get; set; } public List production_countries { get; set; } public string release_date { get; set; } public int revenue { get; set; } public int runtime { get; set; } public List spoken_languages { get; set; } public string status { get; set; } public string tagline { get; set; } public string title { get; set; } public string name { get; set; } public double vote_average { get; set; } public int vote_count { get; set; } public Casts casts { get; set; } public Releases releases { get; set; } public Images images { get; set; } public Keywords keywords { get; set; } public Trailers trailers { get; set; } public string GetOriginalTitle() { return original_name ?? original_title; } public string GetTitle() { return name ?? title ?? GetOriginalTitle(); } } public int Order { get { // After Omdb return 1; } } public Task GetImageResponse(string url, CancellationToken cancellationToken) { return _httpClient.GetResponse(new HttpRequestOptions { CancellationToken = cancellationToken, Url = url }); } } }