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.0Jellyfin.Plugin.CinemaTrailers4Jellyfins
- 1.0.0.2
- 1.0.0.2
+ 1.0.0.3
+ 1.0.0.3enablefalsefalse
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