diff --git a/Jellyfin.Plugin.Tvdb/Providers/ExternalId/TvdbMovieExternalId.cs b/Jellyfin.Plugin.Tvdb/Providers/ExternalId/TvdbMovieExternalId.cs new file mode 100644 index 0000000..8048230 --- /dev/null +++ b/Jellyfin.Plugin.Tvdb/Providers/ExternalId/TvdbMovieExternalId.cs @@ -0,0 +1,26 @@ +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace Jellyfin.Plugin.Tvdb.Providers.ExternalId +{ + /// + public class TvdbMovieExternalId : IExternalId + { + /// + public string ProviderName => TvdbPlugin.ProviderId; + + /// + public string Key => TvdbPlugin.ProviderName; + + /// + public ExternalIdMediaType? Type => ExternalIdMediaType.Movie; + + /// + public string? UrlFormatString => null; + + /// + public bool Supports(IHasProviderIds item) => item is Movie; + } +} diff --git a/Jellyfin.Plugin.Tvdb/Providers/ExternalId/TvdbMovieSlugExternalId.cs b/Jellyfin.Plugin.Tvdb/Providers/ExternalId/TvdbMovieSlugExternalId.cs new file mode 100644 index 0000000..1c0a03c --- /dev/null +++ b/Jellyfin.Plugin.Tvdb/Providers/ExternalId/TvdbMovieSlugExternalId.cs @@ -0,0 +1,26 @@ +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace Jellyfin.Plugin.Tvdb.Providers.ExternalId +{ + /// + public class TvdbMovieSlugExternalId : IExternalId + { + /// + public string ProviderName => TvdbPlugin.ProviderId; + + /// + public string Key => TvdbPlugin.SlugProviderId; + + /// + public ExternalIdMediaType? Type => ExternalIdMediaType.Movie; + + /// + public string? UrlFormatString => TvdbUtils.TvdbBaseUrl + "movies/{0}"; + + /// + public bool Supports(IHasProviderIds item) => item is Movie; + } +} diff --git a/Jellyfin.Plugin.Tvdb/Providers/TvdbMovieImageProvider.cs b/Jellyfin.Plugin.Tvdb/Providers/TvdbMovieImageProvider.cs new file mode 100644 index 0000000..b5b32fd --- /dev/null +++ b/Jellyfin.Plugin.Tvdb/Providers/TvdbMovieImageProvider.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using Tvdb.Sdk; + +namespace Jellyfin.Plugin.Tvdb.Providers +{ + /// + /// Tvdb movie image provider. + /// + public class TvdbMovieImageProvider : IRemoteImageProvider + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly TvdbClientManager _tvdbClientManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of . + public TvdbMovieImageProvider( + IHttpClientFactory httpClientFactory, + ILogger logger, + TvdbClientManager tvdbClientManager) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + _tvdbClientManager = tvdbClientManager; + } + + /// + public string Name => TvdbPlugin.ProviderName; + + /// + public bool Supports(BaseItem item) + { + return item is Movie; + } + + /// + public IEnumerable GetSupportedImages(BaseItem item) + { + yield return ImageType.Primary; + yield return ImageType.Banner; + yield return ImageType.Backdrop; + yield return ImageType.Logo; + yield return ImageType.Art; + } + + /// + public async Task> GetImages(BaseItem item, CancellationToken cancellationToken) + { + if (!item.IsSupported()) + { + return Enumerable.Empty(); + } + + var languages = await _tvdbClientManager.GetLanguagesAsync(cancellationToken) + .ConfigureAwait(false); + var languageLookup = languages + .ToDictionary(l => l.Id, StringComparer.OrdinalIgnoreCase); + + var artworkTypes = await _tvdbClientManager.GetArtworkTypeAsync(cancellationToken) + .ConfigureAwait(false); + var movieArtworkTypeLookup = artworkTypes + .Where(t => string.Equals(t.RecordType, "movie", StringComparison.OrdinalIgnoreCase)) + .Where(t => t.Id.HasValue) + .ToDictionary(t => t.Id!.Value); + + var movieTvdbId = item.GetTvdbId(); + var movieArtworks = await GetMovieArtworks(movieTvdbId, cancellationToken) + .ConfigureAwait(false); + + var remoteImages = new List(); + foreach (var artwork in movieArtworks) + { + var artworkType = artwork.Type is null ? null : movieArtworkTypeLookup.GetValueOrDefault(artwork.Type!.Value); + var imageType = artworkType.GetImageType(); + var artworkLanguage = artwork.Language is null ? null : languageLookup.GetValueOrDefault(artwork.Language); + + // only add if valid RemoteImageInfo + remoteImages.AddIfNotNull(artwork.CreateImageInfo(Name, imageType, artworkLanguage)); + } + + return remoteImages.OrderByLanguageDescending(item.GetPreferredMetadataLanguage()); + } + + private async Task> GetMovieArtworks(int movieTvdbId, CancellationToken cancellationToken) + { + var movieInfo = await _tvdbClientManager.GetMovieExtendedByIdAsync(movieTvdbId, cancellationToken) + .ConfigureAwait(false); + return movieInfo?.Artworks ?? Array.Empty(); + } + + /// + public Task GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(new Uri(url), cancellationToken); + } + } +} diff --git a/Jellyfin.Plugin.Tvdb/Providers/TvdbMovieProvider.cs b/Jellyfin.Plugin.Tvdb/Providers/TvdbMovieProvider.cs new file mode 100644 index 0000000..21bbdf8 --- /dev/null +++ b/Jellyfin.Plugin.Tvdb/Providers/TvdbMovieProvider.cs @@ -0,0 +1,471 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using Tvdb.Sdk; + +namespace Jellyfin.Plugin.Tvdb.Providers +{ + /// + /// The TVDB movie provider. + /// + public class TvdbMovieProvider : IRemoteMetadataProvider + { + private const int MaxSearchResults = 10; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly TvdbClientManager _tvdbClientManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of . + /// Instance of . + /// Instance of . + /// Instance of . + public TvdbMovieProvider( + IHttpClientFactory httpClientFactory, + ILogger logger, + ILibraryManager libraryManager, + TvdbClientManager tvdbClientManager) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + _libraryManager = libraryManager; + _tvdbClientManager = tvdbClientManager; + } + + /// + public string Name => TvdbPlugin.ProviderName; + + /// + public async Task> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) + { + if (searchInfo.IsSupported()) + { + return await FetchMovieSearchResult(searchInfo, cancellationToken).ConfigureAwait(false); + } + + return await FindMovie(searchInfo.Name, searchInfo.Year, searchInfo.MetadataLanguage, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> GetMetadata(MovieInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult + { + QueriedById = true, + }; + + if (!info.IsSupported()) + { + result.QueriedById = false; + await Identify(info).ConfigureAwait(false); + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (info.IsSupported()) + { + result.Item = new Movie(); + result.HasMetadata = true; + + await FetchMovieMetadata(result, info, cancellationToken) + .ConfigureAwait(false); + } + + return result; + } + + private async Task> FetchMovieSearchResult(MovieInfo movieInfo, CancellationToken cancellationToken) + { + async Task TryGetTvdbIdWithRemoteId(MetadataProvider metadataProvider) + { + var id = movieInfo.GetProviderId(metadataProvider); + if (string.IsNullOrEmpty(id)) + { + return null; + } + + return await GetMovieByRemoteId( + id, + cancellationToken).ConfigureAwait(false); + } + + int? tvdbId; + if (movieInfo.HasTvdbId()) + { + tvdbId = movieInfo.GetTvdbId(); + } + else + { + var tvdbIdTxt = await TryGetTvdbIdWithRemoteId(MetadataProvider.Imdb).ConfigureAwait(false) + ?? await TryGetTvdbIdWithRemoteId(MetadataProvider.Zap2It).ConfigureAwait(false) + ?? await TryGetTvdbIdWithRemoteId(MetadataProvider.Tmdb).ConfigureAwait(false); + + tvdbId = tvdbIdTxt is null ? null : Convert.ToInt32(tvdbIdTxt, CultureInfo.InvariantCulture); + } + + if (!tvdbId.HasValue) + { + _logger.LogWarning("No valid tvdb id found for movie {TvdbId}:{MovieName}", tvdbId, movieInfo.Name); + return Array.Empty(); + } + + try + { + var movieResult = + await _tvdbClientManager + .GetMovieExtendedByIdAsync(tvdbId.Value, cancellationToken) + .ConfigureAwait(false); + return new[] { MapMovieToRemoteSearchResult(movieResult, movieInfo.MetadataLanguage) }; + } + catch (Exception e) + { + _logger.LogError(e, "Failed to retrieve movie with id {TvdbId}:{MovieName}", tvdbId, movieInfo.Name); + return Array.Empty(); + } + } + + private RemoteSearchResult MapMovieToRemoteSearchResult(MovieExtendedRecord movie, string language) + { + var remoteResult = new RemoteSearchResult + { + Name = movie.Translations.GetTranslatedNamedOrDefault(language) ?? TvdbUtils.ReturnOriginalLanguageOrDefault(movie.Name), + Overview = movie.Translations.GetTranslatedOverviewOrDefault(language)?.Trim(), + SearchProviderName = Name, + ImageUrl = movie.Image + }; + + if (DateTime.TryParse(movie.First_release.Date, out var date)) + { + // Dates from tvdb are either EST or capital of primary airing country. + remoteResult.PremiereDate = date; + remoteResult.ProductionYear = date.Year; + } + + var imdbID = movie.RemoteIds?.FirstOrDefault(x => string.Equals(x.SourceName, "IMDB", StringComparison.OrdinalIgnoreCase))?.Id; + remoteResult.SetProviderIdIfHasValue(MetadataProvider.Imdb, imdbID); + remoteResult.SetTvdbId(movie.Id); + + return remoteResult; + } + + private async Task GetMovieByRemoteId(string remoteId, CancellationToken cancellationToken) + { + IReadOnlyList resultData; + try + { + resultData = await _tvdbClientManager.GetMovieByRemoteIdAsync(remoteId, cancellationToken) + .ConfigureAwait(false); + } + catch (SearchException ex) when (ex.InnerException is JsonException) + { + _logger.LogError(ex, "Failed to retrieve movie with {RemoteId}", remoteId); + return null; + } + + if (resultData is null || resultData.Count == 0 || resultData[0]?.Movie?.Id is null) + { + _logger.LogWarning("TvdbSearch: No movie found for remote id: {RemoteId}", remoteId); + return null; + } + + return resultData[0].Movie.Id?.ToString(CultureInfo.InvariantCulture); + } + + private async Task> FindMovie(string name, int? year, string language, CancellationToken cancellationToken) + { + _logger.LogDebug("TvdbSearch: Finding id for item: {Name} ({Year})", name, year); + var results = await FindMovieInternal(name, language, cancellationToken).ConfigureAwait(false); + + return results.Where(i => + { + if (year.HasValue && i.ProductionYear.HasValue) + { + // Allow one year tolerance + return Math.Abs(year.Value - i.ProductionYear.Value) <= 1; + } + + return true; + }); + } + + private async Task> FindMovieInternal(string name, string language, CancellationToken cancellationToken) + { + var parsedName = _libraryManager.ParseName(name); + var comparableName = TvdbUtils.GetComparableName(parsedName.Name); + + var list = new List, RemoteSearchResult>>(); + IReadOnlyList result; + try + { + result = await _tvdbClientManager.GetMovieByNameAsync(comparableName, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception e) + { + _logger.LogError(e, "No movie results found for {Name}", comparableName); + return new List(); + } + + foreach (var movieSearchResult in result) + { + var tvdbTitles = new List + { + movieSearchResult.Translations.GetTranslatedNamedOrDefault(language) ?? movieSearchResult.Name + }; + if (movieSearchResult.Aliases is not null) + { + tvdbTitles.AddRange(movieSearchResult.Aliases); + } + + DateTime? firstAired = null; + if (DateTime.TryParse(movieSearchResult.First_air_time, out var parsedFirstAired)) + { + firstAired = parsedFirstAired; + } + + var remoteSearchResult = new RemoteSearchResult + { + Name = tvdbTitles.FirstOrDefault(), + ProductionYear = firstAired?.Year, + SearchProviderName = Name + }; + + if (!string.IsNullOrEmpty(movieSearchResult.Image_url)) + { + remoteSearchResult.ImageUrl = movieSearchResult.Image_url; + } + + try + { + var movieResult = + await _tvdbClientManager.GetMovieExtendedByIdAsync(Convert.ToInt32(movieSearchResult.Tvdb_id, CultureInfo.InvariantCulture), cancellationToken) + .ConfigureAwait(false); + + var imdbId = movieResult.RemoteIds?.FirstOrDefault(x => string.Equals(x.SourceName, "IMDB", StringComparison.OrdinalIgnoreCase))?.Id.ToString(); + remoteSearchResult.SetProviderIdIfHasValue(MetadataProvider.Imdb, imdbId); + + var zap2ItId = movieResult.RemoteIds?.FirstOrDefault(x => string.Equals(x.SourceName, "Zap2It", StringComparison.OrdinalIgnoreCase))?.Id.ToString(); + remoteSearchResult.SetProviderIdIfHasValue(MetadataProvider.Zap2It, zap2ItId); + + var tmdbId = movieResult.RemoteIds?.FirstOrDefault(x => string.Equals(x.SourceName, "TheMovieDB.com", StringComparison.OrdinalIgnoreCase))?.Id.ToString(); + + // Sometimes, tvdb will return tmdbid as {tmdbid}-{title} like in the tmdb url. Grab the tmdbid only. + var tmdbIdLeft = StringExtensions.LeftPart(tmdbId, '-').ToString(); + remoteSearchResult.SetProviderIdIfHasValue(MetadataProvider.Tmdb, tmdbIdLeft); + } + catch (Exception e) + { + _logger.LogError(e, "Unable to retrieve movie with id {TvdbId}:{MovieName}", movieSearchResult.Tvdb_id, movieSearchResult.Name); + } + + remoteSearchResult.SetTvdbId(movieSearchResult.Tvdb_id); + list.Add(new Tuple, RemoteSearchResult>(tvdbTitles, remoteSearchResult)); + } + + return list + .OrderBy(i => i.Item1.Contains(name, StringComparer.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(i => i.Item1.Any(title => title.Contains(parsedName.Name, StringComparison.OrdinalIgnoreCase)) ? 0 : 1) + .ThenBy(i => i.Item2.ProductionYear.HasValue && i.Item2.ProductionYear.Equals(parsedName.Year) ? 0 : 1) + .ThenBy(i => i.Item1.Any(title => title.Contains(comparableName, StringComparison.OrdinalIgnoreCase)) ? 0 : 1) + .ThenBy(i => list.IndexOf(i)) + .Select(i => i.Item2) + .Take(MaxSearchResults) // TVDB returns a lot of unrelated results + .ToList(); + } + + private async Task Identify(MovieInfo info) + { + if (info.HasTvdbId()) + { + return; + } + + var remoteSearchResults = await FindMovie(info.Name, info.Year, info.MetadataLanguage, CancellationToken.None) + .ConfigureAwait(false); + + var entry = remoteSearchResults.FirstOrDefault(); + if (entry.HasTvdbId(out var tvdbId)) + { + info.SetTvdbId(tvdbId); + } + } + + private async Task FetchMovieMetadata( + MetadataResult result, + MovieInfo movieInfo, + CancellationToken cancellationToken) + { + var movieMetadata = result.Item; + async Task TryGetTvdbIdWithRemoteId(string id) + { + return await GetMovieByRemoteId( + id, + cancellationToken).ConfigureAwait(false); + } + + if (movieInfo.HasTvdbId(out var tvdbIdTxt)) + { + movieMetadata.SetTvdbId(tvdbIdTxt); + } + + if (movieInfo.HasProviderId(MetadataProvider.Imdb, out var imdbId)) + { + movieMetadata.SetProviderId(MetadataProvider.Imdb, imdbId!); + tvdbIdTxt ??= await TryGetTvdbIdWithRemoteId(imdbId!).ConfigureAwait(false); + } + + if (movieInfo.HasProviderId(MetadataProvider.Zap2It, out var zap2It)) + { + movieMetadata.SetProviderId(MetadataProvider.Zap2It, zap2It!); + tvdbIdTxt ??= await TryGetTvdbIdWithRemoteId(zap2It!).ConfigureAwait(false); + } + + if (movieInfo.HasProviderId(MetadataProvider.Tmdb, out var tmdbId)) + { + movieMetadata.SetProviderId(MetadataProvider.Tmdb, tmdbId!); + tvdbIdTxt ??= await TryGetTvdbIdWithRemoteId(tmdbId!).ConfigureAwait(false); + } + + if (string.IsNullOrWhiteSpace(tvdbIdTxt)) + { + _logger.LogWarning("No valid tvdb id found for movie {TvdbId}:{MovieName}", tvdbIdTxt, movieInfo.Name); + return; + } + + var tvdbId = Convert.ToInt32(tvdbIdTxt, CultureInfo.InvariantCulture); + try + { + var movieResult = + await _tvdbClientManager + .GetMovieExtendedByIdAsync(tvdbId, cancellationToken) + .ConfigureAwait(false); + MapMovieToResult(result, movieResult, movieInfo); + + result.ResetPeople(); + + List people = new List(); + if (movieResult.Characters is not null) + { + foreach (Character character in movieResult.Characters) + { + people.Add(character); + } + + MapActorsToResult(result, people); + } + else + { + _logger.LogError("Failed to retrieve actors for movie {TvdbId}:{MovieName}", tvdbId, movieInfo.Name); + } + } + catch (Exception e) + { + _logger.LogError(e, "Failed to retrieve movie with id {TvdbId}:{MovieName}", tvdbId, movieInfo.Name); + return; + } + } + + private void MapMovieToResult(MetadataResult result, MovieExtendedRecord tvdbMovie, MovieInfo info) + { + Movie movie = result.Item; + movie.SetTvdbId(tvdbMovie.Id); + // Tvdb uses 3 letter code for language (prob ISO 639-2) + // Reverts to OriginalName if no translation is found + movie.Name = tvdbMovie.Translations.GetTranslatedNamedOrDefault(info.MetadataLanguage) ?? TvdbUtils.ReturnOriginalLanguageOrDefault(tvdbMovie.Name); + movie.Overview = tvdbMovie.Translations.GetTranslatedOverviewOrDefault(info.MetadataLanguage); + movie.OriginalTitle = tvdbMovie.Name; + result.ResultLanguage = info.MetadataLanguage; + // Attempts to default to USA if not found + movie.OfficialRating = tvdbMovie.ContentRatings.FirstOrDefault(x => string.Equals(x.Country, TvdbCultureInfo.GetCountryInfo(info.MetadataCountryCode)?.ThreeLetterISORegionName, StringComparison.OrdinalIgnoreCase))?.Name ?? tvdbMovie.ContentRatings.FirstOrDefault(x => string.Equals(x.Country, "usa", StringComparison.OrdinalIgnoreCase))?.Name; + + var collectionIds = tvdbMovie.Lists.Where(x => x.IsOfficial is true) + .Select(x => x.Id?.ToString(CultureInfo.InvariantCulture)) + .Aggregate(new StringBuilder(), (sb, id) => sb.Append(id).Append(';')); + movie.SetProviderIdIfHasValue(TvdbPlugin.CollectionProviderId, collectionIds.ToString()); + + movie.SetProviderIdIfHasValue(TvdbPlugin.SlugProviderId, tvdbMovie.Slug); + + var imdbId = tvdbMovie.RemoteIds?.FirstOrDefault(x => string.Equals(x.SourceName, "IMDB", StringComparison.OrdinalIgnoreCase))?.Id.ToString(); + movie.SetProviderIdIfHasValue(MetadataProvider.Imdb, imdbId); + + var zap2ItId = tvdbMovie.RemoteIds?.FirstOrDefault(x => string.Equals(x.SourceName, "Zap2It", StringComparison.OrdinalIgnoreCase))?.Id.ToString(); + movie.SetProviderIdIfHasValue(MetadataProvider.Zap2It, zap2ItId); + + var tmdbId = tvdbMovie.RemoteIds?.FirstOrDefault(x => string.Equals(x.SourceName, "TheMovieDB.com", StringComparison.OrdinalIgnoreCase))?.Id.ToString(); + movie.SetProviderIdIfHasValue(MetadataProvider.Tmdb, tmdbId); + + if (DateTime.TryParse(tvdbMovie.First_release.Date, out var date)) + { + // dates from tvdb are UTC but without offset or Z + movie.PremiereDate = date; + movie.ProductionYear = date.Year; + } + + if (tvdbMovie.Runtime is not null) + { + movie.RunTimeTicks = TimeSpan.FromMinutes(tvdbMovie.Runtime.Value).Ticks; + } + + foreach (var genre in tvdbMovie.Genres) + { + movie.AddGenre(genre.Name); + } + + if (tvdbMovie.Companies.Studio is not null) + { + foreach (var studio in tvdbMovie.Companies.Studio) + { + movie.AddStudio(studio.Name); + } + } + } + + private static void MapActorsToResult(MetadataResult result, IEnumerable actors) + { + foreach (Character actor in actors) + { + var personInfo = new PersonInfo + { + Type = PersonKind.Actor, + Name = (actor.PersonName ?? string.Empty).Trim(), + Role = actor.Name + }; + + if (!string.IsNullOrEmpty(actor.PersonImgURL)) + { + personInfo.ImageUrl = actor.PersonImgURL; + } + + if (!string.IsNullOrWhiteSpace(personInfo.Name)) + { + result.AddPerson(personInfo); + } + } + } + + /// + public Task GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(new Uri(url), cancellationToken); + } + } +} diff --git a/Jellyfin.Plugin.Tvdb/Providers/TvdbSeriesProvider.cs b/Jellyfin.Plugin.Tvdb/Providers/TvdbSeriesProvider.cs index 5bb3195..d42ca04 100644 --- a/Jellyfin.Plugin.Tvdb/Providers/TvdbSeriesProvider.cs +++ b/Jellyfin.Plugin.Tvdb/Providers/TvdbSeriesProvider.cs @@ -5,7 +5,6 @@ using System.Net.Http; using System.Text; using System.Text.Json; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; @@ -300,7 +299,7 @@ private async Task> FindSeries(string name, int? private async Task> FindSeriesInternal(string name, string language, CancellationToken cancellationToken) { var parsedName = _libraryManager.ParseName(name); - var comparableName = GetComparableName(parsedName.Name); + var comparableName = TvdbUtils.GetComparableName(parsedName.Name); var list = new List, RemoteSearchResult>>(); IReadOnlyList result; @@ -382,24 +381,6 @@ await _tvdbClientManager.GetSeriesExtendedByIdAsync(Convert.ToInt32(seriesSearch .ToList(); } - /// - /// Gets the name of the comparable. - /// - /// The name. - /// System.String. - private static string GetComparableName(string name) - { - name = name.ToLowerInvariant(); - name = name.Normalize(NormalizationForm.FormC); - name = name.Replace(", the", string.Empty, StringComparison.OrdinalIgnoreCase) - .Replace("the ", " ", StringComparison.OrdinalIgnoreCase) - .Replace(" the ", " ", StringComparison.OrdinalIgnoreCase); - name = name.Replace("&", " and ", StringComparison.OrdinalIgnoreCase); - name = Regex.Replace(name, @"[\p{Lm}\p{Mn}]", string.Empty); // Remove diacritics, etc - name = Regex.Replace(name, @"[\W\p{Pc}]+", " "); // Replace sequences of non-word characters and _ with " " - return name.Trim(); - } - private static void MapActorsToResult(MetadataResult result, IEnumerable actors) { foreach (Character actor in actors) diff --git a/Jellyfin.Plugin.Tvdb/TvdbClientManager.cs b/Jellyfin.Plugin.Tvdb/TvdbClientManager.cs index d5aa322..b7faa57 100644 --- a/Jellyfin.Plugin.Tvdb/TvdbClientManager.cs +++ b/Jellyfin.Plugin.Tvdb/TvdbClientManager.cs @@ -100,6 +100,81 @@ bool IsTokenInvalid() => || _tokenUpdatedAt < DateTime.UtcNow.Subtract(TimeSpan.FromDays(25)); } + /// + /// Gets movie by name. + /// + /// Movie Name. + /// Cancellation token. + /// The movie search result. + public async Task> GetMovieByNameAsync( + string name, + CancellationToken cancellationToken) + { + var key = $"TvdbMovieSearch_{name}"; + if (_memoryCache.TryGetValue(key, out IReadOnlyList? movies) + && movies is not null) + { + return movies; + } + + var searchClient = _serviceProvider.GetRequiredService(); + await LoginAsync().ConfigureAwait(false); + var searchResult = await searchClient.GetSearchResultsAsync(query: name, type: "movie", limit: 5, cancellationToken: cancellationToken) + .ConfigureAwait(false); + _memoryCache.Set(key, searchResult.Data, TimeSpan.FromHours(CacheDurationInHours)); + return searchResult.Data; + } + + /// + /// Get movie by remoteId. + /// + /// Remote Id. + /// Cancellation token. + /// The movie search result. + public async Task> GetMovieByRemoteIdAsync( + string remoteId, + CancellationToken cancellationToken) + { + var key = $"TvdbMovieRemoteId_{remoteId}"; + if (_memoryCache.TryGetValue(key, out IReadOnlyList? movies) + && movies is not null) + { + return movies; + } + + var searchClient = _serviceProvider.GetRequiredService(); + await LoginAsync().ConfigureAwait(false); + var searchResult = await searchClient.GetSearchResultsByRemoteIdAsync(remoteId: remoteId, cancellationToken: cancellationToken) + .ConfigureAwait(false); + _memoryCache.Set(key, searchResult.Data, TimeSpan.FromHours(CacheDurationInHours)); + return searchResult.Data; + } + + /// + /// Get movie by id. + /// + /// The movie tvdb id. + /// Cancellation token. + /// The movie response. + public async Task GetMovieExtendedByIdAsync( + int tvdbId, + CancellationToken cancellationToken) + { + var key = $"TvdbMovie_{tvdbId.ToString(CultureInfo.InvariantCulture)}"; + if (_memoryCache.TryGetValue(key, out MovieExtendedRecord? movie) + && movie is not null) + { + return movie; + } + + var movieClient = _serviceProvider.GetRequiredService(); + await LoginAsync().ConfigureAwait(false); + var movieResult = await movieClient.GetMovieExtendedAsync(id: tvdbId, meta: Meta2.Translations, @short: false, cancellationToken: cancellationToken) + .ConfigureAwait(false); + _memoryCache.Set(key, movieResult.Data, TimeSpan.FromHours(CacheDurationInHours)); + return movieResult.Data; + } + /// /// Get series by name. /// @@ -621,6 +696,7 @@ private ServiceProvider ConfigureService(IApplicationHost applicationHost) services.AddTransient(_ => new Artwork_TypesClient(_sdkClientSettings, _httpClientFactory.CreateClient(TvdbHttpClient))); services.AddTransient(_ => new LanguagesClient(_sdkClientSettings, _httpClientFactory.CreateClient(TvdbHttpClient))); services.AddTransient(_ => new UpdatesClient(_sdkClientSettings, _httpClientFactory.CreateClient(TvdbHttpClient))); + services.AddTransient(_ => new MoviesClient(_sdkClientSettings, _httpClientFactory.CreateClient(TvdbHttpClient))); return services.BuildServiceProvider(); } diff --git a/Jellyfin.Plugin.Tvdb/TvdbPlugin.cs b/Jellyfin.Plugin.Tvdb/TvdbPlugin.cs index 064a0ed..bda780b 100644 --- a/Jellyfin.Plugin.Tvdb/TvdbPlugin.cs +++ b/Jellyfin.Plugin.Tvdb/TvdbPlugin.cs @@ -28,6 +28,11 @@ public class TvdbPlugin : BasePlugin, IHasWebPages /// public const string CollectionProviderId = "TvdbCollection"; + /// + /// Gets the slug provider id. + /// + public const string SlugProviderId = "TvdbSlug"; + /// /// Initializes a new instance of the class. /// diff --git a/Jellyfin.Plugin.Tvdb/TvdbUtils.cs b/Jellyfin.Plugin.Tvdb/TvdbUtils.cs index 22af158..21ac92e 100644 --- a/Jellyfin.Plugin.Tvdb/TvdbUtils.cs +++ b/Jellyfin.Plugin.Tvdb/TvdbUtils.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; - +using System.Text; +using System.Text.RegularExpressions; using Tvdb.Sdk; namespace Jellyfin.Plugin.Tvdb @@ -69,5 +70,23 @@ public static IEnumerable GetAirDays(SeriesAirsDays seriesAirsDays) { return FallbackToOriginalLanguage ? text : null; } + + /// + /// Gets the name of the comparable. + /// + /// The name. + /// System.String. + public static string GetComparableName(string name) + { + name = name.ToLowerInvariant(); + name = name.Normalize(NormalizationForm.FormC); + name = name.Replace(", the", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("the ", " ", StringComparison.OrdinalIgnoreCase) + .Replace(" the ", " ", StringComparison.OrdinalIgnoreCase); + name = name.Replace("&", " and ", StringComparison.OrdinalIgnoreCase); + name = Regex.Replace(name, @"[\p{Lm}\p{Mn}]", string.Empty); // Remove diacritics, etc + name = Regex.Replace(name, @"[\W\p{Pc}]+", " "); // Replace sequences of non-word characters and _ with " " + return name.Trim(); + } } }