10 Commits

Author SHA1 Message Date
Martin
adfa570569 fix: pre-roll/feature pre-roll bumpers not found in library (v1.0.0.8)
All checks were successful
Publish Release / release (push) Successful in 8s
TopParentIds-based queries could fail to match Movie items in the
configured pre-roll library even when they appear correctly in the
Jellyfin UI. Look up the library Folder by ID and walk its recursive
children directly instead, matching the path-based approach already
used for the trailer pool.
2026-06-10 11:27:52 -04:00
gitea-actions
99798b5174 chore: update manifest.json for v1.0.0.7 2026-06-10 15:10:56 +00:00
Martin
c76ddbc352 chore: trace GetIntros calls and outcomes at Information level (v1.0.0.7)
All checks were successful
Publish Release / release (push) Successful in 10s
Logs every GetIntros call (item, path, enabled features), why an item
is skipped, and pre-roll/post-roll lookup outcomes, without depending
on a Debug logging.json override.
2026-06-10 11:09:48 -04:00
gitea-actions
e9d9543941 chore: update manifest.json for v1.0.0.6 2026-06-10 14:35:52 +00:00
Martin
ba56926dc2 chore: add diagnostic logging for pre-roll bumpers (v1.0.0.6)
All checks were successful
Publish Release / release (push) Successful in 13s
Helps diagnose why a Trailer Pre-Roll/Feature Pre-Roll bumper isn't
playing by logging invalid library IDs, empty library results, and
the movie picked.
2026-06-10 10:35:33 -04:00
gitea-actions
2ef39853eb chore: update manifest.json for v1.0.0.5 2026-06-10 05:14:23 +00:00
Martin
a0bddac48d feat: add Trailer Pre-Roll and Feature Pre-Roll bumpers
All checks were successful
Publish Release / release (push) Successful in 14s
Adds two optional IIntroProvider bumper slots, mirroring CherryFloors'
cinema mode plugin: a "Trailer Pre-Roll" played before the trailer block
and a "Feature Pre-Roll" played right before the movie/episode. Each is
configured by picking an existing Jellyfin Movie library, from which a
random Movie is injected as the bumper.

Bump version to 1.0.0.5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 01:13:52 -04:00
gitea-actions
18c3c49a26 chore: update manifest.json for v1.0.0.4 2026-06-10 04:42:17 +00:00
Martin
f49c32f181 feat: add TV show trailer downloads, episode trailers, and movie/TV trailer separation
All checks were successful
Publish Release / release (push) Successful in 23s
- Download trailers for TV shows from TMDB with separate sources and an
  independent max-count cap (0 disables a category)
- Play trailers before TV episodes via IIntroProvider, limited to the first
  episode a user watches each day
- Tag TV show trailers in their NFO so movies only get movie trailers and
  episodes only get TV show trailers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:41:37 -04:00
gitea-actions
f769e33b8d chore: update manifest.json for v1.0.0.3 2026-06-09 21:16:16 +00:00
11 changed files with 837 additions and 176 deletions

View File

@@ -8,13 +8,20 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Configuration
public string TmdbApiKey { get; set; } = string.Empty;
// ── Sources ───────────────────────────────────────────────────────────
// ── Sources (Movies) ─────────────────────────────────────────────────
public bool SourceNowPlaying { get; set; } = true;
public bool SourceUpcoming { get; set; } = true;
public bool SourcePopular { get; set; } = false;
public bool SourceTopRated { get; set; } = false;
// ── Sources (TV Shows) ───────────────────────────────────────────────
public bool SourceTvAiringToday { get; set; } = true;
public bool SourceTvOnTheAir { get; set; } = true;
public bool SourceTvPopular { get; set; } = false;
public bool SourceTvTopRated { get; set; } = false;
// ── Date Range ────────────────────────────────────────────────────────
public int ReleaseDateRangeMonths { get; set; } = 6;
@@ -22,7 +29,13 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Configuration
// ── Download Settings ─────────────────────────────────────────────────
public string DownloadFolder { get; set; } = string.Empty;
/// <summary>Maximum movie trailers to download per run. 0 = don't download movie trailers.</summary>
public int MaxTrailersToDownload { get; set; } = 20;
/// <summary>Maximum TV show trailers to download per run. 0 = don't download TV show trailers.</summary>
public int MaxTvTrailersToDownload { get; set; } = 0;
public int MaxPagesPerSource { get; set; } = 3;
public int PreferredVideoHeight { get; set; } = 720;
public bool SkipAlreadyDownloaded { get; set; } = true;
@@ -52,5 +65,18 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Configuration
/// <summary>Cycle through all trailers before repeating any.</summary>
public bool AvoidRepeats { get; set; } = true;
/// <summary>Also inject trailers before TV episodes, but only before the first episode a user watches each day.</summary>
public bool TrailersForEpisodes { get; set; } = false;
// ── Pre-Roll Bumpers ─────────────────────────────────────────────────
/// <summary>Jellyfin movie library (VirtualFolder ItemId) to pick a random "Trailer Pre-Roll"
/// bumper from, played before the trailer block. Empty = disabled.</summary>
public string TrailerPreRollLibraryId { get; set; } = string.Empty;
/// <summary>Jellyfin movie library (VirtualFolder ItemId) to pick a random "Feature Pre-Roll"
/// bumper from, played after the trailer block, right before the feature. Empty = disabled.</summary>
public string FeaturePreRollLibraryId { get; set; } = string.Empty;
}
}

View File

@@ -72,11 +72,11 @@
</div>
</fieldset>
<!-- Sources -->
<!-- Movie Sources -->
<fieldset class="verticalSection verticalSection-extrabottompadding">
<legend><h3 class="sectionTitle">Trailer Sources</h3></legend>
<legend><h3 class="sectionTitle">Movie Trailer Sources</h3></legend>
<p class="fieldDescription">
Choose which TMDB lists to pull trailers from. Enable multiple for more variety.
Choose which TMDB lists to pull movie trailers from. Enable multiple for more variety.
</p>
<div class="checkboxContainer checkboxContainer-withDescription">
@@ -120,11 +120,60 @@
</div>
</fieldset>
<!-- TV Show Sources -->
<fieldset class="verticalSection verticalSection-extrabottompadding">
<legend><h3 class="sectionTitle">TV Show Trailer Sources</h3></legend>
<p class="fieldDescription">
Choose which TMDB lists to pull TV show trailers from. Enable multiple for more variety.
Has no effect if "Max TV show trailers per run" below is set to 0.
</p>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="source-tv-airing-today" />
<span>Airing Today</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
TV shows airing today.
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="source-tv-on-the-air" />
<span>On The Air</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
TV shows airing in the next 7 days.
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="source-tv-popular" />
<span>Popular</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Most popular TV shows on TMDB right now, filtered by the date range below.
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="source-tv-top-rated" />
<span>Top Rated</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Highest rated TV shows on TMDB, filtered by the date range below.
</div>
</div>
</fieldset>
<!-- Date Range -->
<fieldset class="verticalSection verticalSection-extrabottompadding">
<legend><h3 class="sectionTitle">Date Range</h3></legend>
<div class="selectContainer">
<label class="selectLabel" for="date-range">Only include movies released within</label>
<label class="selectLabel" for="date-range">Only include titles released within</label>
<select is="emby-select" id="date-range" class="emby-select-withcolor emby-select">
<option value="3">Last 3 months</option>
<option value="6">Last 6 months</option>
@@ -133,8 +182,9 @@
<option value="0">All time (no limit)</option>
</select>
<div class="fieldDescription">
Applies to all sources. "Now Playing" and "Upcoming" already have tight date windows
set by TMDB, but this provides an additional filter.
Applies to all sources (movie release date / TV show first-air date). "Now Playing",
"Upcoming", "Airing Today" and "On The Air" already have tight date windows set by
TMDB, but this provides an additional filter.
</div>
</div>
</fieldset>
@@ -153,10 +203,20 @@
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="max-trailers">Max trailers per run</label>
<input type="number" id="max-trailers" is="emby-input" min="1" max="200" />
<label class="inputLabel inputLabelUnfocused" for="max-trailers">Max movie trailers per run</label>
<input type="number" id="max-trailers" is="emby-input" min="0" max="200" />
<div class="fieldDescription">
Maximum number of trailers to download each time the task runs. Default: 20.
Maximum number of movie trailers to download each time the task runs.
Set to 0 to not download any movie trailers. Default: 20.
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="max-tv-trailers">Max TV show trailers per run</label>
<input type="number" id="max-tv-trailers" is="emby-input" min="0" max="200" />
<div class="fieldDescription">
Maximum number of TV show trailers to download each time the task runs.
Set to 0 to not download any TV show trailers. Default: 0.
</div>
</div>
@@ -164,7 +224,7 @@
<label class="inputLabel inputLabelUnfocused" for="max-pages">Pages per source</label>
<input type="number" id="max-pages" is="emby-input" min="1" max="10" />
<div class="fieldDescription">
How many pages to fetch from each TMDB source (20 movies per page). Default: 3.
How many pages to fetch from each TMDB source (20 results per page). Default: 3.
</div>
</div>
@@ -180,10 +240,10 @@
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="skip-library" />
<span>Skip movies already in my Jellyfin library</span>
<span>Skip movies/shows already in my Jellyfin library</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Trailers for movies you already own won't be downloaded.
Trailers for movies and TV shows you already own won't be downloaded.
</div>
</div>
@@ -193,7 +253,7 @@
<span>Skip trailers already downloaded</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
If a folder already exists for a movie, don't re-download it.
If a folder already exists for a movie or TV show, don't re-download it.
</div>
</div>
</fieldset>
@@ -268,6 +328,49 @@
Resets automatically once every trailer has been shown. Default: on.
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="trailers-for-episodes" />
<span>Also play trailers before TV episodes</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Only plays before the first episode a user watches each day —
if you watch several episodes back-to-back, only the first gets trailers.
</div>
</div>
</fieldset>
<!-- Pre-Roll Bumpers -->
<fieldset class="verticalSection verticalSection-extrabottompadding">
<legend><h3 class="sectionTitle">Pre-Roll Bumpers</h3></legend>
<p class="fieldDescription">
Optional: pick existing Jellyfin Movie libraries to pull random bumper
videos from, bookending the trailer block above. Each is independent —
leave either set to "None" to disable it.
</p>
<div class="selectContainer">
<label class="selectLabel" for="trailer-preroll-library">Trailer Pre-Roll library</label>
<select is="emby-select" id="trailer-preroll-library" class="emby-select-withcolor emby-select">
<option value="">— None (disabled) —</option>
</select>
<div class="fieldDescription">
A Movie library to pick a random "Now Playing" style bumper from,
played before the trailer block.
</div>
</div>
<div class="selectContainer">
<label class="selectLabel" for="feature-preroll-library">Feature Pre-Roll library</label>
<select is="emby-select" id="feature-preroll-library" class="emby-select-withcolor emby-select">
<option value="">— None (disabled) —</option>
</select>
<div class="fieldDescription">
A Movie library to pick a random "Feature Presentation" style bumper
from, played right before the movie/episode (after trailers).
</div>
</div>
</fieldset>
<!-- Advanced -->
@@ -295,17 +398,46 @@
<script type="text/javascript">
var pluginId = "b581493e-1046-40ed-b6dc-cb8027624984";
// parseInt(...) || fallback treats 0 as falsy, which breaks fields where
// 0 is a meaningful value (e.g. "don't download any").
function parseIntOrDefault(value, fallback) {
var n = parseInt(value, 10);
return isNaN(n) ? fallback : n;
}
$('.cinemaTrailers4JellyfinsConfigPage').on('pageshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
Promise.all([
ApiClient.getPluginConfiguration(pluginId),
ApiClient.getJSON(ApiClient.getUrl('Library/VirtualFolders'))
]).then(function (results) {
var config = results[0];
var movieFolders = results[1].filter(function (f) { return f.CollectionType === 'movies'; });
[['trailer-preroll-library', config.TrailerPreRollLibraryId],
['feature-preroll-library', config.FeaturePreRollLibraryId]]
.forEach(function (entry) {
var select = document.getElementById(entry[0]);
movieFolders.forEach(function (f) {
var opt = document.createElement('option');
opt.value = f.ItemId;
opt.text = f.Name;
select.appendChild(opt);
});
select.value = entry[1] || '';
});
document.getElementById('tmdb-api-key').value = config.TmdbApiKey || '';
document.getElementById('source-now-playing').checked = config.SourceNowPlaying !== false;
document.getElementById('source-upcoming').checked = config.SourceUpcoming !== false;
document.getElementById('source-popular').checked = !!config.SourcePopular;
document.getElementById('source-top-rated').checked = !!config.SourceTopRated;
document.getElementById('source-tv-airing-today').checked = config.SourceTvAiringToday !== false;
document.getElementById('source-tv-on-the-air').checked = config.SourceTvOnTheAir !== false;
document.getElementById('source-tv-popular').checked = !!config.SourceTvPopular;
document.getElementById('source-tv-top-rated').checked = !!config.SourceTvTopRated;
document.getElementById('date-range').value = String(config.ReleaseDateRangeMonths ?? 6);
document.getElementById('download-folder').value = config.DownloadFolder || '';
document.getElementById('max-trailers').value = config.MaxTrailersToDownload ?? 20;
document.getElementById('max-tv-trailers').value = config.MaxTvTrailersToDownload ?? 0;
document.getElementById('max-pages').value = config.MaxPagesPerSource ?? 3;
document.getElementById('video-quality').value = String(config.PreferredVideoHeight ?? 720);
document.getElementById('skip-library').checked = config.SkipMoviesInLibrary !== false;
@@ -321,6 +453,7 @@
document.getElementById('filter-genre').checked = !!config.FilterByGenre;
document.getElementById('filter-rating').checked = !!config.FilterByRating;
document.getElementById('avoid-repeats').checked = config.AvoidRepeats !== false;
document.getElementById('trailers-for-episodes').checked = !!config.TrailersForEpisodes;
Dashboard.hideLoadingMsg();
});
});
@@ -333,9 +466,14 @@
config.SourceUpcoming = document.getElementById('source-upcoming').checked;
config.SourcePopular = document.getElementById('source-popular').checked;
config.SourceTopRated = document.getElementById('source-top-rated').checked;
config.SourceTvAiringToday = document.getElementById('source-tv-airing-today').checked;
config.SourceTvOnTheAir = document.getElementById('source-tv-on-the-air').checked;
config.SourceTvPopular = document.getElementById('source-tv-popular').checked;
config.SourceTvTopRated = document.getElementById('source-tv-top-rated').checked;
config.ReleaseDateRangeMonths = parseInt(document.getElementById('date-range').value, 10);
config.DownloadFolder = document.getElementById('download-folder').value;
config.MaxTrailersToDownload = parseInt(document.getElementById('max-trailers').value, 10) || 20;
config.MaxTrailersToDownload = parseIntOrDefault(document.getElementById('max-trailers').value, 20);
config.MaxTvTrailersToDownload = parseIntOrDefault(document.getElementById('max-tv-trailers').value, 0);
config.MaxPagesPerSource = parseInt(document.getElementById('max-pages').value, 10) || 3;
config.PreferredVideoHeight = parseInt(document.getElementById('video-quality').value, 10) || 720;
config.SkipMoviesInLibrary = document.getElementById('skip-library').checked;
@@ -347,11 +485,14 @@
});
// If all are checked treat it as "no preference" (empty string)
config.AllowedLanguages = checkedLangs.length === allLangCodes.length ? '' : checkedLangs.join(',');
config.MaxTotalTrailers = parseInt(document.getElementById('max-total-trailers').value, 10) || 50;
config.MaxTotalTrailers = parseIntOrDefault(document.getElementById('max-total-trailers').value, 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;
config.TrailersForEpisodes = document.getElementById('trailers-for-episodes').checked;
config.TrailerPreRollLibraryId = document.getElementById('trailer-preroll-library').value;
config.FeaturePreRollLibraryId = document.getElementById('feature-preroll-library').value;
ApiClient.updatePluginConfiguration(pluginId, config)
.then(Dashboard.processPluginConfigurationUpdateResult);
});

View File

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

View File

@@ -8,6 +8,7 @@ using Jellyfin.Data.Enums;
using Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Tasks;
@@ -25,7 +26,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
public string Name => "Download TMDB Trailers";
public string Key => "CinemaTrailers4JellyfinsDownload";
public string Description => "Downloads trailers from TMDB for upcoming and recently released movies not in your library, packaged as fake-movie folders for use with a Cinema Mode / trailer pre-roll plugin.";
public string Description => "Downloads trailers from TMDB for upcoming and recently released movies and TV shows not in your library, packaged as fake-movie folders for use with a Cinema Mode / trailer pre-roll plugin.";
public string Category => "CinemaTrailers4Jellyfins";
public DownloadTrailersTask(
@@ -67,9 +68,12 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
return;
}
if (!config.SourceNowPlaying && !config.SourceUpcoming && !config.SourcePopular && !config.SourceTopRated)
var downloadMovies = config.MaxTrailersToDownload > 0;
var downloadTvShows = config.MaxTvTrailersToDownload > 0;
if (!downloadMovies && !downloadTvShows)
{
_logger.LogWarning("|CinemaTrailers4Jellyfins| No TMDB sources selected. Enable at least one source. Skipping task.");
_logger.LogWarning("|CinemaTrailers4Jellyfins| Both 'Max movie trailers' and 'Max TV show trailers' are 0. Skipping task.");
return;
}
@@ -79,26 +83,64 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
progress.Report(5);
var libraryTmdbIds = config.SkipMoviesInLibrary
? GetLibraryTmdbIds()
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_logger.LogInformation("|CinemaTrailers4Jellyfins| Library contains {Count} movies with TMDB IDs (will skip these)", libraryTmdbIds.Count);
progress.Report(10);
var allowedLanguages = string.IsNullOrWhiteSpace(config.AllowedLanguages)
? null
: new HashSet<string>(
config.AllowedLanguages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
StringComparer.OrdinalIgnoreCase) as IReadOnlySet<string>;
_logger.LogInformation("|CinemaTrailers4Jellyfins| Fetching candidates from TMDB...");
int totalDownloaded = 0;
if (downloadMovies)
{
if (!config.SourceNowPlaying && !config.SourceUpcoming && !config.SourcePopular && !config.SourceTopRated)
{
_logger.LogWarning("|CinemaTrailers4Jellyfins| No movie TMDB sources selected. Skipping movie trailers.");
}
else
{
var progressEnd = downloadTvShows ? 50 : 95;
totalDownloaded += await DownloadMoviesAsync(config, allowedLanguages, progress, 5, progressEnd, cancellationToken)
.ConfigureAwait(false);
}
}
if (downloadTvShows)
{
if (!config.SourceTvAiringToday && !config.SourceTvOnTheAir && !config.SourceTvPopular && !config.SourceTvTopRated)
{
_logger.LogWarning("|CinemaTrailers4Jellyfins| No TV show TMDB sources selected. Skipping TV show trailers.");
}
else
{
var progressStart = downloadMovies ? 50 : 5;
totalDownloaded += await DownloadTvShowsAsync(config, allowedLanguages, progress, progressStart, 95, cancellationToken)
.ConfigureAwait(false);
}
}
_logger.LogInformation("|CinemaTrailers4Jellyfins| Task complete. Downloaded {Count} trailer(s).", totalDownloaded);
progress.Report(100);
}
private async Task<int> DownloadMoviesAsync(
Configuration.PluginConfiguration config,
IReadOnlySet<string>? allowedLanguages,
IProgress<double> progress,
double progressStart,
double progressEnd,
CancellationToken cancellationToken)
{
var libraryTmdbIds = config.SkipMoviesInLibrary
? GetLibraryMovieTmdbIds()
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_logger.LogInformation("|CinemaTrailers4Jellyfins| Library contains {Count} movies with TMDB IDs (will skip these)", libraryTmdbIds.Count);
_logger.LogInformation("|CinemaTrailers4Jellyfins| Fetching movie candidates from TMDB...");
var candidates = await _tmdbService.GetCandidateMoviesAsync(config, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("|CinemaTrailers4Jellyfins| Found {Count} candidate movies across all sources", candidates.Count);
progress.Report(20);
if (config.SkipMoviesInLibrary)
{
candidates = candidates
@@ -109,9 +151,8 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
if (candidates.Count == 0)
{
_logger.LogInformation("|CinemaTrailers4Jellyfins| No new candidates to download. All done.");
progress.Report(100);
return;
_logger.LogInformation("|CinemaTrailers4Jellyfins| No new movie candidates to download.");
return 0;
}
int downloaded = 0;
@@ -124,20 +165,20 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
if (downloaded >= config.MaxTrailersToDownload)
break;
double taskProgress = 20 + (80.0 * processed / candidates.Count);
double taskProgress = progressStart + (progressEnd - progressStart) * processed / candidates.Count;
progress.Report(taskProgress);
processed++;
var paths = BuildMoviePaths(movie.Title, movie.Year, config);
var paths = BuildMediaPaths(movie.Title, movie.Year, config);
if (config.SkipAlreadyDownloaded && Directory.Exists(paths.MovieFolder))
if (config.SkipAlreadyDownloaded && Directory.Exists(paths.MediaFolder))
{
_logger.LogDebug("|CinemaTrailers4Jellyfins| Already downloaded: {Path}", paths.MovieFolder);
_logger.LogDebug("|CinemaTrailers4Jellyfins| Already downloaded: {Path}", paths.MediaFolder);
continue;
}
var trailers = await _tmdbService.GetTrailersAsync(
movie.Id.ToString(), config.TmdbApiKey, allowedLanguages, cancellationToken).ConfigureAwait(false);
"movie", movie.Id.ToString(), config.TmdbApiKey, allowedLanguages, cancellationToken).ConfigureAwait(false);
if (trailers.Count == 0)
{
@@ -163,27 +204,128 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
{
_logger.LogError(
"|CinemaTrailers4Jellyfins| Could not create the fake movie file for '{Movie}'. Removing incomplete folder {Folder}.",
movie.Title, paths.MovieFolder);
TryDeleteFolder(paths.MovieFolder);
movie.Title, paths.MediaFolder);
TryDeleteFolder(paths.MediaFolder);
continue;
}
var metadata = await _tmdbService.GetMovieMetadataAsync(
movie.Id.ToString(), config.TmdbApiKey, cancellationToken).ConfigureAwait(false);
var metadata = await _tmdbService.GetMediaMetadataAsync(
"movie", movie.Id.ToString(), config.TmdbApiKey, cancellationToken).ConfigureAwait(false);
_fakeMovieService.WriteNfo(paths.NfoPath, movie.Title, movie.Year, metadata.Genres, metadata.Certification);
downloaded++;
_logger.LogInformation(
"|CinemaTrailers4Jellyfins| [{Done}/{Max}] Saved trailer for '{Movie}' → {Path}",
downloaded, config.MaxTrailersToDownload, movie.Title, paths.MovieFolder);
downloaded, config.MaxTrailersToDownload, movie.Title, paths.MediaFolder);
}
_logger.LogInformation("|CinemaTrailers4Jellyfins| Task complete. Downloaded {Count} trailer(s).", downloaded);
progress.Report(100);
_logger.LogInformation("|CinemaTrailers4Jellyfins| Movie trailers complete. Downloaded {Count}.", downloaded);
return downloaded;
}
private HashSet<string> GetLibraryTmdbIds()
private async Task<int> DownloadTvShowsAsync(
Configuration.PluginConfiguration config,
IReadOnlySet<string>? allowedLanguages,
IProgress<double> progress,
double progressStart,
double progressEnd,
CancellationToken cancellationToken)
{
var librarySeriesTmdbIds = config.SkipMoviesInLibrary
? GetLibrarySeriesTmdbIds()
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_logger.LogInformation("|CinemaTrailers4Jellyfins| Library contains {Count} TV shows with TMDB IDs (will skip these)", librarySeriesTmdbIds.Count);
_logger.LogInformation("|CinemaTrailers4Jellyfins| Fetching TV show candidates from TMDB...");
var candidates = await _tmdbService.GetCandidateTvShowsAsync(config, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("|CinemaTrailers4Jellyfins| Found {Count} candidate TV shows across all sources", candidates.Count);
if (config.SkipMoviesInLibrary)
{
candidates = candidates
.Where(s => !librarySeriesTmdbIds.Contains(s.Id.ToString()))
.ToList();
_logger.LogInformation("|CinemaTrailers4Jellyfins| {Count} candidates remain after filtering library TV shows", candidates.Count);
}
if (candidates.Count == 0)
{
_logger.LogInformation("|CinemaTrailers4Jellyfins| No new TV show candidates to download.");
return 0;
}
int downloaded = 0;
int processed = 0;
foreach (var show in candidates)
{
cancellationToken.ThrowIfCancellationRequested();
if (downloaded >= config.MaxTvTrailersToDownload)
break;
double taskProgress = progressStart + (progressEnd - progressStart) * processed / candidates.Count;
progress.Report(taskProgress);
processed++;
var paths = BuildMediaPaths(show.Title, show.Year, config);
if (config.SkipAlreadyDownloaded && Directory.Exists(paths.MediaFolder))
{
_logger.LogDebug("|CinemaTrailers4Jellyfins| Already downloaded: {Path}", paths.MediaFolder);
continue;
}
var trailers = await _tmdbService.GetTrailersAsync(
"tv", show.Id.ToString(), config.TmdbApiKey, allowedLanguages, cancellationToken).ConfigureAwait(false);
if (trailers.Count == 0)
{
_logger.LogDebug("|CinemaTrailers4Jellyfins| No YouTube trailers on TMDB for '{Title}'", show.Title);
continue;
}
var trailer = trailers[0];
_logger.LogInformation("|CinemaTrailers4Jellyfins| Downloading '{Trailer}' for '{Show}'", trailer.Name, show.Title);
var success = await _downloadService.DownloadAsync(
trailer.Key,
paths.TrailerPath,
config.PreferredVideoHeight,
config.YtDlpPath,
cancellationToken).ConfigureAwait(false);
if (!success)
continue;
var fakeMovieReady = await _fakeMovieService.CopyFakeMovieAsync(paths.FakeMoviePath, cancellationToken).ConfigureAwait(false);
if (!fakeMovieReady)
{
_logger.LogError(
"|CinemaTrailers4Jellyfins| Could not create the fake movie file for '{Show}'. Removing incomplete folder {Folder}.",
show.Title, paths.MediaFolder);
TryDeleteFolder(paths.MediaFolder);
continue;
}
var metadata = await _tmdbService.GetMediaMetadataAsync(
"tv", show.Id.ToString(), config.TmdbApiKey, cancellationToken).ConfigureAwait(false);
_fakeMovieService.WriteNfo(paths.NfoPath, show.Title, show.Year, metadata.Genres, metadata.Certification, new[] { TrailerTags.TvShow });
downloaded++;
_logger.LogInformation(
"|CinemaTrailers4Jellyfins| [{Done}/{Max}] Saved trailer for '{Show}' → {Path}",
downloaded, config.MaxTvTrailersToDownload, show.Title, paths.MediaFolder);
}
_logger.LogInformation("|CinemaTrailers4Jellyfins| TV show trailers complete. Downloaded {Count}.", downloaded);
return downloaded;
}
private HashSet<string> GetLibraryMovieTmdbIds()
{
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -201,7 +343,25 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
return ids;
}
// Each trailer lives in its own "{Movie Title} ({Year})" folder alongside a fake
private HashSet<string> GetLibrarySeriesTmdbIds()
{
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var series = _libraryManager
.GetItemList(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.Series }, Recursive = true })
.OfType<Series>();
foreach (var s in series)
{
var tmdbId = s.GetProviderId(MetadataProvider.Tmdb);
if (!string.IsNullOrEmpty(tmdbId))
ids.Add(tmdbId);
}
return ids;
}
// Each trailer lives in its own "{Title} ({Year})" folder alongside a fake
// movie file and NFO. Removing a trailer always means removing that whole folder.
private void CleanupOldTrailers(Configuration.PluginConfiguration config)
{
@@ -238,20 +398,20 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
}
}
private readonly record struct MoviePaths(string MovieFolder, string FakeMoviePath, string TrailerPath, string NfoPath);
private readonly record struct MediaPaths(string MediaFolder, string FakeMoviePath, string TrailerPath, string NfoPath);
private static MoviePaths BuildMoviePaths(string title, int? year, Configuration.PluginConfiguration config)
private static MediaPaths BuildMediaPaths(string title, int? year, Configuration.PluginConfiguration config)
{
var safeTitle = string.Concat(title.Split(Path.GetInvalidFileNameChars())).Trim();
var folderName = year.HasValue ? $"{safeTitle} ({year.Value})" : safeTitle;
var movieFolder = Path.Combine(config.DownloadFolder, folderName);
var mediaFolder = Path.Combine(config.DownloadFolder, folderName);
return new MoviePaths(
MovieFolder: movieFolder,
FakeMoviePath: Path.Combine(movieFolder, $"{folderName}.mp4"),
TrailerPath: Path.Combine(movieFolder, $"{folderName}-trailer.mp4"),
NfoPath: Path.Combine(movieFolder, $"{folderName}.nfo"));
return new MediaPaths(
MediaFolder: mediaFolder,
FakeMoviePath: Path.Combine(mediaFolder, $"{folderName}.mp4"),
TrailerPath: Path.Combine(mediaFolder, $"{folderName}-trailer.mp4"),
NfoPath: Path.Combine(mediaFolder, $"{folderName}.nfo"));
}
}
}

View File

@@ -56,7 +56,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
/// 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.
/// </summary>
public void WriteNfo(string nfoPath, string title, int? year, IReadOnlyList<string>? genres = null, string? mpaa = null)
public void WriteNfo(string nfoPath, string title, int? year, IReadOnlyList<string>? genres = null, string? mpaa = null, IReadOnlyList<string>? tags = null)
{
var settings = new XmlWriterSettings { Indent = true };
using var writer = XmlWriter.Create(nfoPath, settings);
@@ -70,6 +70,9 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
writer.WriteElementString("genre", genre);
if (!string.IsNullOrWhiteSpace(mpaa))
writer.WriteElementString("mpaa", mpaa);
if (tags != null)
foreach (var tag in tags)
writer.WriteElementString("tag", tag);
writer.WriteElementString("lockdata", "true");
writer.WriteEndElement();
writer.WriteEndDocument();

View File

@@ -14,12 +14,12 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
{
public record TmdbVideo(string Key, string Name, string Language, bool Official, int Size);
public record TmdbMovieResult(int Id, string Title, string ReleaseDate)
public record TmdbMediaResult(int Id, string Title, string ReleaseDate)
{
public int? Year => DateTime.TryParse(ReleaseDate, out var d) ? d.Year : (int?)null;
}
public record TmdbMovieMetadata(IReadOnlyList<string> Genres, string? Certification);
public record TmdbMediaMetadata(IReadOnlyList<string> Genres, string? Certification);
public class TmdbService : IDisposable
{
@@ -75,7 +75,39 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
/// Fetches candidate movies from the configured TMDB sources, optionally filtered
/// by a minimum release date. Deduplicates across sources by TMDB ID.
/// </summary>
public async Task<List<TmdbMovieResult>> GetCandidateMoviesAsync(
public Task<List<TmdbMediaResult>> GetCandidateMoviesAsync(
Configuration.PluginConfiguration config,
CancellationToken ct)
{
var sources = new List<string>();
if (config.SourceNowPlaying) sources.Add("now_playing");
if (config.SourceUpcoming) sources.Add("upcoming");
if (config.SourcePopular) sources.Add("popular");
if (config.SourceTopRated) sources.Add("top_rated");
return GetCandidatesAsync("movie", sources, config, ct);
}
/// <summary>
/// Fetches candidate TV shows from the configured TMDB sources, optionally filtered
/// by a minimum first-air date. Deduplicates across sources by TMDB ID.
/// </summary>
public Task<List<TmdbMediaResult>> GetCandidateTvShowsAsync(
Configuration.PluginConfiguration config,
CancellationToken ct)
{
var sources = new List<string>();
if (config.SourceTvAiringToday) sources.Add("airing_today");
if (config.SourceTvOnTheAir) sources.Add("on_the_air");
if (config.SourceTvPopular) sources.Add("popular");
if (config.SourceTvTopRated) sources.Add("top_rated");
return GetCandidatesAsync("tv", sources, config, ct);
}
private async Task<List<TmdbMediaResult>> GetCandidatesAsync(
string mediaType,
List<string> sourceEndpoints,
Configuration.PluginConfiguration config,
CancellationToken ct)
{
@@ -84,36 +116,36 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
: null;
var seen = new HashSet<int>();
var results = new List<TmdbMovieResult>();
var results = new List<TmdbMediaResult>();
async Task FetchSource(string endpoint)
foreach (var endpoint in sourceEndpoints)
{
var movies = await FetchSourcePagesAsync(endpoint, config.TmdbApiKey, releasedAfter, config.MaxPagesPerSource, ct)
var items = await FetchSourcePagesAsync(mediaType, endpoint, config.TmdbApiKey, releasedAfter, config.MaxPagesPerSource, ct)
.ConfigureAwait(false);
foreach (var m in movies)
foreach (var item in items)
{
if (seen.Add(m.Id))
results.Add(m);
if (seen.Add(item.Id))
results.Add(item);
}
}
if (config.SourceNowPlaying) await FetchSource("now_playing");
if (config.SourceUpcoming) await FetchSource("upcoming");
if (config.SourcePopular) await FetchSource("popular");
if (config.SourceTopRated) await FetchSource("top_rated");
return results;
}
private async Task<List<TmdbMovieResult>> FetchSourcePagesAsync(
private async Task<List<TmdbMediaResult>> FetchSourcePagesAsync(
string mediaType,
string endpoint,
string apiKey,
DateTime? releasedAfter,
int maxPages,
CancellationToken ct)
{
var results = new List<TmdbMovieResult>();
var results = new List<TmdbMediaResult>();
// Movies use "title"/"release_date"; TV shows use "name"/"first_air_date".
var titleField = mediaType == "movie" ? "title" : "name";
var dateField = mediaType == "movie" ? "release_date" : "first_air_date";
for (int page = 1; page <= maxPages; page++)
{
@@ -121,7 +153,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
try
{
var url = $"{BaseUrl}/movie/{endpoint}?language=en-US&page={page}";
var url = $"{BaseUrl}/{mediaType}/{endpoint}?language=en-US&page={page}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
ApplyAuth(request, apiKey);
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
@@ -133,11 +165,11 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
int totalPages = doc.RootElement.GetProperty("total_pages").GetInt32();
bool anyInRange = false;
foreach (var movie in pageResults.EnumerateArray())
foreach (var entry in pageResults.EnumerateArray())
{
var releaseDate = movie.TryGetProperty("release_date", out var rd) ? rd.GetString() ?? string.Empty : string.Empty;
var title = movie.TryGetProperty("title", out var t) ? t.GetString() ?? string.Empty : string.Empty;
var id = movie.GetProperty("id").GetInt32();
var releaseDate = entry.TryGetProperty(dateField, out var rd) ? rd.GetString() ?? string.Empty : string.Empty;
var title = entry.TryGetProperty(titleField, out var t) ? t.GetString() ?? string.Empty : string.Empty;
var id = entry.GetProperty("id").GetInt32();
if (releasedAfter.HasValue && DateTime.TryParse(releaseDate, out var parsed))
{
@@ -145,7 +177,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
}
anyInRange = true;
results.Add(new TmdbMovieResult(id, title, releaseDate));
results.Add(new TmdbMediaResult(id, title, releaseDate));
}
if (page >= totalPages || (releasedAfter.HasValue && !anyInRange))
@@ -157,7 +189,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
}
catch (Exception ex)
{
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| Failed to fetch TMDB source '{Endpoint}' page {Page}", endpoint, page);
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| Failed to fetch TMDB source '{MediaType}/{Endpoint}' page {Page}", mediaType, endpoint, page);
break;
}
}
@@ -190,17 +222,21 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
}
/// <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.
/// Returns the genre names and US certification for a movie or TV show
/// (e.g. PG-13 for movies, TV-14 for TV shows). Movies use a single
/// /movie/{id}?append_to_response=release_dates call; TV shows use
/// /tv/{id}?append_to_response=content_ratings.
/// </summary>
public async Task<TmdbMovieMetadata> GetMovieMetadataAsync(
public async Task<TmdbMediaMetadata> GetMediaMetadataAsync(
string mediaType,
string tmdbId,
string apiKey,
CancellationToken ct)
{
try
{
var url = $"{BaseUrl}/movie/{tmdbId}?append_to_response=release_dates&language=en-US";
var appendField = mediaType == "movie" ? "release_dates" : "content_ratings";
var url = $"{BaseUrl}/{mediaType}/{tmdbId}?append_to_response={appendField}&language=en-US";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
ApplyAuth(request, apiKey);
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
@@ -222,10 +258,27 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
}
}
string? certification = null;
if (doc.RootElement.TryGetProperty("release_dates", out var releaseDates)
&& releaseDates.TryGetProperty("results", out var rdResults))
string? certification = mediaType == "movie"
? GetMovieCertification(doc)
: GetTvCertification(doc);
return new TmdbMediaMetadata(genres.AsReadOnly(), certification);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| GetMediaMetadata failed for {MediaType} TMDB ID {Id}", mediaType, tmdbId);
return new TmdbMediaMetadata(Array.Empty<string>(), null);
}
}
// Finds the US theatrical certification (G, PG, PG-13, R, NC-17) from release_dates.
private static string? GetMovieCertification(JsonDocument doc)
{
if (!doc.RootElement.TryGetProperty("release_dates", out var releaseDates)
|| !releaseDates.TryGetProperty("results", out var rdResults))
return null;
foreach (var country in rdResults.EnumerateArray())
{
if (!country.TryGetProperty("iso_3166_1", out var iso)
@@ -233,7 +286,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
continue;
if (!country.TryGetProperty("release_dates", out var dates))
break;
return null;
foreach (var date in dates.EnumerateArray())
{
@@ -241,26 +294,40 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
continue;
var certStr = cert.GetString();
if (!string.IsNullOrWhiteSpace(certStr))
{
certification = certStr;
break;
}
}
break;
}
return certStr;
}
return new TmdbMovieMetadata(genres.AsReadOnly(), certification);
return null;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
return null;
}
// Finds the US TV content rating (TV-Y, TV-PG, TV-14, TV-MA, etc.) from content_ratings.
private static string? GetTvCertification(JsonDocument doc)
{
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| GetMovieMetadata failed for TMDB ID {Id}", tmdbId);
return new TmdbMovieMetadata(Array.Empty<string>(), null);
if (!doc.RootElement.TryGetProperty("content_ratings", out var contentRatings)
|| !contentRatings.TryGetProperty("results", out var crResults))
return null;
foreach (var country in crResults.EnumerateArray())
{
if (!country.TryGetProperty("iso_3166_1", out var iso)
|| !string.Equals(iso.GetString(), "US", StringComparison.OrdinalIgnoreCase))
continue;
if (!country.TryGetProperty("rating", out var rating))
return null;
var ratingStr = rating.GetString();
return string.IsNullOrWhiteSpace(ratingStr) ? null : ratingStr;
}
return null;
}
public async Task<List<TmdbVideo>> GetTrailersAsync(
string mediaType,
string tmdbId,
string apiKey,
IReadOnlySet<string>? allowedLanguages,
@@ -270,7 +337,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
{
// No language filter on the URL — we want all available trailers so we can
// filter by iso_639_1 ourselves based on the user's language preference.
var url = $"{BaseUrl}/movie/{tmdbId}/videos";
var url = $"{BaseUrl}/{mediaType}/{tmdbId}/videos";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
ApplyAuth(request, apiKey);
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
@@ -311,7 +378,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| GetTrailers failed for TMDB ID {Id}", tmdbId);
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| GetTrailers failed for {MediaType} TMDB ID {Id}", mediaType, tmdbId);
return new List<TmdbVideo>();
}
}

View File

@@ -8,6 +8,7 @@ using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
@@ -22,6 +23,10 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
private static readonly ConcurrentDictionary<Guid, HashSet<Guid>> _seenByUser = new();
private static readonly object _seenLock = new();
// Per-user date of the last episode that received trailers, so only the
// first episode a user watches each day gets them.
private static readonly ConcurrentDictionary<Guid, DateTime> _lastEpisodeTrailerDateByUser = new();
private readonly ILibraryManager _libraryManager;
private readonly ILogger<TrailerIntroProvider> _logger;
@@ -36,22 +41,83 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
public Task<IEnumerable<IntroInfo>> GetIntros(BaseItem item, User user)
{
var config = Plugin.Instance?.Configuration;
if (config == null || config.TrailersPerMovie <= 0)
if (config == null)
return Task.FromResult(Enumerable.Empty<IntroInfo>());
if (item is not Movie)
var trailersEnabled = config.TrailersPerMovie > 0;
var preRollEnabled = !string.IsNullOrEmpty(config.TrailerPreRollLibraryId);
var featurePreRollEnabled = !string.IsNullOrEmpty(config.FeaturePreRollLibraryId);
_logger.LogInformation(
"|CinemaTrailers4Jellyfins| GetIntros called for {Type} '{Name}' (Path={Path}). "
+ "trailersEnabled={Trailers} preRollEnabled={PreRoll} featurePreRollEnabled={FeaturePreRoll}",
item.GetType().Name,
item.Name,
item.Path,
trailersEnabled,
preRollEnabled,
featurePreRollEnabled);
if (!trailersEnabled && !preRollEnabled && !featurePreRollEnabled)
return Task.FromResult(Enumerable.Empty<IntroInfo>());
// The item used for genre/rating filtering. For episodes this is the
// parent series, since episodes rarely carry their own genre metadata.
BaseItem feature;
bool isEpisode;
switch (item)
{
case Movie:
feature = item;
isEpisode = false;
break;
case Episode episode:
if (!config.TrailersForEpisodes)
{
_logger.LogInformation("|CinemaTrailers4Jellyfins| Skipping: TrailersForEpisodes is disabled.");
return Task.FromResult(Enumerable.Empty<IntroInfo>());
}
if (!IsFirstEpisodeOfDay(user.Id))
{
_logger.LogInformation("|CinemaTrailers4Jellyfins| Skipping: not the first episode of the day for this user.");
return Task.FromResult(Enumerable.Empty<IntroInfo>());
}
feature = episode.Series ?? (BaseItem)episode;
isEpisode = true;
break;
default:
_logger.LogInformation("|CinemaTrailers4Jellyfins| Skipping: item type {Type} is not a Movie or Episode.", item.GetType().Name);
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)
if (!string.IsNullOrEmpty(outputFolder)
&& item.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true)
{
_logger.LogInformation("|CinemaTrailers4Jellyfins| Skipping: item path is inside the output folder ({Folder}).", outputFolder);
return Task.FromResult(Enumerable.Empty<IntroInfo>());
}
var intros = new List<IntroInfo>();
if (preRollEnabled)
{
var preRoll = GetRandomLibraryMovieIntro("Trailer Pre-Roll", config.TrailerPreRollLibraryId, item.Id);
if (preRoll != null)
intros.Add(preRoll);
}
if (trailersEnabled && !string.IsNullOrEmpty(outputFolder))
{
// Server-scoped query so hidden libraries don't block trailer discovery.
var pairs = _libraryManager
.GetItemList(new InternalItemsQuery
@@ -62,21 +128,24 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
.OfType<Movie>()
.Where(m =>
m.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true
&& m.LocalTrailers.Count > 0)
&& m.LocalTrailers.Count > 0
// Keep TV show trailers for episodes and movie trailers for movies separate.
&& m.Tags.Contains(TrailerTags.TvShow, StringComparer.OrdinalIgnoreCase) == isEpisode)
.SelectMany(m => m.LocalTrailers.Select(t => (Movie: m, Trailer: t)))
.ToList();
if (pairs.Count == 0)
{
_logger.LogDebug(
"|CinemaTrailers4Jellyfins| No indexed trailers in {Folder}. "
"|CinemaTrailers4Jellyfins| No indexed {Kind} trailers in {Folder}. "
+ "Ensure the output folder is added as a Jellyfin Movies library and scanned.",
isEpisode ? "TV show" : "movie",
outputFolder);
return Task.FromResult(Enumerable.Empty<IntroInfo>());
}
else
{
// Apply enabled filters. If nothing survives, fall back to the full pool.
var filtered = ApplyFilters(pairs, item, config);
var filtered = ApplyFilters(pairs, feature, config);
var pool = filtered.Count > 0 ? filtered : pairs;
// Prefer unseen trailers within the pool; reset the cycle when all have been shown.
@@ -90,8 +159,71 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
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());
intros.AddRange(selected.Select(p => new IntroInfo { ItemId = p.Trailer.Id, Path = p.Trailer.Path }));
}
}
if (featurePreRollEnabled)
{
var featureRoll = GetRandomLibraryMovieIntro("Feature Pre-Roll", config.FeaturePreRollLibraryId, item.Id);
if (featureRoll != null)
intros.Add(featureRoll);
}
_logger.LogInformation(
"|CinemaTrailers4Jellyfins| Returning {Count} intro(s): {Paths}",
intros.Count,
string.Join(", ", intros.Select(i => i.Path)));
return Task.FromResult<IEnumerable<IntroInfo>>(intros);
}
/// <summary>
/// Picks a random Movie from the given Jellyfin library (VirtualFolder ItemId) to use as a
/// pre-roll/post-roll bumper, excluding the item currently being played.
/// </summary>
private IntroInfo? GetRandomLibraryMovieIntro(string label, string libraryId, Guid excludeId)
{
if (!Guid.TryParse(libraryId, out var parsedId))
{
_logger.LogWarning(
"|CinemaTrailers4Jellyfins| {Label} library ID '{LibraryId}' is not a valid GUID — check the plugin settings.",
label,
libraryId);
return null;
}
if (_libraryManager.GetItemById(parsedId) is not Folder folder)
{
_logger.LogWarning(
"|CinemaTrailers4Jellyfins| {Label} library {LibraryId} could not be found.",
label,
parsedId);
return null;
}
var movies = folder.GetRecursiveChildren()
.OfType<Movie>()
.Where(m => m.Id != excludeId)
.ToList();
if (movies.Count == 0)
{
_logger.LogInformation(
"|CinemaTrailers4Jellyfins| {Label} library {LibraryId} has no eligible Movie items. "
+ "Ensure the library has been scanned and contains at least one other Movie.",
label,
parsedId);
return null;
}
var movie = movies[_rng.Next(movies.Count)];
_logger.LogInformation(
"|CinemaTrailers4Jellyfins| {Label}: picked '{Title}' ({Path}).",
label,
movie.Name,
movie.Path);
return new IntroInfo { ItemId = movie.Id, Path = movie.Path };
}
private static List<(Movie Movie, BaseItem Trailer)> ApplyFilters(
@@ -149,6 +281,36 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
}
}
/// <summary>
/// Returns true (and records today's date) only the first time this is called
/// for a given user on a given day.
/// </summary>
private static bool IsFirstEpisodeOfDay(Guid userId)
{
var today = DateTime.Now.Date;
var isFirst = false;
_lastEpisodeTrailerDateByUser.AddOrUpdate(
userId,
_ =>
{
isFirst = true;
return today;
},
(_, lastDate) =>
{
if (lastDate < today)
{
isFirst = true;
return today;
}
return lastDate;
});
return isFirst;
}
private static void MarkSeen(Guid userId, IEnumerable<Guid> trailerIds)
{
lock (_seenLock)

View File

@@ -0,0 +1,12 @@
namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
{
/// <summary>
/// Tags written into each downloaded trailer's NFO so <see cref="TrailerIntroProvider"/>
/// can tell movie trailers and TV show trailers apart within the shared output folder.
/// </summary>
public static class TrailerTags
{
/// <summary>Marks a fake-movie folder as containing a TV show trailer.</summary>
public const string TvShow = "ct4j-tvshow";
}
}

View File

@@ -1,30 +1,70 @@
# CinemaTrailers4Jellyfins
A Jellyfin plugin that automatically downloads movie trailers from TMDB/YouTube and packages
each one as a self-contained "fake movie" folder, ready to be picked up by a Cinema Mode /
trailer pre-roll plugin.
A Jellyfin plugin that automatically downloads movie and TV show trailers from TMDB/YouTube and
packages each one as a self-contained "fake movie" folder, ready to be picked up by a Cinema
Mode / trailer pre-roll plugin. It can also act as a Cinema Mode pre-roll source itself, via
Jellyfin's `IIntroProvider` interface (compatible with Wholphin and similar clients).
## How it works
1. A daily scheduled task fetches candidate movies from TMDB (Now Playing, Upcoming, Popular,
Top Rated — configurable), optionally skipping movies already in your library.
### Downloading trailers
1. A daily scheduled task fetches candidate movies and/or TV shows from TMDB (Now Playing,
Upcoming, Popular, Top Rated for movies; Airing Today, On The Air, Popular, Top Rated for TV
shows — all configurable), optionally skipping titles already in your library.
2. For each candidate, it fetches the official trailer(s) from TMDB, which point to YouTube.
3. It downloads the trailer video and builds a folder for it:
```
{OutputFolder}/
{Movie Title} ({Year})/
{Movie Title} ({Year}).mp4 ← placeholder "fake movie" (copy of a master file)
{Movie Title} ({Year})-trailer.mp4 ← the actual downloaded trailer
{Movie Title} ({Year}).nfo ← minimal NFO (title, year, locked metadata)
{Title} ({Year})/
{Title} ({Year}).mp4 ← placeholder "fake movie" (copy of a master file)
{Title} ({Year})-trailer.mp4 ← the actual downloaded trailer
{Title} ({Year}).nfo ← minimal NFO (title, year, genres, rating, locked metadata)
```
TV show trailers are tagged (`<tag>ct4j-tvshow</tag>`) in their NFO so they can be told apart
from movie trailers.
4. Jellyfin scans the placeholder file as a normal movie (its metadata locked via the NFO so
it never queries TMDB for it) and picks up the adjacent `-trailer.mp4` as that movie's
it never queries TMDB for it) and picks up the adjacent `-trailer.mp4` as that item's
local trailer — which a Cinema Mode / trailer pre-roll plugin can then play.
The placeholder "fake movie" is a few seconds of black video with silent audio — just enough
for Jellyfin to treat the file as a valid video. It's generated once via `ffmpeg` and reused
by copying, not regenerated for every trailer.
Movie and TV show downloads each have their own TMDB sources and their own "max trailers per
run" setting — set either to 0 to skip that category entirely.
### Playing trailers (Cinema Mode integration)
The plugin also registers as an `IIntroProvider`, so Jellyfin clients with cinema mode support
(and clients like Wholphin) can play the downloaded trailers as pre-rolls directly, without a
separate trailer plugin:
- Before a **movie**, it injects movie trailers from the output folder.
- Before a **TV episode** (if enabled), it injects TV show trailers — but only before the first
episode a user watches each day. If you binge several episodes in one sitting, only the first
one gets trailers; the next day it resets.
- Movie and TV trailers are kept in separate pools, so movies only ever get movie trailers and
episodes only ever get TV show trailers.
- Optional filters can restrict trailers to those matching the genre and/or age rating of the
movie/show you're about to watch (falling back to the full pool if nothing matches), and an
"avoid repeats" mode cycles through all trailers before repeating any.
The output folder still needs to be added as a Jellyfin **Movies** library and scanned for any
of this to work, since that's how Jellyfin discovers the trailer files in the first place.
#### Pre-roll bumpers
Optionally, you can also bookend the trailer block with bumper videos picked at random from
your own existing Jellyfin **Movies** libraries:
- **Trailer Pre-Roll**: plays *before* the trailer block (e.g. a "Now Playing" bumper).
- **Feature Pre-Roll**: plays *after* the trailer block, right before the movie/episode itself
(e.g. a "Feature Presentation" bumper).
Each is independent, disabled by default, and picks a plain random `Movie` item from the
configured library every time (no genre/rating filtering or repeat-avoidance).
## Requirements
- Jellyfin 10.11+
@@ -58,15 +98,24 @@ Go to **Admin → Plugins → CinemaTrailers4Jellyfins**.
|---|---|
| **TMDB API Key** | Your TMDB Read Access Token (JWT) or v3 API key |
| **Trailer Languages** | Restrict downloads to specific trailer languages |
| **Trailer Sources** | Which TMDB lists to pull candidates from (Now Playing, Upcoming, Popular, Top Rated) |
| **Date Range** | Only consider movies released within the last N months |
| **Movie Trailer Sources** | Which TMDB lists to pull movie candidates from (Now Playing, Upcoming, Popular, Top Rated) |
| **TV Show Trailer Sources** | Which TMDB lists to pull TV show candidates from (Airing Today, On The Air, Popular, Top Rated) |
| **Date Range** | Only consider titles released (or first-aired) within the last N months |
| **Output Folder** | Where the fake-movie folders are created |
| **Max trailers per run** | How many trailers to download per task run |
| **Max movie trailers per run** | How many movie trailers to download per task run. 0 = don't download movie trailers |
| **Max TV show trailers per run** | How many TV show trailers to download per task run. 0 = don't download TV show trailers |
| **Pages per source** | How many TMDB pages to fetch per source |
| **Video quality** | 720p / 480p (built-in) or 1080p (requires yt-dlp) |
| **Skip movies already in my Jellyfin library** | Don't download trailers for movies you already own |
| **Skip movies/shows already in my Jellyfin library** | Don't download trailers for movies/shows you already own |
| **Skip trailers already downloaded** | Don't re-download a trailer if its folder already exists |
| **Max trailers to keep** | Oldest trailer folders are deleted first once this cap is exceeded |
| **Trailers per movie** | How many trailers to play before each item via Cinema Mode (`IIntroProvider`). 0 = disabled |
| **Match genre to the movie being played** | Only pick trailers whose genre overlaps with what you're about to watch |
| **Limit to same age rating or lower** | Never play a trailer rated higher than what you're about to watch |
| **Avoid repeating trailers** | Cycle through all available trailers before repeating any |
| **Also play trailers before TV episodes** | Plays before only the first episode a user watches each day |
| **Trailer Pre-Roll library** | Movie library to pick a random "Now Playing" style bumper from, played before the trailer block. None = disabled |
| **Feature Pre-Roll library** | Movie library to pick a random "Feature Presentation" style bumper from, played after the trailer block. None = disabled |
| **yt-dlp path** | Optional path to `yt-dlp` for 1080p+ downloads |
## Running the task
@@ -75,7 +124,8 @@ After configuring, go to **Admin → Scheduled Tasks → CinemaTrailers4Jellyfin
to do an immediate download pass. The task then runs automatically once per day.
After the task completes, add the output folder as a Jellyfin **Movies** library (and run a
library scan) so your Cinema Mode / trailer pre-roll plugin can use the trailers.
library scan) so your Cinema Mode / trailer pre-roll plugin (or this plugin's own
`IIntroProvider`) can use the trailers.
## Building from source

View File

@@ -1,5 +1,5 @@
---
version: 1.0.0.3
version: 1.0.0.8
name: CinemaTrailers4Jellyfins
guid: b581493e-1046-40ed-b6dc-cb8027624984
description: >
@@ -12,10 +12,10 @@ category: General
owner: 514mart
targetAbi: 10.11.0.0
changelog:
- 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
- Fix Trailer Pre-Roll / Feature Pre-Roll bumpers not being found — the
library lookup now walks the configured library folder's children
directly instead of relying on TopParentIds matching, which could fail
to match movies even when they appear correctly in the library
dotnetProjects:
- name: Jellyfin.Plugin.CinemaTrailers4Jellyfins

View File

@@ -8,6 +8,46 @@
"owner": "514mart",
"imageUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/raw/branch/main/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Images/logo.svg",
"versions": [
{
"checksum": "a52f1354c9737cf9ec92f588bb4e750e",
"changelog": "- Add Information-level diagnostic logging to IIntroProvider.GetIntros \u2014 logs every call (item, path, and which features are enabled), why an item is skipped, and the outcome of Trailer/Feature Pre-Roll lookups, to help troubleshoot why a pre-roll bumper isn't playing\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.7/cinematrailers4jellyfins_1.0.0.7.zip",
"timestamp": "2026-06-10T15:10:56Z",
"version": "1.0.0.7"
},
{
"checksum": "96e0624e5173e5e6bf829d47118e1b40",
"changelog": "- Add diagnostic logging for Trailer Pre-Roll and Feature Pre-Roll bumpers \u2014 logs a warning if the configured library ID is invalid, and a debug message when no eligible movie is found or which movie was picked, to help troubleshoot why a bumper isn't playing\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.6/cinematrailers4jellyfins_1.0.0.6.zip",
"timestamp": "2026-06-10T14:35:52Z",
"version": "1.0.0.6"
},
{
"checksum": "6c88aaeb38023b327e9b042181dc0708",
"changelog": "- Add Trailer Pre-Roll and Feature Pre-Roll bumpers via IIntroProvider \u2014 pick existing Jellyfin Movie libraries in settings and a random movie from each is played before the trailer block (\"Now Playing\") and/or right before the feature (\"Feature Presentation\"); both are independent and disabled by default\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.5/cinematrailers4jellyfins_1.0.0.5.zip",
"timestamp": "2026-06-10T05:14:23Z",
"version": "1.0.0.5"
},
{
"checksum": "af6051b002939e4e5cd6ef4baf5c85d4",
"changelog": "- Add TV show trailer downloads \u2014 separate TMDB sources (Airing Today, On The Air, Popular, Top Rated) and a \"Max TV show trailers per run\" cap independent from movies; set either cap to 0 to skip that category entirely\n- Add trailers before TV episodes via IIntroProvider \u2014 only the first episode a user watches each day gets trailers, picked using the show's genre/rating for filtering\n- Movie and TV trailers are now kept separate \u2014 movies only ever get movie trailers and episodes only ever get TV show trailers, via a tag written to each trailer's NFO at download time\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.4/cinematrailers4jellyfins_1.0.0.4.zip",
"timestamp": "2026-06-10T04:42:17Z",
"version": "1.0.0.4"
},
{
"checksum": "d43b8d007051d04984d9395d29bc306f",
"changelog": "- Add trailer selection filters \u2014 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\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.3/cinematrailers4jellyfins_1.0.0.3.zip",
"timestamp": "2026-06-09T21:16:16Z",
"version": "1.0.0.3"
},
{
"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",