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>
This commit is contained in:
Martin
2026-06-10 00:41:37 -04:00
parent f769e33b8d
commit f49c32f181
9 changed files with 542 additions and 127 deletions

View File

@@ -8,13 +8,20 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Configuration
public string TmdbApiKey { get; set; } = string.Empty; public string TmdbApiKey { get; set; } = string.Empty;
// ── Sources ─────────────────────────────────────────────────────────── // ── Sources (Movies) ─────────────────────────────────────────────────
public bool SourceNowPlaying { get; set; } = true; public bool SourceNowPlaying { get; set; } = true;
public bool SourceUpcoming { get; set; } = true; public bool SourceUpcoming { get; set; } = true;
public bool SourcePopular { get; set; } = false; public bool SourcePopular { get; set; } = false;
public bool SourceTopRated { 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 ──────────────────────────────────────────────────────── // ── Date Range ────────────────────────────────────────────────────────
public int ReleaseDateRangeMonths { get; set; } = 6; public int ReleaseDateRangeMonths { get; set; } = 6;
@@ -22,7 +29,13 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Configuration
// ── Download Settings ───────────────────────────────────────────────── // ── Download Settings ─────────────────────────────────────────────────
public string DownloadFolder { get; set; } = string.Empty; 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; 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 MaxPagesPerSource { get; set; } = 3;
public int PreferredVideoHeight { get; set; } = 720; public int PreferredVideoHeight { get; set; } = 720;
public bool SkipAlreadyDownloaded { get; set; } = true; public bool SkipAlreadyDownloaded { get; set; } = true;
@@ -52,5 +65,8 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Configuration
/// <summary>Cycle through all trailers before repeating any.</summary> /// <summary>Cycle through all trailers before repeating any.</summary>
public bool AvoidRepeats { get; set; } = true; 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;
} }
} }

View File

@@ -72,11 +72,11 @@
</div> </div>
</fieldset> </fieldset>
<!-- Sources --> <!-- Movie Sources -->
<fieldset class="verticalSection verticalSection-extrabottompadding"> <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"> <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> </p>
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
@@ -120,11 +120,60 @@
</div> </div>
</fieldset> </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 --> <!-- Date Range -->
<fieldset class="verticalSection verticalSection-extrabottompadding"> <fieldset class="verticalSection verticalSection-extrabottompadding">
<legend><h3 class="sectionTitle">Date Range</h3></legend> <legend><h3 class="sectionTitle">Date Range</h3></legend>
<div class="selectContainer"> <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"> <select is="emby-select" id="date-range" class="emby-select-withcolor emby-select">
<option value="3">Last 3 months</option> <option value="3">Last 3 months</option>
<option value="6">Last 6 months</option> <option value="6">Last 6 months</option>
@@ -133,8 +182,9 @@
<option value="0">All time (no limit)</option> <option value="0">All time (no limit)</option>
</select> </select>
<div class="fieldDescription"> <div class="fieldDescription">
Applies to all sources. "Now Playing" and "Upcoming" already have tight date windows Applies to all sources (movie release date / TV show first-air date). "Now Playing",
set by TMDB, but this provides an additional filter. "Upcoming", "Airing Today" and "On The Air" already have tight date windows set by
TMDB, but this provides an additional filter.
</div> </div>
</div> </div>
</fieldset> </fieldset>
@@ -153,10 +203,20 @@
</div> </div>
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="max-trailers">Max trailers per run</label> <label class="inputLabel inputLabelUnfocused" for="max-trailers">Max movie trailers per run</label>
<input type="number" id="max-trailers" is="emby-input" min="1" max="200" /> <input type="number" id="max-trailers" is="emby-input" min="0" max="200" />
<div class="fieldDescription"> <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>
</div> </div>
@@ -164,7 +224,7 @@
<label class="inputLabel inputLabelUnfocused" for="max-pages">Pages per source</label> <label class="inputLabel inputLabelUnfocused" for="max-pages">Pages per source</label>
<input type="number" id="max-pages" is="emby-input" min="1" max="10" /> <input type="number" id="max-pages" is="emby-input" min="1" max="10" />
<div class="fieldDescription"> <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>
</div> </div>
@@ -180,10 +240,10 @@
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label> <label>
<input is="emby-checkbox" type="checkbox" id="skip-library" /> <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> </label>
<div class="fieldDescription checkboxFieldDescription"> <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>
</div> </div>
@@ -193,7 +253,7 @@
<span>Skip trailers already downloaded</span> <span>Skip trailers already downloaded</span>
</label> </label>
<div class="fieldDescription checkboxFieldDescription"> <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>
</div> </div>
</fieldset> </fieldset>
@@ -268,6 +328,17 @@
Resets automatically once every trailer has been shown. Default: on. Resets automatically once every trailer has been shown. Default: on.
</div> </div>
</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> </fieldset>
<!-- Advanced --> <!-- Advanced -->
@@ -295,6 +366,13 @@
<script type="text/javascript"> <script type="text/javascript">
var pluginId = "b581493e-1046-40ed-b6dc-cb8027624984"; 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 () { $('.cinemaTrailers4JellyfinsConfigPage').on('pageshow', function () {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(pluginId).then(function (config) { ApiClient.getPluginConfiguration(pluginId).then(function (config) {
@@ -303,9 +381,14 @@
document.getElementById('source-upcoming').checked = config.SourceUpcoming !== false; document.getElementById('source-upcoming').checked = config.SourceUpcoming !== false;
document.getElementById('source-popular').checked = !!config.SourcePopular; document.getElementById('source-popular').checked = !!config.SourcePopular;
document.getElementById('source-top-rated').checked = !!config.SourceTopRated; 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('date-range').value = String(config.ReleaseDateRangeMonths ?? 6);
document.getElementById('download-folder').value = config.DownloadFolder || ''; document.getElementById('download-folder').value = config.DownloadFolder || '';
document.getElementById('max-trailers').value = config.MaxTrailersToDownload ?? 20; 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('max-pages').value = config.MaxPagesPerSource ?? 3;
document.getElementById('video-quality').value = String(config.PreferredVideoHeight ?? 720); document.getElementById('video-quality').value = String(config.PreferredVideoHeight ?? 720);
document.getElementById('skip-library').checked = config.SkipMoviesInLibrary !== false; document.getElementById('skip-library').checked = config.SkipMoviesInLibrary !== false;
@@ -321,6 +404,7 @@
document.getElementById('filter-genre').checked = !!config.FilterByGenre; document.getElementById('filter-genre').checked = !!config.FilterByGenre;
document.getElementById('filter-rating').checked = !!config.FilterByRating; document.getElementById('filter-rating').checked = !!config.FilterByRating;
document.getElementById('avoid-repeats').checked = config.AvoidRepeats !== false; document.getElementById('avoid-repeats').checked = config.AvoidRepeats !== false;
document.getElementById('trailers-for-episodes').checked = !!config.TrailersForEpisodes;
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
}); });
}); });
@@ -333,9 +417,14 @@
config.SourceUpcoming = document.getElementById('source-upcoming').checked; config.SourceUpcoming = document.getElementById('source-upcoming').checked;
config.SourcePopular = document.getElementById('source-popular').checked; config.SourcePopular = document.getElementById('source-popular').checked;
config.SourceTopRated = document.getElementById('source-top-rated').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.ReleaseDateRangeMonths = parseInt(document.getElementById('date-range').value, 10);
config.DownloadFolder = document.getElementById('download-folder').value; 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.MaxPagesPerSource = parseInt(document.getElementById('max-pages').value, 10) || 3;
config.PreferredVideoHeight = parseInt(document.getElementById('video-quality').value, 10) || 720; config.PreferredVideoHeight = parseInt(document.getElementById('video-quality').value, 10) || 720;
config.SkipMoviesInLibrary = document.getElementById('skip-library').checked; config.SkipMoviesInLibrary = document.getElementById('skip-library').checked;
@@ -347,11 +436,12 @@
}); });
// 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 = parseIntOrDefault(document.getElementById('max-total-trailers').value, 50);
config.TrailersPerMovie = parseInt(document.getElementById('trailers-per-movie').value, 10) || 0; config.TrailersPerMovie = parseInt(document.getElementById('trailers-per-movie').value, 10) || 0;
config.FilterByGenre = document.getElementById('filter-genre').checked; config.FilterByGenre = document.getElementById('filter-genre').checked;
config.FilterByRating = document.getElementById('filter-rating').checked; config.FilterByRating = document.getElementById('filter-rating').checked;
config.AvoidRepeats = document.getElementById('avoid-repeats').checked; config.AvoidRepeats = document.getElementById('avoid-repeats').checked;
config.TrailersForEpisodes = document.getElementById('trailers-for-episodes').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.3</AssemblyVersion> <AssemblyVersion>1.0.0.4</AssemblyVersion>
<FileVersion>1.0.0.3</FileVersion> <FileVersion>1.0.0.4</FileVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateDocumentationFile>false</GenerateDocumentationFile> <GenerateDocumentationFile>false</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>false</TreatWarningsAsErrors>

View File

@@ -8,6 +8,7 @@ using Jellyfin.Data.Enums;
using Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services; using Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
@@ -25,7 +26,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
public string Name => "Download TMDB Trailers"; public string Name => "Download TMDB Trailers";
public string Key => "CinemaTrailers4JellyfinsDownload"; 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 string Category => "CinemaTrailers4Jellyfins";
public DownloadTrailersTask( public DownloadTrailersTask(
@@ -67,9 +68,12 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
return; 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; return;
} }
@@ -79,26 +83,64 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
progress.Report(5); 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) var allowedLanguages = string.IsNullOrWhiteSpace(config.AllowedLanguages)
? null ? null
: new HashSet<string>( : new HashSet<string>(
config.AllowedLanguages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), config.AllowedLanguages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
StringComparer.OrdinalIgnoreCase) as IReadOnlySet<string>; 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); var candidates = await _tmdbService.GetCandidateMoviesAsync(config, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("|CinemaTrailers4Jellyfins| Found {Count} candidate movies across all sources", candidates.Count); _logger.LogInformation("|CinemaTrailers4Jellyfins| Found {Count} candidate movies across all sources", candidates.Count);
progress.Report(20);
if (config.SkipMoviesInLibrary) if (config.SkipMoviesInLibrary)
{ {
candidates = candidates candidates = candidates
@@ -109,9 +151,8 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
if (candidates.Count == 0) if (candidates.Count == 0)
{ {
_logger.LogInformation("|CinemaTrailers4Jellyfins| No new candidates to download. All done."); _logger.LogInformation("|CinemaTrailers4Jellyfins| No new movie candidates to download.");
progress.Report(100); return 0;
return;
} }
int downloaded = 0; int downloaded = 0;
@@ -124,20 +165,20 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
if (downloaded >= config.MaxTrailersToDownload) if (downloaded >= config.MaxTrailersToDownload)
break; break;
double taskProgress = 20 + (80.0 * processed / candidates.Count); double taskProgress = progressStart + (progressEnd - progressStart) * processed / candidates.Count;
progress.Report(taskProgress); progress.Report(taskProgress);
processed++; 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; continue;
} }
var trailers = await _tmdbService.GetTrailersAsync( 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) if (trailers.Count == 0)
{ {
@@ -163,27 +204,128 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
{ {
_logger.LogError( _logger.LogError(
"|CinemaTrailers4Jellyfins| Could not create the fake movie file for '{Movie}'. Removing incomplete folder {Folder}.", "|CinemaTrailers4Jellyfins| Could not create the fake movie file for '{Movie}'. Removing incomplete folder {Folder}.",
movie.Title, paths.MovieFolder); movie.Title, paths.MediaFolder);
TryDeleteFolder(paths.MovieFolder); TryDeleteFolder(paths.MediaFolder);
continue; continue;
} }
var metadata = await _tmdbService.GetMovieMetadataAsync( var metadata = await _tmdbService.GetMediaMetadataAsync(
movie.Id.ToString(), config.TmdbApiKey, cancellationToken).ConfigureAwait(false); "movie", movie.Id.ToString(), config.TmdbApiKey, cancellationToken).ConfigureAwait(false);
_fakeMovieService.WriteNfo(paths.NfoPath, movie.Title, movie.Year, metadata.Genres, metadata.Certification); _fakeMovieService.WriteNfo(paths.NfoPath, movie.Title, movie.Year, metadata.Genres, metadata.Certification);
downloaded++; downloaded++;
_logger.LogInformation( _logger.LogInformation(
"|CinemaTrailers4Jellyfins| [{Done}/{Max}] Saved trailer for '{Movie}' → {Path}", "|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); _logger.LogInformation("|CinemaTrailers4Jellyfins| Movie trailers complete. Downloaded {Count}.", downloaded);
progress.Report(100); 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); var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -201,7 +343,25 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
return ids; 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. // movie file and NFO. Removing a trailer always means removing that whole folder.
private void CleanupOldTrailers(Configuration.PluginConfiguration config) 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 safeTitle = string.Concat(title.Split(Path.GetInvalidFileNameChars())).Trim();
var folderName = year.HasValue ? $"{safeTitle} ({year.Value})" : safeTitle; 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( return new MediaPaths(
MovieFolder: movieFolder, MediaFolder: mediaFolder,
FakeMoviePath: Path.Combine(movieFolder, $"{folderName}.mp4"), FakeMoviePath: Path.Combine(mediaFolder, $"{folderName}.mp4"),
TrailerPath: Path.Combine(movieFolder, $"{folderName}-trailer.mp4"), TrailerPath: Path.Combine(mediaFolder, $"{folderName}-trailer.mp4"),
NfoPath: Path.Combine(movieFolder, $"{folderName}.nfo")); 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; /// 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, 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 }; var settings = new XmlWriterSettings { Indent = true };
using var writer = XmlWriter.Create(nfoPath, settings); using var writer = XmlWriter.Create(nfoPath, settings);
@@ -70,6 +70,9 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
writer.WriteElementString("genre", genre); writer.WriteElementString("genre", genre);
if (!string.IsNullOrWhiteSpace(mpaa)) if (!string.IsNullOrWhiteSpace(mpaa))
writer.WriteElementString("mpaa", mpaa); writer.WriteElementString("mpaa", mpaa);
if (tags != null)
foreach (var tag in tags)
writer.WriteElementString("tag", tag);
writer.WriteElementString("lockdata", "true"); writer.WriteElementString("lockdata", "true");
writer.WriteEndElement(); writer.WriteEndElement();
writer.WriteEndDocument(); 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 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 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 public class TmdbService : IDisposable
{ {
@@ -75,7 +75,39 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
/// Fetches candidate movies from the configured TMDB sources, optionally filtered /// Fetches candidate movies from the configured TMDB sources, optionally filtered
/// by a minimum release date. Deduplicates across sources by TMDB ID. /// by a minimum release date. Deduplicates across sources by TMDB ID.
/// </summary> /// </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, Configuration.PluginConfiguration config,
CancellationToken ct) CancellationToken ct)
{ {
@@ -84,36 +116,36 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
: null; : null;
var seen = new HashSet<int>(); 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); .ConfigureAwait(false);
foreach (var m in movies) foreach (var item in items)
{ {
if (seen.Add(m.Id)) if (seen.Add(item.Id))
results.Add(m); 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; return results;
} }
private async Task<List<TmdbMovieResult>> FetchSourcePagesAsync( private async Task<List<TmdbMediaResult>> FetchSourcePagesAsync(
string mediaType,
string endpoint, string endpoint,
string apiKey, string apiKey,
DateTime? releasedAfter, DateTime? releasedAfter,
int maxPages, int maxPages,
CancellationToken ct) 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++) for (int page = 1; page <= maxPages; page++)
{ {
@@ -121,7 +153,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
try 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); using var request = new HttpRequestMessage(HttpMethod.Get, url);
ApplyAuth(request, apiKey); ApplyAuth(request, apiKey);
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); 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(); int totalPages = doc.RootElement.GetProperty("total_pages").GetInt32();
bool anyInRange = false; 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 releaseDate = entry.TryGetProperty(dateField, out var rd) ? rd.GetString() ?? string.Empty : string.Empty;
var title = movie.TryGetProperty("title", out var t) ? t.GetString() ?? string.Empty : string.Empty; var title = entry.TryGetProperty(titleField, out var t) ? t.GetString() ?? string.Empty : string.Empty;
var id = movie.GetProperty("id").GetInt32(); var id = entry.GetProperty("id").GetInt32();
if (releasedAfter.HasValue && DateTime.TryParse(releaseDate, out var parsed)) if (releasedAfter.HasValue && DateTime.TryParse(releaseDate, out var parsed))
{ {
@@ -145,7 +177,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
} }
anyInRange = true; anyInRange = true;
results.Add(new TmdbMovieResult(id, title, releaseDate)); results.Add(new TmdbMediaResult(id, title, releaseDate));
} }
if (page >= totalPages || (releasedAfter.HasValue && !anyInRange)) if (page >= totalPages || (releasedAfter.HasValue && !anyInRange))
@@ -157,7 +189,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
} }
catch (Exception ex) 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; break;
} }
} }
@@ -190,17 +222,21 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
} }
/// <summary> /// <summary>
/// Returns the genre names and US theatrical certification (G, PG, PG-13, R, NC-17) /// Returns the genre names and US certification for a movie or TV show
/// for a movie. Uses a single /movie/{id}?append_to_response=release_dates call. /// (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> /// </summary>
public async Task<TmdbMovieMetadata> GetMovieMetadataAsync( public async Task<TmdbMediaMetadata> GetMediaMetadataAsync(
string mediaType,
string tmdbId, string tmdbId,
string apiKey, string apiKey,
CancellationToken ct) CancellationToken ct)
{ {
try 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); using var request = new HttpRequestMessage(HttpMethod.Get, url);
ApplyAuth(request, apiKey); ApplyAuth(request, apiKey);
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
@@ -222,10 +258,27 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
} }
} }
string? certification = null; string? certification = mediaType == "movie"
if (doc.RootElement.TryGetProperty("release_dates", out var releaseDates) ? GetMovieCertification(doc)
&& releaseDates.TryGetProperty("results", out var rdResults)) : 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()) foreach (var country in rdResults.EnumerateArray())
{ {
if (!country.TryGetProperty("iso_3166_1", out var iso) if (!country.TryGetProperty("iso_3166_1", out var iso)
@@ -233,7 +286,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
continue; continue;
if (!country.TryGetProperty("release_dates", out var dates)) if (!country.TryGetProperty("release_dates", out var dates))
break; return null;
foreach (var date in dates.EnumerateArray()) foreach (var date in dates.EnumerateArray())
{ {
@@ -241,26 +294,40 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
continue; continue;
var certStr = cert.GetString(); var certStr = cert.GetString();
if (!string.IsNullOrWhiteSpace(certStr)) if (!string.IsNullOrWhiteSpace(certStr))
{ return certStr;
certification = certStr;
break;
}
}
break;
}
} }
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); if (!doc.RootElement.TryGetProperty("content_ratings", out var contentRatings)
return new TmdbMovieMetadata(Array.Empty<string>(), null); || !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( public async Task<List<TmdbVideo>> GetTrailersAsync(
string mediaType,
string tmdbId, string tmdbId,
string apiKey, string apiKey,
IReadOnlySet<string>? allowedLanguages, 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 // 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. // 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); using var request = new HttpRequestMessage(HttpMethod.Get, url);
ApplyAuth(request, apiKey); ApplyAuth(request, apiKey);
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
@@ -311,7 +378,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch (Exception ex) 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>(); return new List<TmdbVideo>();
} }
} }

View File

@@ -8,6 +8,7 @@ using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging; 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 ConcurrentDictionary<Guid, HashSet<Guid>> _seenByUser = new();
private static readonly object _seenLock = 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 ILibraryManager _libraryManager;
private readonly ILogger<TrailerIntroProvider> _logger; private readonly ILogger<TrailerIntroProvider> _logger;
@@ -39,9 +44,33 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
if (config == null || config.TrailersPerMovie <= 0) if (config == null || config.TrailersPerMovie <= 0)
return Task.FromResult(Enumerable.Empty<IntroInfo>()); return Task.FromResult(Enumerable.Empty<IntroInfo>());
if (item is not Movie) // 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)
return Task.FromResult(Enumerable.Empty<IntroInfo>()); return Task.FromResult(Enumerable.Empty<IntroInfo>());
if (!IsFirstEpisodeOfDay(user.Id))
return Task.FromResult(Enumerable.Empty<IntroInfo>());
feature = episode.Series ?? (BaseItem)episode;
isEpisode = true;
break;
default:
return Task.FromResult(Enumerable.Empty<IntroInfo>());
}
var outputFolder = config.DownloadFolder?.TrimEnd( var outputFolder = config.DownloadFolder?.TrimEnd(
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (string.IsNullOrEmpty(outputFolder)) if (string.IsNullOrEmpty(outputFolder))
@@ -62,21 +91,24 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
.OfType<Movie>() .OfType<Movie>()
.Where(m => .Where(m =>
m.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true m.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true
&& m.LocalTrailers.Count > 0) && m.LocalTrailers.Count > 0
// 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))) .SelectMany(m => m.LocalTrailers.Select(t => (Movie: m, Trailer: t)))
.ToList(); .ToList();
if (pairs.Count == 0) if (pairs.Count == 0)
{ {
_logger.LogDebug( _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.", + "Ensure the output folder is added as a Jellyfin Movies library and scanned.",
isEpisode ? "TV show" : "movie",
outputFolder); outputFolder);
return Task.FromResult(Enumerable.Empty<IntroInfo>()); return Task.FromResult(Enumerable.Empty<IntroInfo>());
} }
// Apply enabled filters. If nothing survives, fall back to the full pool. // 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; var pool = filtered.Count > 0 ? filtered : pairs;
// Prefer unseen trailers within the pool; reset the cycle when all have been shown. // Prefer unseen trailers within the pool; reset the cycle when all have been shown.
@@ -149,6 +181,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) private static void MarkSeen(Guid userId, IEnumerable<Guid> trailerIds)
{ {
lock (_seenLock) 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,5 +1,5 @@
--- ---
version: 1.0.0.3 version: 1.0.0.4
name: CinemaTrailers4Jellyfins name: CinemaTrailers4Jellyfins
guid: b581493e-1046-40ed-b6dc-cb8027624984 guid: b581493e-1046-40ed-b6dc-cb8027624984
description: > description: >
@@ -12,10 +12,15 @@ category: General
owner: 514mart owner: 514mart
targetAbi: 10.11.0.0 targetAbi: 10.11.0.0
changelog: changelog:
- Add trailer selection filters — genre match, age rating ceiling, and avoid-repeats - Add TV show trailer downloads — separate TMDB sources (Airing Today, On The Air,
cycle; genre and rating are written to each fake-movie NFO at download time from Popular, Top Rated) and a "Max TV show trailers per run" cap independent from
TMDB so Jellyfin can use them for filtering; filters fall back to random when no movies; set either cap to 0 to skip that category entirely
match is found - Add trailers before TV episodes via IIntroProvider — only the first episode a
user watches each day gets trailers, picked using the show's genre/rating for
filtering
- Movie and TV trailers are now kept separate — 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
dotnetProjects: dotnetProjects:
- name: Jellyfin.Plugin.CinemaTrailers4Jellyfins - name: Jellyfin.Plugin.CinemaTrailers4Jellyfins