Files
Martin d850e12964
All checks were successful
Publish Release / release (push) Successful in 9s
feat: restrict anime trailers, not all animation (v1.0.0.12)
v1.0.0.11 kept every animated trailer away from non-animated content, which
also blocked Western animation (Pixar, Disney...). Only anime should be
restricted.

At download, a trailer is tagged ct4j-anime when TMDB has the "anime" or
"based on manga" keyword, or it is Animation in ja/zh/ko (keywords are now
appended to the details request). At playback, anime trailers are dropped
unless the feature is anime (genre "Anime", or those keywords as tags).
Western animation follows the normal genre/rating filters again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:57:12 -04:00

430 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
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;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
{
public class DownloadTrailersTask : IScheduledTask
{
private readonly ILogger<DownloadTrailersTask> _logger;
private readonly ILibraryManager _libraryManager;
private readonly TmdbService _tmdbService;
private readonly TrailerDownloadService _downloadService;
private readonly FakeMovieService _fakeMovieService;
public string Name => "Download TMDB Trailers";
public string Key => "CinemaTrailers4JellyfinsDownload";
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(
ILogger<DownloadTrailersTask> logger,
ILibraryManager libraryManager,
TmdbService tmdbService,
TrailerDownloadService downloadService,
FakeMovieService fakeMovieService)
{
_logger = logger;
_libraryManager = libraryManager;
_tmdbService = tmdbService;
_downloadService = downloadService;
_fakeMovieService = fakeMovieService;
}
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
yield return new TaskTriggerInfo
{
Type = TaskTriggerInfoType.IntervalTrigger,
IntervalTicks = TimeSpan.FromHours(24).Ticks,
};
}
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var config = Plugin.Instance.Configuration;
if (string.IsNullOrWhiteSpace(config.TmdbApiKey))
{
_logger.LogWarning("|CinemaTrailers4Jellyfins| No TMDB API key configured. Skipping task.");
return;
}
if (string.IsNullOrWhiteSpace(config.DownloadFolder))
{
_logger.LogWarning("|CinemaTrailers4Jellyfins| No output folder configured. Skipping task.");
return;
}
var downloadMovies = config.MaxTrailersToDownload > 0;
var downloadTvShows = config.MaxTvTrailersToDownload > 0;
if (!downloadMovies && !downloadTvShows)
{
_logger.LogWarning("|CinemaTrailers4Jellyfins| Both 'Max movie trailers' and 'Max TV show trailers' are 0. Skipping task.");
return;
}
Directory.CreateDirectory(config.DownloadFolder);
CleanupOldTrailers(config);
progress.Report(5);
var allowedLanguages = string.IsNullOrWhiteSpace(config.AllowedLanguages)
? null
: new HashSet<string>(
config.AllowedLanguages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
StringComparer.OrdinalIgnoreCase) as IReadOnlySet<string>;
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);
if (config.SkipMoviesInLibrary)
{
candidates = candidates
.Where(m => !libraryTmdbIds.Contains(m.Id.ToString()))
.ToList();
_logger.LogInformation("|CinemaTrailers4Jellyfins| {Count} candidates remain after filtering library movies", candidates.Count);
}
if (candidates.Count == 0)
{
_logger.LogInformation("|CinemaTrailers4Jellyfins| No new movie candidates to download.");
return 0;
}
int downloaded = 0;
int processed = 0;
foreach (var movie in candidates)
{
cancellationToken.ThrowIfCancellationRequested();
if (downloaded >= config.MaxTrailersToDownload)
break;
double taskProgress = progressStart + (progressEnd - progressStart) * processed / candidates.Count;
progress.Report(taskProgress);
processed++;
var paths = BuildMediaPaths(movie.Title, movie.Year, config);
if (config.SkipAlreadyDownloaded && File.Exists(paths.TrailerPath))
{
_logger.LogDebug("|CinemaTrailers4Jellyfins| Already downloaded: {Path}", paths.MediaFolder);
continue;
}
var trailers = await _tmdbService.GetTrailersAsync(
"movie", movie.Id.ToString(), config.TmdbApiKey, allowedLanguages, cancellationToken).ConfigureAwait(false);
if (trailers.Count == 0)
{
_logger.LogDebug("|CinemaTrailers4Jellyfins| No YouTube trailers on TMDB for '{Title}'", movie.Title);
continue;
}
var trailer = trailers[0];
_logger.LogInformation("|CinemaTrailers4Jellyfins| Downloading '{Trailer}' for '{Movie}'", trailer.Name, movie.Title);
var success = await _downloadService.DownloadAsync(
trailer.Key,
paths.TrailerPath,
config.PreferredVideoHeight,
config.YtDlpPath,
cancellationToken).ConfigureAwait(false);
if (!success)
{
// Don't leave an empty folder behind: it would count toward MaxTotalTrailers
// (pushing out real trailers) and would never be retried.
TryDeleteFolder(paths.MediaFolder);
continue;
}
var fakeMovieReady = await _fakeMovieService.CopyFakeMovieAsync(paths.FakeMoviePath, cancellationToken).ConfigureAwait(false);
if (!fakeMovieReady)
{
_logger.LogError(
"|CinemaTrailers4Jellyfins| Could not create the fake movie file for '{Movie}'. Removing incomplete folder {Folder}.",
movie.Title, paths.MediaFolder);
TryDeleteFolder(paths.MediaFolder);
continue;
}
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,
metadata.IsAnime ? new[] { TrailerTags.Anime } : null);
downloaded++;
_logger.LogInformation(
"|CinemaTrailers4Jellyfins| [{Done}/{Max}] Saved trailer for '{Movie}' → {Path}",
downloaded, config.MaxTrailersToDownload, movie.Title, paths.MediaFolder);
}
_logger.LogInformation("|CinemaTrailers4Jellyfins| Movie trailers complete. Downloaded {Count}.", downloaded);
return downloaded;
}
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 && File.Exists(paths.TrailerPath))
{
_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)
{
// Don't leave an empty folder behind: it would count toward MaxTotalTrailers
// (pushing out real trailers) and would never be retried.
TryDeleteFolder(paths.MediaFolder);
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,
metadata.IsAnime ? new[] { TrailerTags.TvShow, TrailerTags.Anime } : 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 movies = _libraryManager
.GetItemList(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.Movie }, Recursive = true })
.OfType<Movie>();
foreach (var movie in movies)
{
var tmdbId = movie.GetProviderId(MetadataProvider.Tmdb);
if (!string.IsNullOrEmpty(tmdbId))
ids.Add(tmdbId);
}
return ids;
}
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)
{
if (config.MaxTotalTrailers <= 0)
return;
var folders = Directory.GetDirectories(config.DownloadFolder)
.Select(f => new DirectoryInfo(f))
.OrderBy(d => d.CreationTimeUtc)
.ToList();
if (folders.Count <= config.MaxTotalTrailers)
return;
var toDelete = folders.Take(folders.Count - config.MaxTotalTrailers);
foreach (var folder in toDelete)
{
_logger.LogInformation("|CinemaTrailers4Jellyfins| Deleting oldest trailer to stay under cap: {Folder}", folder.Name);
TryDeleteFolder(folder.FullName);
}
}
private void TryDeleteFolder(string folderPath)
{
try
{
if (Directory.Exists(folderPath))
Directory.Delete(folderPath, recursive: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| Failed to delete folder {Folder}", folderPath);
}
}
private readonly record struct MediaPaths(string MediaFolder, string FakeMoviePath, string TrailerPath, string NfoPath);
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 mediaFolder = Path.Combine(config.DownloadFolder, folderName);
return new MediaPaths(
MediaFolder: mediaFolder,
FakeMoviePath: Path.Combine(mediaFolder, $"{folderName}.mp4"),
TrailerPath: Path.Combine(mediaFolder, $"{folderName}-trailer.mp4"),
NfoPath: Path.Combine(mediaFolder, $"{folderName}.nfo"));
}
}
}