feat: add genre, rating, and avoid-repeats trailer filters
All checks were successful
Publish Release / release (push) Successful in 23s

Trailer selection now supports three optional filters:
- Genre match: prefer trailers whose fake-movie genre overlaps with the
  feature being played (requires TMDB genres in NFO)
- Age rating ceiling: exclude trailers rated higher than the feature
  (requires TMDB certification in NFO)
- Avoid repeats: cycle through all trailers before replaying any;
  resets per-user once the pool is exhausted

Genre and certification are fetched from TMDB at download time via a
single /movie/{id}?append_to_response=release_dates call and written
into the fake-movie NFO as <genre> and <mpaa> tags. All filters fall
back to the full unfiltered pool when no match is found.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-06-09 17:15:11 -04:00
parent 6772bde3b4
commit 83966f40ea
8 changed files with 231 additions and 24 deletions

View File

@@ -43,5 +43,14 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Configuration
/// <summary>Number of trailers to inject before each movie via IIntroProvider. 0 = disabled.</summary> /// <summary>Number of trailers to inject before each movie via IIntroProvider. 0 = disabled.</summary>
public int TrailersPerMovie { get; set; } = 1; public int TrailersPerMovie { get; set; } = 1;
/// <summary>Only pick trailers whose genre overlaps with the movie being played.</summary>
public bool FilterByGenre { get; set; } = false;
/// <summary>Only pick trailers rated at most the same as the movie being played.</summary>
public bool FilterByRating { get; set; } = false;
/// <summary>Cycle through all trailers before repeating any.</summary>
public bool AvoidRepeats { get; set; } = true;
} }
} }

View File

@@ -235,6 +235,39 @@
Default: 1. Default: 1.
</div> </div>
</div> </div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="filter-genre" />
<span>Match genre to the movie being played</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Only pick trailers whose genre overlaps with the movie you are watching.
Falls back to any trailer if no match is found.
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="filter-rating" />
<span>Limit to same age rating or lower</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Never show a trailer rated higher than the movie being played.
Falls back to any trailer if no match is found.
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="avoid-repeats" />
<span>Avoid repeating trailers</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Cycle through all available trailers before playing any again.
Resets automatically once every trailer has been shown. Default: on.
</div>
</div>
</fieldset> </fieldset>
<!-- Advanced --> <!-- Advanced -->
@@ -285,6 +318,9 @@
}); });
document.getElementById('max-total-trailers').value = config.MaxTotalTrailers ?? 50; document.getElementById('max-total-trailers').value = config.MaxTotalTrailers ?? 50;
document.getElementById('trailers-per-movie').value = config.TrailersPerMovie ?? 1; 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(); Dashboard.hideLoadingMsg();
}); });
}); });
@@ -313,6 +349,9 @@
config.AllowedLanguages = checkedLangs.length === allLangCodes.length ? '' : checkedLangs.join(','); config.AllowedLanguages = checkedLangs.length === allLangCodes.length ? '' : checkedLangs.join(',');
config.MaxTotalTrailers = parseInt(document.getElementById('max-total-trailers').value, 10) || 50; config.MaxTotalTrailers = parseInt(document.getElementById('max-total-trailers').value, 10) || 50;
config.TrailersPerMovie = parseInt(document.getElementById('trailers-per-movie').value, 10) || 0; 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) ApiClient.updatePluginConfiguration(pluginId, config)
.then(Dashboard.processPluginConfigurationUpdateResult); .then(Dashboard.processPluginConfigurationUpdateResult);
}); });

View File

@@ -3,8 +3,8 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.CinemaTrailers4Jellyfins</RootNamespace> <RootNamespace>Jellyfin.Plugin.CinemaTrailers4Jellyfins</RootNamespace>
<AssemblyVersion>1.0.0.2</AssemblyVersion> <AssemblyVersion>1.0.0.3</AssemblyVersion>
<FileVersion>1.0.0.2</FileVersion> <FileVersion>1.0.0.3</FileVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateDocumentationFile>false</GenerateDocumentationFile> <GenerateDocumentationFile>false</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>false</TreatWarningsAsErrors>

View File

@@ -168,7 +168,10 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
continue; 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++; downloaded++;
_logger.LogInformation( _logger.LogInformation(

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Threading; using System.Threading;
@@ -55,7 +56,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
/// Writes a minimal Jellyfin/Kodi-compatible movie NFO with &lt;lockdata&gt;true&lt;/lockdata&gt; /// Writes a minimal Jellyfin/Kodi-compatible movie NFO with &lt;lockdata&gt;true&lt;/lockdata&gt;
/// so Jellyfin never tries to refresh metadata for the fake entry from TMDB. /// so Jellyfin never tries to refresh metadata for the fake entry from TMDB.
/// </summary> /// </summary>
public void WriteNfo(string nfoPath, string title, int? year) public void WriteNfo(string nfoPath, string title, int? year, IReadOnlyList<string>? genres = null, string? mpaa = null)
{ {
var settings = new XmlWriterSettings { Indent = true }; var settings = new XmlWriterSettings { Indent = true };
using var writer = XmlWriter.Create(nfoPath, settings); using var writer = XmlWriter.Create(nfoPath, settings);
@@ -64,6 +65,11 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
writer.WriteElementString("title", title); writer.WriteElementString("title", title);
if (year.HasValue) if (year.HasValue)
writer.WriteElementString("year", year.Value.ToString()); 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.WriteElementString("lockdata", "true");
writer.WriteEndElement(); writer.WriteEndElement();
writer.WriteEndDocument(); writer.WriteEndDocument();

View File

@@ -19,6 +19,8 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
public int? Year => DateTime.TryParse(ReleaseDate, out var d) ? d.Year : (int?)null; public int? Year => DateTime.TryParse(ReleaseDate, out var d) ? d.Year : (int?)null;
} }
public record TmdbMovieMetadata(IReadOnlyList<string> Genres, string? Certification);
public class TmdbService : IDisposable public class TmdbService : IDisposable
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
@@ -187,6 +189,77 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
return null; return null;
} }
/// <summary>
/// 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.
/// </summary>
public async Task<TmdbMovieMetadata> 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<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 = 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<string>(), null);
}
}
public async Task<List<TmdbVideo>> GetTrailersAsync( public async Task<List<TmdbVideo>> GetTrailersAsync(
string tmdbId, string tmdbId,
string apiKey, string apiKey,

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
@@ -15,6 +16,12 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
public class TrailerIntroProvider : IIntroProvider public class TrailerIntroProvider : IIntroProvider
{ {
private static readonly Random _rng = new(); 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();
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
private readonly ILogger<TrailerIntroProvider> _logger; private readonly ILogger<TrailerIntroProvider> _logger;
@@ -40,15 +47,13 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
if (string.IsNullOrEmpty(outputFolder)) if (string.IsNullOrEmpty(outputFolder))
return Task.FromResult(Enumerable.Empty<IntroInfo>()); return Task.FromResult(Enumerable.Empty<IntroInfo>());
// Break potential recursion: don't inject intros before items that live inside // Break recursion: don't inject intros before items from our own output folder
// our output folder. This covers both the fake-movie files and their trailer // (covers both the fake-movie files and their trailer extras).
// extras, so Wholphin can't trigger a getIntros loop on the intro item itself.
if (item.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true) if (item.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true)
return Task.FromResult(Enumerable.Empty<IntroInfo>()); return Task.FromResult(Enumerable.Empty<IntroInfo>());
// Server-scoped query (no User parameter) so library visibility settings don't // Server-scoped query so hidden libraries don't block trailer discovery.
// prevent trailer discovery even when the output library is hidden from users. var pairs = _libraryManager
var fakeMovies = _libraryManager
.GetItemList(new InternalItemsQuery .GetItemList(new InternalItemsQuery
{ {
IncludeItemTypes = new[] { BaseItemKind.Movie }, IncludeItemTypes = new[] { BaseItemKind.Movie },
@@ -58,28 +63,100 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
.Where(m => .Where(m =>
m.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true m.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true
&& m.LocalTrailers.Count > 0) && m.LocalTrailers.Count > 0)
.SelectMany(m => m.LocalTrailers.Select(t => (Movie: m, Trailer: t)))
.ToList(); .ToList();
if (fakeMovies.Count == 0) if (pairs.Count == 0)
{ {
_logger.LogDebug( _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.", + "Ensure the output folder is added as a Jellyfin Movies library and scanned.",
outputFolder); outputFolder);
return Task.FromResult(Enumerable.Empty<IntroInfo>()); return Task.FromResult(Enumerable.Empty<IntroInfo>());
} }
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()) .OrderBy(_ => _rng.Next())
.Take(config.TrailersPerMovie) .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(); .ToList();
return Task.FromResult<IEnumerable<IntroInfo>>(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<IEnumerable<IntroInfo>>(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<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();
}
}
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);
}
} }
} }
} }

View File

@@ -1,5 +1,5 @@
--- ---
version: 1.0.0.2 version: 1.0.0.3
name: CinemaTrailers4Jellyfins name: CinemaTrailers4Jellyfins
guid: b581493e-1046-40ed-b6dc-cb8027624984 guid: b581493e-1046-40ed-b6dc-cb8027624984
description: > description: >
@@ -12,10 +12,10 @@ category: General
owner: 514mart owner: 514mart
targetAbi: 10.11.0.0 targetAbi: 10.11.0.0
changelog: changelog:
- Add IIntroProvider support — downloaded trailers now play as pre-rolls before - Add trailer selection filters — genre match, age rating ceiling, and avoid-repeats
movies in Jellyfin clients with cinema mode (Wholphin, etc.); configurable cycle; genre and rating are written to each fake-movie NFO at download time from
trailers-per-movie count; recursion-safe so the intro items themselves never TMDB so Jellyfin can use them for filtering; filters fall back to random when no
trigger a second round of intro injection match is found
dotnetProjects: dotnetProjects:
- name: Jellyfin.Plugin.CinemaTrailers4Jellyfins - name: Jellyfin.Plugin.CinemaTrailers4Jellyfins