Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83966f40ea | ||
|
|
6772bde3b4 | ||
|
|
e84a897c27 | ||
|
|
3868876401 | ||
|
|
c83d7cbdde | ||
|
|
f1b36f6d0a |
@@ -38,5 +38,19 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Configuration
|
|||||||
|
|
||||||
/// <summary>Maximum trailers to keep on disk. Oldest are deleted first when exceeded. 0 = unlimited.</summary>
|
/// <summary>Maximum trailers to keep on disk. Oldest are deleted first when exceeded. 0 = unlimited.</summary>
|
||||||
public int MaxTotalTrailers { get; set; } = 50;
|
public int MaxTotalTrailers { get; set; } = 50;
|
||||||
|
|
||||||
|
// ── IIntroProvider (Cinema Mode / Wholphin) ───────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Number of trailers to inject before each movie via IIntroProvider. 0 = disabled.</summary>
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Cinema Mode Integration -->
|
||||||
|
<fieldset class="verticalSection verticalSection-extrabottompadding">
|
||||||
|
<legend><h3 class="sectionTitle">Cinema Mode Integration</h3></legend>
|
||||||
|
<p class="fieldDescription">
|
||||||
|
When enabled, this plugin registers as an <strong>IIntroProvider</strong> and
|
||||||
|
injects downloaded trailers before movies — compatible with Jellyfin's built-in
|
||||||
|
cinema mode support and clients like Wholphin.
|
||||||
|
The output folder must be added as a Jellyfin <strong>Movies</strong> library
|
||||||
|
and scanned before trailers appear.
|
||||||
|
</p>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="trailers-per-movie">Trailers per movie</label>
|
||||||
|
<input type="number" id="trailers-per-movie" is="emby-input" min="0" max="10" />
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Number of trailers to play before each movie. Set to 0 to disable.
|
||||||
|
Default: 1.
|
||||||
|
</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>
|
||||||
|
|
||||||
<!-- Advanced -->
|
<!-- Advanced -->
|
||||||
<fieldset class="verticalSection verticalSection-extrabottompadding">
|
<fieldset class="verticalSection verticalSection-extrabottompadding">
|
||||||
<legend><h3 class="sectionTitle">Advanced</h3></legend>
|
<legend><h3 class="sectionTitle">Advanced</h3></legend>
|
||||||
@@ -264,6 +317,10 @@
|
|||||||
document.getElementById('lang-' + code).checked = langs.length === 0 || langs.indexOf(code) !== -1;
|
document.getElementById('lang-' + code).checked = langs.length === 0 || langs.indexOf(code) !== -1;
|
||||||
});
|
});
|
||||||
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('filter-genre').checked = !!config.FilterByGenre;
|
||||||
|
document.getElementById('filter-rating').checked = !!config.FilterByRating;
|
||||||
|
document.getElementById('avoid-repeats').checked = config.AvoidRepeats !== false;
|
||||||
Dashboard.hideLoadingMsg();
|
Dashboard.hideLoadingMsg();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -291,6 +348,10 @@
|
|||||||
// If all are checked treat it as "no preference" (empty string)
|
// If all are checked treat it as "no preference" (empty string)
|
||||||
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.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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.0</AssemblyVersion>
|
<AssemblyVersion>1.0.0.3</AssemblyVersion>
|
||||||
<FileVersion>1.0.0.0</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>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks;
|
using Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks;
|
||||||
using Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services;
|
using Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Plugins;
|
using MediaBrowser.Controller.Plugins;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -15,6 +16,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins
|
|||||||
serviceCollection.AddSingleton<TrailerDownloadService>();
|
serviceCollection.AddSingleton<TrailerDownloadService>();
|
||||||
serviceCollection.AddSingleton<FakeMovieService>();
|
serviceCollection.AddSingleton<FakeMovieService>();
|
||||||
serviceCollection.AddTransient<IScheduledTask, DownloadTrailersTask>();
|
serviceCollection.AddTransient<IScheduledTask, DownloadTrailersTask>();
|
||||||
|
serviceCollection.AddTransient<IIntroProvider, TrailerIntroProvider>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 <lockdata>true</lockdata>
|
/// 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.
|
/// 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();
|
||||||
@@ -97,13 +103,19 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
|||||||
"-shortest",
|
"-shortest",
|
||||||
"-c:v libx264 -tune stillimage -pix_fmt yuv420p",
|
"-c:v libx264 -tune stillimage -pix_fmt yuv420p",
|
||||||
"-c:a aac -b:a 64k",
|
"-c:a aac -b:a 64k",
|
||||||
|
"-f mp4",
|
||||||
$"\"{tempPath}\"");
|
$"\"{tempPath}\"");
|
||||||
|
|
||||||
|
// Prefer Jellyfin's bundled ffmpeg; fall back to whatever is on PATH.
|
||||||
|
var ffmpegPath = File.Exists("/usr/lib/jellyfin-ffmpeg/ffmpeg")
|
||||||
|
? "/usr/lib/jellyfin-ffmpeg/ffmpeg"
|
||||||
|
: "ffmpeg";
|
||||||
|
|
||||||
using var process = new Process
|
using var process = new Process
|
||||||
{
|
{
|
||||||
StartInfo = new ProcessStartInfo
|
StartInfo = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = "ffmpeg",
|
FileName = ffmpegPath,
|
||||||
Arguments = args,
|
Arguments = args,
|
||||||
RedirectStandardOutput = true,
|
RedirectStandardOutput = true,
|
||||||
RedirectStandardError = true,
|
RedirectStandardError = true,
|
||||||
@@ -135,7 +147,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| Failed to generate the master fake-movie file. Is ffmpeg installed and on PATH?");
|
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| Failed to generate the master fake-movie file.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
build.yaml
10
build.yaml
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
version: 1.0.0.0
|
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:
|
||||||
- Initial release based on Trailers4Jellyfin — TMDB/YouTube trailer downloads,
|
- Add trailer selection filters — genre match, age rating ceiling, and avoid-repeats
|
||||||
scheduled task, language/date filters, and trailer rotation, repackaged so
|
cycle; genre and rating are written to each fake-movie NFO at download time from
|
||||||
each trailer ships inside its own fake-movie folder (placeholder video +
|
TMDB so Jellyfin can use them for filtering; filters fall back to random when no
|
||||||
locked NFO + trailer) for use with a Cinema Mode pre-roll plugin
|
match is found
|
||||||
|
|
||||||
dotnetProjects:
|
dotnetProjects:
|
||||||
- name: Jellyfin.Plugin.CinemaTrailers4Jellyfins
|
- name: Jellyfin.Plugin.CinemaTrailers4Jellyfins
|
||||||
|
|||||||
@@ -7,6 +7,31 @@
|
|||||||
"overview": "Builds a self-contained trailer library for Cinema Mode pre-roll plugins",
|
"overview": "Builds a self-contained trailer library for Cinema Mode pre-roll plugins",
|
||||||
"owner": "514mart",
|
"owner": "514mart",
|
||||||
"imageUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/raw/branch/main/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Images/logo.svg",
|
"imageUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/raw/branch/main/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Images/logo.svg",
|
||||||
"versions": []
|
"versions": [
|
||||||
|
{
|
||||||
|
"checksum": "0259ff74b5e12089c45b8823d6ff6a19",
|
||||||
|
"changelog": "- Add IIntroProvider support \u2014 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\n",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.2/cinematrailers4jellyfins_1.0.0.2.zip",
|
||||||
|
"timestamp": "2026-06-09T17:44:48Z",
|
||||||
|
"version": "1.0.0.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"checksum": "70edda53c848d5481223dda7afc763c2",
|
||||||
|
"changelog": "- Initial release based on Trailers4Jellyfin \u2014 TMDB/YouTube trailer downloads, scheduled task, language/date filters, and trailer rotation, repackaged so each trailer ships inside its own fake-movie folder (placeholder video + locked NFO + trailer) for use with a Cinema Mode pre-roll plugin\n",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.1/cinematrailers4jellyfins_1.0.0.1.zip",
|
||||||
|
"timestamp": "2026-06-08T20:48:56Z",
|
||||||
|
"version": "1.0.0.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"checksum": "b8a6e246b2f07dadafb196c120dab2f6",
|
||||||
|
"changelog": "- Initial release based on Trailers4Jellyfin \u2014 TMDB/YouTube trailer downloads, scheduled task, language/date filters, and trailer rotation, repackaged so each trailer ships inside its own fake-movie folder (placeholder video + locked NFO + trailer) for use with a Cinema Mode pre-roll plugin\n",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.0/cinematrailers4jellyfins_1.0.0.0.zip",
|
||||||
|
"timestamp": "2026-06-08T20:31:51Z",
|
||||||
|
"version": "1.0.0.0"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user