All checks were successful
Publish Release / release (push) Successful in 9s
v1.0.0.11 kept every animated trailer away from non-animated content, which also blocked Western animation (Pixar, Disney...). Only anime should be restricted. At download, a trailer is tagged ct4j-anime when TMDB has the "anime" or "based on manga" keyword, or it is Animation in ja/zh/ko (keywords are now appended to the details request). At playback, anime trailers are dropped unless the feature is anime (genre "Anime", or those keywords as tags). Western animation follows the normal genre/rating filters again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
411 lines
18 KiB
C#
411 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Sockets;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
|
{
|
|
public record TmdbVideo(string Key, string Name, string Language, bool Official, int Size);
|
|
|
|
public record TmdbMediaResult(int Id, string Title, string ReleaseDate)
|
|
{
|
|
public int? Year => DateTime.TryParse(ReleaseDate, out var d) ? d.Year : (int?)null;
|
|
}
|
|
|
|
public record TmdbMediaMetadata(IReadOnlyList<string> Genres, string? Certification, bool IsAnime);
|
|
|
|
public class TmdbService : IDisposable
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ILogger<TmdbService> _logger;
|
|
private const string BaseUrl = "https://api.themoviedb.org/3";
|
|
|
|
public TmdbService(ILogger<TmdbService> logger)
|
|
{
|
|
_logger = logger;
|
|
|
|
// Force IPv4 to avoid ~80s delay when IPv6 is unreachable (Happy Eyeballs fallback).
|
|
var handler = new SocketsHttpHandler
|
|
{
|
|
ConnectCallback = async (ctx, ct) =>
|
|
{
|
|
var entry = await Dns.GetHostEntryAsync(ctx.DnsEndPoint.Host, AddressFamily.InterNetwork, ct).ConfigureAwait(false);
|
|
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
socket.NoDelay = true;
|
|
try
|
|
{
|
|
await socket.ConnectAsync(entry.AddressList[0], ctx.DnsEndPoint.Port, ct).ConfigureAwait(false);
|
|
return new NetworkStream(socket, ownsSocket: true);
|
|
}
|
|
catch
|
|
{
|
|
socket.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
};
|
|
_httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
|
}
|
|
|
|
public void Dispose() => _httpClient.Dispose();
|
|
|
|
// JWT Read Access Tokens start with "eyJ"; v3 short keys (32 hex chars) use ?api_key=.
|
|
private static void ApplyAuth(HttpRequestMessage request, string apiKey)
|
|
{
|
|
if (apiKey.StartsWith("eyJ", StringComparison.Ordinal))
|
|
{
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
|
}
|
|
else
|
|
{
|
|
var uri = request.RequestUri!.ToString();
|
|
var separator = uri.Contains('?') ? "&" : "?";
|
|
request.RequestUri = new Uri($"{uri}{separator}api_key={apiKey}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fetches candidate movies from the configured TMDB sources, optionally filtered
|
|
/// by a minimum release date. Deduplicates across sources by TMDB ID.
|
|
/// </summary>
|
|
public Task<List<TmdbMediaResult>> GetCandidateMoviesAsync(
|
|
Configuration.PluginConfiguration config,
|
|
CancellationToken ct)
|
|
{
|
|
var sources = new List<string>();
|
|
if (config.SourceNowPlaying) sources.Add("now_playing");
|
|
if (config.SourceUpcoming) sources.Add("upcoming");
|
|
if (config.SourcePopular) sources.Add("popular");
|
|
if (config.SourceTopRated) sources.Add("top_rated");
|
|
|
|
return GetCandidatesAsync("movie", sources, config, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fetches candidate TV shows from the configured TMDB sources, optionally filtered
|
|
/// by a minimum first-air date. Deduplicates across sources by TMDB ID.
|
|
/// </summary>
|
|
public Task<List<TmdbMediaResult>> GetCandidateTvShowsAsync(
|
|
Configuration.PluginConfiguration config,
|
|
CancellationToken ct)
|
|
{
|
|
var sources = new List<string>();
|
|
if (config.SourceTvAiringToday) sources.Add("airing_today");
|
|
if (config.SourceTvOnTheAir) sources.Add("on_the_air");
|
|
if (config.SourceTvPopular) sources.Add("popular");
|
|
if (config.SourceTvTopRated) sources.Add("top_rated");
|
|
|
|
return GetCandidatesAsync("tv", sources, config, ct);
|
|
}
|
|
|
|
private async Task<List<TmdbMediaResult>> GetCandidatesAsync(
|
|
string mediaType,
|
|
List<string> sourceEndpoints,
|
|
Configuration.PluginConfiguration config,
|
|
CancellationToken ct)
|
|
{
|
|
DateTime? releasedAfter = config.ReleaseDateRangeMonths > 0
|
|
? DateTime.UtcNow.AddMonths(-config.ReleaseDateRangeMonths)
|
|
: null;
|
|
|
|
var seen = new HashSet<int>();
|
|
var results = new List<TmdbMediaResult>();
|
|
|
|
foreach (var endpoint in sourceEndpoints)
|
|
{
|
|
var items = await FetchSourcePagesAsync(mediaType, endpoint, config.TmdbApiKey, releasedAfter, config.MaxPagesPerSource, ct)
|
|
.ConfigureAwait(false);
|
|
|
|
foreach (var item in items)
|
|
{
|
|
if (seen.Add(item.Id))
|
|
results.Add(item);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
private async Task<List<TmdbMediaResult>> FetchSourcePagesAsync(
|
|
string mediaType,
|
|
string endpoint,
|
|
string apiKey,
|
|
DateTime? releasedAfter,
|
|
int maxPages,
|
|
CancellationToken ct)
|
|
{
|
|
var results = new List<TmdbMediaResult>();
|
|
|
|
// Movies use "title"/"release_date"; TV shows use "name"/"first_air_date".
|
|
var titleField = mediaType == "movie" ? "title" : "name";
|
|
var dateField = mediaType == "movie" ? "release_date" : "first_air_date";
|
|
|
|
for (int page = 1; page <= maxPages; page++)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
try
|
|
{
|
|
var url = $"{BaseUrl}/{mediaType}/{endpoint}?language=en-US&page={page}";
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
ApplyAuth(request, apiKey);
|
|
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
|
|
response.EnsureSuccessStatusCode();
|
|
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
|
using var doc = JsonDocument.Parse(json);
|
|
|
|
var pageResults = doc.RootElement.GetProperty("results");
|
|
int totalPages = doc.RootElement.GetProperty("total_pages").GetInt32();
|
|
bool anyInRange = false;
|
|
|
|
foreach (var entry in pageResults.EnumerateArray())
|
|
{
|
|
var releaseDate = entry.TryGetProperty(dateField, out var rd) ? rd.GetString() ?? string.Empty : string.Empty;
|
|
var title = entry.TryGetProperty(titleField, out var t) ? t.GetString() ?? string.Empty : string.Empty;
|
|
var id = entry.GetProperty("id").GetInt32();
|
|
|
|
if (releasedAfter.HasValue && DateTime.TryParse(releaseDate, out var parsed))
|
|
{
|
|
if (parsed < releasedAfter.Value) continue;
|
|
}
|
|
|
|
anyInRange = true;
|
|
results.Add(new TmdbMediaResult(id, title, releaseDate));
|
|
}
|
|
|
|
if (page >= totalPages || (releasedAfter.HasValue && !anyInRange))
|
|
break;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| Failed to fetch TMDB source '{MediaType}/{Endpoint}' page {Page}", mediaType, endpoint, page);
|
|
break;
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
public async Task<string?> SearchMovieAsync(string title, int? year, string apiKey, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var url = $"{BaseUrl}/search/movie?query={Uri.EscapeDataString(title)}&language=en-US";
|
|
if (year.HasValue) url += $"&year={year.Value}";
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
ApplyAuth(request, apiKey);
|
|
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
|
|
response.EnsureSuccessStatusCode();
|
|
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
|
using var doc = JsonDocument.Parse(json);
|
|
var res = doc.RootElement.GetProperty("results");
|
|
if (res.GetArrayLength() > 0)
|
|
return res[0].GetProperty("id").GetInt32().ToString();
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| TMDB search failed for '{Title}'", title);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the genre names and US certification for a movie or TV show
|
|
/// (e.g. PG-13 for movies, TV-14 for TV shows). Movies use a single
|
|
/// /movie/{id}?append_to_response=release_dates call; TV shows use
|
|
/// /tv/{id}?append_to_response=content_ratings.
|
|
/// </summary>
|
|
public async Task<TmdbMediaMetadata> GetMediaMetadataAsync(
|
|
string mediaType,
|
|
string tmdbId,
|
|
string apiKey,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var appendField = mediaType == "movie" ? "release_dates" : "content_ratings";
|
|
var url = $"{BaseUrl}/{mediaType}/{tmdbId}?append_to_response={appendField},keywords&language=en-US";
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
ApplyAuth(request, apiKey);
|
|
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
|
|
response.EnsureSuccessStatusCode();
|
|
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
|
using var doc = JsonDocument.Parse(json);
|
|
|
|
var genres = new List<string>();
|
|
if (doc.RootElement.TryGetProperty("genres", out var genreArr))
|
|
{
|
|
foreach (var g in genreArr.EnumerateArray())
|
|
{
|
|
if (g.TryGetProperty("name", out var name))
|
|
{
|
|
var genreName = name.GetString();
|
|
if (!string.IsNullOrEmpty(genreName))
|
|
genres.Add(genreName);
|
|
}
|
|
}
|
|
}
|
|
|
|
string? certification = mediaType == "movie"
|
|
? GetMovieCertification(doc)
|
|
: GetTvCertification(doc);
|
|
|
|
return new TmdbMediaMetadata(genres.AsReadOnly(), certification, IsAnime(doc, mediaType, genres));
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| GetMediaMetadata failed for {MediaType} TMDB ID {Id}", mediaType, tmdbId);
|
|
return new TmdbMediaMetadata(Array.Empty<string>(), null, false);
|
|
}
|
|
}
|
|
|
|
private static readonly string[] AnimeKeywords = { "anime", "based on manga" };
|
|
private static readonly string[] AnimeLanguages = { "ja", "zh", "ko" };
|
|
|
|
// Anime vs. Western animation: both carry TMDB's "Animation" genre, so use the "anime" /
|
|
// "based on manga" keywords, or Animation made in Japanese, Chinese or Korean.
|
|
private static bool IsAnime(JsonDocument doc, string mediaType, List<string> genres)
|
|
{
|
|
// Movie keywords live under keywords.keywords, TV keywords under keywords.results.
|
|
var listName = mediaType == "movie" ? "keywords" : "results";
|
|
if (doc.RootElement.TryGetProperty("keywords", out var kw)
|
|
&& kw.TryGetProperty(listName, out var list)
|
|
&& list.ValueKind == JsonValueKind.Array
|
|
&& list.EnumerateArray().Any(k =>
|
|
k.TryGetProperty("name", out var n)
|
|
&& AnimeKeywords.Contains(n.GetString(), StringComparer.OrdinalIgnoreCase)))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return genres.Contains("Animation", StringComparer.OrdinalIgnoreCase)
|
|
&& doc.RootElement.TryGetProperty("original_language", out var lang)
|
|
&& AnimeLanguages.Contains(lang.GetString(), StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// Finds the US theatrical certification (G, PG, PG-13, R, NC-17) from release_dates.
|
|
private static string? GetMovieCertification(JsonDocument doc)
|
|
{
|
|
if (!doc.RootElement.TryGetProperty("release_dates", out var releaseDates)
|
|
|| !releaseDates.TryGetProperty("results", out var rdResults))
|
|
return null;
|
|
|
|
foreach (var country in rdResults.EnumerateArray())
|
|
{
|
|
if (!country.TryGetProperty("iso_3166_1", out var iso)
|
|
|| !string.Equals(iso.GetString(), "US", StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
|
|
if (!country.TryGetProperty("release_dates", out var dates))
|
|
return null;
|
|
|
|
foreach (var date in dates.EnumerateArray())
|
|
{
|
|
if (!date.TryGetProperty("certification", out var cert))
|
|
continue;
|
|
var certStr = cert.GetString();
|
|
if (!string.IsNullOrWhiteSpace(certStr))
|
|
return certStr;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Finds the US TV content rating (TV-Y, TV-PG, TV-14, TV-MA, etc.) from content_ratings.
|
|
private static string? GetTvCertification(JsonDocument doc)
|
|
{
|
|
if (!doc.RootElement.TryGetProperty("content_ratings", out var contentRatings)
|
|
|| !contentRatings.TryGetProperty("results", out var crResults))
|
|
return null;
|
|
|
|
foreach (var country in crResults.EnumerateArray())
|
|
{
|
|
if (!country.TryGetProperty("iso_3166_1", out var iso)
|
|
|| !string.Equals(iso.GetString(), "US", StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
|
|
if (!country.TryGetProperty("rating", out var rating))
|
|
return null;
|
|
|
|
var ratingStr = rating.GetString();
|
|
return string.IsNullOrWhiteSpace(ratingStr) ? null : ratingStr;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public async Task<List<TmdbVideo>> GetTrailersAsync(
|
|
string mediaType,
|
|
string tmdbId,
|
|
string apiKey,
|
|
IReadOnlySet<string>? allowedLanguages,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
// No language filter on the URL — we want all available trailers so we can
|
|
// filter by iso_639_1 ourselves based on the user's language preference.
|
|
var url = $"{BaseUrl}/{mediaType}/{tmdbId}/videos";
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
ApplyAuth(request, apiKey);
|
|
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
|
|
response.EnsureSuccessStatusCode();
|
|
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
|
using var doc = JsonDocument.Parse(json);
|
|
|
|
var videos = new List<TmdbVideo>();
|
|
foreach (var result in doc.RootElement.GetProperty("results").EnumerateArray())
|
|
{
|
|
var type = result.GetProperty("type").GetString();
|
|
var site = result.GetProperty("site").GetString();
|
|
if (!string.Equals(type, "Trailer", StringComparison.OrdinalIgnoreCase)
|
|
|| !string.Equals(site, "YouTube", StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
|
|
var key = result.GetProperty("key").GetString();
|
|
if (string.IsNullOrEmpty(key)) continue;
|
|
|
|
var lang = result.TryGetProperty("iso_639_1", out var l) ? (l.GetString() ?? string.Empty) : string.Empty;
|
|
|
|
if (allowedLanguages != null && allowedLanguages.Count > 0 && !allowedLanguages.Contains(lang))
|
|
continue;
|
|
|
|
videos.Add(new TmdbVideo(
|
|
key,
|
|
result.GetProperty("name").GetString() ?? "Trailer",
|
|
lang,
|
|
result.GetProperty("official").GetBoolean(),
|
|
result.GetProperty("size").GetInt32()));
|
|
}
|
|
|
|
return videos
|
|
.OrderByDescending(v => v.Official)
|
|
.ThenByDescending(v => v.Size)
|
|
.ToList();
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| GetTrailers failed for {MediaType} TMDB ID {Id}", mediaType, tmdbId);
|
|
return new List<TmdbVideo>();
|
|
}
|
|
}
|
|
}
|
|
}
|