4 Commits

Author SHA1 Message Date
Martin
83966f40ea 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>
2026-06-09 17:15:38 -04:00
gitea-actions
6772bde3b4 chore: update manifest.json for v1.0.0.2 2026-06-09 17:44:48 +00:00
Martin
e84a897c27 feat: add IIntroProvider for Wholphin/cinema-mode compatibility
All checks were successful
Publish Release / release (push) Successful in 1m34s
Registers TrailerIntroProvider as IIntroProvider. Queries fake-movie
items in the output folder and returns their local trailer extras
(LocalTrailers) as IntroInfo — mirroring jellyfin-plugin-cinemamode's
proven pattern so Wholphin plays the actual trailer, not the 3-second
black placeholder.

Recursion guard: items whose path starts with the output folder are
excluded from intro injection, so Wholphin's getIntros call on the
intro item itself returns empty. Server-scoped query bypasses library
visibility restrictions so hidden trailer libraries still work.

Adds TrailersPerMovie config option (default 1, 0 = disabled).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 13:03:34 -04:00
gitea-actions
3868876401 chore: update manifest.json for v1.0.0.1 2026-06-08 20:48:56 +00:00
10 changed files with 346 additions and 9 deletions

View File

@@ -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;
} }
} }

View File

@@ -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);
}); });

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.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>

View File

@@ -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>();
} }
} }
} }

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

@@ -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);
}
}
}
}

View File

@@ -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

View File

@@ -8,6 +8,22 @@
"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", "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", "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",