From 172f27310e4ae87c75b541c990f262a30a48c346 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:31:14 -0600 Subject: [PATCH 1/2] feat: move to TheIntroDB API v4 with episode-order aware lookups The API's v4 surface keys every response by tvdb_id and treats TVDB as the media's identity. Address by tvdb_id first (tmdb_id and imdb_id still resolve), and read the identity from the response DTO instead of tmdb_id. Episode lookups now carry the series' display order, so a series whose episodes are numbered in DVD or absolute order is resolved by the numbers Emby actually has. Previously a TMDB-addressed series with a non-default order was answered with another episode's segments, silently (Jellyfin watched this happen on Futurama under 'alternate': S07E01 answered default S06E01, whose intro ends at 29.3s instead of 14.6s). Emby's Series.DisplayOrder is an enum rather than Jellyfin's free-form string, so Aired maps to no preference and Dvd/Absolute map to their TVDB order names. Emby cannot express alternate or regional ordering. The episode's own TVDB id is sent as tvdb_episode_id when it differs from the series id. Emby populates provider ids per item and some fetchers repeat the series id on episodes, which the API would read as a non-existent episode; the episode id used to be passed as tvdb_id in that case, which the API reads as a series id. A series with a non-default order but no TVDB id is skipped and logged once - no other id can express the order. Rate limits went up with v4: /media is 120 requests / 10s and 100,000 lookups per day for signed-in callers (was 30 and 1,000) and 5,000 lookups per day anonymous. Pacing now follows the tier in use instead of always assuming the anonymous ceiling. The API key probe moves to /v4/user/stats: v2 and v3 are frozen at GET /media and /v3/user/stats now 404s, which silently disabled key validation. --- README.md | 10 +- TheIntroDB.Tests/V4MigrationTests.cs | 94 +++++++++++ TheIntroDB/Api/MediaResponse.cs | 8 +- .../Api/TheIntroDbApiKeyValidationService.cs | 4 +- TheIntroDB/Api/TheIntroDbClient.cs | 142 +++++++++++----- TheIntroDB/Configuration/configPage.html | 4 +- .../Providers/TheIntroDbSegmentProvider.cs | 152 ++++++++++++++++-- TheIntroDB/TheIntroDB.csproj | 4 + 8 files changed, 354 insertions(+), 64 deletions(-) create mode 100644 TheIntroDB.Tests/V4MigrationTests.cs 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..fec1923 --- /dev/null +++ b/TheIntroDB.Tests/V4MigrationTests.cs @@ -0,0 +1,94 @@ +using System; +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)); + } + } +} 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..6af0ca9 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,15 @@ 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)) + { + LogOrderSkipOnce(itemId, item.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 +188,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); 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 +204,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 +309,115 @@ public bool Supports(BaseItem item) return supported; } - private static string BuildNotFoundKey(bool isMovie, int? tmdbId, int? tvdbId, string imdbId, int? season, int? episode) + private static string BuildNotFoundKey(bool isMovie, int? tmdbId, int? tvdbId, string imdbId, int? season, int? episode, string mapping) { 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 "alternate" S6E1 are + // different episodes, so a miss recorded for one must not answer for the other. + var orderPart = string.IsNullOrWhiteSpace(mapping) ? string.Empty : ":" + mapping; + return type + ":" + idPart + ":" + season + ":" + episode + orderPart; + } + + /// + /// 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 @@ + + + + From 2d96af69fb44e4493adbd8a1c7860dd7fbcc89a3 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:36:07 -0600 Subject: [PATCH 2/2] fix: address review findings on the v4 lookup path - Key the order-skip warning by the series id, not the episode id, so a large non-default-order series logs one warning instead of one per episode. - Include the pinned TVDB episode id in the not-found cache key: the id outranks season/episode when sent, so a 404 recorded against one id could suppress a later lookup whose id had been corrected within the TTL. The unsupported-display-order finding does not apply here: Emby's Series.DisplayOrder is an enum (Aired, Dvd, Absolute), so no value can reach the API under a name it does not know. --- TheIntroDB.Tests/V4MigrationTests.cs | 17 +++++++++++++++++ .../Providers/TheIntroDbSegmentProvider.cs | 17 +++++++++++------ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/TheIntroDB.Tests/V4MigrationTests.cs b/TheIntroDB.Tests/V4MigrationTests.cs index fec1923..8b0e23f 100644 --- a/TheIntroDB.Tests/V4MigrationTests.cs +++ b/TheIntroDB.Tests/V4MigrationTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Model.Entities; using TheIntroDB.Api; @@ -90,5 +91,21 @@ 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/Providers/TheIntroDbSegmentProvider.cs b/TheIntroDB/Providers/TheIntroDbSegmentProvider.cs index 6af0ca9..8df7b76 100644 --- a/TheIntroDB/Providers/TheIntroDbSegmentProvider.cs +++ b/TheIntroDB/Providers/TheIntroDbSegmentProvider.cs @@ -156,7 +156,8 @@ public async Task GetMediaSegmentsAsync( // 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)) { - LogOrderSkipOnce(itemId, item.Name); + var series = (item as Episode)?.Series; + LogOrderSkipOnce(series == null ? itemId : series.Id, series == null ? item.Name : series.Name); return SegmentFetchResult.NotAttempted(); } @@ -188,7 +189,7 @@ 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, mapping); + 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); @@ -309,7 +310,7 @@ public bool Supports(BaseItem item) return supported; } - private static string BuildNotFoundKey(bool isMovie, int? tmdbId, int? tvdbId, string imdbId, int? season, int? episode, string mapping) + 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; @@ -331,10 +332,14 @@ private static string BuildNotFoundKey(bool isMovie, int? tmdbId, int? tvdbId, s return type + ":" + idPart; } - // The order is part of the address: "default" S6E1 and "alternate" S6E1 are - // different episodes, so a miss recorded for one must not answer for the other. + // 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; - return type + ":" + idPart + ":" + season + ":" + episode + orderPart; + var episodeIdPart = tvdbEpisodeId.HasValue && tvdbEpisodeId.Value > 0 ? ":ep" + tvdbEpisodeId.Value : string.Empty; + return type + ":" + idPart + ":" + season + ":" + episode + orderPart + episodeIdPart; } ///