diff --git a/README.md b/README.md index adc4939..4f880ae 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This plugin fetches intro, recap, credits, and preview timestamps from [TheIntroDB](https://theintrodb.org) for your Emby library. It uses this data to enable intro skipping features in compatible Emby clients. -**Requirements:** Emby Server 4.7+. **TMDb metadata is recommended** for best accuracy (IMDb works as a fallback but is less accurate for TV). +**Requirements:** Emby Server 4.7+. **TVDB metadata is recommended**: TVDB is TheIntroDB's identity, and it is what lets series with a non-default display order (DVD, absolute) resolve to the correct episode. TMDB and IMDb ids still work as fallbacks. **Important:** Segments are **not** fetched when you press play. They are populated when the **TheIntroDB Media Segment Scan** scheduled task runs. Until that task has run for your library, skip features may not be available. @@ -37,15 +37,17 @@ Tip: use [Emby.GitHubRepoPluginInstall](https://github.com/bakes82/Emby.GitHubRe ### Metadata Requirements -**TMDb is recommended.** The plugin matches content by TMDb ID for best accuracy. Ensure your libraries are configured to fetch TMDb IDs for your movies and shows. +**TVDB is recommended.** The plugin addresses media by TVDB id when one is available, then falls back to TMDB, then IMDb. -IMDb IDs work as a fallback but are less accurate for TV episodes. The plugin will use whichever IDs are available on your items. +The series' **display order** is sent with every episode lookup, so a show set to DVD or absolute order is looked up by the numbers you actually have — not another order's. (Emby cannot express alternate/regional ordering, so those series cannot be represented; Jellyfin can.) The episode's own TVDB id is sent as well when it is distinct from the series id, which pins the exact episode record. A series with a non-default order and no TVDB id is skipped and logged once, because no other id can express the order. + +IMDb and TMDB ids are less accurate for TV episodes: those requests are always read in that provider's numbering. ## Configuration TheIntroDB plugin includes some configuration options to adjust and improve your experience. -- **API Key**: You can enter your TheIntroDB API key to fetch your submissions even if they're still pending and prioritize yours in the averaging calculation. +- **API Key**: You can enter your TheIntroDB API key to fetch your submissions even if they're still pending and prioritize yours in the averaging calculation. It also moves requests to the signed-in rate limits (120 requests per 10 s, 100,000 lookups per day instead of 30 and 5,000), which is what lets a full library scan finish in one run. - **Segment Toggles**: (Intro, Recap, Credits, and Preview — all enabled by default) You can disable each segment individually so they're not applied when fetching. - **Ignore Media That Already Has Segments**: (Enabled by default) Prevent refetching of media that already has segments. This is recommended for large libraries. diff --git a/TheIntroDB.Tests/V4MigrationTests.cs b/TheIntroDB.Tests/V4MigrationTests.cs new file mode 100644 index 0000000..8b0e23f --- /dev/null +++ b/TheIntroDB.Tests/V4MigrationTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Linq; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using TheIntroDB.Api; +using TheIntroDB.Providers; +using Xunit; + +namespace TheIntroDB.Tests +{ + /// + /// Guards the v4 contract: the client must not silently drift back to a frozen + /// version, the signed-in rate tier must stay under its ceiling, and a series' + /// display order must reach the API only when it is a real non-default order. + /// + public class V4MigrationTests + { + private const int ApiMediaAuthenticatedBurstCeiling = 120; + + [Fact] + public void ClientSpeaksV4() + { + Assert.Equal("v4", TheIntroDbClient.ApiVersion); + Assert.Equal("https://api.theintrodb.org/v4", TheIntroDbClient.BaseUrl); + } + + [Fact] + public void AuthenticatedPacingStaysUnderTheSignedInCeiling() + { + // Signed-in callers get 120/10s. 100/window keeps a margin for jitter. + Assert.True( + TheIntroDbClient.MinDelayBetweenRequestsAuthenticated.TotalMilliseconds * (ApiMediaAuthenticatedBurstCeiling - 1) >= 10_000, + $"Authenticated pacing of {TheIntroDbClient.MinDelayBetweenRequestsAuthenticated.TotalMilliseconds} ms exceeds the ceiling."); + Assert.True(TheIntroDbClient.MinDelayBetweenRequestsAuthenticated < TheIntroDbClient.MinDelayBetweenRequests); + } + + [Theory] + [InlineData(SeriesDisplayOrder.Aired, null)] + [InlineData(SeriesDisplayOrder.Dvd, "dvd")] + [InlineData(SeriesDisplayOrder.Absolute, "absolute")] + public void DisplayOrderIsSentOnlyWhenItIsNotTheDefaultOrder(SeriesDisplayOrder displayOrder, string? expected) + { + var series = new Series { DisplayOrder = displayOrder }; + Assert.Equal(expected, TheIntroDbSegmentProvider.GetDisplayOrder(series)); + } + + [Fact] + public void DisplayOrderIsNullWithoutASeries() + { + Assert.Null(TheIntroDbSegmentProvider.GetDisplayOrder(null!)); + } + + [Fact] + public void TvdbAddressedEpisodeCarriesItsOrderAndEpisodeId() + { + // A DVD-ordered series: the numbers Emby has are DVD numbers, and the API + // stores TVDB's default order, so the order has to travel with the request. + var query = TheIntroDbClient.BuildMediaQuery( + tmdbId: 456, + tvdbId: 71663, + imdbId: "tt0096697", + isMovie: false, + season: 1, + episode: 1, + durationMs: 1400000, + mapping: "dvd", + tvdbEpisodeId: 55452); + + Assert.Equal("?tvdb_id=71663&season=1&episode=1&mapping=dvd&tvdb_episode_id=55452&duration_ms=1400000", query); + } + + [Fact] + public void DefaultOrderedEpisodeSendsNoOrderAndNoTmdbId() + { + var query = TheIntroDbClient.BuildMediaQuery(456, 71663, null, false, 1, 1, null, null, null); + Assert.Equal("?tvdb_id=71663&season=1&episode=1", query); + } + + [Fact] + public void OrderAndEpisodeIdAreDroppedWithoutATvdbIdentity() + { + // A tmdb_id request is always read as TMDB numbering, so an order beside it + // would be a lie rather than a hint (the provider skips those series). + var query = TheIntroDbClient.BuildMediaQuery(615, 0, null, false, 7, 1, null, "dvd", 1051911); + Assert.Equal("?tmdb_id=615&season=7&episode=1", query); + } + + [Fact] + public void TmdbIsOnlyUsedWhenNoTvdbIdExistsAndImdbIsLast() + { + Assert.Equal("?tmdb_id=615", TheIntroDbClient.BuildMediaQuery(615, 0, "tt0149460", true, null, null, null, null, null)); + Assert.Equal("?imdb_id=tt0149460", TheIntroDbClient.BuildMediaQuery(0, 0, "tt0149460", true, null, null, null, null, null)); + } + + [Fact] + public void NotFoundCacheKeyCarriesTheOrderAndThePinnedEpisodeId() + { + // Both change the answer, so neither may be dropped: a 404 recorded for one + // address must not suppress a later lookup of another, in particular after a + // series' metadata is corrected. + var dvd = TheIntroDbSegmentProvider.BuildNotFoundKey(false, 456, 71663, null, 1, 1, "dvd", 55452); + var aired = TheIntroDbSegmentProvider.BuildNotFoundKey(false, 456, 71663, null, 1, 1, null, 55452); + var otherPin = TheIntroDbSegmentProvider.BuildNotFoundKey(false, 456, 71663, null, 1, 1, "dvd", 55453); + var noPin = TheIntroDbSegmentProvider.BuildNotFoundKey(false, 456, 71663, null, 1, 1, "dvd", null); + + Assert.Equal("episode:tvdb:71663:1:1:dvd:ep55452", dvd); + Assert.Equal(4, new[] { dvd, aired, otherPin, noPin }.Distinct().Count()); + Assert.Equal("movie:tvdb:169", TheIntroDbSegmentProvider.BuildNotFoundKey(true, 0, 169, null, null, null, null, null)); + } + } +} diff --git a/TheIntroDB/Api/MediaResponse.cs b/TheIntroDB/Api/MediaResponse.cs index 7facf0e..45379d8 100644 --- a/TheIntroDB/Api/MediaResponse.cs +++ b/TheIntroDB/Api/MediaResponse.cs @@ -9,10 +9,12 @@ namespace TheIntroDB.Api public class MediaResponse { /// - /// Gets or sets the TMDB ID. + /// Gets or sets the TVDB ID. TVDB is the media's identity in v4: the response + /// carries this and no TMDB id, since a media with no TMDB counterpart is a + /// normal media rather than a fake tmdb_id: 0. /// - [JsonPropertyName("tmdb_id")] - public int TmdbId { get; set; } + [JsonPropertyName("tvdb_id")] + public int? TvdbId { get; set; } /// /// Gets or sets the type: "movie" or "tv". diff --git a/TheIntroDB/Api/TheIntroDbApiKeyValidationService.cs b/TheIntroDB/Api/TheIntroDbApiKeyValidationService.cs index 9723965..98c0e73 100644 --- a/TheIntroDB/Api/TheIntroDbApiKeyValidationService.cs +++ b/TheIntroDB/Api/TheIntroDbApiKeyValidationService.cs @@ -10,7 +10,9 @@ namespace TheIntroDB.Api public sealed class TheIntroDbApiKeyValidationService { private static readonly HttpClient _httpClient = new HttpClient(); - private static readonly Uri UserStatsUri = new Uri("https://api.theintrodb.org/v3/user/stats", UriKind.Absolute); + // v2 and v3 no longer serve /user/stats (they are frozen at GET /media), so the + // key probe has to speak v4 as well. + private static readonly Uri UserStatsUri = new Uri(TheIntroDbClient.BaseUrl + "/user/stats", UriKind.Absolute); private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); public async Task ValidateAsync(string apiKey, CancellationToken cancellationToken) diff --git a/TheIntroDB/Api/TheIntroDbClient.cs b/TheIntroDB/Api/TheIntroDbClient.cs index 1e64046..76d4ad0 100644 --- a/TheIntroDB/Api/TheIntroDbClient.cs +++ b/TheIntroDB/Api/TheIntroDbClient.cs @@ -14,12 +14,26 @@ namespace TheIntroDB.Api { public class TheIntroDbClient { - // Keep a safety margin below the provider ceiling of 30 requests per 10 seconds. - private const int MaxRequestsPerWindow = 25; + // The API's /media burst ceiling is 30 requests per 10 seconds anonymous and + // 120 for signed-in callers. Keep a safety margin below whichever tier is in + // use: pacing exactly at the ceiling draws 429s under jitter. + private const int AnonymousRequestsPerWindow = 25; + private const int AuthenticatedRequestsPerWindow = 100; private const int UsageResetClampSeconds = 24 * 60 * 60; private const int MaxConsecutiveRateLimitMultiplier = 8; + + /// + /// The API version this client speaks. v2 and v3 are frozen compatibility + /// surfaces that serve GET /media only; v4 is the current surface and the only + /// one that understands the episode-order parameters. + /// + internal const string ApiVersion = "v4"; + + internal const string BaseUrl = "https://api.theintrodb.org/" + ApiVersion; + private static readonly TimeSpan RateLimitWindow = TimeSpan.FromSeconds(10); - private static readonly TimeSpan MinDelayBetweenRequests = TimeSpan.FromMilliseconds(RateLimitWindow.TotalMilliseconds / MaxRequestsPerWindow); + internal static readonly TimeSpan MinDelayBetweenRequests = TimeSpan.FromMilliseconds(RateLimitWindow.TotalMilliseconds / AnonymousRequestsPerWindow); + internal static readonly TimeSpan MinDelayBetweenRequestsAuthenticated = TimeSpan.FromMilliseconds(RateLimitWindow.TotalMilliseconds / AuthenticatedRequestsPerWindow); private static readonly TimeSpan MaxRateLimitDelay = TimeSpan.FromMinutes(5); private static readonly SemaphoreSlim RateLimitLock = new SemaphoreSlim(1, 1); @@ -48,7 +62,9 @@ public async Task GetMediaAsync( int? episode, long? durationMs, CancellationToken cancellationToken, - bool trackUsage = true) + bool trackUsage = true, + string mapping = null, + long? tvdbEpisodeId = null) { if (IsDailyUsageExhausted(_logger)) { @@ -69,7 +85,6 @@ public async Task GetMediaAsync( } var config = _plugin.Configuration ?? new PluginConfiguration(); - const string baseUrl = "https://api.theintrodb.org/v3"; var tmdbIdValue = tmdbId.GetValueOrDefault(); var hasTmdb = tmdbIdValue > 0; @@ -95,33 +110,10 @@ public async Task GetMediaAsync( ["has_theintrodb_api_key"] = !string.IsNullOrWhiteSpace(config.ApiKey) ? 1 : 0 }); - string query; - if (hasTmdb) - { - query = isMovie - ? $"?tmdb_id={tmdbIdValue}" - : $"?tmdb_id={tmdbIdValue}&season={season}&episode={episode}"; - } - else if (hasTvdb) - { - query = isMovie - ? $"?tvdb_id={tvdbIdValue}" - : $"?tvdb_id={tvdbIdValue}&season={season}&episode={episode}"; - } - else - { - var encodedImdb = Uri.EscapeDataString(imdbId); - query = isMovie - ? $"?imdb_id={encodedImdb}" - : $"?imdb_id={encodedImdb}&season={season}&episode={episode}"; - } - - if (durationMs.HasValue && durationMs.Value > 0) - { - query += $"&duration_ms={durationMs.Value}"; - } - - var requestUri = new Uri(baseUrl + "/media" + query, UriKind.Absolute); + var hasApiKey = !string.IsNullOrWhiteSpace(config.ApiKey); + var requestUri = new Uri( + BaseUrl + "/media" + BuildMediaQuery(tmdbIdValue, tvdbIdValue, imdbId, isMovie, season, episode, durationMs, mapping, tvdbEpisodeId), + UriKind.Absolute); _logger.Info("TheIntroDB API request: {0}", requestUri); const int maxRetries = 3; @@ -129,7 +121,7 @@ public async Task GetMediaAsync( { using (var request = new HttpRequestMessage(HttpMethod.Get, requestUri)) { - if (!string.IsNullOrWhiteSpace(config.ApiKey)) + if (hasApiKey) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.ApiKey.Trim()); } @@ -141,7 +133,7 @@ public async Task GetMediaAsync( try { - await WaitForRateLimitAsync(cancellationToken).ConfigureAwait(false); + await WaitForRateLimitAsync(hasApiKey ? MinDelayBetweenRequestsAuthenticated : MinDelayBetweenRequests, cancellationToken).ConfigureAwait(false); using (var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false)) { _logger.Info("TheIntroDB API response: StatusCode={0} for {1}", response.StatusCode, requestUri); @@ -338,6 +330,77 @@ public async Task GetMediaAsync( return MediaFetchResult.Error(); } + /// + /// Builds the query string for a GET /media request. + /// + /// + /// The media is addressed by exactly one id, in the API's own priority order + /// (tvdb_id -> tmdb_id -> imdb_id): TVDB is the identity, and it is the only id a + /// request can carry the caller's episode order alongside. + /// + /// TMDB id, 0 when unknown. + /// TVDB id, 0 when unknown. + /// IMDB id, null when unknown. + /// True for a movie (no season/episode). + /// Season number, in 's order. + /// Episode number, in 's order. + /// Optional video duration, used to pick a release version. + /// Episode order for a TVDB-addressed request; null for the default order. + /// Optional TVDB episode id pinning one exact episode record. + /// The query string, starting with '?'. + internal static string BuildMediaQuery( + int tmdbId, + int tvdbId, + string imdbId, + bool isMovie, + int? season, + int? episode, + long? durationMs, + string mapping, + long? tvdbEpisodeId) + { + var hasTvdb = tvdbId > 0; + var queryParams = new List(6); + if (hasTvdb) + { + queryParams.Add("tvdb_id=" + tvdbId); + } + else if (tmdbId > 0) + { + queryParams.Add("tmdb_id=" + tmdbId); + } + else + { + queryParams.Add("imdb_id=" + Uri.EscapeDataString(imdbId ?? string.Empty)); + } + + if (!isMovie) + { + queryParams.Add("season=" + season); + queryParams.Add("episode=" + episode); + + // Only a TVDB-addressed request carries the caller's own numbering: a + // tmdb_id request is always read as TMDB numbering, and an unknown + // mapping value is a 400 rather than a silent fallback. + if (hasTvdb && !string.IsNullOrWhiteSpace(mapping)) + { + queryParams.Add("mapping=" + Uri.EscapeDataString(mapping.Trim())); + } + + if (hasTvdb && tvdbEpisodeId.HasValue && tvdbEpisodeId.Value > 0) + { + queryParams.Add("tvdb_episode_id=" + tvdbEpisodeId.Value); + } + } + + if (durationMs.HasValue && durationMs.Value > 0) + { + queryParams.Add("duration_ms=" + durationMs.Value); + } + + return "?" + string.Join("&", queryParams); + } + private static void TrackUsage( bool trackUsage, string eventName, @@ -520,10 +583,13 @@ private static void UpdateRateWindowFromHeaders(HttpResponseHeaders headers) } /// - /// Waits if necessary to respect the API rate limit (30 requests per 10 seconds), - /// plus any hold imposed by a nearly-exhausted rate window. + /// Waits if necessary to respect the API rate limit (30 requests per 10 seconds + /// anonymous, 120 signed in), plus any hold imposed by a nearly-exhausted rate + /// window. /// - private static async Task WaitForRateLimitAsync(CancellationToken cancellationToken) + /// Spacing between request starts for the caller's rate-limit tier. + /// Cancellation token. + private static async Task WaitForRateLimitAsync(TimeSpan minimumDelay, CancellationToken cancellationToken) { await RateLimitLock.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -532,7 +598,7 @@ private static async Task WaitForRateLimitAsync(CancellationToken cancellationTo var waitUntil = now; if (_lastRequestUtc != DateTime.MinValue) { - var pacedSend = _lastRequestUtc + MinDelayBetweenRequests; + var pacedSend = _lastRequestUtc + minimumDelay; if (pacedSend > waitUntil) { waitUntil = pacedSend; diff --git a/TheIntroDB/Configuration/configPage.html b/TheIntroDB/Configuration/configPage.html index ff80f95..fd473eb 100644 --- a/TheIntroDB/Configuration/configPage.html +++ b/TheIntroDB/Configuration/configPage.html @@ -20,7 +20,7 @@

TheIntroDB

Segment data (intro, recap, credits, preview) is fetched from TheIntroDB API - using each item's TMDB or IMDB ID. Segments are not loaded when you press play; run the TheIntroDB media segment scan scheduled task to populate them. + (v4) using each item's TVDB, TMDB or IMDB ID. TVDB is preferred: it is the API's identity, and it is what lets series with a non-default display order (DVD, absolute) resolve to the right episode. Segments are not loaded when you press play; run the TheIntroDB media segment scan scheduled task to populate them.

@@ -28,7 +28,7 @@

Connection

-
Optional Bearer token; when set, your pending and accepted submissions are weighted higher in results.
+
Optional Bearer token; when set, your pending and accepted submissions are weighted higher in results, and requests use the signed-in rate limits (120 requests per 10 seconds, 100,000 lookups per day instead of 30 and 5,000). A key is what lets a large library scan finish in one run.
diff --git a/TheIntroDB/Providers/TheIntroDbSegmentProvider.cs b/TheIntroDB/Providers/TheIntroDbSegmentProvider.cs index 775e7da..8df7b76 100644 --- a/TheIntroDB/Providers/TheIntroDbSegmentProvider.cs +++ b/TheIntroDB/Providers/TheIntroDbSegmentProvider.cs @@ -8,6 +8,7 @@ using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Logging; using TheIntroDB.Api; using TheIntroDB.Models; @@ -23,6 +24,12 @@ public class TheIntroDbSegmentProvider { private static readonly HttpClient _httpClient = new HttpClient(); + // One warning per series whose display order cannot be expressed, not one per + // episode of it. Static because a provider instance is short-lived (one per + // scan or per event), so an instance field would not dedupe a scan. + private static readonly System.Collections.Concurrent.ConcurrentDictionary _orderSkipsLogged = + new System.Collections.Concurrent.ConcurrentDictionary(); + private readonly ILibraryManager _libraryManager; private readonly ILogger _logger; @@ -99,6 +106,8 @@ public async Task GetMediaSegmentsAsync( bool isMovie = false; int? season = null; int? episode = null; + string mapping = null; + long? tvdbEpisodeId = null; if (item is Movie movie) { @@ -110,13 +119,26 @@ public async Task GetMediaSegmentsAsync( } else if (item is Episode ep) { - tmdbId = GetTmdbId(ep) ?? GetTmdbId(ep.Series); - tvdbId = GetTvdbId(ep) ?? GetTvdbId(ep.Series); + tmdbId = GetTmdbId(ep.Series); + tvdbId = GetTvdbId(ep.Series); imdbId = GetImdbId(ep) ?? GetImdbId(ep.Series); season = ep.ParentIndexNumber; episode = ep.IndexNumber; - _logger.Debug("Episode: Name={0}, Series={1}, S{2}E{3}, TmdbId={4}, TvdbId={5}, ImdbId={6}", - item.Name, ep.SeriesName, season, episode, tmdbId, tvdbId, imdbId ?? "(none)"); + + // Emby numbers an episode in the series' display order — the order the + // metadata fetcher used. TVDB's default order is what TheIntroDB stores, + // so a series with any other order has to say which one its numbers are + // in, or the API answers a different episode (issue #31: Futurama under + // `alternate`, where S07E01 is default S06E01). + mapping = GetDisplayOrder(ep.Series); + + // The episode's own TVDB id pins one exact record. Emby stores provider + // ids per item, and which id lands on an episode varies by fetcher, so + // it is only used as a pin when it is distinct from the series id. + tvdbEpisodeId = GetTvdbEpisodeId(ep, tvdbId); + + _logger.Debug("Episode: Name={0}, Series={1}, S{2}E{3}, TmdbId={4}, TvdbId={5}, ImdbId={6}, Order={7}, TvdbEpisodeId={8}", + item.Name, ep.SeriesName, season, episode, tmdbId, tvdbId, imdbId ?? "(none)", mapping ?? "default", tvdbEpisodeId); } else if (item is Video video) { @@ -129,6 +151,16 @@ public async Task GetMediaSegmentsAsync( _logger.Debug("Video: Name={0}, TmdbId={1}, TvdbId={2}, ImdbId={3}, Season={4}, Episode={5}", item.Name, tmdbId, tvdbId, imdbId ?? "(none)", season, episode); } + // A non-default display order can only be honoured with a TVDB identity: a + // tmdb_id request is always read as TMDB numbering, so answering would look + // right and be another episode's data. No segments beats wrong segments. + if (!isMovie && season.HasValue && episode.HasValue && mapping != null && (!tvdbId.HasValue || tvdbId.Value <= 0)) + { + var series = (item as Episode)?.Series; + LogOrderSkipOnce(series == null ? itemId : series.Id, series == null ? item.Name : series.Name); + return SegmentFetchResult.NotAttempted(); + } + // Daily usage budget exhausted: the client parks until the bucket // rolls over, so nothing would be sent. Skip before the fetch path // instead of warning once per item in the scan. @@ -157,15 +189,15 @@ public async Task GetMediaSegmentsAsync( _logger.Debug("Segment toggles: EnableIntro={0}, EnableRecap={1}, EnableCredits={2}, EnablePreview={3}, IgnoreMediaWithExistingSegments={4}", config.EnableIntro, config.EnableRecap, config.EnableCredits, config.EnablePreview, config.IgnoreMediaWithExistingSegments); - var lookupKey = BuildNotFoundKey(isMovie, tmdbId, tvdbId, imdbId, season, episode); + var lookupKey = BuildNotFoundKey(isMovie, tmdbId, tvdbId, imdbId, season, episode, mapping, tvdbEpisodeId); if (TheIntroDbNotFoundCache.Instance.TryGetHit(lookupKey)) { _logger.Debug("Skipping {0}: known not found in TheIntroDB (cached 404)", item.Name); return SegmentFetchResult.NotAttempted(); } - _logger.Info("Fetching from TheIntroDB API: tmdbId={0}, tvdbId={1}, imdbId={2}, isMovie={3}, season={4}, episode={5}", - tmdbId, tvdbId, imdbId, isMovie, season, episode); + _logger.Info("Fetching from TheIntroDB API: tmdbId={0}, tvdbId={1}, imdbId={2}, isMovie={3}, season={4}, episode={5}, order={6}, tvdbEpisodeId={7}", + tmdbId, tvdbId, imdbId, isMovie, season, episode, mapping ?? "default", tvdbEpisodeId); var client = new TheIntroDbClient(_httpClient, Plugin.Instance, _logger); long? durationMs = item.RunTimeTicks.HasValue && item.RunTimeTicks.Value > 0 ? @@ -173,7 +205,7 @@ public async Task GetMediaSegmentsAsync( (long?)null; var mediaResult = await client.GetMediaAsync( tmdbId, tvdbId, imdbId, isMovie, season, episode, - durationMs, cancellationToken, trackUsage).ConfigureAwait(false); + durationMs, cancellationToken, trackUsage, mapping, tvdbEpisodeId).ConfigureAwait(false); if (mediaResult.IsNotFound) { @@ -278,26 +310,119 @@ public bool Supports(BaseItem item) return supported; } - private static string BuildNotFoundKey(bool isMovie, int? tmdbId, int? tvdbId, string imdbId, int? season, int? episode) + internal static string BuildNotFoundKey(bool isMovie, int? tmdbId, int? tvdbId, string imdbId, int? season, int? episode, string mapping, long? tvdbEpisodeId) { var type = isMovie ? "movie" : "episode"; string idPart; - if (tmdbId.HasValue && tmdbId.Value > 0) + if (tvdbId.HasValue && tvdbId.Value > 0) { - idPart = "tmdb:" + tmdbId.Value; + idPart = "tvdb:" + tvdbId.Value; } - else if (tvdbId.HasValue && tvdbId.Value > 0) + else if (tmdbId.HasValue && tmdbId.Value > 0) { - idPart = "tvdb:" + tvdbId.Value; + idPart = "tmdb:" + tmdbId.Value; } else { idPart = "imdb:" + imdbId; } - return isMovie - ? type + ":" + idPart - : type + ":" + idPart + ":" + season + ":" + episode; + if (isMovie) + { + return type + ":" + idPart; + } + + // The order is part of the address: "default" S6E1 and "dvd" S6E1 are + // different episodes, so a miss recorded for one must not answer for the + // other. The pinned episode id is part of it too — it outranks the numbers + // when it is sent, so a 404 recorded against one id must not suppress a + // later lookup whose id has been corrected. + var orderPart = string.IsNullOrWhiteSpace(mapping) ? string.Empty : ":" + mapping; + var episodeIdPart = tvdbEpisodeId.HasValue && tvdbEpisodeId.Value > 0 ? ":ep" + tvdbEpisodeId.Value : string.Empty; + return type + ":" + idPart + ":" + season + ":" + episode + orderPart + episodeIdPart; + } + + /// + /// Reads the series' display order as an explicit TVDB order name, or null when + /// the series numbers its episodes in the default (aired) order. + /// + /// + /// Emby's Series.DisplayOrder is an enum with three values (Aired, Dvd, + /// Absolute), not a free-form string like Jellyfin's. Aired is the ordinary + /// order — the one TheIntroDB stores — so it is reported as no preference and + /// costs no conversion. Emby cannot express alternate/regional ordering at all. + /// + /// The series the episode belongs to. + /// The order name to send as mapping, or null. + internal static string GetDisplayOrder(Series series) + { + if (series == null) + { + return null; + } + + switch (series.DisplayOrder) + { + case SeriesDisplayOrder.Dvd: + return "dvd"; + case SeriesDisplayOrder.Absolute: + return "absolute"; + default: + return null; + } + } + + /// + /// Returns the episode's own TVDB id, when it carries one that is distinct from + /// the series id. + /// + /// + /// Emby populates provider ids per item and which id lands on an Episode depends + /// on the fetcher: some store the episode record, others repeat the series id. + /// Only the former is a useful pin, and sending the series id as an episode id + /// would make the API look for an episode that does not exist. + /// + /// The episode. + /// The series' TVDB id, already resolved. + /// The episode id to pin, or null. + private static long? GetTvdbEpisodeId(BaseItem episode, int? seriesTvdbId) + { + if (episode?.ProviderIds == null) + { + return null; + } + + var id = GetProviderId(episode, "Tvdb"); + long parsed; + if (string.IsNullOrWhiteSpace(id) + || !long.TryParse(id, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out parsed) + || parsed <= 0) + { + return null; + } + + if (seriesTvdbId.HasValue && seriesTvdbId.Value > 0 && parsed == seriesTvdbId.Value) + { + return null; + } + + return parsed; + } + + /// + /// Logs the "non-default order without a TVDB id" skip once per series rather + /// than once per episode, which would be hundreds of lines on a large show. + /// + /// Emby id of the item being skipped. + /// Item name, for the log line. + private void LogOrderSkipOnce(Guid itemId, string name) + { + if (_orderSkipsLogged.TryAdd(itemId, 0)) + { + _logger.Warn( + "Skipping {0}: the series uses a non-default display order, but Emby carries no TVDB id to express it against. Add TVDB metadata to the series so the API can convert the episode numbers.", + name); + } } private static int? GetTmdbId(BaseItem item) diff --git a/TheIntroDB/TheIntroDB.csproj b/TheIntroDB/TheIntroDB.csproj index 84e06d2..973341f 100644 --- a/TheIntroDB/TheIntroDB.csproj +++ b/TheIntroDB/TheIntroDB.csproj @@ -26,4 +26,8 @@ + + + +