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>
349 lines
15 KiB
C#
349 lines
15 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Data.Enums;
|
|
using Jellyfin.Database.Implementations.Entities;
|
|
using MediaBrowser.Controller.Entities;
|
|
using MediaBrowser.Controller.Entities.Movies;
|
|
using MediaBrowser.Controller.Entities.TV;
|
|
using MediaBrowser.Controller.Library;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
|
{
|
|
public class TrailerIntroProvider : IIntroProvider
|
|
{
|
|
private static readonly Random _rng = new();
|
|
|
|
// Per-user set of trailer IDs already shown in the current cycle.
|
|
// Resets automatically once every trailer in the active pool has been seen.
|
|
private static readonly ConcurrentDictionary<Guid, HashSet<Guid>> _seenByUser = new();
|
|
private static readonly object _seenLock = new();
|
|
|
|
// Per-user date of the last episode that received trailers, so only the
|
|
// first episode a user watches each day gets them.
|
|
private static readonly ConcurrentDictionary<Guid, DateTime> _lastEpisodeTrailerDateByUser = new();
|
|
|
|
private readonly ILibraryManager _libraryManager;
|
|
private readonly ILogger<TrailerIntroProvider> _logger;
|
|
|
|
public string Name => "CinemaTrailers4Jellyfins";
|
|
|
|
public TrailerIntroProvider(ILibraryManager libraryManager, ILogger<TrailerIntroProvider> logger)
|
|
{
|
|
_libraryManager = libraryManager;
|
|
_logger = logger;
|
|
}
|
|
|
|
public Task<IEnumerable<IntroInfo>> GetIntros(BaseItem item, User user)
|
|
{
|
|
var config = Plugin.Instance?.Configuration;
|
|
if (config == null)
|
|
return Task.FromResult(Enumerable.Empty<IntroInfo>());
|
|
|
|
var trailersEnabled = config.TrailersPerMovie > 0;
|
|
var preRollEnabled = !string.IsNullOrEmpty(config.TrailerPreRollLibraryId);
|
|
var featurePreRollEnabled = !string.IsNullOrEmpty(config.FeaturePreRollLibraryId);
|
|
|
|
_logger.LogInformation(
|
|
"|CinemaTrailers4Jellyfins| GetIntros called for {Type} '{Name}' (Path={Path}). "
|
|
+ "trailersEnabled={Trailers} preRollEnabled={PreRoll} featurePreRollEnabled={FeaturePreRoll}",
|
|
item.GetType().Name,
|
|
item.Name,
|
|
item.Path,
|
|
trailersEnabled,
|
|
preRollEnabled,
|
|
featurePreRollEnabled);
|
|
|
|
if (!trailersEnabled && !preRollEnabled && !featurePreRollEnabled)
|
|
return Task.FromResult(Enumerable.Empty<IntroInfo>());
|
|
|
|
// The item used for genre/rating filtering. For episodes this is the
|
|
// parent series, since episodes rarely carry their own genre metadata.
|
|
BaseItem feature;
|
|
bool isEpisode;
|
|
|
|
switch (item)
|
|
{
|
|
case Movie:
|
|
feature = item;
|
|
isEpisode = false;
|
|
break;
|
|
|
|
case Episode episode:
|
|
if (!config.TrailersForEpisodes)
|
|
{
|
|
_logger.LogInformation("|CinemaTrailers4Jellyfins| Skipping: TrailersForEpisodes is disabled.");
|
|
return Task.FromResult(Enumerable.Empty<IntroInfo>());
|
|
}
|
|
|
|
if (!IsFirstEpisodeOfDay(user.Id))
|
|
{
|
|
_logger.LogInformation("|CinemaTrailers4Jellyfins| Skipping: not the first episode of the day for this user.");
|
|
return Task.FromResult(Enumerable.Empty<IntroInfo>());
|
|
}
|
|
|
|
feature = episode.Series ?? (BaseItem)episode;
|
|
isEpisode = true;
|
|
break;
|
|
|
|
default:
|
|
_logger.LogInformation("|CinemaTrailers4Jellyfins| Skipping: item type {Type} is not a Movie or Episode.", item.GetType().Name);
|
|
return Task.FromResult(Enumerable.Empty<IntroInfo>());
|
|
}
|
|
|
|
var outputFolder = config.DownloadFolder?.TrimEnd(
|
|
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
|
|
|
// Break recursion: don't inject intros before items from our own output folder
|
|
// (covers both the fake-movie files and their trailer extras).
|
|
if (!string.IsNullOrEmpty(outputFolder)
|
|
&& item.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true)
|
|
{
|
|
_logger.LogInformation("|CinemaTrailers4Jellyfins| Skipping: item path is inside the output folder ({Folder}).", outputFolder);
|
|
return Task.FromResult(Enumerable.Empty<IntroInfo>());
|
|
}
|
|
|
|
var intros = new List<IntroInfo>();
|
|
|
|
if (preRollEnabled)
|
|
{
|
|
var preRoll = GetRandomLibraryMovieIntro("Trailer Pre-Roll", config.TrailerPreRollLibraryId, item.Id, user);
|
|
if (preRoll != null)
|
|
intros.Add(preRoll);
|
|
}
|
|
|
|
if (trailersEnabled && !string.IsNullOrEmpty(outputFolder))
|
|
{
|
|
// Server-scoped query so hidden libraries don't block trailer discovery.
|
|
var pairs = _libraryManager
|
|
.GetItemList(new InternalItemsQuery
|
|
{
|
|
IncludeItemTypes = new[] { BaseItemKind.Movie },
|
|
Recursive = true,
|
|
})
|
|
.OfType<Movie>()
|
|
.Where(m =>
|
|
m.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true
|
|
&& m.LocalTrailers.Count > 0
|
|
// Skip items the user can't actually play (e.g. library not in their access list) —
|
|
// some clients hang indefinitely trying to play an inaccessible item.
|
|
&& m.IsVisibleStandalone(user)
|
|
// Keep TV show trailers for episodes and movie trailers for movies separate.
|
|
&& m.Tags.Contains(TrailerTags.TvShow, StringComparer.OrdinalIgnoreCase) == isEpisode)
|
|
.SelectMany(m => m.LocalTrailers.Select(t => (Movie: m, Trailer: t)))
|
|
.ToList();
|
|
|
|
if (pairs.Count == 0)
|
|
{
|
|
_logger.LogDebug(
|
|
"|CinemaTrailers4Jellyfins| No indexed {Kind} trailers in {Folder}. "
|
|
+ "Ensure the output folder is added as a Jellyfin Movies library and scanned.",
|
|
isEpisode ? "TV show" : "movie",
|
|
outputFolder);
|
|
}
|
|
else
|
|
{
|
|
// Anime trailers only play before anime. Applied before the fallback below, so
|
|
// non-anime content never gets one — it gets no trailer instead. Western animation
|
|
// is not affected and follows the normal filters.
|
|
var eligible = IsAnimeFeature(feature)
|
|
? pairs
|
|
: pairs.Where(p => !IsAnimeTrailer(p.Movie)).ToList();
|
|
|
|
// Apply enabled filters. If nothing survives, fall back to the eligible pool.
|
|
var filtered = ApplyFilters(eligible, feature, config);
|
|
var pool = filtered.Count > 0 ? filtered : eligible;
|
|
|
|
// Prefer unseen trailers within the pool; reset the cycle when all have been shown.
|
|
pool = ApplyAvoidRepeats(pool, user.Id, config.AvoidRepeats);
|
|
|
|
var selected = pool
|
|
.OrderBy(_ => _rng.Next())
|
|
.Take(config.TrailersPerMovie)
|
|
.ToList();
|
|
|
|
if (config.AvoidRepeats)
|
|
MarkSeen(user.Id, selected.Select(p => p.Trailer.Id));
|
|
|
|
intros.AddRange(selected.Select(p => new IntroInfo { ItemId = p.Trailer.Id, Path = p.Trailer.Path }));
|
|
}
|
|
}
|
|
|
|
if (featurePreRollEnabled)
|
|
{
|
|
var featureRoll = GetRandomLibraryMovieIntro("Feature Pre-Roll", config.FeaturePreRollLibraryId, item.Id, user);
|
|
if (featureRoll != null)
|
|
intros.Add(featureRoll);
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"|CinemaTrailers4Jellyfins| Returning {Count} intro(s): {Paths}",
|
|
intros.Count,
|
|
string.Join(", ", intros.Select(i => i.Path)));
|
|
|
|
return Task.FromResult<IEnumerable<IntroInfo>>(intros);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Picks a random Movie from the given Jellyfin library (VirtualFolder ItemId) to use as a
|
|
/// pre-roll/post-roll bumper, excluding the item currently being played.
|
|
/// </summary>
|
|
private IntroInfo? GetRandomLibraryMovieIntro(string label, string libraryId, Guid excludeId, User user)
|
|
{
|
|
if (!Guid.TryParse(libraryId, out var parsedId))
|
|
{
|
|
_logger.LogWarning(
|
|
"|CinemaTrailers4Jellyfins| {Label} library ID '{LibraryId}' is not a valid GUID — check the plugin settings.",
|
|
label,
|
|
libraryId);
|
|
return null;
|
|
}
|
|
|
|
if (_libraryManager.GetItemById(parsedId) is not Folder folder)
|
|
{
|
|
_logger.LogWarning(
|
|
"|CinemaTrailers4Jellyfins| {Label} library {LibraryId} could not be found.",
|
|
label,
|
|
parsedId);
|
|
return null;
|
|
}
|
|
|
|
var movies = folder.GetRecursiveChildren()
|
|
.OfType<Movie>()
|
|
.Where(m => m.Id != excludeId && m.IsVisibleStandalone(user))
|
|
.ToList();
|
|
|
|
if (movies.Count == 0)
|
|
{
|
|
_logger.LogInformation(
|
|
"|CinemaTrailers4Jellyfins| {Label} library {LibraryId} has no eligible Movie items visible to this user. "
|
|
+ "Ensure the library has been scanned, contains at least one other Movie, and is in this user's library access list.",
|
|
label,
|
|
parsedId);
|
|
return null;
|
|
}
|
|
|
|
var movie = movies[_rng.Next(movies.Count)];
|
|
_logger.LogInformation(
|
|
"|CinemaTrailers4Jellyfins| {Label}: picked '{Title}' ({Path}).",
|
|
label,
|
|
movie.Name,
|
|
movie.Path);
|
|
return new IntroInfo { ItemId = movie.Id, Path = movie.Path };
|
|
}
|
|
|
|
// Library items: anime providers (e.g. AniDB) set the "Anime" genre; TMDB-scraped items
|
|
// carry TMDB keywords as tags ("anime", "based on manga"). "Animation" alone is not enough —
|
|
// it also covers Western animation.
|
|
private static readonly string[] AnimeFeatureTags = { "anime", "based on manga" };
|
|
|
|
private static bool IsAnimeFeature(BaseItem item)
|
|
=> item.Genres.Contains("Anime", StringComparer.OrdinalIgnoreCase)
|
|
|| item.Tags.Any(t => AnimeFeatureTags.Contains(t, StringComparer.OrdinalIgnoreCase));
|
|
|
|
// Trailers are tagged at download time from TMDB data (see TmdbService.IsAnime).
|
|
private static bool IsAnimeTrailer(BaseItem item)
|
|
=> item.Tags.Contains(TrailerTags.Anime, StringComparer.OrdinalIgnoreCase)
|
|
|| item.Genres.Contains("Anime", StringComparer.OrdinalIgnoreCase);
|
|
|
|
private static List<(Movie Movie, BaseItem Trailer)> ApplyFilters(
|
|
List<(Movie Movie, BaseItem Trailer)> pairs,
|
|
BaseItem feature,
|
|
Configuration.PluginConfiguration config)
|
|
{
|
|
var result = pairs;
|
|
|
|
if (config.FilterByGenre && feature.Genres.Length > 0)
|
|
{
|
|
var featureGenres = new HashSet<string>(feature.Genres, StringComparer.OrdinalIgnoreCase);
|
|
result = result
|
|
.Where(p => p.Movie.Genres.Any(g => featureGenres.Contains(g)))
|
|
.ToList();
|
|
}
|
|
|
|
if (config.FilterByRating)
|
|
{
|
|
result = result
|
|
.Where(p =>
|
|
// Unrated trailer: no restriction — include it.
|
|
p.Movie.InheritedParentalRatingValue == null
|
|
// Unrated feature: can't determine a ceiling — include everything.
|
|
|| feature.InheritedParentalRatingValue == null
|
|
// Trailer rating must be at most the feature's rating.
|
|
|| p.Movie.InheritedParentalRatingValue <= feature.InheritedParentalRatingValue)
|
|
.ToList();
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static List<(Movie Movie, BaseItem Trailer)> ApplyAvoidRepeats(
|
|
List<(Movie Movie, BaseItem Trailer)> pool,
|
|
Guid userId,
|
|
bool enabled)
|
|
{
|
|
if (!enabled)
|
|
return pool;
|
|
|
|
lock (_seenLock)
|
|
{
|
|
var seen = _seenByUser.GetOrAdd(userId, _ => new HashSet<Guid>());
|
|
var unseen = pool.Where(p => !seen.Contains(p.Trailer.Id)).ToList();
|
|
|
|
if (unseen.Count > 0)
|
|
return unseen;
|
|
|
|
// All trailers in this pool have been seen — reset the cycle.
|
|
foreach (var p in pool)
|
|
seen.Remove(p.Trailer.Id);
|
|
|
|
return pool.ToList();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true (and records today's date) only the first time this is called
|
|
/// for a given user on a given day.
|
|
/// </summary>
|
|
private static bool IsFirstEpisodeOfDay(Guid userId)
|
|
{
|
|
var today = DateTime.Now.Date;
|
|
var isFirst = false;
|
|
|
|
_lastEpisodeTrailerDateByUser.AddOrUpdate(
|
|
userId,
|
|
_ =>
|
|
{
|
|
isFirst = true;
|
|
return today;
|
|
},
|
|
(_, lastDate) =>
|
|
{
|
|
if (lastDate < today)
|
|
{
|
|
isFirst = true;
|
|
return today;
|
|
}
|
|
|
|
return lastDate;
|
|
});
|
|
|
|
return isFirst;
|
|
}
|
|
|
|
private static void MarkSeen(Guid userId, IEnumerable<Guid> trailerIds)
|
|
{
|
|
lock (_seenLock)
|
|
{
|
|
var seen = _seenByUser.GetOrAdd(userId, _ => new HashSet<Guid>());
|
|
foreach (var id in trailerIds)
|
|
seen.Add(id);
|
|
}
|
|
}
|
|
}
|
|
}
|