diff options
Diffstat (limited to 'MediaBrowser.Providers/Plugins')
26 files changed, 4865 insertions, 0 deletions
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs new file mode 100644 index 000000000..dee2d59f0 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Model.Serialization; + +namespace MediaBrowser.Providers.Plugins.AudioDb +{ + public class AudioDbAlbumImageProvider : IRemoteImageProvider, IHasOrder + { + private readonly IServerConfigurationManager _config; + private readonly IHttpClient _httpClient; + private readonly IJsonSerializer _json; + + public AudioDbAlbumImageProvider(IServerConfigurationManager config, IHttpClient httpClient, IJsonSerializer json) + { + _config = config; + _httpClient = httpClient; + _json = json; + } + + /// <inheritdoc /> + public string Name => "TheAudioDB"; + + /// <inheritdoc /> + // After embedded and fanart + public int Order => 2; + + /// <inheritdoc /> + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + { + return new List<ImageType> + { + ImageType.Primary, + ImageType.Disc + }; + } + + /// <inheritdoc /> + public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) + { + var id = item.GetProviderId(MetadataProviders.MusicBrainzReleaseGroup); + + if (!string.IsNullOrWhiteSpace(id)) + { + await AudioDbAlbumProvider.Current.EnsureInfo(id, cancellationToken).ConfigureAwait(false); + + var path = AudioDbAlbumProvider.GetAlbumInfoPath(_config.ApplicationPaths, id); + + var obj = _json.DeserializeFromFile<AudioDbAlbumProvider.RootObject>(path); + + if (obj != null && obj.album != null && obj.album.Count > 0) + { + return GetImages(obj.album[0]); + } + } + + return new List<RemoteImageInfo>(); + } + + private IEnumerable<RemoteImageInfo> GetImages(AudioDbAlbumProvider.Album item) + { + var list = new List<RemoteImageInfo>(); + + if (!string.IsNullOrWhiteSpace(item.strAlbumThumb)) + { + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Url = item.strAlbumThumb, + Type = ImageType.Primary + }); + } + + if (!string.IsNullOrWhiteSpace(item.strAlbumCDart)) + { + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Url = item.strAlbumCDart, + Type = ImageType.Disc + }); + } + + return list; + } + + /// <inheritdoc /> + public Task<HttpResponseInfo> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClient.GetResponse(new HttpRequestOptions + { + CancellationToken = cancellationToken, + Url = url + }); + } + + /// <inheritdoc /> + public bool Supports(BaseItem item) + => Plugin.Instance.Configuration.Enable && item is MusicAlbum; + } +} diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs new file mode 100644 index 000000000..1a0e87871 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Providers; +using MediaBrowser.Model.Serialization; +using MediaBrowser.Providers.Music; + +namespace MediaBrowser.Providers.Plugins.AudioDb +{ + public class AudioDbAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder + { + private readonly IServerConfigurationManager _config; + private readonly IFileSystem _fileSystem; + private readonly IHttpClient _httpClient; + private readonly IJsonSerializer _json; + + public static AudioDbAlbumProvider Current; + + public AudioDbAlbumProvider(IServerConfigurationManager config, IFileSystem fileSystem, IHttpClient httpClient, IJsonSerializer json) + { + _config = config; + _fileSystem = fileSystem; + _httpClient = httpClient; + _json = json; + + Current = this; + } + + /// <inheritdoc /> + public string Name => "TheAudioDB"; + + /// <inheritdoc /> + // After music brainz + public int Order => 1; + + /// <inheritdoc /> + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken) + => Task.FromResult(Enumerable.Empty<RemoteSearchResult>()); + + /// <inheritdoc /> + public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<MusicAlbum>(); + + // TODO maybe remove when artist metadata can be disabled + if (!Plugin.Instance.Configuration.Enable) + { + return result; + } + + var id = info.GetReleaseGroupId(); + + if (!string.IsNullOrWhiteSpace(id)) + { + await EnsureInfo(id, cancellationToken).ConfigureAwait(false); + + var path = GetAlbumInfoPath(_config.ApplicationPaths, id); + + var obj = _json.DeserializeFromFile<RootObject>(path); + + if (obj != null && obj.album != null && obj.album.Count > 0) + { + result.Item = new MusicAlbum(); + result.HasMetadata = true; + ProcessResult(result.Item, obj.album[0], info.MetadataLanguage); + } + } + + return result; + } + + private void ProcessResult(MusicAlbum item, Album result, string preferredLanguage) + { + if (Plugin.Instance.Configuration.ReplaceAlbumName && !string.IsNullOrWhiteSpace(result.strAlbum)) + { + item.Album = result.strAlbum; + } + + if (!string.IsNullOrWhiteSpace(result.strArtist)) + { + item.AlbumArtists = new string[] { result.strArtist }; + } + + if (!string.IsNullOrEmpty(result.intYearReleased)) + { + item.ProductionYear = int.Parse(result.intYearReleased, CultureInfo.InvariantCulture); + } + + if (!string.IsNullOrEmpty(result.strGenre)) + { + item.Genres = new[] { result.strGenre }; + } + + item.SetProviderId(MetadataProviders.AudioDbArtist, result.idArtist); + item.SetProviderId(MetadataProviders.AudioDbAlbum, result.idAlbum); + + item.SetProviderId(MetadataProviders.MusicBrainzAlbumArtist, result.strMusicBrainzArtistID); + item.SetProviderId(MetadataProviders.MusicBrainzReleaseGroup, result.strMusicBrainzID); + + string overview = null; + + if (string.Equals(preferredLanguage, "de", StringComparison.OrdinalIgnoreCase)) + { + overview = result.strDescriptionDE; + } + else if (string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase)) + { + overview = result.strDescriptionFR; + } + else if (string.Equals(preferredLanguage, "nl", StringComparison.OrdinalIgnoreCase)) + { + overview = result.strDescriptionNL; + } + else if (string.Equals(preferredLanguage, "ru", StringComparison.OrdinalIgnoreCase)) + { + overview = result.strDescriptionRU; + } + else if (string.Equals(preferredLanguage, "it", StringComparison.OrdinalIgnoreCase)) + { + overview = result.strDescriptionIT; + } + else if ((preferredLanguage ?? string.Empty).StartsWith("pt", StringComparison.OrdinalIgnoreCase)) + { + overview = result.strDescriptionPT; + } + + if (string.IsNullOrWhiteSpace(overview)) + { + overview = result.strDescriptionEN; + } + + item.Overview = (overview ?? string.Empty).StripHtml(); + } + + internal Task EnsureInfo(string musicBrainzReleaseGroupId, CancellationToken cancellationToken) + { + var xmlPath = GetAlbumInfoPath(_config.ApplicationPaths, musicBrainzReleaseGroupId); + + var fileInfo = _fileSystem.GetFileSystemInfo(xmlPath); + + if (fileInfo.Exists) + { + if ((DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 2) + { + return Task.CompletedTask; + } + } + + return DownloadInfo(musicBrainzReleaseGroupId, cancellationToken); + } + + internal async Task DownloadInfo(string musicBrainzReleaseGroupId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var url = AudioDbArtistProvider.BaseUrl + "/album-mb.php?i=" + musicBrainzReleaseGroupId; + + var path = GetAlbumInfoPath(_config.ApplicationPaths, musicBrainzReleaseGroupId); + + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + using (var httpResponse = await _httpClient.SendAsync( + new HttpRequestOptions + { + Url = url, + CancellationToken = cancellationToken + }, + HttpMethod.Get).ConfigureAwait(false)) + using (var response = httpResponse.Content) + using (var xmlFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true)) + { + await response.CopyToAsync(xmlFileStream).ConfigureAwait(false); + } + } + + private static string GetAlbumDataPath(IApplicationPaths appPaths, string musicBrainzReleaseGroupId) + { + var dataPath = Path.Combine(GetAlbumDataPath(appPaths), musicBrainzReleaseGroupId); + + return dataPath; + } + + private static string GetAlbumDataPath(IApplicationPaths appPaths) + { + var dataPath = Path.Combine(appPaths.CachePath, "audiodb-album"); + + return dataPath; + } + + internal static string GetAlbumInfoPath(IApplicationPaths appPaths, string musicBrainzReleaseGroupId) + { + var dataPath = GetAlbumDataPath(appPaths, musicBrainzReleaseGroupId); + + return Path.Combine(dataPath, "album.json"); + } + + public class Album + { + public string idAlbum { get; set; } + public string idArtist { get; set; } + public string strAlbum { get; set; } + public string strArtist { get; set; } + public string intYearReleased { get; set; } + public string strGenre { get; set; } + public string strSubGenre { get; set; } + public string strReleaseFormat { get; set; } + public string intSales { get; set; } + public string strAlbumThumb { get; set; } + public string strAlbumCDart { get; set; } + public string strDescriptionEN { get; set; } + public string strDescriptionDE { get; set; } + public string strDescriptionFR { get; set; } + public string strDescriptionCN { get; set; } + public string strDescriptionIT { get; set; } + public string strDescriptionJP { get; set; } + public string strDescriptionRU { get; set; } + public string strDescriptionES { get; set; } + public string strDescriptionPT { get; set; } + public string strDescriptionSE { get; set; } + public string strDescriptionNL { get; set; } + public string strDescriptionHU { get; set; } + public string strDescriptionNO { get; set; } + public string strDescriptionIL { get; set; } + public string strDescriptionPL { get; set; } + public object intLoved { get; set; } + public object intScore { get; set; } + public string strReview { get; set; } + public object strMood { get; set; } + public object strTheme { get; set; } + public object strSpeed { get; set; } + public object strLocation { get; set; } + public string strMusicBrainzID { get; set; } + public string strMusicBrainzArtistID { get; set; } + public object strItunesID { get; set; } + public object strAmazonID { get; set; } + public string strLocked { get; set; } + } + + public class RootObject + { + public List<Album> album { get; set; } + } + + /// <inheritdoc /> + public Task<HttpResponseInfo> GetImageResponse(string url, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs new file mode 100644 index 000000000..18afd5dd5 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs @@ -0,0 +1,149 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Model.Serialization; + +namespace MediaBrowser.Providers.Plugins.AudioDb +{ + public class AudioDbArtistImageProvider : IRemoteImageProvider, IHasOrder + { + private readonly IServerConfigurationManager _config; + private readonly IHttpClient _httpClient; + private readonly IJsonSerializer _json; + + public AudioDbArtistImageProvider(IServerConfigurationManager config, IJsonSerializer json, IHttpClient httpClient) + { + _config = config; + _json = json; + _httpClient = httpClient; + } + + /// <inheritdoc /> + public string Name => "TheAudioDB"; + + /// <inheritdoc /> + // After fanart + public int Order => 1; + + /// <inheritdoc /> + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + { + return new List<ImageType> + { + ImageType.Primary, + ImageType.Logo, + ImageType.Banner, + ImageType.Backdrop + }; + } + + /// <inheritdoc /> + public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) + { + var id = item.GetProviderId(MetadataProviders.MusicBrainzArtist); + + if (!string.IsNullOrWhiteSpace(id)) + { + await AudioDbArtistProvider.Current.EnsureArtistInfo(id, cancellationToken).ConfigureAwait(false); + + var path = AudioDbArtistProvider.GetArtistInfoPath(_config.ApplicationPaths, id); + + var obj = _json.DeserializeFromFile<AudioDbArtistProvider.RootObject>(path); + + if (obj != null && obj.artists != null && obj.artists.Count > 0) + { + return GetImages(obj.artists[0]); + } + } + + return new List<RemoteImageInfo>(); + } + + private IEnumerable<RemoteImageInfo> GetImages(AudioDbArtistProvider.Artist item) + { + var list = new List<RemoteImageInfo>(); + + if (!string.IsNullOrWhiteSpace(item.strArtistThumb)) + { + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Url = item.strArtistThumb, + Type = ImageType.Primary + }); + } + + if (!string.IsNullOrWhiteSpace(item.strArtistLogo)) + { + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Url = item.strArtistLogo, + Type = ImageType.Logo + }); + } + + if (!string.IsNullOrWhiteSpace(item.strArtistBanner)) + { + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Url = item.strArtistBanner, + Type = ImageType.Banner + }); + } + + if (!string.IsNullOrWhiteSpace(item.strArtistFanart)) + { + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Url = item.strArtistFanart, + Type = ImageType.Backdrop + }); + } + + if (!string.IsNullOrWhiteSpace(item.strArtistFanart2)) + { + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Url = item.strArtistFanart2, + Type = ImageType.Backdrop + }); + } + + if (!string.IsNullOrWhiteSpace(item.strArtistFanart3)) + { + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Url = item.strArtistFanart3, + Type = ImageType.Backdrop + }); + } + + return list; + } + + public Task<HttpResponseInfo> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClient.GetResponse(new HttpRequestOptions + { + CancellationToken = cancellationToken, + Url = url + }); + } + + /// <inheritdoc /> + public bool Supports(BaseItem item) + => Plugin.Instance.Configuration.Enable && item is MusicArtist; + } +} diff --git a/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs new file mode 100644 index 000000000..df0f3df8f --- /dev/null +++ b/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Providers; +using MediaBrowser.Model.Serialization; +using MediaBrowser.Providers.Music; + +namespace MediaBrowser.Providers.Plugins.AudioDb +{ + public class AudioDbArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IHasOrder + { + private readonly IServerConfigurationManager _config; + private readonly IFileSystem _fileSystem; + private readonly IHttpClient _httpClient; + private readonly IJsonSerializer _json; + + public static AudioDbArtistProvider Current; + + private const string ApiKey = "195003"; + public const string BaseUrl = "https://www.theaudiodb.com/api/v1/json/" + ApiKey; + + public AudioDbArtistProvider(IServerConfigurationManager config, IFileSystem fileSystem, IHttpClient httpClient, IJsonSerializer json) + { + _config = config; + _fileSystem = fileSystem; + _httpClient = httpClient; + _json = json; + Current = this; + } + + /// <inheritdoc /> + public string Name => "TheAudioDB"; + + /// <inheritdoc /> + // After musicbrainz + public int Order => 1; + + /// <inheritdoc /> + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken) + => Task.FromResult(Enumerable.Empty<RemoteSearchResult>()); + + /// <inheritdoc /> + public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<MusicArtist>(); + + // TODO maybe remove when artist metadata can be disabled + if (!Plugin.Instance.Configuration.Enable) + { + return result; + } + + var id = info.GetMusicBrainzArtistId(); + + if (!string.IsNullOrWhiteSpace(id)) + { + await EnsureArtistInfo(id, cancellationToken).ConfigureAwait(false); + + var path = GetArtistInfoPath(_config.ApplicationPaths, id); + + var obj = _json.DeserializeFromFile<RootObject>(path); + + if (obj != null && obj.artists != null && obj.artists.Count > 0) + { + result.Item = new MusicArtist(); + result.HasMetadata = true; + ProcessResult(result.Item, obj.artists[0], info.MetadataLanguage); + } + } + + return result; + } + + private void ProcessResult(MusicArtist item, Artist result, string preferredLanguage) + { + //item.HomePageUrl = result.strWebsite; + + if (!string.IsNullOrEmpty(result.strGenre)) + { + item.Genres = new[] { result.strGenre }; + } + + item.SetProviderId(MetadataProviders.AudioDbArtist, result.idArtist); + item.SetProviderId(MetadataProviders.MusicBrainzArtist, result.strMusicBrainzID); + + string overview = null; + + if (string.Equals(preferredLanguage, "de", StringComparison.OrdinalIgnoreCase)) + { + overview = result.strBiographyDE; + } + else if (string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase)) + { + overview = result.strBiographyFR; + } + else if (string.Equals(preferredLanguage, "nl", StringComparison.OrdinalIgnoreCase)) + { + overview = result.strBiographyNL; + } + else if (string.Equals(preferredLanguage, "ru", StringComparison.OrdinalIgnoreCase)) + { + overview = result.strBiographyRU; + } + else if (string.Equals(preferredLanguage, "it", StringComparison.OrdinalIgnoreCase)) + { + overview = result.strBiographyIT; + } + else if ((preferredLanguage ?? string.Empty).StartsWith("pt", StringComparison.OrdinalIgnoreCase)) + { + overview = result.strBiographyPT; + } + + if (string.IsNullOrWhiteSpace(overview)) + { + overview = result.strBiographyEN; + } + + item.Overview = (overview ?? string.Empty).StripHtml(); + } + + internal Task EnsureArtistInfo(string musicBrainzId, CancellationToken cancellationToken) + { + var xmlPath = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); + + var fileInfo = _fileSystem.GetFileSystemInfo(xmlPath); + + if (fileInfo.Exists + && (DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 2) + { + return Task.CompletedTask; + } + + return DownloadArtistInfo(musicBrainzId, cancellationToken); + } + + internal async Task DownloadArtistInfo(string musicBrainzId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var url = BaseUrl + "/artist-mb.php?i=" + musicBrainzId; + + var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); + + using (var httpResponse = await _httpClient.SendAsync( + new HttpRequestOptions + { + Url = url, + CancellationToken = cancellationToken, + BufferContent = true + }, + HttpMethod.Get).ConfigureAwait(false)) + using (var response = httpResponse.Content) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + using (var xmlFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true)) + { + await response.CopyToAsync(xmlFileStream).ConfigureAwait(false); + } + } + } + + /// <summary> + /// Gets the artist data path. + /// </summary> + /// <param name="appPaths">The application paths.</param> + /// <param name="musicBrainzArtistId">The music brainz artist identifier.</param> + /// <returns>System.String.</returns> + private static string GetArtistDataPath(IApplicationPaths appPaths, string musicBrainzArtistId) + => Path.Combine(GetArtistDataPath(appPaths), musicBrainzArtistId); + + /// <summary> + /// Gets the artist data path. + /// </summary> + /// <param name="appPaths">The application paths.</param> + /// <returns>System.String.</returns> + private static string GetArtistDataPath(IApplicationPaths appPaths) + => Path.Combine(appPaths.CachePath, "audiodb-artist"); + + internal static string GetArtistInfoPath(IApplicationPaths appPaths, string musicBrainzArtistId) + { + var dataPath = GetArtistDataPath(appPaths, musicBrainzArtistId); + + return Path.Combine(dataPath, "artist.json"); + } + + public class Artist + { + public string idArtist { get; set; } + public string strArtist { get; set; } + public string strArtistAlternate { get; set; } + public object idLabel { get; set; } + public string intFormedYear { get; set; } + public string intBornYear { get; set; } + public object intDiedYear { get; set; } + public object strDisbanded { get; set; } + public string strGenre { get; set; } + public string strSubGenre { get; set; } + public string strWebsite { get; set; } + public string strFacebook { get; set; } + public string strTwitter { get; set; } + public string strBiographyEN { get; set; } + public string strBiographyDE { get; set; } + public string strBiographyFR { get; set; } + public string strBiographyCN { get; set; } + public string strBiographyIT { get; set; } + public string strBiographyJP { get; set; } + public string strBiographyRU { get; set; } + public string strBiographyES { get; set; } + public string strBiographyPT { get; set; } + public string strBiographySE { get; set; } + public string strBiographyNL { get; set; } + public string strBiographyHU { get; set; } + public string strBiographyNO { get; set; } + public string strBiographyIL { get; set; } + public string strBiographyPL { get; set; } + public string strGender { get; set; } + public string intMembers { get; set; } + public string strCountry { get; set; } + public string strCountryCode { get; set; } + public string strArtistThumb { get; set; } + public string strArtistLogo { get; set; } + public string strArtistFanart { get; set; } + public string strArtistFanart2 { get; set; } + public string strArtistFanart3 { get; set; } + public string strArtistBanner { get; set; } + public string strMusicBrainzID { get; set; } + public object strLastFMChart { get; set; } + public string strLocked { get; set; } + } + + public class RootObject + { + public List<Artist> artists { get; set; } + } + + /// <inheritdoc /> + public Task<HttpResponseInfo> GetImageResponse(string url, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/AudioDb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/PluginConfiguration.cs new file mode 100644 index 000000000..ad3c7eb4b --- /dev/null +++ b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/PluginConfiguration.cs @@ -0,0 +1,11 @@ +using MediaBrowser.Model.Plugins; + +namespace MediaBrowser.Providers.Plugins.AudioDb +{ + public class PluginConfiguration : BasePluginConfiguration + { + public bool Enable { get; set; } + + public bool ReplaceAlbumName { get; set; } + } +} diff --git a/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html new file mode 100644 index 000000000..34494644d --- /dev/null +++ b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html @@ -0,0 +1,57 @@ +<!DOCTYPE html> +<html> +<head> + <title>AudioDB</title> +</head> +<body> + <div data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox"> + <div data-role="content"> + <div class="content-primary"> + <form class="configForm"> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="enable" /> + <span>Enable this provider for metadata searches on artists and albums.</span> + </label> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="replaceAlbumName" /> + <span>When an album is found during a metadata search, replace the name with the value on the server.</span> + </label> + <br /> + <div> + <button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button> + </div> + </form> + </div> + </div> + <script type="text/javascript"> + var PluginConfig = { + pluginId: "a629c0da-fac5-4c7e-931a-7174223f14c8" + }; + + $('.configPage').on('pageshow', function () { + Dashboard.showLoadingMsg(); + ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) { + $('#enable').checked(config.Enable); + $('#replaceAlbumName').checked(config.ReplaceAlbumName); + + Dashboard.hideLoadingMsg(); + }); + }); + + $('.configForm').on('submit', function (e) { + Dashboard.showLoadingMsg(); + + var form = this; + ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) { + config.Enable = $('#enable', form).checked(); + config.ReplaceAlbumName = $('#replaceAlbumName', form).checked(); + + ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); + }); + + return false; + }); + </script> + </div> +</body> +</html> diff --git a/MediaBrowser.Providers/Plugins/AudioDb/ExternalIds.cs b/MediaBrowser.Providers/Plugins/AudioDb/ExternalIds.cs new file mode 100644 index 000000000..2d8cb431c --- /dev/null +++ b/MediaBrowser.Providers/Plugins/AudioDb/ExternalIds.cs @@ -0,0 +1,66 @@ +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.Plugins.AudioDb +{ + public class AudioDbAlbumExternalId : IExternalId + { + /// <inheritdoc /> + public string Name => "TheAudioDb"; + + /// <inheritdoc /> + public string Key => MetadataProviders.AudioDbAlbum.ToString(); + + /// <inheritdoc /> + public string UrlFormatString => "https://www.theaudiodb.com/album/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is MusicAlbum; + } + + public class AudioDbOtherAlbumExternalId : IExternalId + { + /// <inheritdoc /> + public string Name => "TheAudioDb Album"; + + /// <inheritdoc /> + public string Key => MetadataProviders.AudioDbAlbum.ToString(); + + /// <inheritdoc /> + public string UrlFormatString => "https://www.theaudiodb.com/album/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio; + } + + public class AudioDbArtistExternalId : IExternalId + { + /// <inheritdoc /> + public string Name => "TheAudioDb"; + + /// <inheritdoc /> + public string Key => MetadataProviders.AudioDbArtist.ToString(); + + /// <inheritdoc /> + public string UrlFormatString => "https://www.theaudiodb.com/artist/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is MusicArtist; + } + + public class AudioDbOtherArtistExternalId : IExternalId + { + /// <inheritdoc /> + public string Name => "TheAudioDb Artist"; + + /// <inheritdoc /> + public string Key => MetadataProviders.AudioDbArtist.ToString(); + + /// <inheritdoc /> + public string UrlFormatString => "https://www.theaudiodb.com/artist/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; + } +} diff --git a/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs b/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs new file mode 100644 index 000000000..8532c4df3 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace MediaBrowser.Providers.Plugins.AudioDb +{ + public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages + { + public static Plugin Instance { get; private set; } + + public override Guid Id => new Guid("a629c0da-fac5-4c7e-931a-7174223f14c8"); + + public override string Name => "AudioDB"; + + public override string Description => "Get artist and album metadata or images from AudioDB."; + + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + public IEnumerable<PluginPageInfo> GetPages() + { + yield return new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = GetType().Namespace + ".Configuration.config.html" + }; + } + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/AlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/AlbumProvider.cs new file mode 100644 index 000000000..bc973dee5 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/AlbumProvider.cs @@ -0,0 +1,799 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Plugins.MusicBrainz; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Music +{ + public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder + { + /// <summary> + /// The Jellyfin user-agent is unrestricted but source IP must not exceed + /// one request per second, therefore we rate limit to avoid throttling. + /// Be prudent, use a value slightly above the minimun required. + /// https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting + /// </summary> + private readonly long _musicBrainzQueryIntervalMs; + + /// <summary> + /// For each single MB lookup/search, this is the maximum number of + /// attempts that shall be made whilst receiving a 503 Server + /// Unavailable (indicating throttled) response. + /// </summary> + private const uint MusicBrainzQueryAttempts = 5u; + + internal static MusicBrainzAlbumProvider Current; + + private readonly IHttpClient _httpClient; + private readonly IApplicationHost _appHost; + private readonly ILogger _logger; + + private readonly string _musicBrainzBaseUrl; + + private Stopwatch _stopWatchMusicBrainz = new Stopwatch(); + + public MusicBrainzAlbumProvider( + IHttpClient httpClient, + IApplicationHost appHost, + ILogger<MusicBrainzAlbumProvider> logger) + { + _httpClient = httpClient; + _appHost = appHost; + _logger = logger; + + _musicBrainzBaseUrl = Plugin.Instance.Configuration.Server; + _musicBrainzQueryIntervalMs = Plugin.Instance.Configuration.RateLimit; + + // Use a stopwatch to ensure we don't exceed the MusicBrainz rate limit + _stopWatchMusicBrainz.Start(); + + Current = this; + } + + /// <inheritdoc /> + public string Name => "MusicBrainz"; + + /// <inheritdoc /> + public int Order => 0; + + /// <inheritdoc /> + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken) + { + // TODO maybe remove when artist metadata can be disabled + if (!Plugin.Instance.Configuration.Enable) + { + return Enumerable.Empty<RemoteSearchResult>(); + } + + var releaseId = searchInfo.GetReleaseId(); + var releaseGroupId = searchInfo.GetReleaseGroupId(); + + string url; + + if (!string.IsNullOrEmpty(releaseId)) + { + url = "/ws/2/release/?query=reid:" + releaseId.ToString(CultureInfo.InvariantCulture); + } + else if (!string.IsNullOrEmpty(releaseGroupId)) + { + url = "/ws/2/release?release-group=" + releaseGroupId.ToString(CultureInfo.InvariantCulture); + } + else + { + var artistMusicBrainzId = searchInfo.GetMusicBrainzArtistId(); + + if (!string.IsNullOrWhiteSpace(artistMusicBrainzId)) + { + url = string.Format( + CultureInfo.InvariantCulture, + "/ws/2/release/?query=\"{0}\" AND arid:{1}", + WebUtility.UrlEncode(searchInfo.Name), + artistMusicBrainzId); + } + else + { + // I'm sure there is a better way but for now it resolves search for 12" Mixes + var queryName = searchInfo.Name.Replace("\"", string.Empty); + + url = string.Format( + CultureInfo.InvariantCulture, + "/ws/2/release/?query=\"{0}\" AND artist:\"{1}\"", + WebUtility.UrlEncode(queryName), + WebUtility.UrlEncode(searchInfo.GetAlbumArtist())); + } + } + + if (!string.IsNullOrWhiteSpace(url)) + { + using (var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false)) + using (var stream = response.Content) + { + return GetResultsFromResponse(stream); + } + } + + return Enumerable.Empty<RemoteSearchResult>(); + } + + private IEnumerable<RemoteSearchResult> GetResultsFromResponse(Stream stream) + { + using (var oReader = new StreamReader(stream, Encoding.UTF8)) + { + var settings = new XmlReaderSettings() + { + ValidationType = ValidationType.None, + CheckCharacters = false, + IgnoreProcessingInstructions = true, + IgnoreComments = true + }; + + using (var reader = XmlReader.Create(oReader, settings)) + { + var results = ReleaseResult.Parse(reader); + + return results.Select(i => + { + var result = new RemoteSearchResult + { + Name = i.Title, + ProductionYear = i.Year + }; + + if (i.Artists.Count > 0) + { + result.AlbumArtist = new RemoteSearchResult + { + SearchProviderName = Name, + Name = i.Artists[0].Item1 + }; + + result.AlbumArtist.SetProviderId(MetadataProviders.MusicBrainzArtist, i.Artists[0].Item2); + } + + if (!string.IsNullOrWhiteSpace(i.ReleaseId)) + { + result.SetProviderId(MetadataProviders.MusicBrainzAlbum, i.ReleaseId); + } + + if (!string.IsNullOrWhiteSpace(i.ReleaseGroupId)) + { + result.SetProviderId(MetadataProviders.MusicBrainzReleaseGroup, i.ReleaseGroupId); + } + + return result; + }); + } + } + } + + /// <inheritdoc /> + public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo id, CancellationToken cancellationToken) + { + var releaseId = id.GetReleaseId(); + var releaseGroupId = id.GetReleaseGroupId(); + + var result = new MetadataResult<MusicAlbum> + { + Item = new MusicAlbum() + }; + + // TODO maybe remove when artist metadata can be disabled + if (!Plugin.Instance.Configuration.Enable) + { + return result; + } + + // If we have a release group Id but not a release Id... + if (string.IsNullOrWhiteSpace(releaseId) && !string.IsNullOrWhiteSpace(releaseGroupId)) + { + releaseId = await GetReleaseIdFromReleaseGroupId(releaseGroupId, cancellationToken).ConfigureAwait(false); + result.HasMetadata = true; + } + + if (string.IsNullOrWhiteSpace(releaseId)) + { + var artistMusicBrainzId = id.GetMusicBrainzArtistId(); + + var releaseResult = await GetReleaseResult(artistMusicBrainzId, id.GetAlbumArtist(), id.Name, cancellationToken).ConfigureAwait(false); + + if (releaseResult != null) + { + if (!string.IsNullOrWhiteSpace(releaseResult.ReleaseId)) + { + releaseId = releaseResult.ReleaseId; + result.HasMetadata = true; + } + + if (!string.IsNullOrWhiteSpace(releaseResult.ReleaseGroupId)) + { + releaseGroupId = releaseResult.ReleaseGroupId; + result.HasMetadata = true; + } + + result.Item.ProductionYear = releaseResult.Year; + result.Item.Overview = releaseResult.Overview; + } + } + + // If we have a release Id but not a release group Id... + if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId)) + { + releaseGroupId = await GetReleaseGroupFromReleaseId(releaseId, cancellationToken).ConfigureAwait(false); + result.HasMetadata = true; + } + + if (!string.IsNullOrWhiteSpace(releaseId) || !string.IsNullOrWhiteSpace(releaseGroupId)) + { + result.HasMetadata = true; + } + + if (result.HasMetadata) + { + if (!string.IsNullOrEmpty(releaseId)) + { + result.Item.SetProviderId(MetadataProviders.MusicBrainzAlbum, releaseId); + } + + if (!string.IsNullOrEmpty(releaseGroupId)) + { + result.Item.SetProviderId(MetadataProviders.MusicBrainzReleaseGroup, releaseGroupId); + } + } + + return result; + } + + private Task<ReleaseResult> GetReleaseResult(string artistMusicBrainId, string artistName, string albumName, CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(artistMusicBrainId)) + { + return GetReleaseResult(albumName, artistMusicBrainId, cancellationToken); + } + + if (string.IsNullOrWhiteSpace(artistName)) + { + return Task.FromResult(new ReleaseResult()); + } + + return GetReleaseResultByArtistName(albumName, artistName, cancellationToken); + } + + private async Task<ReleaseResult> GetReleaseResult(string albumName, string artistId, CancellationToken cancellationToken) + { + var url = string.Format("/ws/2/release/?query=\"{0}\" AND arid:{1}", + WebUtility.UrlEncode(albumName), + artistId); + + using (var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false)) + using (var stream = response.Content) + using (var oReader = new StreamReader(stream, Encoding.UTF8)) + { + var settings = new XmlReaderSettings() + { + ValidationType = ValidationType.None, + CheckCharacters = false, + IgnoreProcessingInstructions = true, + IgnoreComments = true + }; + + using (var reader = XmlReader.Create(oReader, settings)) + { + return ReleaseResult.Parse(reader).FirstOrDefault(); + } + } + } + + private async Task<ReleaseResult> GetReleaseResultByArtistName(string albumName, string artistName, CancellationToken cancellationToken) + { + var url = string.Format( + CultureInfo.InvariantCulture, + "/ws/2/release/?query=\"{0}\" AND artist:\"{1}\"", + WebUtility.UrlEncode(albumName), + WebUtility.UrlEncode(artistName)); + + using (var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false)) + using (var stream = response.Content) + using (var oReader = new StreamReader(stream, Encoding.UTF8)) + { + var settings = new XmlReaderSettings() + { + ValidationType = ValidationType.None, + CheckCharacters = false, + IgnoreProcessingInstructions = true, + IgnoreComments = true + }; + + using (var reader = XmlReader.Create(oReader, settings)) + { + return ReleaseResult.Parse(reader).FirstOrDefault(); + } + } + } + + private class ReleaseResult + { + public string ReleaseId; + public string ReleaseGroupId; + public string Title; + public string Overview; + public int? Year; + + public List<ValueTuple<string, string>> Artists = new List<ValueTuple<string, string>>(); + + public static IEnumerable<ReleaseResult> Parse(XmlReader reader) + { + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "release-list": + { + if (reader.IsEmptyElement) + { + reader.Read(); + continue; + } + + using (var subReader = reader.ReadSubtree()) + { + return ParseReleaseList(subReader).ToList(); + } + } + default: + { + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + + return Enumerable.Empty<ReleaseResult>(); + } + + private static IEnumerable<ReleaseResult> ParseReleaseList(XmlReader reader) + { + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "release": + { + if (reader.IsEmptyElement) + { + reader.Read(); + continue; + } + var releaseId = reader.GetAttribute("id"); + + using (var subReader = reader.ReadSubtree()) + { + var release = ParseRelease(subReader, releaseId); + if (release != null) + { + yield return release; + } + } + break; + } + default: + { + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + } + + private static ReleaseResult ParseRelease(XmlReader reader, string releaseId) + { + var result = new ReleaseResult + { + ReleaseId = releaseId + }; + + reader.MoveToContent(); + reader.Read(); + + // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "title": + { + result.Title = reader.ReadElementContentAsString(); + break; + } + case "date": + { + var val = reader.ReadElementContentAsString(); + if (DateTime.TryParse(val, out var date)) + { + result.Year = date.Year; + } + break; + } + case "annotation": + { + result.Overview = reader.ReadElementContentAsString(); + break; + } + case "release-group": + { + result.ReleaseGroupId = reader.GetAttribute("id"); + reader.Skip(); + break; + } + case "artist-credit": + { + using (var subReader = reader.ReadSubtree()) + { + var artist = ParseArtistCredit(subReader); + + if (!string.IsNullOrEmpty(artist.Item1)) + { + result.Artists.Add(artist); + } + } + + break; + } + default: + { + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + + return result; + } + } + + private static ValueTuple<string, string> ParseArtistCredit(XmlReader reader) + { + reader.MoveToContent(); + reader.Read(); + + // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "name-credit": + { + using (var subReader = reader.ReadSubtree()) + { + return ParseArtistNameCredit(subReader); + } + } + default: + { + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + + return new ValueTuple<string, string>(); + } + + private static (string, string) ParseArtistNameCredit(XmlReader reader) + { + reader.MoveToContent(); + reader.Read(); + + // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "artist": + { + var id = reader.GetAttribute("id"); + using (var subReader = reader.ReadSubtree()) + { + return ParseArtistArtistCredit(subReader, id); + } + } + default: + { + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + + return (null, null); + } + + private static (string name, string id) ParseArtistArtistCredit(XmlReader reader, string artistId) + { + reader.MoveToContent(); + reader.Read(); + + string name = null; + + // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "name": + { + name = reader.ReadElementContentAsString(); + break; + } + default: + { + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + + return (name, artistId); + } + + private async Task<string> GetReleaseIdFromReleaseGroupId(string releaseGroupId, CancellationToken cancellationToken) + { + var url = "/ws/2/release?release-group=" + releaseGroupId.ToString(CultureInfo.InvariantCulture); + + using (var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false)) + using (var stream = response.Content) + using (var oReader = new StreamReader(stream, Encoding.UTF8)) + { + var settings = new XmlReaderSettings() + { + ValidationType = ValidationType.None, + CheckCharacters = false, + IgnoreProcessingInstructions = true, + IgnoreComments = true + }; + + using (var reader = XmlReader.Create(oReader, settings)) + { + var result = ReleaseResult.Parse(reader).FirstOrDefault(); + + if (result != null) + { + return result.ReleaseId; + } + } + } + + return null; + } + + /// <summary> + /// Gets the release group id internal. + /// </summary> + /// <param name="releaseEntryId">The release entry id.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{System.String}.</returns> + private async Task<string> GetReleaseGroupFromReleaseId(string releaseEntryId, CancellationToken cancellationToken) + { + var url = "/ws/2/release-group/?query=reid:" + releaseEntryId.ToString(CultureInfo.InvariantCulture); + + using (var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false)) + using (var stream = response.Content) + using (var oReader = new StreamReader(stream, Encoding.UTF8)) + { + var settings = new XmlReaderSettings() + { + ValidationType = ValidationType.None, + CheckCharacters = false, + IgnoreProcessingInstructions = true, + IgnoreComments = true + }; + + using (var reader = XmlReader.Create(oReader, settings)) + { + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "release-group-list": + { + if (reader.IsEmptyElement) + { + reader.Read(); + continue; + } + using (var subReader = reader.ReadSubtree()) + { + return GetFirstReleaseGroupId(subReader); + } + } + default: + { + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + + return null; + } + } + } + + private string GetFirstReleaseGroupId(XmlReader reader) + { + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "release-group": + { + return reader.GetAttribute("id"); + } + default: + { + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + + return null; + } + + /// <summary> + /// Makes request to MusicBrainz server and awaits a response. + /// A 503 Service Unavailable response indicates throttling to maintain a rate limit. + /// A number of retries shall be made in order to try and satisfy the request before + /// giving up and returning null. + /// </summary> + internal async Task<HttpResponseInfo> GetMusicBrainzResponse(string url, CancellationToken cancellationToken) + { + var options = new HttpRequestOptions + { + Url = _musicBrainzBaseUrl.TrimEnd('/') + url, + CancellationToken = cancellationToken, + // MusicBrainz request a contact email address is supplied, as comment, in user agent field: + // https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting#User-Agent + UserAgent = string.Format( + CultureInfo.InvariantCulture, + "{0} ( {1} )", + _appHost.ApplicationUserAgent, + _appHost.ApplicationUserAgentAddress), + BufferContent = false + }; + + HttpResponseInfo response; + var attempts = 0u; + + do + { + attempts++; + + if (_stopWatchMusicBrainz.ElapsedMilliseconds < _musicBrainzQueryIntervalMs) + { + // MusicBrainz is extremely adamant about limiting to one request per second + var delayMs = _musicBrainzQueryIntervalMs - _stopWatchMusicBrainz.ElapsedMilliseconds; + await Task.Delay((int)delayMs, cancellationToken).ConfigureAwait(false); + } + + // Write time since last request to debug log as evidence we're meeting rate limit + // requirement, before resetting stopwatch back to zero. + _logger.LogDebug("GetMusicBrainzResponse: Time since previous request: {0} ms", _stopWatchMusicBrainz.ElapsedMilliseconds); + _stopWatchMusicBrainz.Restart(); + + response = await _httpClient.SendAsync(options, "GET").ConfigureAwait(false); + + // We retry a finite number of times, and only whilst MB is indicating 503 (throttling) + } + while (attempts < MusicBrainzQueryAttempts && response.StatusCode == HttpStatusCode.ServiceUnavailable); + + // Log error if unable to query MB database due to throttling + if (attempts == MusicBrainzQueryAttempts && response.StatusCode == HttpStatusCode.ServiceUnavailable) + { + _logger.LogError("GetMusicBrainzResponse: 503 Service Unavailable (throttled) response received {0} times whilst requesting {1}", attempts, options.Url); + } + + return response; + } + + /// <inheritdoc /> + public Task<HttpResponseInfo> GetImageResponse(string url, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs new file mode 100644 index 000000000..260a3b6e7 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Extensions; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Plugins.MusicBrainz; + +namespace MediaBrowser.Providers.Music +{ + public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo> + { + /// <inheritdoc /> + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken) + { + // TODO maybe remove when artist metadata can be disabled + if (!Plugin.Instance.Configuration.Enable) + { + return Enumerable.Empty<RemoteSearchResult>(); + } + + var musicBrainzId = searchInfo.GetMusicBrainzArtistId(); + + if (!string.IsNullOrWhiteSpace(musicBrainzId)) + { + var url = "/ws/2/artist/?query=arid:{0}" + musicBrainzId.ToString(CultureInfo.InvariantCulture); + + using (var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false)) + using (var stream = response.Content) + { + return GetResultsFromResponse(stream); + } + } + else + { + // They seem to throw bad request failures on any term with a slash + var nameToSearch = searchInfo.Name.Replace('/', ' '); + + var url = string.Format("/ws/2/artist/?query=\"{0}\"&dismax=true", UrlEncode(nameToSearch)); + + using (var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false)) + using (var stream = response.Content) + { + var results = GetResultsFromResponse(stream).ToList(); + + if (results.Count > 0) + { + return results; + } + } + + if (HasDiacritics(searchInfo.Name)) + { + // Try again using the search with accent characters url + url = string.Format("/ws/2/artist/?query=artistaccent:\"{0}\"", UrlEncode(nameToSearch)); + + using (var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false)) + { + using (var stream = response.Content) + { + return GetResultsFromResponse(stream); + } + } + } + } + + return Enumerable.Empty<RemoteSearchResult>(); + } + + private IEnumerable<RemoteSearchResult> GetResultsFromResponse(Stream stream) + { + using (var oReader = new StreamReader(stream, Encoding.UTF8)) + { + var settings = new XmlReaderSettings() + { + ValidationType = ValidationType.None, + CheckCharacters = false, + IgnoreProcessingInstructions = true, + IgnoreComments = true + }; + + using (var reader = XmlReader.Create(oReader, settings)) + { + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "artist-list": + { + if (reader.IsEmptyElement) + { + reader.Read(); + continue; + } + using (var subReader = reader.ReadSubtree()) + { + return ParseArtistList(subReader).ToList(); + } + } + default: + { + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + + return Enumerable.Empty<RemoteSearchResult>(); + } + } + } + + private IEnumerable<RemoteSearchResult> ParseArtistList(XmlReader reader) + { + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "artist": + { + if (reader.IsEmptyElement) + { + reader.Read(); + continue; + } + var mbzId = reader.GetAttribute("id"); + + using (var subReader = reader.ReadSubtree()) + { + var artist = ParseArtist(subReader, mbzId); + if (artist != null) + { + yield return artist; + } + } + break; + } + default: + { + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + } + + private RemoteSearchResult ParseArtist(XmlReader reader, string artistId) + { + var result = new RemoteSearchResult(); + + reader.MoveToContent(); + reader.Read(); + + // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "name": + { + result.Name = reader.ReadElementContentAsString(); + break; + } + case "annotation": + { + result.Overview = reader.ReadElementContentAsString(); + break; + } + default: + { + // there is sort-name if ever needed + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + + result.SetProviderId(MetadataProviders.MusicBrainzArtist, artistId); + + if (string.IsNullOrWhiteSpace(artistId) || string.IsNullOrWhiteSpace(result.Name)) + { + return null; + } + + return result; + } + + public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo id, CancellationToken cancellationToken) + { + var result = new MetadataResult<MusicArtist> + { + Item = new MusicArtist() + }; + + // TODO maybe remove when artist metadata can be disabled + if (!Plugin.Instance.Configuration.Enable) + { + return result; + } + + var musicBrainzId = id.GetMusicBrainzArtistId(); + + if (string.IsNullOrWhiteSpace(musicBrainzId)) + { + var searchResults = await GetSearchResults(id, cancellationToken).ConfigureAwait(false); + + var singleResult = searchResults.FirstOrDefault(); + + if (singleResult != null) + { + musicBrainzId = singleResult.GetProviderId(MetadataProviders.MusicBrainzArtist); + result.Item.Overview = singleResult.Overview; + + if (Plugin.Instance.Configuration.ReplaceArtistName) + { + result.Item.Name = singleResult.Name; + } + } + } + + if (!string.IsNullOrWhiteSpace(musicBrainzId)) + { + result.HasMetadata = true; + result.Item.SetProviderId(MetadataProviders.MusicBrainzArtist, musicBrainzId); + } + + return result; + } + + /// <summary> + /// Determines whether the specified text has diacritics. + /// </summary> + /// <param name="text">The text.</param> + /// <returns><c>true</c> if the specified text has diacritics; otherwise, <c>false</c>.</returns> + private bool HasDiacritics(string text) + { + return !string.Equals(text, text.RemoveDiacritics(), StringComparison.Ordinal); + } + + /// <summary> + /// Encodes an URL. + /// </summary> + /// <param name="name">The name.</param> + /// <returns>System.String.</returns> + private static string UrlEncode(string name) + { + return WebUtility.UrlEncode(name); + } + + public string Name => "MusicBrainz"; + + public Task<HttpResponseInfo> GetImageResponse(string url, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs new file mode 100644 index 000000000..6910b4bb4 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs @@ -0,0 +1,44 @@ +using MediaBrowser.Model.Plugins; + +namespace MediaBrowser.Providers.Plugins.MusicBrainz +{ + public class PluginConfiguration : BasePluginConfiguration + { + private string _server = Plugin.DefaultServer; + + private long _rateLimit = Plugin.DefaultRateLimit; + + public string Server + { + get + { + return _server; + } + + set + { + _server = value.TrimEnd('/'); + } + } + + public long RateLimit + { + get + { + return _rateLimit; + } + + set + { + if (value < Plugin.DefaultRateLimit && _server == Plugin.DefaultServer) + { + RateLimit = Plugin.DefaultRateLimit; + } + } + } + + public bool Enable { get; set; } + + public bool ReplaceArtistName { get; set; } + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html new file mode 100644 index 000000000..1f02461da --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html @@ -0,0 +1,69 @@ +<!DOCTYPE html> +<html> +<head> + <title>MusicBrainz</title> +</head> +<body> + <div data-role="page" class="page type-interior pluginConfigurationPage musicBrainzConfigPage" data-require="emby-input,emby-button,emby-checkbox"> + <div data-role="content"> + <div class="content-primary"> + <form class="musicBrainzConfigForm"> + <div class="inputContainer"> + <input is="emby-input" type="text" id="server" required label="Server" /> + <div class="fieldDescription">This can be a mirror of the official server or even a custom server.</div> + </div> + <div class="inputContainer"> + <input is="emby-input" type="number" id="rateLimit" pattern="[0-9]*" required min="0" max="10000" label="Rate Limit" /> + <div class="fieldDescription">Span of time between requests in milliseconds. The official server is limited to one request every two seconds.</div> + </div> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="enable" /> + <span>Enable this provider for metadata searches on artists and albums.</span> + </label> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="replaceArtistName" /> + <span>When an artist is found during a metadata search, replace the artist name with the value on the server.</span> + </label> + <br /> + <div> + <button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button> + </div> + </form> + </div> + </div> + <script type="text/javascript"> + var MusicBrainzPluginConfig = { + uniquePluginId: "8c95c4d2-e50c-4fb0-a4f3-6c06ff0f9a1a" + }; + + $('.musicBrainzConfigPage').on('pageshow', function () { + Dashboard.showLoadingMsg(); + ApiClient.getPluginConfiguration(MusicBrainzPluginConfig.uniquePluginId).then(function (config) { + $('#server').val(config.Server).change(); + $('#rateLimit').val(config.RateLimit).change(); + $('#enable').checked(config.Enable); + $('#replaceArtistName').checked(config.ReplaceArtistName); + + Dashboard.hideLoadingMsg(); + }); + }); + + $('.musicBrainzConfigForm').on('submit', function (e) { + Dashboard.showLoadingMsg(); + + var form = this; + ApiClient.getPluginConfiguration(MusicBrainzPluginConfig.uniquePluginId).then(function (config) { + config.Server = $('#server', form).val(); + config.RateLimit = $('#rateLimit', form).val(); + config.Enable = $('#enable', form).checked(); + config.ReplaceArtistName = $('#replaceArtistName', form).checked(); + + ApiClient.updatePluginConfiguration(MusicBrainzPluginConfig.uniquePluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); + }); + + return false; + }); + </script> + </div> +</body> +</html> diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/ExternalIds.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/ExternalIds.cs new file mode 100644 index 000000000..03565a34c --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/ExternalIds.cs @@ -0,0 +1,98 @@ +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Plugins.MusicBrainz; + +namespace MediaBrowser.Providers.Music +{ + public class MusicBrainzReleaseGroupExternalId : IExternalId + { + /// <inheritdoc /> + public string Name => "MusicBrainz Release Group"; + + /// <inheritdoc /> + public string Key => MetadataProviders.MusicBrainzReleaseGroup.ToString(); + + /// <inheritdoc /> + public string UrlFormatString => Plugin.Instance.Configuration.Server + "/release-group/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; + } + + public class MusicBrainzAlbumArtistExternalId : IExternalId + { + /// <inheritdoc /> + public string Name => "MusicBrainz Album Artist"; + + /// <inheritdoc /> + public string Key => MetadataProviders.MusicBrainzAlbumArtist.ToString(); + + /// <inheritdoc /> + public string UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio; + } + + public class MusicBrainzAlbumExternalId : IExternalId + { + /// <inheritdoc /> + public string Name => "MusicBrainz Album"; + + /// <inheritdoc /> + public string Key => MetadataProviders.MusicBrainzAlbum.ToString(); + + /// <inheritdoc /> + public string UrlFormatString => Plugin.Instance.Configuration.Server + "/release/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; + } + + public class MusicBrainzArtistExternalId : IExternalId + { + /// <inheritdoc /> + public string Name => "MusicBrainz"; + + /// <inheritdoc /> + public string Key => MetadataProviders.MusicBrainzArtist.ToString(); + + /// <inheritdoc /> + public string UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is MusicArtist; + } + + public class MusicBrainzOtherArtistExternalId : IExternalId + { + /// <inheritdoc /> + public string Name => "MusicBrainz Artist"; + + /// <inheritdoc /> + + public string Key => MetadataProviders.MusicBrainzArtist.ToString(); + + /// <inheritdoc /> + public string UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; + } + + public class MusicBrainzTrackId : IExternalId + { + /// <inheritdoc /> + public string Name => "MusicBrainz Track"; + + /// <inheritdoc /> + public string Key => MetadataProviders.MusicBrainzTrack.ToString(); + + /// <inheritdoc /> + public string UrlFormatString => Plugin.Instance.Configuration.Server + "/track/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio; + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs new file mode 100644 index 000000000..8e1b3ea37 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace MediaBrowser.Providers.Plugins.MusicBrainz +{ + public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages + { + public static Plugin Instance { get; private set; } + + public override Guid Id => new Guid("8c95c4d2-e50c-4fb0-a4f3-6c06ff0f9a1a"); + + public override string Name => "MusicBrainz"; + + public override string Description => "Get artist and album metadata from any MusicBrainz server."; + + public const string DefaultServer = "https://musicbrainz.org"; + + public const long DefaultRateLimit = 2000u; + + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + public IEnumerable<PluginPageInfo> GetPages() + { + yield return new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = GetType().Namespace + ".Configuration.config.html" + }; + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs new file mode 100644 index 000000000..37160dd2c --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Providers; +using MediaBrowser.Model.Serialization; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Plugins.Omdb +{ + public class OmdbEpisodeProvider : + IRemoteMetadataProvider<Episode, EpisodeInfo>, + IHasOrder + { + private readonly IJsonSerializer _jsonSerializer; + private readonly IHttpClient _httpClient; + private readonly OmdbItemProvider _itemProvider; + private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _configurationManager; + private readonly IApplicationHost _appHost; + + public OmdbEpisodeProvider(IJsonSerializer jsonSerializer, IApplicationHost appHost, IHttpClient httpClient, ILogger logger, ILibraryManager libraryManager, IFileSystem fileSystem, IServerConfigurationManager configurationManager) + { + _jsonSerializer = jsonSerializer; + _httpClient = httpClient; + _fileSystem = fileSystem; + _configurationManager = configurationManager; + _appHost = appHost; + _itemProvider = new OmdbItemProvider(jsonSerializer, _appHost, httpClient, logger, libraryManager, fileSystem, configurationManager); + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) + { + return _itemProvider.GetSearchResults(searchInfo, "episode", cancellationToken); + } + + public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<Episode>() + { + Item = new Episode(), + QueriedById = true + }; + + // Allowing this will dramatically increase scan times + if (info.IsMissingEpisode) + { + return result; + } + + if (info.SeriesProviderIds.TryGetValue(MetadataProviders.Imdb.ToString(), out string seriesImdbId) && !string.IsNullOrEmpty(seriesImdbId)) + { + if (info.IndexNumber.HasValue && info.ParentIndexNumber.HasValue) + { + result.HasMetadata = await new OmdbProvider(_jsonSerializer, _httpClient, _fileSystem, _appHost, _configurationManager) + .FetchEpisodeData(result, info.IndexNumber.Value, info.ParentIndexNumber.Value, info.GetProviderId(MetadataProviders.Imdb), seriesImdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + } + } + + return result; + } + // After TheTvDb + public int Order => 1; + + public string Name => "The Open Movie Database"; + + public Task<HttpResponseInfo> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _itemProvider.GetImageResponse(url, cancellationToken); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs new file mode 100644 index 000000000..a450c2a6d --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Providers; +using MediaBrowser.Model.Serialization; + +namespace MediaBrowser.Providers.Plugins.Omdb +{ + public class OmdbImageProvider : IRemoteImageProvider, IHasOrder + { + private readonly IHttpClient _httpClient; + private readonly IJsonSerializer _jsonSerializer; + private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _configurationManager; + private readonly IApplicationHost _appHost; + + public OmdbImageProvider(IJsonSerializer jsonSerializer, IApplicationHost appHost, IHttpClient httpClient, IFileSystem fileSystem, IServerConfigurationManager configurationManager) + { + _jsonSerializer = jsonSerializer; + _httpClient = httpClient; + _fileSystem = fileSystem; + _configurationManager = configurationManager; + _appHost = appHost; + } + + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + { + return new List<ImageType> + { + ImageType.Primary + }; + } + + public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) + { + var imdbId = item.GetProviderId(MetadataProviders.Imdb); + + var list = new List<RemoteImageInfo>(); + + var provider = new OmdbProvider(_jsonSerializer, _httpClient, _fileSystem, _appHost, _configurationManager); + + if (!string.IsNullOrWhiteSpace(imdbId)) + { + var rootObject = await provider.GetRootObject(imdbId, cancellationToken).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(rootObject.Poster)) + { + if (item is Episode) + { + // img.omdbapi.com is returning 404's + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Url = rootObject.Poster + }); + } + else + { + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Url = string.Format("https://img.omdbapi.com/?i={0}&apikey=2c9d9507", imdbId) + }); + } + } + } + + return list; + } + + public Task<HttpResponseInfo> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClient.GetResponse(new HttpRequestOptions + { + CancellationToken = cancellationToken, + Url = url + }); + } + + public string Name => "The Open Movie Database"; + + public bool Supports(BaseItem item) + { + return item is Movie || item is Trailer || item is Episode; + } + // After other internet providers, because they're better + // But before fallback providers like screengrab + public int Order => 90; + } +} diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs new file mode 100644 index 000000000..3aadda5d0 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Providers; +using MediaBrowser.Model.Serialization; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Plugins.Omdb +{ + public class OmdbItemProvider : IRemoteMetadataProvider<Series, SeriesInfo>, + IRemoteMetadataProvider<Movie, MovieInfo>, IRemoteMetadataProvider<Trailer, TrailerInfo>, IHasOrder + { + private readonly IJsonSerializer _jsonSerializer; + private readonly IHttpClient _httpClient; + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _configurationManager; + private readonly IApplicationHost _appHost; + + public OmdbItemProvider(IJsonSerializer jsonSerializer, IApplicationHost appHost, IHttpClient httpClient, ILogger logger, ILibraryManager libraryManager, IFileSystem fileSystem, IServerConfigurationManager configurationManager) + { + _jsonSerializer = jsonSerializer; + _httpClient = httpClient; + _logger = logger; + _libraryManager = libraryManager; + _fileSystem = fileSystem; + _configurationManager = configurationManager; + _appHost = appHost; + } + // After primary option + public int Order => 2; + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) + { + return GetSearchResults(searchInfo, "series", cancellationToken); + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) + { + return GetSearchResults(searchInfo, "movie", cancellationToken); + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ItemLookupInfo searchInfo, string type, CancellationToken cancellationToken) + { + return GetSearchResultsInternal(searchInfo, type, true, cancellationToken); + } + + private async Task<IEnumerable<RemoteSearchResult>> GetSearchResultsInternal(ItemLookupInfo searchInfo, string type, bool isSearch, CancellationToken cancellationToken) + { + var episodeSearchInfo = searchInfo as EpisodeInfo; + + var imdbId = searchInfo.GetProviderId(MetadataProviders.Imdb); + + var urlQuery = "plot=full&r=json"; + if (type == "episode" && episodeSearchInfo != null) + { + episodeSearchInfo.SeriesProviderIds.TryGetValue(MetadataProviders.Imdb.ToString(), out imdbId); + } + + var name = searchInfo.Name; + var year = searchInfo.Year; + + if (!string.IsNullOrWhiteSpace(name)) + { + var parsedName = _libraryManager.ParseName(name); + var yearInName = parsedName.Year; + name = parsedName.Name; + year = year ?? yearInName; + } + + if (string.IsNullOrWhiteSpace(imdbId)) + { + if (year.HasValue) + { + urlQuery += "&y=" + year.Value.ToString(CultureInfo.InvariantCulture); + } + + // &s means search and returns a list of results as opposed to t + if (isSearch) + { + urlQuery += "&s=" + WebUtility.UrlEncode(name); + } + else + { + urlQuery += "&t=" + WebUtility.UrlEncode(name); + } + urlQuery += "&type=" + type; + } + else + { + urlQuery += "&i=" + imdbId; + isSearch = false; + } + + if (type == "episode") + { + if (searchInfo.IndexNumber.HasValue) + { + urlQuery += string.Format(CultureInfo.InvariantCulture, "&Episode={0}", searchInfo.IndexNumber); + } + if (searchInfo.ParentIndexNumber.HasValue) + { + urlQuery += string.Format(CultureInfo.InvariantCulture, "&Season={0}", searchInfo.ParentIndexNumber); + } + } + + var url = OmdbProvider.GetOmdbUrl(urlQuery, _appHost, cancellationToken); + + using (var response = await OmdbProvider.GetOmdbResponse(_httpClient, url, cancellationToken).ConfigureAwait(false)) + { + using (var stream = response.Content) + { + var resultList = new List<SearchResult>(); + + if (isSearch) + { + var searchResultList = await _jsonSerializer.DeserializeFromStreamAsync<SearchResultList>(stream).ConfigureAwait(false); + if (searchResultList != null && searchResultList.Search != null) + { + resultList.AddRange(searchResultList.Search); + } + } + else + { + var result = await _jsonSerializer.DeserializeFromStreamAsync<SearchResult>(stream).ConfigureAwait(false); + if (string.Equals(result.Response, "true", StringComparison.OrdinalIgnoreCase)) + { + resultList.Add(result); + } + } + + return resultList.Select(result => + { + var item = new RemoteSearchResult + { + IndexNumber = searchInfo.IndexNumber, + Name = result.Title, + ParentIndexNumber = searchInfo.ParentIndexNumber, + SearchProviderName = Name + }; + + if (episodeSearchInfo != null && episodeSearchInfo.IndexNumberEnd.HasValue) + { + item.IndexNumberEnd = episodeSearchInfo.IndexNumberEnd.Value; + } + + item.SetProviderId(MetadataProviders.Imdb, result.imdbID); + + if (result.Year.Length > 0 + && int.TryParse(result.Year.Substring(0, Math.Min(result.Year.Length, 4)), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedYear)) + { + item.ProductionYear = parsedYear; + } + + if (!string.IsNullOrEmpty(result.Released) + && DateTime.TryParse(result.Released, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var released)) + { + item.PremiereDate = released; + } + + if (!string.IsNullOrWhiteSpace(result.Poster) && !string.Equals(result.Poster, "N/A", StringComparison.OrdinalIgnoreCase)) + { + item.ImageUrl = result.Poster; + } + + return item; + }); + } + } + } + + public Task<MetadataResult<Trailer>> GetMetadata(TrailerInfo info, CancellationToken cancellationToken) + { + return GetMovieResult<Trailer>(info, cancellationToken); + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(TrailerInfo searchInfo, CancellationToken cancellationToken) + { + return GetSearchResults(searchInfo, "movie", cancellationToken); + } + + public string Name => "The Open Movie Database"; + + public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<Series> + { + Item = new Series(), + QueriedById = true + }; + + var imdbId = info.GetProviderId(MetadataProviders.Imdb); + if (string.IsNullOrWhiteSpace(imdbId)) + { + imdbId = await GetSeriesImdbId(info, cancellationToken).ConfigureAwait(false); + result.QueriedById = false; + } + + if (!string.IsNullOrEmpty(imdbId)) + { + result.Item.SetProviderId(MetadataProviders.Imdb, imdbId); + result.HasMetadata = true; + + await new OmdbProvider(_jsonSerializer, _httpClient, _fileSystem, _appHost, _configurationManager).Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + } + + return result; + } + + public Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken) + { + return GetMovieResult<Movie>(info, cancellationToken); + } + + private async Task<MetadataResult<T>> GetMovieResult<T>(ItemLookupInfo info, CancellationToken cancellationToken) + where T : BaseItem, new() + { + var result = new MetadataResult<T> + { + Item = new T(), + QueriedById = true + }; + + var imdbId = info.GetProviderId(MetadataProviders.Imdb); + if (string.IsNullOrWhiteSpace(imdbId)) + { + imdbId = await GetMovieImdbId(info, cancellationToken).ConfigureAwait(false); + result.QueriedById = false; + } + + if (!string.IsNullOrEmpty(imdbId)) + { + result.Item.SetProviderId(MetadataProviders.Imdb, imdbId); + result.HasMetadata = true; + + await new OmdbProvider(_jsonSerializer, _httpClient, _fileSystem, _appHost, _configurationManager).Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + } + + return result; + } + + private async Task<string> GetMovieImdbId(ItemLookupInfo info, CancellationToken cancellationToken) + { + var results = await GetSearchResultsInternal(info, "movie", false, cancellationToken).ConfigureAwait(false); + var first = results.FirstOrDefault(); + return first == null ? null : first.GetProviderId(MetadataProviders.Imdb); + } + + private async Task<string> GetSeriesImdbId(SeriesInfo info, CancellationToken cancellationToken) + { + var results = await GetSearchResultsInternal(info, "series", false, cancellationToken).ConfigureAwait(false); + var first = results.FirstOrDefault(); + return first == null ? null : first.GetProviderId(MetadataProviders.Imdb); + } + + public Task<HttpResponseInfo> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClient.GetResponse(new HttpRequestOptions + { + CancellationToken = cancellationToken, + Url = url + }); + } + + class SearchResult + { + public string Title { get; set; } + public string Year { get; set; } + public string Rated { get; set; } + public string Released { get; set; } + public string Season { get; set; } + public string Episode { get; set; } + public string Runtime { get; set; } + public string Genre { get; set; } + public string Director { get; set; } + public string Writer { get; set; } + public string Actors { get; set; } + public string Plot { get; set; } + public string Language { get; set; } + public string Country { get; set; } + public string Awards { get; set; } + public string Poster { get; set; } + public string Metascore { get; set; } + public string imdbRating { get; set; } + public string imdbVotes { get; set; } + public string imdbID { get; set; } + public string seriesID { get; set; } + public string Type { get; set; } + public string Response { get; set; } + } + + private class SearchResultList + { + /// <summary> + /// Gets or sets the results. + /// </summary> + /// <value>The results.</value> + public List<SearchResult> Search { get; set; } + } + + } +} diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs new file mode 100644 index 000000000..fbdd293ed --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -0,0 +1,521 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Serialization; + +namespace MediaBrowser.Providers.Plugins.Omdb +{ + public class OmdbProvider + { + private readonly IJsonSerializer _jsonSerializer; + private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _configurationManager; + private readonly IHttpClient _httpClient; + private readonly CultureInfo _usCulture = new CultureInfo("en-US"); + private readonly IApplicationHost _appHost; + + public OmdbProvider(IJsonSerializer jsonSerializer, IHttpClient httpClient, IFileSystem fileSystem, IApplicationHost appHost, IServerConfigurationManager configurationManager) + { + _jsonSerializer = jsonSerializer; + _httpClient = httpClient; + _fileSystem = fileSystem; + _configurationManager = configurationManager; + _appHost = appHost; + } + + public async Task Fetch<T>(MetadataResult<T> itemResult, string imdbId, string language, string country, CancellationToken cancellationToken) + where T : BaseItem + { + if (string.IsNullOrWhiteSpace(imdbId)) + { + throw new ArgumentNullException(nameof(imdbId)); + } + + var item = itemResult.Item; + + var result = await GetRootObject(imdbId, cancellationToken).ConfigureAwait(false); + + // Only take the name and rating if the user's language is set to english, since Omdb has no localization + if (string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) || _configurationManager.Configuration.EnableNewOmdbSupport) + { + item.Name = result.Title; + + if (string.Equals(country, "us", StringComparison.OrdinalIgnoreCase)) + { + item.OfficialRating = result.Rated; + } + } + + if (!string.IsNullOrEmpty(result.Year) && result.Year.Length >= 4 + && int.TryParse(result.Year.Substring(0, 4), NumberStyles.Number, _usCulture, out var year) + && year >= 0) + { + item.ProductionYear = year; + } + + var tomatoScore = result.GetRottenTomatoScore(); + + if (tomatoScore.HasValue) + { + item.CriticRating = tomatoScore; + } + + if (!string.IsNullOrEmpty(result.imdbVotes) + && int.TryParse(result.imdbVotes, NumberStyles.Number, _usCulture, out var voteCount) + && voteCount >= 0) + { + //item.VoteCount = voteCount; + } + + if (!string.IsNullOrEmpty(result.imdbRating) + && float.TryParse(result.imdbRating, NumberStyles.Any, _usCulture, out var imdbRating) + && imdbRating >= 0) + { + item.CommunityRating = imdbRating; + } + + //if (!string.IsNullOrEmpty(result.Website)) + //{ + // item.HomePageUrl = result.Website; + //} + + if (!string.IsNullOrWhiteSpace(result.imdbID)) + { + item.SetProviderId(MetadataProviders.Imdb, result.imdbID); + } + + ParseAdditionalMetadata(itemResult, result); + } + + public async Task<bool> FetchEpisodeData<T>(MetadataResult<T> itemResult, int episodeNumber, int seasonNumber, string episodeImdbId, string seriesImdbId, string language, string country, CancellationToken cancellationToken) + where T : BaseItem + { + if (string.IsNullOrWhiteSpace(seriesImdbId)) + { + throw new ArgumentNullException(nameof(seriesImdbId)); + } + + var item = itemResult.Item; + + var seasonResult = await GetSeasonRootObject(seriesImdbId, seasonNumber, cancellationToken).ConfigureAwait(false); + + if (seasonResult == null) + { + return false; + } + + RootObject result = null; + + if (!string.IsNullOrWhiteSpace(episodeImdbId)) + { + foreach (var episode in (seasonResult.Episodes ?? new RootObject[] { })) + { + if (string.Equals(episodeImdbId, episode.imdbID, StringComparison.OrdinalIgnoreCase)) + { + result = episode; + break; + } + } + } + + // finally, search by numbers + if (result == null) + { + foreach (var episode in (seasonResult.Episodes ?? new RootObject[] { })) + { + if (episode.Episode == episodeNumber) + { + result = episode; + break; + } + } + } + + if (result == null) + { + return false; + } + + // Only take the name and rating if the user's language is set to english, since Omdb has no localization + if (string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) || _configurationManager.Configuration.EnableNewOmdbSupport) + { + item.Name = result.Title; + + if (string.Equals(country, "us", StringComparison.OrdinalIgnoreCase)) + { + item.OfficialRating = result.Rated; + } + } + + if (!string.IsNullOrEmpty(result.Year) && result.Year.Length >= 4 + && int.TryParse(result.Year.Substring(0, 4), NumberStyles.Number, _usCulture, out var year) + && year >= 0) + { + item.ProductionYear = year; + } + + var tomatoScore = result.GetRottenTomatoScore(); + + if (tomatoScore.HasValue) + { + item.CriticRating = tomatoScore; + } + + if (!string.IsNullOrEmpty(result.imdbVotes) + && int.TryParse(result.imdbVotes, NumberStyles.Number, _usCulture, out var voteCount) + && voteCount >= 0) + { + //item.VoteCount = voteCount; + } + + if (!string.IsNullOrEmpty(result.imdbRating) + && float.TryParse(result.imdbRating, NumberStyles.Any, _usCulture, out var imdbRating) + && imdbRating >= 0) + { + item.CommunityRating = imdbRating; + } + + //if (!string.IsNullOrEmpty(result.Website)) + //{ + // item.HomePageUrl = result.Website; + //} + + if (!string.IsNullOrWhiteSpace(result.imdbID)) + { + item.SetProviderId(MetadataProviders.Imdb, result.imdbID); + } + + ParseAdditionalMetadata(itemResult, result); + + return true; + } + + internal async Task<RootObject> GetRootObject(string imdbId, CancellationToken cancellationToken) + { + var path = await EnsureItemInfo(imdbId, cancellationToken).ConfigureAwait(false); + + string resultString; + + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + using (var reader = new StreamReader(stream, new UTF8Encoding(false))) + { + resultString = reader.ReadToEnd(); + resultString = resultString.Replace("\"N/A\"", "\"\""); + } + } + + var result = _jsonSerializer.DeserializeFromString<RootObject>(resultString); + return result; + } + + internal async Task<SeasonRootObject> GetSeasonRootObject(string imdbId, int seasonId, CancellationToken cancellationToken) + { + var path = await EnsureSeasonInfo(imdbId, seasonId, cancellationToken).ConfigureAwait(false); + + string resultString; + + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + using (var reader = new StreamReader(stream, new UTF8Encoding(false))) + { + resultString = reader.ReadToEnd(); + resultString = resultString.Replace("\"N/A\"", "\"\""); + } + } + + var result = _jsonSerializer.DeserializeFromString<SeasonRootObject>(resultString); + return result; + } + + internal static bool IsValidSeries(Dictionary<string, string> seriesProviderIds) + { + if (seriesProviderIds.TryGetValue(MetadataProviders.Imdb.ToString(), out string id) && !string.IsNullOrEmpty(id)) + { + // This check should ideally never be necessary but we're seeing some cases of this and haven't tracked them down yet. + if (!string.IsNullOrWhiteSpace(id)) + { + return true; + } + } + + return false; + } + + public static string GetOmdbUrl(string query, IApplicationHost appHost, CancellationToken cancellationToken) + { + const string url = "https://www.omdbapi.com?apikey=2c9d9507"; + + if (string.IsNullOrWhiteSpace(query)) + { + return url; + } + return url + "&" + query; + } + + private async Task<string> EnsureItemInfo(string imdbId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(imdbId)) + { + throw new ArgumentNullException(nameof(imdbId)); + } + + var imdbParam = imdbId.StartsWith("tt", StringComparison.OrdinalIgnoreCase) ? imdbId : "tt" + imdbId; + + var path = GetDataFilePath(imdbParam); + + 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 <= 1) + { + return path; + } + } + + var url = GetOmdbUrl(string.Format("i={0}&plot=short&tomatoes=true&r=json", imdbParam), _appHost, cancellationToken); + + using (var response = await GetOmdbResponse(_httpClient, url, cancellationToken).ConfigureAwait(false)) + { + using (var stream = response.Content) + { + var rootObject = await _jsonSerializer.DeserializeFromStreamAsync<RootObject>(stream).ConfigureAwait(false); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + _jsonSerializer.SerializeToFile(rootObject, path); + } + } + + return path; + } + + private async Task<string> EnsureSeasonInfo(string seriesImdbId, int seasonId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(seriesImdbId)) + { + throw new ArgumentException("The series IMDb ID was null or whitespace.", nameof(seriesImdbId)); + } + + var imdbParam = seriesImdbId.StartsWith("tt", StringComparison.OrdinalIgnoreCase) ? seriesImdbId : "tt" + seriesImdbId; + + var path = GetSeasonFilePath(imdbParam, seasonId); + + 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 <= 1) + { + return path; + } + } + + var url = GetOmdbUrl(string.Format("i={0}&season={1}&detail=full", imdbParam, seasonId), _appHost, cancellationToken); + + using (var response = await GetOmdbResponse(_httpClient, url, cancellationToken).ConfigureAwait(false)) + { + using (var stream = response.Content) + { + var rootObject = await _jsonSerializer.DeserializeFromStreamAsync<SeasonRootObject>(stream).ConfigureAwait(false); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + _jsonSerializer.SerializeToFile(rootObject, path); + } + } + + return path; + } + + public static Task<HttpResponseInfo> GetOmdbResponse(IHttpClient httpClient, string url, CancellationToken cancellationToken) + { + return httpClient.SendAsync(new HttpRequestOptions + { + Url = url, + CancellationToken = cancellationToken, + BufferContent = true, + EnableDefaultUserAgent = true + }, HttpMethod.Get); + } + + internal string GetDataFilePath(string imdbId) + { + if (string.IsNullOrEmpty(imdbId)) + { + throw new ArgumentNullException(nameof(imdbId)); + } + + var dataPath = Path.Combine(_configurationManager.ApplicationPaths.CachePath, "omdb"); + + var filename = string.Format("{0}.json", imdbId); + + return Path.Combine(dataPath, filename); + } + + internal string GetSeasonFilePath(string imdbId, int seasonId) + { + if (string.IsNullOrEmpty(imdbId)) + { + throw new ArgumentNullException(nameof(imdbId)); + } + + var dataPath = Path.Combine(_configurationManager.ApplicationPaths.CachePath, "omdb"); + + var filename = string.Format("{0}_season_{1}.json", imdbId, seasonId); + + return Path.Combine(dataPath, filename); + } + + private void ParseAdditionalMetadata<T>(MetadataResult<T> itemResult, RootObject result) + where T : BaseItem + { + var item = itemResult.Item; + + var isConfiguredForEnglish = IsConfiguredForEnglish(item) || _configurationManager.Configuration.EnableNewOmdbSupport; + + // Grab series genres because imdb data is better than tvdb. Leave movies alone + // But only do it if english is the preferred language because this data will not be localized + if (isConfiguredForEnglish && !string.IsNullOrWhiteSpace(result.Genre)) + { + item.Genres = Array.Empty<string>(); + + foreach (var genre in result.Genre + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(i => i.Trim()) + .Where(i => !string.IsNullOrWhiteSpace(i))) + { + item.AddGenre(genre); + } + } + + if (isConfiguredForEnglish) + { + // Omdb is currently english only, so for other languages skip this and let secondary providers fill it in + item.Overview = result.Plot; + } + + //if (!string.IsNullOrWhiteSpace(result.Director)) + //{ + // var person = new PersonInfo + // { + // Name = result.Director.Trim(), + // Type = PersonType.Director + // }; + + // itemResult.AddPerson(person); + //} + + //if (!string.IsNullOrWhiteSpace(result.Writer)) + //{ + // var person = new PersonInfo + // { + // Name = result.Director.Trim(), + // Type = PersonType.Writer + // }; + + // itemResult.AddPerson(person); + //} + + //if (!string.IsNullOrWhiteSpace(result.Actors)) + //{ + // var actorList = result.Actors.Split(','); + // foreach (var actor in actorList) + // { + // if (!string.IsNullOrWhiteSpace(actor)) + // { + // var person = new PersonInfo + // { + // Name = actor.Trim(), + // Type = PersonType.Actor + // }; + + // itemResult.AddPerson(person); + // } + // } + //} + } + + private bool IsConfiguredForEnglish(BaseItem item) + { + var lang = item.GetPreferredMetadataLanguage(); + + // The data isn't localized and so can only be used for english users + return string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase); + } + + internal class SeasonRootObject + { + public string Title { get; set; } + public string seriesID { get; set; } + public int Season { get; set; } + public int? totalSeasons { get; set; } + public RootObject[] Episodes { get; set; } + public string Response { get; set; } + } + + internal class RootObject + { + public string Title { get; set; } + public string Year { get; set; } + public string Rated { get; set; } + public string Released { get; set; } + public string Runtime { get; set; } + public string Genre { get; set; } + public string Director { get; set; } + public string Writer { get; set; } + public string Actors { get; set; } + public string Plot { get; set; } + public string Language { get; set; } + public string Country { get; set; } + public string Awards { get; set; } + public string Poster { get; set; } + public List<OmdbRating> Ratings { get; set; } + public string Metascore { get; set; } + public string imdbRating { get; set; } + public string imdbVotes { get; set; } + public string imdbID { get; set; } + public string Type { get; set; } + public string DVD { get; set; } + public string BoxOffice { get; set; } + public string Production { get; set; } + public string Website { get; set; } + public string Response { get; set; } + public int Episode { get; set; } + + public float? GetRottenTomatoScore() + { + if (Ratings != null) + { + var rating = Ratings.FirstOrDefault(i => string.Equals(i.Source, "Rotten Tomatoes", StringComparison.OrdinalIgnoreCase)); + if (rating != null && rating.Value != null) + { + var value = rating.Value.TrimEnd('%'); + if (float.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var score)) + { + return score; + } + } + } + return null; + } + } + public class OmdbRating + { + public string Source { get; set; } + public string Value { get; set; } + } + } +} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs new file mode 100644 index 000000000..a12b4d3ad --- /dev/null +++ b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Caching.Memory; +using TvDbSharper; +using TvDbSharper.Dto; + +namespace MediaBrowser.Providers.Plugins.TheTvdb +{ + public class TvdbClientManager + { + private const string DefaultLanguage = "en"; + + private readonly SemaphoreSlim _cacheWriteLock = new SemaphoreSlim(1, 1); + private readonly IMemoryCache _cache; + private readonly TvDbClient _tvDbClient; + private DateTime _tokenCreatedAt; + + public TvdbClientManager(IMemoryCache memoryCache) + { + _cache = memoryCache; + _tvDbClient = new TvDbClient(); + } + + private TvDbClient TvDbClient + { + get + { + if (string.IsNullOrEmpty(_tvDbClient.Authentication.Token)) + { + _tvDbClient.Authentication.AuthenticateAsync(TvdbUtils.TvdbApiKey).GetAwaiter().GetResult(); + _tokenCreatedAt = DateTime.Now; + } + + // Refresh if necessary + if (_tokenCreatedAt < DateTime.Now.Subtract(TimeSpan.FromHours(20))) + { + try + { + _tvDbClient.Authentication.RefreshTokenAsync().GetAwaiter().GetResult(); + } + catch + { + _tvDbClient.Authentication.AuthenticateAsync(TvdbUtils.TvdbApiKey).GetAwaiter().GetResult(); + } + + _tokenCreatedAt = DateTime.Now; + } + + return _tvDbClient; + } + } + + public Task<TvDbResponse<SeriesSearchResult[]>> GetSeriesByNameAsync(string name, string language, + CancellationToken cancellationToken) + { + var cacheKey = GenerateKey("series", name, language); + return TryGetValue(cacheKey, language,() => TvDbClient.Search.SearchSeriesByNameAsync(name, cancellationToken)); + } + + public Task<TvDbResponse<Series>> GetSeriesByIdAsync(int tvdbId, string language, + CancellationToken cancellationToken) + { + var cacheKey = GenerateKey("series", tvdbId, language); + return TryGetValue(cacheKey, language,() => TvDbClient.Series.GetAsync(tvdbId, cancellationToken)); + } + + public Task<TvDbResponse<EpisodeRecord>> GetEpisodesAsync(int episodeTvdbId, string language, + CancellationToken cancellationToken) + { + var cacheKey = GenerateKey("episode", episodeTvdbId, language); + return TryGetValue(cacheKey, language,() => TvDbClient.Episodes.GetAsync(episodeTvdbId, cancellationToken)); + } + + public async Task<List<EpisodeRecord>> GetAllEpisodesAsync(int tvdbId, string language, + CancellationToken cancellationToken) + { + // Traverse all episode pages and join them together + var episodes = new List<EpisodeRecord>(); + var episodePage = await GetEpisodesPageAsync(tvdbId, new EpisodeQuery(), language, cancellationToken) + .ConfigureAwait(false); + episodes.AddRange(episodePage.Data); + if (!episodePage.Links.Next.HasValue || !episodePage.Links.Last.HasValue) + { + return episodes; + } + + int next = episodePage.Links.Next.Value; + int last = episodePage.Links.Last.Value; + + for (var page = next; page <= last; ++page) + { + episodePage = await GetEpisodesPageAsync(tvdbId, page, new EpisodeQuery(), language, cancellationToken) + .ConfigureAwait(false); + episodes.AddRange(episodePage.Data); + } + + return episodes; + } + + public Task<TvDbResponse<SeriesSearchResult[]>> GetSeriesByImdbIdAsync( + string imdbId, + string language, + CancellationToken cancellationToken) + { + var cacheKey = GenerateKey("series", imdbId, language); + return TryGetValue(cacheKey, language,() => TvDbClient.Search.SearchSeriesByImdbIdAsync(imdbId, cancellationToken)); + } + + public Task<TvDbResponse<SeriesSearchResult[]>> GetSeriesByZap2ItIdAsync( + string zap2ItId, + string language, + CancellationToken cancellationToken) + { + var cacheKey = GenerateKey("series", zap2ItId, language); + return TryGetValue(cacheKey, language, () => TvDbClient.Search.SearchSeriesByZap2ItIdAsync(zap2ItId, cancellationToken)); + } + public Task<TvDbResponse<Actor[]>> GetActorsAsync( + int tvdbId, + string language, + CancellationToken cancellationToken) + { + var cacheKey = GenerateKey("actors", tvdbId, language); + return TryGetValue(cacheKey, language, () => TvDbClient.Series.GetActorsAsync(tvdbId, cancellationToken)); + } + + public Task<TvDbResponse<Image[]>> GetImagesAsync( + int tvdbId, + ImagesQuery imageQuery, + string language, + CancellationToken cancellationToken) + { + var cacheKey = GenerateKey("images", tvdbId, language, imageQuery); + return TryGetValue(cacheKey, language, () => TvDbClient.Series.GetImagesAsync(tvdbId, imageQuery, cancellationToken)); + } + + public Task<TvDbResponse<Language[]>> GetLanguagesAsync(CancellationToken cancellationToken) + { + return TryGetValue("languages", null, () => TvDbClient.Languages.GetAllAsync(cancellationToken)); + } + + public Task<TvDbResponse<EpisodesSummary>> GetSeriesEpisodeSummaryAsync( + int tvdbId, + string language, + CancellationToken cancellationToken) + { + var cacheKey = GenerateKey("seriesepisodesummary", tvdbId, language); + return TryGetValue(cacheKey, language, + () => TvDbClient.Series.GetEpisodesSummaryAsync(tvdbId, cancellationToken)); + } + + public Task<TvDbResponse<EpisodeRecord[]>> GetEpisodesPageAsync( + int tvdbId, + int page, + EpisodeQuery episodeQuery, + string language, + CancellationToken cancellationToken) + { + var cacheKey = GenerateKey(language, tvdbId, episodeQuery); + + return TryGetValue(cacheKey, language, + () => TvDbClient.Series.GetEpisodesAsync(tvdbId, page, episodeQuery, cancellationToken)); + } + + public Task<string> GetEpisodeTvdbId( + EpisodeInfo searchInfo, + string language, + CancellationToken cancellationToken) + { + searchInfo.SeriesProviderIds.TryGetValue(MetadataProviders.Tvdb.ToString(), + out var seriesTvdbId); + + var episodeQuery = new EpisodeQuery(); + + // Prefer SxE over premiere date as it is more robust + if (searchInfo.IndexNumber.HasValue && searchInfo.ParentIndexNumber.HasValue) + { + switch (searchInfo.SeriesDisplayOrder) + { + case "dvd": + episodeQuery.DvdEpisode = searchInfo.IndexNumber.Value; + episodeQuery.DvdSeason = searchInfo.ParentIndexNumber.Value; + break; + case "absolute": + episodeQuery.AbsoluteNumber = searchInfo.IndexNumber.Value; + break; + default: + //aired order + episodeQuery.AiredEpisode = searchInfo.IndexNumber.Value; + episodeQuery.AiredSeason = searchInfo.ParentIndexNumber.Value; + break; + } + } + else if (searchInfo.PremiereDate.HasValue) + { + // tvdb expects yyyy-mm-dd format + episodeQuery.FirstAired = searchInfo.PremiereDate.Value.ToString("yyyy-MM-dd"); + } + + return GetEpisodeTvdbId(Convert.ToInt32(seriesTvdbId), episodeQuery, language, cancellationToken); + } + + public async Task<string> GetEpisodeTvdbId( + int seriesTvdbId, + EpisodeQuery episodeQuery, + string language, + CancellationToken cancellationToken) + { + var episodePage = + await GetEpisodesPageAsync(Convert.ToInt32(seriesTvdbId), episodeQuery, language, cancellationToken) + .ConfigureAwait(false); + return episodePage.Data.FirstOrDefault()?.Id.ToString(); + } + + public Task<TvDbResponse<EpisodeRecord[]>> GetEpisodesPageAsync( + int tvdbId, + EpisodeQuery episodeQuery, + string language, + CancellationToken cancellationToken) + { + return GetEpisodesPageAsync(tvdbId, 1, episodeQuery, language, cancellationToken); + } + + private async Task<T> TryGetValue<T>(string key, string language, Func<Task<T>> resultFactory) + { + if (_cache.TryGetValue(key, out T cachedValue)) + { + return cachedValue; + } + + await _cacheWriteLock.WaitAsync().ConfigureAwait(false); + try + { + if (_cache.TryGetValue(key, out cachedValue)) + { + return cachedValue; + } + + _tvDbClient.AcceptedLanguage = TvdbUtils.NormalizeLanguage(language) ?? DefaultLanguage; + var result = await resultFactory.Invoke().ConfigureAwait(false); + _cache.Set(key, result, TimeSpan.FromHours(1)); + return result; + } + finally + { + _cacheWriteLock.Release(); + } + } + + private static string GenerateKey(params object[] objects) + { + var key = string.Empty; + + foreach (var obj in objects) + { + var objType = obj.GetType(); + if (objType.IsPrimitive || objType == typeof(string)) + { + key += obj + ";"; + } + else + { + foreach (PropertyInfo propertyInfo in objType.GetProperties()) + { + var currentValue = propertyInfo.GetValue(obj, null); + if (currentValue == null) + { + continue; + } + + key += propertyInfo.Name + "=" + currentValue + ";"; + } + } + } + + return key; + } + } +} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs new file mode 100644 index 000000000..6118a9c53 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using TvDbSharper; +using TvDbSharper.Dto; + +namespace MediaBrowser.Providers.Plugins.TheTvdb +{ + public class TvdbEpisodeImageProvider : IRemoteImageProvider + { + private readonly IHttpClient _httpClient; + private readonly ILogger _logger; + private readonly TvdbClientManager _tvdbClientManager; + + public TvdbEpisodeImageProvider(IHttpClient httpClient, ILogger<TvdbEpisodeImageProvider> logger, TvdbClientManager tvdbClientManager) + { + _httpClient = httpClient; + _logger = logger; + _tvdbClientManager = tvdbClientManager; + } + + public string Name => "TheTVDB"; + + public bool Supports(BaseItem item) + { + return item is Episode; + } + + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + { + return new List<ImageType> + { + ImageType.Primary + }; + } + + public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) + { + var episode = (Episode)item; + var series = episode.Series; + var imageResult = new List<RemoteImageInfo>(); + var language = item.GetPreferredMetadataLanguage(); + if (series != null && TvdbSeriesProvider.IsValidSeries(series.ProviderIds)) + { + // Process images + try + { + var episodeInfo = new EpisodeInfo + { + IndexNumber = episode.IndexNumber.Value, + ParentIndexNumber = episode.ParentIndexNumber.Value, + SeriesProviderIds = series.ProviderIds, + SeriesDisplayOrder = series.DisplayOrder + }; + string episodeTvdbId = await _tvdbClientManager + .GetEpisodeTvdbId(episodeInfo, language, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrEmpty(episodeTvdbId)) + { + _logger.LogError( + "Episode {SeasonNumber}x{EpisodeNumber} not found for series {SeriesTvdbId}", + episodeInfo.ParentIndexNumber, + episodeInfo.IndexNumber, + series.GetProviderId(MetadataProviders.Tvdb)); + return imageResult; + } + + var episodeResult = + await _tvdbClientManager + .GetEpisodesAsync(Convert.ToInt32(episodeTvdbId), language, cancellationToken) + .ConfigureAwait(false); + + var image = GetImageInfo(episodeResult.Data); + if (image != null) + { + imageResult.Add(image); + } + } + catch (TvDbServerException e) + { + _logger.LogError(e, "Failed to retrieve episode images for series {TvDbId}", series.GetProviderId(MetadataProviders.Tvdb)); + } + } + + return imageResult; + } + + private RemoteImageInfo GetImageInfo(EpisodeRecord episode) + { + if (string.IsNullOrEmpty(episode.Filename)) + { + return null; + } + + return new RemoteImageInfo + { + Width = Convert.ToInt32(episode.ThumbWidth), + Height = Convert.ToInt32(episode.ThumbHeight), + ProviderName = Name, + Url = TvdbUtils.BannerUrl + episode.Filename, + Type = ImageType.Primary + }; + } + + public int Order => 0; + + public Task<HttpResponseInfo> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClient.GetResponse(new HttpRequestOptions + { + CancellationToken = cancellationToken, + Url = url + }); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs new file mode 100644 index 000000000..f58c58a2e --- /dev/null +++ b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using TvDbSharper; +using TvDbSharper.Dto; + +namespace MediaBrowser.Providers.Plugins.TheTvdb +{ + + /// <summary> + /// Class RemoteEpisodeProvider + /// </summary> + public class TvdbEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasOrder + { + private readonly IHttpClient _httpClient; + private readonly ILogger _logger; + private readonly TvdbClientManager _tvdbClientManager; + + public TvdbEpisodeProvider(IHttpClient httpClient, ILogger<TvdbEpisodeProvider> logger, TvdbClientManager tvdbClientManager) + { + _httpClient = httpClient; + _logger = logger; + _tvdbClientManager = tvdbClientManager; + } + + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) + { + var list = new List<RemoteSearchResult>(); + + // Either an episode number or date must be provided; and the dictionary of provider ids must be valid + if ((searchInfo.IndexNumber == null && searchInfo.PremiereDate == null) + || !TvdbSeriesProvider.IsValidSeries(searchInfo.SeriesProviderIds)) + { + return list; + } + + var metadataResult = await GetEpisode(searchInfo, cancellationToken).ConfigureAwait(false); + + if (!metadataResult.HasMetadata) + { + return list; + } + + var item = metadataResult.Item; + + list.Add(new RemoteSearchResult + { + IndexNumber = item.IndexNumber, + Name = item.Name, + ParentIndexNumber = item.ParentIndexNumber, + PremiereDate = item.PremiereDate, + ProductionYear = item.ProductionYear, + ProviderIds = item.ProviderIds, + SearchProviderName = Name, + IndexNumberEnd = item.IndexNumberEnd + }); + + return list; + } + + public string Name => "TheTVDB"; + + public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo searchInfo, CancellationToken cancellationToken) + { + var result = new MetadataResult<Episode> + { + QueriedById = true + }; + + if (TvdbSeriesProvider.IsValidSeries(searchInfo.SeriesProviderIds) && + (searchInfo.IndexNumber.HasValue || searchInfo.PremiereDate.HasValue)) + { + result = await GetEpisode(searchInfo, cancellationToken).ConfigureAwait(false); + } + else + { + _logger.LogDebug("No series identity found for {EpisodeName}", searchInfo.Name); + } + + return result; + } + + private async Task<MetadataResult<Episode>> GetEpisode(EpisodeInfo searchInfo, CancellationToken cancellationToken) + { + var result = new MetadataResult<Episode> + { + QueriedById = true + }; + + string seriesTvdbId = searchInfo.GetProviderId(MetadataProviders.Tvdb); + string episodeTvdbId = null; + try + { + episodeTvdbId = await _tvdbClientManager + .GetEpisodeTvdbId(searchInfo, searchInfo.MetadataLanguage, cancellationToken) + .ConfigureAwait(false); + if (string.IsNullOrEmpty(episodeTvdbId)) + { + _logger.LogError("Episode {SeasonNumber}x{EpisodeNumber} not found for series {SeriesTvdbId}", + searchInfo.ParentIndexNumber, searchInfo.IndexNumber, seriesTvdbId); + return result; + } + + var episodeResult = await _tvdbClientManager.GetEpisodesAsync( + Convert.ToInt32(episodeTvdbId), searchInfo.MetadataLanguage, + cancellationToken).ConfigureAwait(false); + + result = MapEpisodeToResult(searchInfo, episodeResult.Data); + } + catch (TvDbServerException e) + { + _logger.LogError(e, "Failed to retrieve episode with id {EpisodeTvDbId}, series id {SeriesTvdbId}", episodeTvdbId, seriesTvdbId); + } + + return result; + } + + private static MetadataResult<Episode> MapEpisodeToResult(EpisodeInfo id, EpisodeRecord episode) + { + var result = new MetadataResult<Episode> + { + HasMetadata = true, + Item = new Episode + { + IndexNumber = id.IndexNumber, + ParentIndexNumber = id.ParentIndexNumber, + IndexNumberEnd = id.IndexNumberEnd, + AirsBeforeEpisodeNumber = episode.AirsBeforeEpisode, + AirsAfterSeasonNumber = episode.AirsAfterSeason, + AirsBeforeSeasonNumber = episode.AirsBeforeSeason, + Name = episode.EpisodeName, + Overview = episode.Overview, + CommunityRating = (float?)episode.SiteRating, + + } + }; + result.ResetPeople(); + + var item = result.Item; + item.SetProviderId(MetadataProviders.Tvdb, episode.Id.ToString()); + item.SetProviderId(MetadataProviders.Imdb, episode.ImdbId); + + if (string.Equals(id.SeriesDisplayOrder, "dvd", StringComparison.OrdinalIgnoreCase)) + { + item.IndexNumber = Convert.ToInt32(episode.DvdEpisodeNumber ?? episode.AiredEpisodeNumber); + item.ParentIndexNumber = episode.DvdSeason ?? episode.AiredSeason; + } + else if (episode.AiredEpisodeNumber.HasValue) + { + item.IndexNumber = episode.AiredEpisodeNumber; + } + else if (episode.AiredSeason.HasValue) + { + item.ParentIndexNumber = episode.AiredSeason; + } + + if (DateTime.TryParse(episode.FirstAired, out var date)) + { + // dates from tvdb are UTC but without offset or Z + item.PremiereDate = date; + item.ProductionYear = date.Year; + } + + foreach (var director in episode.Directors) + { + result.AddPerson(new PersonInfo + { + Name = director, + Type = PersonType.Director + }); + } + + // GuestStars is a weird list of names and roles + // Example: + // 1: Some Actor (Role1 + // 2: Role2 + // 3: Role3) + // 4: Another Actor (Role1 + // ... + for (var i = 0; i < episode.GuestStars.Length; ++i) + { + var currentActor = episode.GuestStars[i]; + var roleStartIndex = currentActor.IndexOf('('); + + if (roleStartIndex == -1) + { + result.AddPerson(new PersonInfo + { + Type = PersonType.GuestStar, + Name = currentActor, + Role = string.Empty + }); + continue; + } + + var roles = new List<string> {currentActor.Substring(roleStartIndex + 1)}; + + // Fetch all roles + for (var j = i + 1; j < episode.GuestStars.Length; ++j) + { + var currentRole = episode.GuestStars[j]; + var roleEndIndex = currentRole.IndexOf(')'); + + if (roleEndIndex == -1) + { + roles.Add(currentRole); + continue; + } + + roles.Add(currentRole.TrimEnd(')')); + // Update the outer index (keep in mind it adds 1 after the iteration) + i = j; + break; + } + + result.AddPerson(new PersonInfo + { + Type = PersonType.GuestStar, + Name = currentActor.Substring(0, roleStartIndex).Trim(), + Role = string.Join(", ", roles) + }); + } + + foreach (var writer in episode.Writers) + { + result.AddPerson(new PersonInfo + { + Name = writer, + Type = PersonType.Writer + }); + } + + result.ResultLanguage = episode.Language.EpisodeName; + return result; + } + + public Task<HttpResponseInfo> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClient.GetResponse(new HttpRequestOptions + { + CancellationToken = cancellationToken, + Url = url + }); + } + + public int Order => 0; + } +} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs new file mode 100644 index 000000000..c1cdc90e9 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using TvDbSharper; + +namespace MediaBrowser.Providers.Plugins.TheTvdb +{ + public class TvdbPersonImageProvider : IRemoteImageProvider, IHasOrder + { + private readonly IHttpClient _httpClient; + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly TvdbClientManager _tvdbClientManager; + + public TvdbPersonImageProvider(ILibraryManager libraryManager, IHttpClient httpClient, ILogger<TvdbPersonImageProvider> logger, TvdbClientManager tvdbClientManager) + { + _libraryManager = libraryManager; + _httpClient = httpClient; + _logger = logger; + _tvdbClientManager = tvdbClientManager; + } + + /// <inheritdoc /> + public string Name => "TheTVDB"; + + /// <inheritdoc /> + public int Order => 1; + + /// <inheritdoc /> + public bool Supports(BaseItem item) => item is Person; + + /// <inheritdoc /> + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + { + yield return ImageType.Primary; + } + + /// <inheritdoc /> + public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) + { + var seriesWithPerson = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { typeof(Series).Name }, + PersonIds = new[] { item.Id }, + DtoOptions = new DtoOptions(false) + { + EnableImages = false + } + + }).Cast<Series>() + .Where(i => TvdbSeriesProvider.IsValidSeries(i.ProviderIds)) + .ToList(); + + var infos = (await Task.WhenAll(seriesWithPerson.Select(async i => + await GetImageFromSeriesData(i, item.Name, cancellationToken).ConfigureAwait(false))) + .ConfigureAwait(false)) + .Where(i => i != null) + .Take(1); + + return infos; + } + + private async Task<RemoteImageInfo> GetImageFromSeriesData(Series series, string personName, CancellationToken cancellationToken) + { + var tvdbId = Convert.ToInt32(series.GetProviderId(MetadataProviders.Tvdb)); + + try + { + var actorsResult = await _tvdbClientManager + .GetActorsAsync(tvdbId, series.GetPreferredMetadataLanguage(), cancellationToken) + .ConfigureAwait(false); + var actor = actorsResult.Data.FirstOrDefault(a => + string.Equals(a.Name, personName, StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrEmpty(a.Image)); + if (actor == null) + { + return null; + } + + return new RemoteImageInfo + { + Url = TvdbUtils.BannerUrl + actor.Image, + Type = ImageType.Primary, + ProviderName = Name + }; + } + catch (TvDbServerException e) + { + _logger.LogError(e, "Failed to retrieve actor {ActorName} from series {SeriesTvdbId}", personName, tvdbId); + return null; + } + } + + /// <inheritdoc /> + public Task<HttpResponseInfo> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClient.GetResponse(new HttpRequestOptions + { + CancellationToken = cancellationToken, + Url = url + }); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs new file mode 100644 index 000000000..a5d183df7 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using TvDbSharper; +using TvDbSharper.Dto; +using RatingType = MediaBrowser.Model.Dto.RatingType; + +namespace MediaBrowser.Providers.Plugins.TheTvdb +{ + public class TvdbSeasonImageProvider : IRemoteImageProvider, IHasOrder + { + private readonly IHttpClient _httpClient; + private readonly ILogger _logger; + private readonly TvdbClientManager _tvdbClientManager; + + public TvdbSeasonImageProvider(IHttpClient httpClient, ILogger<TvdbSeasonImageProvider> logger, TvdbClientManager tvdbClientManager) + { + _httpClient = httpClient; + _logger = logger; + _tvdbClientManager = tvdbClientManager; + } + + public string Name => ProviderName; + + public static string ProviderName => "TheTVDB"; + + public bool Supports(BaseItem item) + { + return item is Season; + } + + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + { + return new List<ImageType> + { + ImageType.Primary, + ImageType.Banner, + ImageType.Backdrop + }; + } + + public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) + { + var season = (Season)item; + var series = season.Series; + + if (series == null || !season.IndexNumber.HasValue || !TvdbSeriesProvider.IsValidSeries(series.ProviderIds)) + { + return new RemoteImageInfo[] { }; + } + + var tvdbId = Convert.ToInt32(series.GetProviderId(MetadataProviders.Tvdb)); + var seasonNumber = season.IndexNumber.Value; + var language = item.GetPreferredMetadataLanguage(); + var remoteImages = new List<RemoteImageInfo>(); + + var keyTypes = new[] { KeyType.Season, KeyType.Seasonwide, KeyType.Fanart }; + foreach (var keyType in keyTypes) + { + var imageQuery = new ImagesQuery + { + KeyType = keyType, + SubKey = seasonNumber.ToString() + }; + try + { + var imageResults = await _tvdbClientManager + .GetImagesAsync(tvdbId, imageQuery, language, cancellationToken).ConfigureAwait(false); + remoteImages.AddRange(GetImages(imageResults.Data, language)); + } + catch (TvDbServerException) + { + _logger.LogDebug("No images of type {KeyType} found for series {TvdbId}", keyType, tvdbId); + } + } + + return remoteImages; + } + + private IEnumerable<RemoteImageInfo> GetImages(Image[] images, string preferredLanguage) + { + var list = new List<RemoteImageInfo>(); + var languages = _tvdbClientManager.GetLanguagesAsync(CancellationToken.None).Result.Data; + foreach (Image image in images) + { + var imageInfo = new RemoteImageInfo + { + RatingType = RatingType.Score, + CommunityRating = (double?)image.RatingsInfo.Average, + VoteCount = image.RatingsInfo.Count, + Url = TvdbUtils.BannerUrl + image.FileName, + ProviderName = ProviderName, + Language = languages.FirstOrDefault(lang => lang.Id == image.LanguageId)?.Abbreviation, + ThumbnailUrl = TvdbUtils.BannerUrl + image.Thumbnail + }; + + var resolution = image.Resolution.Split('x'); + if (resolution.Length == 2) + { + imageInfo.Width = Convert.ToInt32(resolution[0]); + imageInfo.Height = Convert.ToInt32(resolution[1]); + } + + imageInfo.Type = TvdbUtils.GetImageTypeFromKeyType(image.KeyType); + list.Add(imageInfo); + } + var isLanguageEn = string.Equals(preferredLanguage, "en", StringComparison.OrdinalIgnoreCase); + + return list.OrderByDescending(i => + { + if (string.Equals(preferredLanguage, i.Language, StringComparison.OrdinalIgnoreCase)) + { + return 3; + } + + if (!isLanguageEn) + { + if (string.Equals("en", i.Language, StringComparison.OrdinalIgnoreCase)) + { + return 2; + } + } + + if (string.IsNullOrEmpty(i.Language)) + { + return isLanguageEn ? 3 : 2; + } + + return 0; + }) + .ThenByDescending(i => i.CommunityRating ?? 0) + .ThenByDescending(i => i.VoteCount ?? 0); + } + + public int Order => 0; + + public Task<HttpResponseInfo> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClient.GetResponse(new HttpRequestOptions + { + CancellationToken = cancellationToken, + Url = url + }); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs new file mode 100644 index 000000000..1bad60756 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using TvDbSharper; +using TvDbSharper.Dto; +using RatingType = MediaBrowser.Model.Dto.RatingType; +using Series = MediaBrowser.Controller.Entities.TV.Series; + +namespace MediaBrowser.Providers.Plugins.TheTvdb +{ + public class TvdbSeriesImageProvider : IRemoteImageProvider, IHasOrder + { + private readonly IHttpClient _httpClient; + private readonly ILogger _logger; + private readonly TvdbClientManager _tvdbClientManager; + + public TvdbSeriesImageProvider(IHttpClient httpClient, ILogger<TvdbSeriesImageProvider> logger, TvdbClientManager tvdbClientManager) + { + _httpClient = httpClient; + _logger = logger; + _tvdbClientManager = tvdbClientManager; + } + + public string Name => ProviderName; + + public static string ProviderName => "TheTVDB"; + + public bool Supports(BaseItem item) + { + return item is Series; + } + + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + { + return new List<ImageType> + { + ImageType.Primary, + ImageType.Banner, + ImageType.Backdrop + }; + } + + public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) + { + if (!TvdbSeriesProvider.IsValidSeries(item.ProviderIds)) + { + return Array.Empty<RemoteImageInfo>(); + } + + var language = item.GetPreferredMetadataLanguage(); + var remoteImages = new List<RemoteImageInfo>(); + var keyTypes = new[] { KeyType.Poster, KeyType.Series, KeyType.Fanart }; + var tvdbId = Convert.ToInt32(item.GetProviderId(MetadataProviders.Tvdb)); + foreach (KeyType keyType in keyTypes) + { + var imageQuery = new ImagesQuery + { + KeyType = keyType + }; + try + { + var imageResults = + await _tvdbClientManager.GetImagesAsync(tvdbId, imageQuery, language, cancellationToken) + .ConfigureAwait(false); + + remoteImages.AddRange(GetImages(imageResults.Data, language)); + } + catch (TvDbServerException) + { + _logger.LogDebug("No images of type {KeyType} exist for series {TvDbId}", keyType, + tvdbId); + } + } + return remoteImages; + } + + private IEnumerable<RemoteImageInfo> GetImages(Image[] images, string preferredLanguage) + { + var list = new List<RemoteImageInfo>(); + var languages = _tvdbClientManager.GetLanguagesAsync(CancellationToken.None).Result.Data; + + foreach (Image image in images) + { + var imageInfo = new RemoteImageInfo + { + RatingType = RatingType.Score, + CommunityRating = (double?)image.RatingsInfo.Average, + VoteCount = image.RatingsInfo.Count, + Url = TvdbUtils.BannerUrl + image.FileName, + ProviderName = Name, + Language = languages.FirstOrDefault(lang => lang.Id == image.LanguageId)?.Abbreviation, + ThumbnailUrl = TvdbUtils.BannerUrl + image.Thumbnail + }; + + var resolution = image.Resolution.Split('x'); + if (resolution.Length == 2) + { + imageInfo.Width = Convert.ToInt32(resolution[0]); + imageInfo.Height = Convert.ToInt32(resolution[1]); + } + + imageInfo.Type = TvdbUtils.GetImageTypeFromKeyType(image.KeyType); + list.Add(imageInfo); + } + var isLanguageEn = string.Equals(preferredLanguage, "en", StringComparison.OrdinalIgnoreCase); + + return list.OrderByDescending(i => + { + if (string.Equals(preferredLanguage, i.Language, StringComparison.OrdinalIgnoreCase)) + { + return 3; + } + + if (!isLanguageEn) + { + if (string.Equals("en", i.Language, StringComparison.OrdinalIgnoreCase)) + { + return 2; + } + } + + if (string.IsNullOrEmpty(i.Language)) + { + return isLanguageEn ? 3 : 2; + } + + return 0; + }) + .ThenByDescending(i => i.CommunityRating ?? 0) + .ThenByDescending(i => i.VoteCount ?? 0); + } + + public int Order => 0; + + public Task<HttpResponseInfo> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClient.GetResponse(new HttpRequestOptions + { + CancellationToken = cancellationToken, + Url = url + }); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs new file mode 100644 index 000000000..97a5b3478 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs @@ -0,0 +1,437 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using TvDbSharper; +using TvDbSharper.Dto; +using Series = MediaBrowser.Controller.Entities.TV.Series; + +namespace MediaBrowser.Providers.Plugins.TheTvdb +{ + public class TvdbSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>, IHasOrder + { + internal static TvdbSeriesProvider Current { get; private set; } + private readonly IHttpClient _httpClient; + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly ILocalizationManager _localizationManager; + private readonly TvdbClientManager _tvdbClientManager; + + public TvdbSeriesProvider(IHttpClient httpClient, ILogger<TvdbSeriesProvider> logger, ILibraryManager libraryManager, ILocalizationManager localizationManager, TvdbClientManager tvdbClientManager) + { + _httpClient = httpClient; + _logger = logger; + _libraryManager = libraryManager; + _localizationManager = localizationManager; + Current = this; + _tvdbClientManager = tvdbClientManager; + } + + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) + { + if (IsValidSeries(searchInfo.ProviderIds)) + { + var metadata = await GetMetadata(searchInfo, cancellationToken).ConfigureAwait(false); + + if (metadata.HasMetadata) + { + return new List<RemoteSearchResult> + { + new RemoteSearchResult + { + Name = metadata.Item.Name, + PremiereDate = metadata.Item.PremiereDate, + ProductionYear = metadata.Item.ProductionYear, + ProviderIds = metadata.Item.ProviderIds, + SearchProviderName = Name + } + }; + } + } + + return await FindSeries(searchInfo.Name, searchInfo.Year, searchInfo.MetadataLanguage, cancellationToken).ConfigureAwait(false); + } + + public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo itemId, CancellationToken cancellationToken) + { + var result = new MetadataResult<Series> + { + QueriedById = true + }; + + if (!IsValidSeries(itemId.ProviderIds)) + { + result.QueriedById = false; + await Identify(itemId).ConfigureAwait(false); + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (IsValidSeries(itemId.ProviderIds)) + { + result.Item = new Series(); + result.HasMetadata = true; + + await FetchSeriesData(result, itemId.MetadataLanguage, itemId.ProviderIds, cancellationToken) + .ConfigureAwait(false); + } + + return result; + } + + private async Task FetchSeriesData(MetadataResult<Series> result, string metadataLanguage, Dictionary<string, string> seriesProviderIds, CancellationToken cancellationToken) + { + var series = result.Item; + + if (seriesProviderIds.TryGetValue(MetadataProviders.Tvdb.ToString(), out var tvdbId) && !string.IsNullOrEmpty(tvdbId)) + { + series.SetProviderId(MetadataProviders.Tvdb, tvdbId); + } + + if (seriesProviderIds.TryGetValue(MetadataProviders.Imdb.ToString(), out var imdbId) && !string.IsNullOrEmpty(imdbId)) + { + series.SetProviderId(MetadataProviders.Imdb, imdbId); + tvdbId = await GetSeriesByRemoteId(imdbId, MetadataProviders.Imdb.ToString(), metadataLanguage, + cancellationToken).ConfigureAwait(false); + } + + if (seriesProviderIds.TryGetValue(MetadataProviders.Zap2It.ToString(), out var zap2It) && !string.IsNullOrEmpty(zap2It)) + { + series.SetProviderId(MetadataProviders.Zap2It, zap2It); + tvdbId = await GetSeriesByRemoteId(zap2It, MetadataProviders.Zap2It.ToString(), metadataLanguage, + cancellationToken).ConfigureAwait(false); + } + + try + { + var seriesResult = + await _tvdbClientManager + .GetSeriesByIdAsync(Convert.ToInt32(tvdbId), metadataLanguage, cancellationToken) + .ConfigureAwait(false); + MapSeriesToResult(result, seriesResult.Data, metadataLanguage); + } + catch (TvDbServerException e) + { + _logger.LogError(e, "Failed to retrieve series with id {TvdbId}", tvdbId); + return; + } + + cancellationToken.ThrowIfCancellationRequested(); + + result.ResetPeople(); + + try + { + var actorsResult = await _tvdbClientManager + .GetActorsAsync(Convert.ToInt32(tvdbId), metadataLanguage, cancellationToken).ConfigureAwait(false); + MapActorsToResult(result, actorsResult.Data); + } + catch (TvDbServerException e) + { + _logger.LogError(e, "Failed to retrieve actors for series {TvdbId}", tvdbId); + } + } + + private async Task<string> GetSeriesByRemoteId(string id, string idType, string language, CancellationToken cancellationToken) + { + + TvDbResponse<SeriesSearchResult[]> result = null; + + try + { + if (string.Equals(idType, MetadataProviders.Zap2It.ToString(), StringComparison.OrdinalIgnoreCase)) + { + result = await _tvdbClientManager.GetSeriesByZap2ItIdAsync(id, language, cancellationToken) + .ConfigureAwait(false); + } + else + { + result = await _tvdbClientManager.GetSeriesByImdbIdAsync(id, language, cancellationToken) + .ConfigureAwait(false); + } + } + catch (TvDbServerException e) + { + _logger.LogError(e, "Failed to retrieve series with remote id {RemoteId}", id); + } + + return result?.Data.First().Id.ToString(); + } + + /// <summary> + /// Check whether a dictionary of provider IDs includes an entry for a valid TV metadata provider. + /// </summary> + /// <param name="seriesProviderIds">The dictionary to check.</param> + /// <returns>True, if the dictionary contains a valid TV provider ID, otherwise false.</returns> + internal static bool IsValidSeries(Dictionary<string, string> seriesProviderIds) + { + return seriesProviderIds.ContainsKey(MetadataProviders.Tvdb.ToString()) || + seriesProviderIds.ContainsKey(MetadataProviders.Imdb.ToString()) || + seriesProviderIds.ContainsKey(MetadataProviders.Zap2It.ToString()); + } + + /// <summary> + /// Finds the series. + /// </summary> + /// <param name="name">The name.</param> + /// <param name="year">The year.</param> + /// <param name="language">The language.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{System.String}.</returns> + private async Task<IEnumerable<RemoteSearchResult>> FindSeries(string name, int? year, string language, CancellationToken cancellationToken) + { + var results = await FindSeriesInternal(name, language, cancellationToken).ConfigureAwait(false); + + if (results.Count == 0) + { + var parsedName = _libraryManager.ParseName(name); + var nameWithoutYear = parsedName.Name; + + if (!string.IsNullOrWhiteSpace(nameWithoutYear) && !string.Equals(nameWithoutYear, name, StringComparison.OrdinalIgnoreCase)) + { + results = await FindSeriesInternal(nameWithoutYear, 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<List<RemoteSearchResult>> FindSeriesInternal(string name, string language, CancellationToken cancellationToken) + { + var comparableName = GetComparableName(name); + var list = new List<Tuple<List<string>, RemoteSearchResult>>(); + TvDbResponse<SeriesSearchResult[]> result; + try + { + result = await _tvdbClientManager.GetSeriesByNameAsync(comparableName, language, cancellationToken) + .ConfigureAwait(false); + } + catch (TvDbServerException e) + { + _logger.LogError(e, "No series results found for {Name}", comparableName); + return new List<RemoteSearchResult>(); + } + + foreach (var seriesSearchResult in result.Data) + { + var tvdbTitles = new List<string> + { + GetComparableName(seriesSearchResult.SeriesName) + }; + tvdbTitles.AddRange(seriesSearchResult.Aliases.Select(GetComparableName)); + + DateTime.TryParse(seriesSearchResult.FirstAired, out var firstAired); + var remoteSearchResult = new RemoteSearchResult + { + Name = tvdbTitles.FirstOrDefault(), + ProductionYear = firstAired.Year, + SearchProviderName = Name, + ImageUrl = TvdbUtils.BannerUrl + seriesSearchResult.Banner + + }; + try + { + var seriesSesult = + await _tvdbClientManager.GetSeriesByIdAsync(seriesSearchResult.Id, language, cancellationToken) + .ConfigureAwait(false); + remoteSearchResult.SetProviderId(MetadataProviders.Imdb, seriesSesult.Data.ImdbId); + remoteSearchResult.SetProviderId(MetadataProviders.Zap2It, seriesSesult.Data.Zap2itId); + } + catch (TvDbServerException e) + { + _logger.LogError(e, "Unable to retrieve series with id {TvdbId}", seriesSearchResult.Id); + } + + remoteSearchResult.SetProviderId(MetadataProviders.Tvdb, seriesSearchResult.Id.ToString()); + list.Add(new Tuple<List<string>, RemoteSearchResult>(tvdbTitles, remoteSearchResult)); + } + + return list + .OrderBy(i => i.Item1.Contains(comparableName, StringComparer.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(i => list.IndexOf(i)) + .Select(i => i.Item2) + .ToList(); + } + + /// <summary> + /// The remove + /// </summary> + const string remove = "\"'!`?"; + /// <summary> + /// The spacers + /// </summary> + const string spacers = "/,.:;\\(){}[]+-_=–*"; // (there are two types of dashes, short and long) + + /// <summary> + /// Gets the name of the comparable. + /// </summary> + /// <param name="name">The name.</param> + /// <returns>System.String.</returns> + private string GetComparableName(string name) + { + name = name.ToLowerInvariant(); + name = name.Normalize(NormalizationForm.FormKD); + var sb = new StringBuilder(); + foreach (var c in name) + { + if (c >= 0x2B0 && c <= 0x0333) + { + // skip char modifier and diacritics + } + else if (remove.IndexOf(c) > -1) + { + // skip chars we are removing + } + else if (spacers.IndexOf(c) > -1) + { + sb.Append(" "); + } + else if (c == '&') + { + sb.Append(" and "); + } + else + { + sb.Append(c); + } + } + sb.Replace(", the", string.Empty).Replace("the ", " ").Replace(" the ", " "); + + return Regex.Replace(sb.ToString().Trim(), @"\s+", " "); + } + + private void MapSeriesToResult(MetadataResult<Series> result, TvDbSharper.Dto.Series tvdbSeries, string metadataLanguage) + { + Series series = result.Item; + series.SetProviderId(MetadataProviders.Tvdb, tvdbSeries.Id.ToString()); + series.Name = tvdbSeries.SeriesName; + series.Overview = (tvdbSeries.Overview ?? string.Empty).Trim(); + result.ResultLanguage = metadataLanguage; + series.AirDays = TVUtils.GetAirDays(tvdbSeries.AirsDayOfWeek); + series.AirTime = tvdbSeries.AirsTime; + series.CommunityRating = (float?)tvdbSeries.SiteRating; + series.SetProviderId(MetadataProviders.Imdb, tvdbSeries.ImdbId); + series.SetProviderId(MetadataProviders.Zap2It, tvdbSeries.Zap2itId); + if (Enum.TryParse(tvdbSeries.Status, true, out SeriesStatus seriesStatus)) + { + series.Status = seriesStatus; + } + + if (DateTime.TryParse(tvdbSeries.FirstAired, out var date)) + { + // dates from tvdb are UTC but without offset or Z + series.PremiereDate = date; + series.ProductionYear = date.Year; + } + + series.RunTimeTicks = TimeSpan.FromMinutes(Convert.ToDouble(tvdbSeries.Runtime)).Ticks; + foreach (var genre in tvdbSeries.Genre) + { + series.AddGenre(genre); + } + + if (!string.IsNullOrEmpty(tvdbSeries.Network)) + { + series.AddStudio(tvdbSeries.Network); + } + + if (result.Item.Status.HasValue && result.Item.Status.Value == SeriesStatus.Ended) + { + try + { + var episodeSummary = _tvdbClientManager + .GetSeriesEpisodeSummaryAsync(tvdbSeries.Id, metadataLanguage, CancellationToken.None).Result.Data; + var maxSeasonNumber = episodeSummary.AiredSeasons.Select(s => Convert.ToInt32(s)).Max(); + var episodeQuery = new EpisodeQuery + { + AiredSeason = maxSeasonNumber + }; + var episodesPage = + _tvdbClientManager.GetEpisodesPageAsync(tvdbSeries.Id, episodeQuery, metadataLanguage, CancellationToken.None).Result.Data; + result.Item.EndDate = episodesPage.Select(e => + { + DateTime.TryParse(e.FirstAired, out var firstAired); + return firstAired; + }).Max(); + } + catch (TvDbServerException e) + { + _logger.LogError(e, "Failed to find series end date for series {TvdbId}", tvdbSeries.Id); + } + } + } + + private static void MapActorsToResult(MetadataResult<Series> result, IEnumerable<Actor> actors) + { + foreach (Actor actor in actors) + { + var personInfo = new PersonInfo + { + Type = PersonType.Actor, + Name = (actor.Name ?? string.Empty).Trim(), + Role = actor.Role, + ImageUrl = TvdbUtils.BannerUrl + actor.Image, + SortOrder = actor.SortOrder + }; + + if (!string.IsNullOrWhiteSpace(personInfo.Name)) + { + result.AddPerson(personInfo); + } + } + } + + public string Name => "TheTVDB"; + + public async Task Identify(SeriesInfo info) + { + if (!string.IsNullOrWhiteSpace(info.GetProviderId(MetadataProviders.Tvdb))) + { + return; + } + + var srch = await FindSeries(info.Name, info.Year, info.MetadataLanguage, CancellationToken.None) + .ConfigureAwait(false); + + var entry = srch.FirstOrDefault(); + + if (entry != null) + { + var id = entry.GetProviderId(MetadataProviders.Tvdb); + info.SetProviderId(MetadataProviders.Tvdb, id); + } + } + + public int Order => 0; + + public Task<HttpResponseInfo> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClient.GetResponse(new HttpRequestOptions + { + CancellationToken = cancellationToken, + Url = url, + BufferContent = false + }); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs new file mode 100644 index 000000000..79d879aa1 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs @@ -0,0 +1,36 @@ +using System; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.Plugins.TheTvdb +{ + public static class TvdbUtils + { + public const string TvdbApiKey = "OG4V3YJ3FAP7FP2K"; + public const string TvdbBaseUrl = "https://www.thetvdb.com/"; + public const string BannerUrl = TvdbBaseUrl + "banners/"; + + public static ImageType GetImageTypeFromKeyType(string keyType) + { + switch (keyType.ToLowerInvariant()) + { + case "poster": + case "season": return ImageType.Primary; + case "series": + case "seasonwide": return ImageType.Banner; + case "fanart": return ImageType.Backdrop; + default: throw new ArgumentException($"Invalid or unknown keytype: {keyType}", nameof(keyType)); + } + } + + public static string NormalizeLanguage(string language) + { + if (string.IsNullOrWhiteSpace(language)) + { + return null; + } + + // pt-br is just pt to tvdb + return language.Split('-')[0].ToLowerInvariant(); + } + } +} |
