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.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> _seenByUser = new(); private static readonly object _seenLock = new(); private readonly ILibraryManager _libraryManager; private readonly ILogger _logger; public string Name => "CinemaTrailers4Jellyfins"; public TrailerIntroProvider(ILibraryManager libraryManager, ILogger logger) { _libraryManager = libraryManager; _logger = logger; } public Task> GetIntros(BaseItem item, User user) { var config = Plugin.Instance?.Configuration; if (config == null || config.TrailersPerMovie <= 0) return Task.FromResult(Enumerable.Empty()); if (item is not Movie) return Task.FromResult(Enumerable.Empty()); var outputFolder = config.DownloadFolder?.TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (string.IsNullOrEmpty(outputFolder)) return Task.FromResult(Enumerable.Empty()); // Break recursion: don't inject intros before items from our own output folder // (covers both the fake-movie files and their trailer extras). if (item.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true) return Task.FromResult(Enumerable.Empty()); // Server-scoped query so hidden libraries don't block trailer discovery. var pairs = _libraryManager .GetItemList(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.Movie }, Recursive = true, }) .OfType() .Where(m => m.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true && m.LocalTrailers.Count > 0) .SelectMany(m => m.LocalTrailers.Select(t => (Movie: m, Trailer: t))) .ToList(); if (pairs.Count == 0) { _logger.LogDebug( "|CinemaTrailers4Jellyfins| No indexed trailers in {Folder}. " + "Ensure the output folder is added as a Jellyfin Movies library and scanned.", outputFolder); return Task.FromResult(Enumerable.Empty()); } // Apply enabled filters. If nothing survives, fall back to the full pool. var filtered = ApplyFilters(pairs, item, config); var pool = filtered.Count > 0 ? filtered : pairs; // 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)); var intros = selected.Select(p => new IntroInfo { ItemId = p.Trailer.Id, Path = p.Trailer.Path }); return Task.FromResult>(intros.ToList()); } 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(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()); 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(); } } private static void MarkSeen(Guid userId, IEnumerable trailerIds) { lock (_seenLock) { var seen = _seenByUser.GetOrAdd(userId, _ => new HashSet()); foreach (var id in trailerIds) seen.Add(id); } } } }