diff --git a/Jellyfin.Plugin.TvMaze/Providers/TvMazeEpisodeImageProvider.cs b/Jellyfin.Plugin.TvMaze/Providers/TvMazeEpisodeImageProvider.cs index f92c466..5685e5f 100644 --- a/Jellyfin.Plugin.TvMaze/Providers/TvMazeEpisodeImageProvider.cs +++ b/Jellyfin.Plugin.TvMaze/Providers/TvMazeEpisodeImageProvider.cs @@ -64,21 +64,15 @@ public async Task> GetImages(BaseItem item, Cancell return Enumerable.Empty(); } - var tvMazeId = TvHelpers.GetTvMazeId(episode.Series.ProviderIds); + var tvMazeId = TvHelpers.GetTvMazeId(episode.ProviderIds); if (tvMazeId == null) { - // Requires series TVMaze id. - return Enumerable.Empty(); - } - - if (episode.IndexNumber == null || episode.ParentIndexNumber == null) - { - // Missing episode or season number. + // Requires episode TVMaze id. return Enumerable.Empty(); } var tvMazeClient = new TvMazeClient(_httpClientFactory.CreateClient(NamedClient.Default), new RetryRateLimitingStrategy()); - var tvMazeEpisode = await tvMazeClient.Shows.GetEpisodeByNumberAsync(tvMazeId.Value, episode.ParentIndexNumber.Value, episode.IndexNumber.Value).ConfigureAwait(false); + var tvMazeEpisode = await tvMazeClient.Episodes.GetEpisodeMainInformationAsync(tvMazeId.Value).ConfigureAwait(false); if (tvMazeEpisode == null) { return Enumerable.Empty(); diff --git a/Jellyfin.Plugin.TvMaze/Providers/TvMazeEpisodeProvider.cs b/Jellyfin.Plugin.TvMaze/Providers/TvMazeEpisodeProvider.cs index 063a890..c60a7b0 100644 --- a/Jellyfin.Plugin.TvMaze/Providers/TvMazeEpisodeProvider.cs +++ b/Jellyfin.Plugin.TvMaze/Providers/TvMazeEpisodeProvider.cs @@ -1,18 +1,23 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; using TvMaze.Api.Client; using TvMaze.Api.Client.Configuration; +using TvMaze.Api.Client.Models; +using Episode = MediaBrowser.Controller.Entities.TV.Episode; +using TvMazeEpisode = TvMaze.Api.Client.Models.Episode; namespace Jellyfin.Plugin.TvMaze.Providers { @@ -21,6 +26,10 @@ namespace Jellyfin.Plugin.TvMaze.Providers /// public class TvMazeEpisodeProvider : IRemoteMetadataProvider { + private static readonly char[] _normalizedEpisodeNamesIgnoredChars = { ' ', '+', '.', '-', '_' }; + private static readonly Regex _filenameDateRegex = new ("[0-9]{4}-[0-9]{2}-[0-9]{2}"); + private static readonly Regex _filenameIdRegex = new (@"\[([0-9]+)\]"); + private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; @@ -117,26 +126,105 @@ public Task GetImageResponse(string url, CancellationToken return null; } - // The search query must provide an episode number. - if (!info.IndexNumber.HasValue || !info.ParentIndexNumber.HasValue) + var tvMazeClient = new TvMazeClient(_httpClientFactory.CreateClient(NamedClient.Default), new RetryRateLimitingStrategy()); + // TODO maybe we should cache the last 10 shows or so because it is reasonable to assume that the metadata for multiple episodes of the same show will be requested + var allEpisodes = (await tvMazeClient.Shows.GetShowEpisodeListAsync(tvMazeId.Value, true).ConfigureAwait(false)).ToArray(); + var possibleEpisodes = allEpisodes; + + var episode = new Episode(); + TvMazeEpisode? tvMazeEpisode = null; + + if (info.ParentIndexNumber.HasValue && info.ParentIndexNumber != 0 && info.IndexNumber.HasValue) { - return null; + tvMazeEpisode = possibleEpisodes.FirstOrDefault(e => e.Season == info.ParentIndexNumber && e.Number == info.IndexNumber); + + if (tvMazeEpisode != null) + { + episode.ParentIndexNumber = tvMazeEpisode.Season; + episode.IndexNumber = tvMazeEpisode.Number; + } + } + + if (tvMazeEpisode == null) + { + var filename = Path.GetFileNameWithoutExtension(info.Path); + + var dateMatch = _filenameDateRegex.Match(filename); + if (dateMatch.Success) + { + possibleEpisodes = possibleEpisodes.Where(e => e.AirDate == dateMatch.Value).ToArray(); + + if (possibleEpisodes.Length == 0) + { + // Get rid of the date filter because there is no episode found + possibleEpisodes = allEpisodes; + } + else if (possibleEpisodes.Length == 1) + { + tvMazeEpisode = possibleEpisodes.First(); + } + } + + if (tvMazeEpisode == null) + { + var idMatch = _filenameIdRegex.Match(filename); + if (idMatch.Success) + { + tvMazeEpisode = possibleEpisodes.FirstOrDefault(e => e.Id.ToString(CultureInfo.InvariantCulture) == idMatch.Groups[1].Value); + } + + if (tvMazeEpisode == null) + { + var normalizedFileName = NormalizeEpisodeName(filename); + var nameMatchedEpisodes = possibleEpisodes.Where(e => normalizedFileName.Contains(NormalizeEpisodeName(e.Name), StringComparison.CurrentCultureIgnoreCase)).ToArray(); + if (nameMatchedEpisodes.Length > 0) + { + possibleEpisodes = nameMatchedEpisodes; + } + + tvMazeEpisode = possibleEpisodes.FirstOrDefault(); + if (possibleEpisodes.Length > 1) + { + var potentialEpisodesString = string.Join(", ", possibleEpisodes.Take(10).Select(ep => $"{ep.Name}(ID: {ep.Id})")); + var fileNameWithIdExample = tvMazeEpisode!.Name + $"[{tvMazeEpisode.Id}]" + Path.GetExtension(info.Path); + _logger.LogWarning("[GetMetadata] Found multiple possible episodes: {possibleEpisodes}. Include the name or the ID of the correct episode in the file name to make the match unique. TVmaze IDs have to be in brackets. (e.G., '{fileNameWithIdExample}')", potentialEpisodesString, fileNameWithIdExample); + return null; + } + } + } } - var tvMazeClient = new TvMazeClient(_httpClientFactory.CreateClient(NamedClient.Default), new RetryRateLimitingStrategy()); - var tvMazeEpisode = await tvMazeClient.Shows.GetEpisodeByNumberAsync(tvMazeId.Value, info.ParentIndexNumber.Value, info.IndexNumber.Value).ConfigureAwait(false); if (tvMazeEpisode == null) { - // No episode found. return null; } - var episode = new Episode + switch (tvMazeEpisode.Type) { - Name = tvMazeEpisode.Name, - IndexNumber = tvMazeEpisode.Number, - ParentIndexNumber = tvMazeEpisode.Season - }; + case EpisodeType.Regular: + episode.ParentIndexNumber = tvMazeEpisode.Season; + episode.IndexNumber = tvMazeEpisode.Number; + break; + case EpisodeType.SignificantSpecial: + case EpisodeType.InsignificantSpecial: + episode.ParentIndexNumber = 0; + // This way of calculating the index number is not perfectly stable because older specials could be added later but we need some index number to allow sorting by. + episode.IndexNumber = allEpisodes + .Where(e => e.Type == EpisodeType.InsignificantSpecial || e.Type == EpisodeType.SignificantSpecial) + .TakeWhile(e => e.Id != tvMazeEpisode.Id) + .Aggregate(1, (i, _) => i + 1); + break; + default: + _logger.LogWarning("[GetMetadata] Found unknown episode type '{episodeType}'.", tvMazeEpisode.Type); + break; + } + + if (tvMazeEpisode.Type == EpisodeType.SignificantSpecial) + { + SetPositionInSeason(allEpisodes, tvMazeEpisode, episode); + } + + episode.Name = tvMazeEpisode.Name; if (DateTime.TryParse(tvMazeEpisode.AirDate, out var airDate)) { @@ -153,5 +241,48 @@ public Task GetImageResponse(string url, CancellationToken return episode; } + + private static void SetPositionInSeason(IReadOnlyList allEpisodes, TvMazeEpisode tvMazeEpisode, Episode episode) + { + for (int i = 0; i < allEpisodes.Count; i++) + { + if (allEpisodes[i].Id != tvMazeEpisode.Id) + { + continue; + } + + if (i == 0 || allEpisodes[i - 1].Season != tvMazeEpisode.Season) + { + episode.AirsBeforeSeasonNumber = tvMazeEpisode.Season; + } + else + { + var firstRegularEpisodeAfter = allEpisodes.Skip(i + 1).SkipWhile(e => e.Type != EpisodeType.Regular).FirstOrDefault(); + if (firstRegularEpisodeAfter != null && firstRegularEpisodeAfter.Season == tvMazeEpisode.Season) + { + episode.AirsBeforeSeasonNumber = tvMazeEpisode.Season; + episode.AirsBeforeEpisodeNumber = firstRegularEpisodeAfter.Number; + } + else + { + episode.AirsAfterSeasonNumber = tvMazeEpisode.Season; + } + } + + return; + } + } + + private static string NormalizeEpisodeName(string originalName) + { + var normalizedNameBuilder = new StringBuilder(); + + foreach (var character in originalName.Where(character => !_normalizedEpisodeNamesIgnoredChars.Contains(character))) + { + normalizedNameBuilder.Append(character); + } + + return normalizedNameBuilder.ToString(); + } } }