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>
163 lines
6.4 KiB
C#
163 lines
6.4 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.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();
|
|
|
|
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 || config.TrailersPerMovie <= 0)
|
|
return Task.FromResult(Enumerable.Empty<IntroInfo>());
|
|
|
|
if (item is not Movie)
|
|
return Task.FromResult(Enumerable.Empty<IntroInfo>());
|
|
|
|
var outputFolder = config.DownloadFolder?.TrimEnd(
|
|
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
|
if (string.IsNullOrEmpty(outputFolder))
|
|
return Task.FromResult(Enumerable.Empty<IntroInfo>());
|
|
|
|
// 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<IntroInfo>());
|
|
|
|
// 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)
|
|
.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<IntroInfo>());
|
|
}
|
|
|
|
// 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<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);
|
|
}
|
|
}
|
|
}
|
|
}
|