Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
111 changes: 111 additions & 0 deletions TheIntroDB.Tests/V4MigrationTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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));
}
}
}
8 changes: 5 additions & 3 deletions TheIntroDB/Api/MediaResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ namespace TheIntroDB.Api
public class MediaResponse
{
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("tmdb_id")]
public int TmdbId { get; set; }
[JsonPropertyName("tvdb_id")]
public int? TvdbId { get; set; }

/// <summary>
/// Gets or sets the type: "movie" or "tv".
Expand Down
4 changes: 3 additions & 1 deletion TheIntroDB/Api/TheIntroDbApiKeyValidationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiKeyValidationResponse> ValidateAsync(string apiKey, CancellationToken cancellationToken)
Expand Down
142 changes: 104 additions & 38 deletions TheIntroDB/Api/TheIntroDbClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// 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.
/// </summary>
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);
Expand Down Expand Up @@ -48,7 +62,9 @@ public async Task<MediaFetchResult> GetMediaAsync(
int? episode,
long? durationMs,
CancellationToken cancellationToken,
bool trackUsage = true)
bool trackUsage = true,
string mapping = null,
long? tvdbEpisodeId = null)
{
if (IsDailyUsageExhausted(_logger))
{
Expand All @@ -69,7 +85,6 @@ public async Task<MediaFetchResult> GetMediaAsync(
}

var config = _plugin.Configuration ?? new PluginConfiguration();
const string baseUrl = "https://api.theintrodb.org/v3";

var tmdbIdValue = tmdbId.GetValueOrDefault();
var hasTmdb = tmdbIdValue > 0;
Expand All @@ -95,41 +110,18 @@ public async Task<MediaFetchResult> 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;
for (var attempt = 0; attempt < maxRetries; attempt++)
{
using (var request = new HttpRequestMessage(HttpMethod.Get, requestUri))
{
if (!string.IsNullOrWhiteSpace(config.ApiKey))
if (hasApiKey)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.ApiKey.Trim());
}
Expand All @@ -141,7 +133,7 @@ public async Task<MediaFetchResult> 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);
Expand Down Expand Up @@ -338,6 +330,77 @@ public async Task<MediaFetchResult> GetMediaAsync(
return MediaFetchResult.Error();
}

/// <summary>
/// Builds the query string for a GET /media request.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="tmdbId">TMDB id, 0 when unknown.</param>
/// <param name="tvdbId">TVDB id, 0 when unknown.</param>
/// <param name="imdbId">IMDB id, null when unknown.</param>
/// <param name="isMovie">True for a movie (no season/episode).</param>
/// <param name="season">Season number, in <paramref name="mapping"/>'s order.</param>
/// <param name="episode">Episode number, in <paramref name="mapping"/>'s order.</param>
/// <param name="durationMs">Optional video duration, used to pick a release version.</param>
/// <param name="mapping">Episode order for a TVDB-addressed request; null for the default order.</param>
/// <param name="tvdbEpisodeId">Optional TVDB episode id pinning one exact episode record.</param>
/// <returns>The query string, starting with '?'.</returns>
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<string>(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,
Expand Down Expand Up @@ -520,10 +583,13 @@ private static void UpdateRateWindowFromHeaders(HttpResponseHeaders headers)
}

/// <summary>
/// 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.
/// </summary>
private static async Task WaitForRateLimitAsync(CancellationToken cancellationToken)
/// <param name="minimumDelay">Spacing between request starts for the caller's rate-limit tier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
private static async Task WaitForRateLimitAsync(TimeSpan minimumDelay, CancellationToken cancellationToken)
{
await RateLimitLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
Expand All @@ -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;
Expand Down
Loading
Loading