diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Configuration/PluginConfiguration.cs index 7b3d1a4..7c6515e 100644 --- a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Configuration/PluginConfiguration.cs @@ -43,5 +43,14 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Configuration /// Number of trailers to inject before each movie via IIntroProvider. 0 = disabled. public int TrailersPerMovie { get; set; } = 1; + + /// Only pick trailers whose genre overlaps with the movie being played. + public bool FilterByGenre { get; set; } = false; + + /// Only pick trailers rated at most the same as the movie being played. + public bool FilterByRating { get; set; } = false; + + /// Cycle through all trailers before repeating any. + public bool AvoidRepeats { get; set; } = true; } } diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Configuration/config.html b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Configuration/config.html index a39f565..e6f1fc5 100644 --- a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Configuration/config.html +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Configuration/config.html @@ -235,6 +235,39 @@ Default: 1. + +
+ +
+ Only pick trailers whose genre overlaps with the movie you are watching. + Falls back to any trailer if no match is found. +
+
+ +
+ +
+ Never show a trailer rated higher than the movie being played. + Falls back to any trailer if no match is found. +
+
+ +
+ +
+ Cycle through all available trailers before playing any again. + Resets automatically once every trailer has been shown. Default: on. +
+
@@ -285,6 +318,9 @@ }); document.getElementById('max-total-trailers').value = config.MaxTotalTrailers ?? 50; document.getElementById('trailers-per-movie').value = config.TrailersPerMovie ?? 1; + document.getElementById('filter-genre').checked = !!config.FilterByGenre; + document.getElementById('filter-rating').checked = !!config.FilterByRating; + document.getElementById('avoid-repeats').checked = config.AvoidRepeats !== false; Dashboard.hideLoadingMsg(); }); }); @@ -313,6 +349,9 @@ config.AllowedLanguages = checkedLangs.length === allLangCodes.length ? '' : checkedLangs.join(','); config.MaxTotalTrailers = parseInt(document.getElementById('max-total-trailers').value, 10) || 50; config.TrailersPerMovie = parseInt(document.getElementById('trailers-per-movie').value, 10) || 0; + config.FilterByGenre = document.getElementById('filter-genre').checked; + config.FilterByRating = document.getElementById('filter-rating').checked; + config.AvoidRepeats = document.getElementById('avoid-repeats').checked; ApiClient.updatePluginConfiguration(pluginId, config) .then(Dashboard.processPluginConfigurationUpdateResult); }); diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Jellyfin.Plugin.CinemaTrailers4Jellyfins.csproj b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Jellyfin.Plugin.CinemaTrailers4Jellyfins.csproj index 941f042..5c11696 100644 --- a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Jellyfin.Plugin.CinemaTrailers4Jellyfins.csproj +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Jellyfin.Plugin.CinemaTrailers4Jellyfins.csproj @@ -3,8 +3,8 @@ net9.0 Jellyfin.Plugin.CinemaTrailers4Jellyfins - 1.0.0.2 - 1.0.0.2 + 1.0.0.3 + 1.0.0.3 enable false false diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/ScheduledTasks/DownloadTrailersTask.cs b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/ScheduledTasks/DownloadTrailersTask.cs index f3462a5..631db65 100644 --- a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/ScheduledTasks/DownloadTrailersTask.cs +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/ScheduledTasks/DownloadTrailersTask.cs @@ -168,7 +168,10 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks continue; } - _fakeMovieService.WriteNfo(paths.NfoPath, movie.Title, movie.Year); + var metadata = await _tmdbService.GetMovieMetadataAsync( + movie.Id.ToString(), config.TmdbApiKey, cancellationToken).ConfigureAwait(false); + + _fakeMovieService.WriteNfo(paths.NfoPath, movie.Title, movie.Year, metadata.Genres, metadata.Certification); downloaded++; _logger.LogInformation( diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FakeMovieService.cs b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FakeMovieService.cs index cc265b2..ae4f2b3 100644 --- a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FakeMovieService.cs +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FakeMovieService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading; @@ -55,7 +56,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services /// Writes a minimal Jellyfin/Kodi-compatible movie NFO with <lockdata>true</lockdata> /// so Jellyfin never tries to refresh metadata for the fake entry from TMDB. /// - public void WriteNfo(string nfoPath, string title, int? year) + public void WriteNfo(string nfoPath, string title, int? year, IReadOnlyList? genres = null, string? mpaa = null) { var settings = new XmlWriterSettings { Indent = true }; using var writer = XmlWriter.Create(nfoPath, settings); @@ -64,6 +65,11 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services writer.WriteElementString("title", title); if (year.HasValue) writer.WriteElementString("year", year.Value.ToString()); + if (genres != null) + foreach (var genre in genres) + writer.WriteElementString("genre", genre); + if (!string.IsNullOrWhiteSpace(mpaa)) + writer.WriteElementString("mpaa", mpaa); writer.WriteElementString("lockdata", "true"); writer.WriteEndElement(); writer.WriteEndDocument(); diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TmdbService.cs b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TmdbService.cs index 1062a99..b5ffeeb 100644 --- a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TmdbService.cs +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TmdbService.cs @@ -19,6 +19,8 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services public int? Year => DateTime.TryParse(ReleaseDate, out var d) ? d.Year : (int?)null; } + public record TmdbMovieMetadata(IReadOnlyList Genres, string? Certification); + public class TmdbService : IDisposable { private readonly HttpClient _httpClient; @@ -187,6 +189,77 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services return null; } + /// + /// Returns the genre names and US theatrical certification (G, PG, PG-13, R, NC-17) + /// for a movie. Uses a single /movie/{id}?append_to_response=release_dates call. + /// + public async Task GetMovieMetadataAsync( + string tmdbId, + string apiKey, + CancellationToken ct) + { + try + { + var url = $"{BaseUrl}/movie/{tmdbId}?append_to_response=release_dates&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(); + 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 = null; + if (doc.RootElement.TryGetProperty("release_dates", out var releaseDates) + && releaseDates.TryGetProperty("results", out var rdResults)) + { + 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)) + break; + + foreach (var date in dates.EnumerateArray()) + { + if (!date.TryGetProperty("certification", out var cert)) + continue; + var certStr = cert.GetString(); + if (!string.IsNullOrWhiteSpace(certStr)) + { + certification = certStr; + break; + } + } + break; + } + } + + return new TmdbMovieMetadata(genres.AsReadOnly(), certification); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _logger.LogError(ex, "|CinemaTrailers4Jellyfins| GetMovieMetadata failed for TMDB ID {Id}", tmdbId); + return new TmdbMovieMetadata(Array.Empty(), null); + } + } + public async Task> GetTrailersAsync( string tmdbId, string apiKey, diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TrailerIntroProvider.cs b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TrailerIntroProvider.cs index cf67432..0932474 100644 --- a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TrailerIntroProvider.cs +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TrailerIntroProvider.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; @@ -15,6 +16,12 @@ 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; @@ -40,15 +47,13 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services if (string.IsNullOrEmpty(outputFolder)) return Task.FromResult(Enumerable.Empty()); - // Break potential recursion: don't inject intros before items that live inside - // our output folder. This covers both the fake-movie files and their trailer - // extras, so Wholphin can't trigger a getIntros loop on the intro item itself. + // 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 (no User parameter) so library visibility settings don't - // prevent trailer discovery even when the output library is hidden from users. - var fakeMovies = _libraryManager + // Server-scoped query so hidden libraries don't block trailer discovery. + var pairs = _libraryManager .GetItemList(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.Movie }, @@ -58,28 +63,100 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services .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 (fakeMovies.Count == 0) + if (pairs.Count == 0) { _logger.LogDebug( - "|CinemaTrailers4Jellyfins| No indexed trailers found in {Folder}. " + "|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()); } - var selected = fakeMovies + // 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) - .Select(m => - { - var t = m.LocalTrailers.ElementAt(_rng.Next(m.LocalTrailers.Count)); - return new IntroInfo { ItemId = t.Id, Path = t.Path }; - }) .ToList(); - return Task.FromResult>(selected); + 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); + } } } } diff --git a/build.yaml b/build.yaml index 98ea84d..b9bdb47 100644 --- a/build.yaml +++ b/build.yaml @@ -1,5 +1,5 @@ --- -version: 1.0.0.2 +version: 1.0.0.3 name: CinemaTrailers4Jellyfins guid: b581493e-1046-40ed-b6dc-cb8027624984 description: > @@ -12,10 +12,10 @@ category: General owner: 514mart targetAbi: 10.11.0.0 changelog: - - Add IIntroProvider support — downloaded trailers now play as pre-rolls before - movies in Jellyfin clients with cinema mode (Wholphin, etc.); configurable - trailers-per-movie count; recursion-safe so the intro items themselves never - trigger a second round of intro injection + - Add trailer selection filters — genre match, age rating ceiling, and avoid-repeats + cycle; genre and rating are written to each fake-movie NFO at download time from + TMDB so Jellyfin can use them for filtering; filters fall back to random when no + match is found dotnetProjects: - name: Jellyfin.Plugin.CinemaTrailers4Jellyfins