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.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 _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 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 logger, ILibraryManager libraryManager, TmdbService tmdbService, TrailerDownloadService downloadService, FakeMovieService fakeMovieService) { _logger = logger; _libraryManager = libraryManager; _tmdbService = tmdbService; _downloadService = downloadService; _fakeMovieService = fakeMovieService; } public IEnumerable GetDefaultTriggers() { yield return new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks, }; } public async Task ExecuteAsync(IProgress 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; } if (!config.SourceNowPlaying && !config.SourceUpcoming && !config.SourcePopular && !config.SourceTopRated) { _logger.LogWarning("|CinemaTrailers4Jellyfins| No TMDB sources selected. Enable at least one source. Skipping task."); return; } Directory.CreateDirectory(config.DownloadFolder); CleanupOldTrailers(config); progress.Report(5); var libraryTmdbIds = config.SkipMoviesInLibrary ? GetLibraryTmdbIds() : new HashSet(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( config.AllowedLanguages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), StringComparer.OrdinalIgnoreCase) as IReadOnlySet; _logger.LogInformation("|CinemaTrailers4Jellyfins| Fetching 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 .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 candidates to download. All done."); progress.Report(100); return; } int downloaded = 0; int processed = 0; foreach (var movie in candidates) { cancellationToken.ThrowIfCancellationRequested(); if (downloaded >= config.MaxTrailersToDownload) break; double taskProgress = 20 + (80.0 * processed / candidates.Count); progress.Report(taskProgress); processed++; var paths = BuildMoviePaths(movie.Title, movie.Year, config); if (config.SkipAlreadyDownloaded && Directory.Exists(paths.MovieFolder)) { _logger.LogDebug("|CinemaTrailers4Jellyfins| Already downloaded: {Path}", paths.MovieFolder); continue; } var trailers = await _tmdbService.GetTrailersAsync( 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) 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.MovieFolder); TryDeleteFolder(paths.MovieFolder); continue; } var metadata = await _tmdbService.GetMovieMetadataAsync( movie.Id.ToString(), config.TmdbApiKey, cancellationToken).ConfigureAwait(false); _fakeMovieService.WriteNfo(paths.NfoPath, movie.Title, movie.Year, metadata.Genres, metadata.Certification); downloaded++; _logger.LogInformation( "|CinemaTrailers4Jellyfins| [{Done}/{Max}] Saved trailer for '{Movie}' → {Path}", downloaded, config.MaxTrailersToDownload, movie.Title, paths.MovieFolder); } _logger.LogInformation("|CinemaTrailers4Jellyfins| Task complete. Downloaded {Count} trailer(s).", downloaded); progress.Report(100); } private HashSet GetLibraryTmdbIds() { var ids = new HashSet(StringComparer.OrdinalIgnoreCase); var movies = _libraryManager .GetItemList(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.Movie }, Recursive = true }) .OfType(); foreach (var movie in movies) { var tmdbId = movie.GetProviderId(MetadataProvider.Tmdb); if (!string.IsNullOrEmpty(tmdbId)) ids.Add(tmdbId); } return ids; } // Each trailer lives in its own "{Movie 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 MoviePaths(string MovieFolder, string FakeMoviePath, string TrailerPath, string NfoPath); private static MoviePaths BuildMoviePaths(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); return new MoviePaths( MovieFolder: movieFolder, FakeMoviePath: Path.Combine(movieFolder, $"{folderName}.mp4"), TrailerPath: Path.Combine(movieFolder, $"{folderName}-trailer.mp4"), NfoPath: Path.Combine(movieFolder, $"{folderName}.nfo")); } } }