using System; using System.Collections.Frozen; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Text.RegularExpressions; using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; using TMDbLib.Objects.General; using TMDbLib.Objects.Search; using TMDbLib.Objects.TvShows; using PersonInfo = MediaBrowser.Controller.Entities.PersonInfo; namespace MediaBrowser.Providers.Plugins.Tmdb { /// /// Utilities for the TMDb provider. /// public static partial class TmdbUtils { /// /// URL of the TMDb instance to use. /// public const string BaseTmdbUrl = "https://www.themoviedb.org/"; /// /// Name of the provider. /// public const string ProviderName = "TheMovieDb"; /// /// API key to use when performing an API call. /// public const string ApiKey = "4219e299c89411838049ab0dab19ebd5"; private const int TitleExactScore = 8; private const int TitlePrefixScore = 4; private const int YearExactScore = 2; private const int YearAdjacentScore = 1; /// /// The crew types to keep. /// public static readonly string[] WantedCrewTypes = { PersonType.Director, PersonType.Writer, PersonType.Producer }; /// /// The crew kinds to keep. /// public static readonly PersonKind[] WantedCrewKinds = { PersonKind.Director, PersonKind.Writer, PersonKind.Producer }; /// /// Writing jobs to keep. /// private static readonly FrozenSet _writerJobs = new[] { "writer", "screenplay", "novel" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); /// /// Everything that is not a letter, a number or a combining mark separates two search terms. The /// interpunct is kept because TMDb uses it inside titles such as "WALL·E", where it matches better /// than a space does. /// [GeneratedRegex(@"[^\p{L}\p{N}\p{M}·]+")] private static partial Regex NonSearchTermRegex(); /// /// As , but the interpunct is a separator too, so a "WALL-E" folder /// and the "WALL·E" title TMDb returns compare equal. /// [GeneratedRegex(@"[^\p{L}\p{N}\p{M}]+")] private static partial Regex NonComparableRegex(); /// /// Gets the TMDb id of an item, if it has one TMDb can be queried with. /// /// The item. /// The TMDb id. /// true if the item has a usable TMDb id; otherwise, false. public static bool TryGetTmdbId(this IHasProviderIds instance, out int tmdbId) { instance.TryGetProviderId(MetadataProvider.Tmdb, out var value); return TryParseTmdbId(value, out tmdbId); } /// /// Parses a TMDb id. /// /// The stored id. /// The TMDb id. /// true if the value is a usable TMDb id; otherwise, false. public static bool TryParseTmdbId(string? value, out int tmdbId) { // Another provider can have filed one of its own ids under the TMDb key, e.g. an IMDb person // id. Reporting that as "no id" lets the caller fall back to a search and repair the id, // instead of throwing on every refresh of the item. return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out tmdbId) && tmdbId > 0; } /// /// Cleans the name according to TMDb requirements. /// /// The name of the entity. /// The cleaned name. public static string CleanName(string name) { // TMDb expects a space separated list of words make sure that is the case return NonSearchTermRegex().Replace(name, " ").Trim(); } /// /// Reduces a title to the form used to compare a local name against a TMDb search result. /// /// The title to normalize. /// The normalized title, or an empty string if there was nothing to normalize. public static string NormalizeTitle(string? title) { return string.IsNullOrEmpty(title) ? string.Empty : NonComparableRegex().Replace(title, " ").Trim().ToLowerInvariant(); } /// /// Picks the movie search result that best matches the name and year an item was looked up by. /// /// The search results, in the order TMDb returned them. /// The parsed name of the local item. /// The year of the local item, or 0 if it is unknown. /// The best match, or null if there were no results. public static SearchMovie? FindBestMatch(IReadOnlyList? results, string? name, int year) { return FindBestMatch( results, name, year, static movie => movie.Title, static movie => movie.OriginalTitle, static movie => movie.ReleaseDate); } /// /// Picks the series search result that best matches the name and year an item was looked up by. /// /// The search results, in the order TMDb returned them. /// The parsed name of the local item. /// The year of the local item, or 0 if it is unknown. /// The best match, or null if there were no results. public static SearchTv? FindBestMatch(IReadOnlyList? results, string? name, int year) { return FindBestMatch( results, name, year, static series => series.Name, static series => series.OriginalName, static series => series.FirstAirDate); } /// /// Picks the search result that best matches the name and year an item was looked up by. /// /// /// TMDb's year parameter only nudges relevance, it does not filter, so the first hit is regularly a /// different film or show that happens to share the title - searching for "Mulan" with year 2020 /// returns the 1998 film first. A title that matches outranks one that does not, and the year only /// separates candidates that are otherwise equally good. When nothing matches at all TMDb's own /// ordering is kept, so a name that needs fuzzy matching, such as "A Christmas No. 1" for /// "A Christmas Number One", still resolves. /// private static T? FindBestMatch( IReadOnlyList? results, string? name, int year, Func titleSelector, Func originalTitleSelector, Func releaseDateSelector) where T : class { if (results is null || results.Count == 0) { return null; } var normalizedName = NormalizeTitle(name); if (normalizedName.Length == 0) { return results[0]; } var best = results[0]; var bestScore = 0; foreach (var result in results) { var score = Math.Max( ScoreTitle(normalizedName, titleSelector(result)), ScoreTitle(normalizedName, originalTitleSelector(result))) + ScoreYear(year, releaseDateSelector(result)?.Year); // Strictly greater, so ties keep the earlier, more relevant result. if (score > bestScore) { bestScore = score; best = result; } } return best; } private static int ScoreTitle(string normalizedName, string? title) { var normalizedTitle = NormalizeTitle(title); if (string.Equals(normalizedName, normalizedTitle, StringComparison.Ordinal)) { return TitleExactScore; } // Whole words only, otherwise "Wall" half matches "Wall Street". return normalizedTitle.Length > normalizedName.Length && normalizedTitle[normalizedName.Length] == ' ' && normalizedTitle.StartsWith(normalizedName, StringComparison.Ordinal) ? TitlePrefixScore : 0; } private static int ScoreYear(int year, int? resultYear) { if (year <= 0 || resultYear is not int candidateYear) { return 0; } return Math.Abs(candidateYear - year) switch { 0 => YearExactScore, // Regional release dates routinely straddle a new year. 1 => YearAdjacentScore, _ => 0 }; } /// /// Maps the TMDb provided roles for crew members to Jellyfin roles. /// /// Crew member to map against the Jellyfin person types. /// The Jellyfin person type. public static PersonKind MapCrewToPersonType(Crew crew) { if (string.Equals(crew.Department, "directing", StringComparison.OrdinalIgnoreCase) && string.Equals(crew.Job, "director", StringComparison.OrdinalIgnoreCase)) { return PersonKind.Director; } if (string.Equals(crew.Department, "production", StringComparison.OrdinalIgnoreCase) && string.Equals(crew.Job, "producer", StringComparison.OrdinalIgnoreCase)) { return PersonKind.Producer; } if (string.Equals(crew.Department, "writing", StringComparison.OrdinalIgnoreCase) && crew.Job is not null && _writerJobs.Contains(crew.Job)) { return PersonKind.Writer; } return PersonKind.Unknown; } /// /// Maps an aggregated TMDb cast list, whose entries hold every role their member played. /// /// The aggregated cast list, or null. /// The configuration deciding how much of the cast to keep. /// Resolves a profile path into an absolute image url. /// One credit per role played. internal static IEnumerable MapAggregateCast( IReadOnlyList? cast, PluginConfiguration config, Func getProfileUrl) { if (cast is null) { yield break; } var billed = cast .Where(member => !string.IsNullOrWhiteSpace(member.Name)) .Where(member => !config.HideMissingCastMembers || !string.IsNullOrEmpty(member.ProfilePath)) .OrderBy(member => member.Order) .Take(config.MaxCastMembers); foreach (var member in billed) { // An actor playing several characters over the run gets one aggregated entry holding // every role, so each of them becomes a credit of its own here. Their own billing puts // the character they played the longest first. var characters = member.Roles? .Where(role => !string.IsNullOrWhiteSpace(role.Character)) .OrderByDescending(role => role.EpisodeCount) .Select(role => role.Character!.Trim()) .ToArray(); if (characters is null || characters.Length == 0) { characters = [string.Empty]; } foreach (var character in characters) { yield return CreateCredit(member.Name!, member.Id, member.ProfilePath, member.Order, character, getProfileUrl); } } } /// /// Maps a TMDb cast list whose entries hold the one character their member is credited for. /// /// The cast list, or null. /// The configuration deciding how much of the cast to keep. /// Resolves a profile path into an absolute image url. /// One credit per cast entry. internal static IEnumerable MapCast( IReadOnlyList? cast, PluginConfiguration config, Func getProfileUrl) { if (cast is null) { yield break; } var billed = cast .Where(member => !string.IsNullOrWhiteSpace(member.Name)) .Where(member => !config.HideMissingCastMembers || !string.IsNullOrEmpty(member.ProfilePath)) .OrderBy(member => member.Order) .Take(config.MaxCastMembers); foreach (var member in billed) { yield return CreateCredit(member.Name!, member.Id, member.ProfilePath, member.Order, member.Character?.Trim() ?? string.Empty, getProfileUrl); } } private static PersonInfo CreateCredit(string name, int id, string? profilePath, int? order, string role, Func getProfileUrl) { var personInfo = new PersonInfo { Name = name.Trim(), Role = role, Type = PersonKind.Actor, SortOrder = order, ImageUrl = getProfileUrl(profilePath) }; if (id > 0) { personInfo.SetProviderId(MetadataProvider.Tmdb, id.ToString(CultureInfo.InvariantCulture)); } return personInfo; } /// /// Determines whether a video is a trailer. /// /// The TMDb video. /// A boolean indicating whether the video is a trailer. public static bool IsTrailerType(Video video) { return string.Equals(video.Site, "youtube", StringComparison.OrdinalIgnoreCase) && (string.Equals(video.Type, "trailer", StringComparison.OrdinalIgnoreCase) || string.Equals(video.Type, "teaser", StringComparison.OrdinalIgnoreCase)); } /// /// Normalizes a language string for use with TMDb's include image language parameter. /// /// The preferred language as either a 2 letter code with or without country code. /// The country code, ISO 3166-1. /// The comma separated language string. public static string GetImageLanguagesParam(string preferredLanguage, string? countryCode = null) { var languages = new List(); if (!string.IsNullOrEmpty(preferredLanguage)) { preferredLanguage = NormalizeLanguage(preferredLanguage, countryCode); languages.Add(preferredLanguage); } languages.Add("null"); // Always add English as fallback language if (!string.Equals(preferredLanguage, "en", StringComparison.OrdinalIgnoreCase)) { languages.Add("en"); } return string.Join(',', languages); } /// /// Normalizes a language string for use with TMDb's language parameter. /// /// The language code. /// The country code. /// The normalized language code. [return: NotNullIfNotNull(nameof(language))] public static string? NormalizeLanguage(string? language, string? countryCode = null) { if (string.IsNullOrEmpty(language)) { return language; } // Handle es-419 (Latin American Spanish) by converting to regional variant if (string.Equals(language, "es-419", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(countryCode)) { language = string.Equals(countryCode, "AR", StringComparison.OrdinalIgnoreCase) ? "es-AR" : "es-MX"; } // TMDb requires 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) { // TMDb doesn't support Switzerland (de-CH, it-CH or fr-CH) so use the language (de, it or fr) without country code if (string.Equals(parts[1], "CH", StringComparison.OrdinalIgnoreCase)) { return parts[0]; } language = parts[0] + "-" + parts[1].ToUpperInvariant(); } return language; } /// /// Adjusts the image's language code preferring the 5 letter language code eg. en-US. /// /// The image's actual language code. /// The requested language code. /// The language code. public static string AdjustImageLanguage(string? imageLanguage, string requestLanguage) { if (string.IsNullOrEmpty(imageLanguage)) { return string.Empty; } if (!string.IsNullOrEmpty(requestLanguage) && requestLanguage.Length > 2 && imageLanguage.Length == 2 && requestLanguage.StartsWith(imageLanguage, StringComparison.OrdinalIgnoreCase)) { return requestLanguage; } // TMDb now returns xx for no language instead of an empty string. return string.Equals(imageLanguage, "xx", StringComparison.OrdinalIgnoreCase) ? string.Empty : imageLanguage; } /// /// Combines the metadata country code and the parental rating from the API into the value we store in our database. /// /// The ISO 3166-1 country code of the rating country. /// The rating value returned by the TMDb API. /// The combined parental rating of country code+rating value. public static string BuildParentalRating(string countryCode, string ratingValue) { // Exclude US because we store US values as TV-14 without the country code. var ratingPrefix = string.Equals(countryCode, "US", StringComparison.OrdinalIgnoreCase) ? string.Empty : countryCode + "-"; var newRating = ratingPrefix + ratingValue; return newRating.Replace("DE-", "FSK-", StringComparison.OrdinalIgnoreCase); } } }