From 69e9b079c6095ebbdd16150302ecd88f50645f00 Mon Sep 17 00:00:00 2001 From: knom Date: Wed, 16 Jul 2025 20:40:50 +0200 Subject: [PATCH 1/2] Add trailer download functionality with TMDb and YouTube integration - Implemented trailer downloading logic in a new TrailerDownloader class. - Added scheduled task for automatic trailer downloads. - Added necessary dependencies for TMDb and YouTube downloading. - Introduced new configuration options for TMDb API key and trailer download libraries. - Updated HTML configuration to support new features. - Updated README --- .../Configuration/PluginConfiguration.cs | 9 + .../Configuration/config.html | 150 ++++++++- .../Jellyfin.Plugin.CinemaMode.csproj | 2 + .../TrailerDownloader/MediaItem.cs | 19 ++ .../TrailerDownloader/TrailerDownloader.cs | 287 ++++++++++++++++++ .../TrailerDownloader/YoutubeDownloader.cs | 68 +++++ .../TrailerDownloaderScheduledTask.cs | 82 +++++ README.md | 63 +++- 8 files changed, 676 insertions(+), 4 deletions(-) create mode 100644 Jellyfin.Plugin.CinemaMode/TrailerDownloader/MediaItem.cs create mode 100644 Jellyfin.Plugin.CinemaMode/TrailerDownloader/TrailerDownloader.cs create mode 100644 Jellyfin.Plugin.CinemaMode/TrailerDownloader/YoutubeDownloader.cs create mode 100644 Jellyfin.Plugin.CinemaMode/TrailerDownloaderScheduledTask.cs diff --git a/Jellyfin.Plugin.CinemaMode/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.CinemaMode/Configuration/PluginConfiguration.cs index e5575bb..fdbecb7 100644 --- a/Jellyfin.Plugin.CinemaMode/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.CinemaMode/Configuration/PluginConfiguration.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using MediaBrowser.Model.Plugins; @@ -75,6 +76,10 @@ public class PluginConfiguration : BasePluginConfiguration public int NumberOfTrailers { get; set; } public bool TrailerConsumeMode { get; set; } + public string TmdbApiKey { get; set; } + + public string[] TrailerDownloadLibraries { get; set; } + public PluginConfiguration() { TrailerPreRollsLibrary = "-"; @@ -90,6 +95,10 @@ public PluginConfiguration() NumberOfTrailers = 2; EnforceRatingLimitTrailers = true; TrailerConsumeMode = false; + + TrailerDownloadLibraries = []; + + TmdbApiKey = string.Empty; } } } diff --git a/Jellyfin.Plugin.CinemaMode/Configuration/config.html b/Jellyfin.Plugin.CinemaMode/Configuration/config.html index a49bb72..9c7df2c 100644 --- a/Jellyfin.Plugin.CinemaMode/Configuration/config.html +++ b/Jellyfin.Plugin.CinemaMode/Configuration/config.html @@ -21,6 +21,11 @@

Cinema Mode

Playback settings. For more information, access the user guide via the above help button. Pro-tip: Skip any pre-roll or trailer by pressing the next button in the player.

+

+ NEW: It is also possible to automatically download trailers from TMDb / Youtube on a daily + schedule as a task. + The trailers will be stored locally, alongside the movies as "xyz-trailer.mp4". +

Cinema Mode Diagram
@@ -197,8 +202,49 @@

Seasonal Tag Definitions

-
- + + +
+ +

Auto Trailer Download

+
+
+
+ + Help +
+ +
+ The API Key, which must be obtained from TMDb for the downloads to work. +
+
+
+ +
+ When checked, trailers will be downloaded for all movie libraries. When unchecked, only + trailers for selected libraries below will be downloaded. +
+
+
+

Selected Libraries

+
+
+
+
+ Select only specific movie libraries to download trailers from. If no libraries are + selected and "Include All Libraries" is unchecked, no trailers will be downloaded at all. +
+
+
+
@@ -469,6 +515,93 @@

Seasonal Tag Definitions

}); }; + function initLibraryCheckboxes(selectedLibraries, selectAllID, libraryListID, prefix) { + var container = document.getElementById(libraryListID); + container.innerHTML = ""; + + // Check if "all libraries" should be selected + var selectedArray = selectedLibraries; + var includeAllLibraries = selectedArray.includes("*") && selectedArray.length === 1; + + var selectAllCheckBox = document.getElementById(selectAllID); + selectAllCheckBox.checked = includeAllLibraries; + + mediaFolders.then((folderArray) => { + + // Add movie libraries as checkboxes + for (const folder of folderArray) { + if (folder.CollectionType == "movies") { + var listItem = document.createElement('div'); + listItem.setAttribute("class", "listItem listItem-border"); + + var isSelected = selectedArray.includes(folder.ItemId); + + + // Create label + var label = document.createElement('label'); + + // Create checkbox input + var input = document.createElement('input'); + input.setAttribute('is', 'emby-checkbox'); + input.setAttribute('type', 'checkbox'); + input.setAttribute('id', prefix + folder.ItemId); + input.setAttribute('name', prefix + folder.ItemId); + input.setAttribute('data-itemId', folder.ItemId); + if (isSelected) { + input.setAttribute('checked', 'true'); + } + + // Create span for label text + var span = document.createElement('span'); + span.textContent = folder.Name; + + // Assemble elements + label.appendChild(input); + label.appendChild(span); + listItem.appendChild(label); + + container.appendChild(listItem); + } + } + + // Add event listener for "Include All Libraries" checkbox + document.getElementById(selectAllID).addEventListener('change', function () { + var checkboxes = container.querySelectorAll('input[type="checkbox"]'); + for (let checkbox of checkboxes) { + checkbox.checked = this.checked; + checkbox.disabled = this.checked; + } + }); + + // Run same logic right away: if "includeAll" is ticked, all other checkboxes should be checked and disabled + if (includeAllLibraries) { + var checkboxes = container.querySelectorAll('input[type="checkbox"]'); + for (let checkbox of checkboxes) { + checkbox.checked = true; + checkbox.disabled = true; + } + } + }); + } + + function getSelectedLibrariesFromCheckboxes(selectAllID, libraryListID, prefix) { + var includeAllLibraries = document.getElementById(selectAllID).checked; + if (includeAllLibraries) { + return ["*"]; + } + + var container = document.getElementById(libraryListID); + var checkboxes = container.querySelectorAll('input[type="checkbox"]:checked'); + var selected = []; + + for (let checkbox of checkboxes) { + var id = checkbox.getAttribute("data-itemId"); + selected.push(id); + } + + return selected; + } + $('.cinemaModeConfigurationPage').on('pageshow', function () { Dashboard.showLoadingMsg(); clearList('trailerPreRollLibrarySelect'); @@ -480,6 +613,7 @@

Seasonal Tag Definitions

var page = this; ApiClient.getPluginConfiguration(pluginId).then(function (config) { $('#number-trailers', page).val(config.NumberOfTrailers); + $('#tmdb-api-key', page).val(config.TmdbApiKey); document.getElementById('enforce-rating-limit-trailers').checked = config.EnforceRatingLimitTrailers; initLibrarySelector('trailerPreRollLibrarySelect', config.TrailerPreRollsLibrary, config.TrailerPreRollsLibrary); initLibrarySelector('featurePreRollLibrarySelect', config.FeaturePreRollsLibrary, config.FeaturePreRollsLibrary); @@ -494,6 +628,13 @@

Seasonal Tag Definitions

initSeasonalTagDefs(config.SeasonalTagDefinitions); initTrailerRules(config.TrailerSelectionRules); + + initLibraryCheckboxes(config.TrailerDownloadLibraries, + 'trailer-download-include-all-libraries', + 'trailer-download-libraries-list', + 'trailer-download-list-' + ); + Dashboard.hideLoadingMsg(); }); }); @@ -504,6 +645,7 @@

Seasonal Tag Definitions

var TrailerPreRollsLibrary = $('#trailerPreRollLibrarySelect').val(); var FeaturePreRollsLibrary = $('#featurePreRollLibrarySelect').val(); var NumberOfTrailers = $('#number-trailers').val(); + var TmdbApiKey = $('#tmdb-api-key').val(); ApiClient.getPluginConfiguration(pluginId).then(function (config) { config.TrailerPreRollsLibrary = TrailerPreRollsLibrary; @@ -517,8 +659,10 @@

Seasonal Tag Definitions

config.SeasonalTagDefinitions = getSeasonalTagDefs(); config.TrailerSelectionRules = getTrailerRules(); config.NumberOfTrailers = parseInt(NumberOfTrailers); + config.TmdbApiKey = TmdbApiKey; config.EnforceRatingLimitTrailers = document.getElementById('enforce-rating-limit-trailers').checked; config.TrailerConsumeMode = document.getElementById('trailer-rules-consume-mode').checked; + config.TrailerDownloadLibraries = getSelectedLibrariesFromCheckboxes('trailer-download-include-all-libraries', 'trailer-download-libraries-list', 'trailer-download-list-'); ApiClient.updatePluginConfiguration(pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); }); @@ -528,4 +672,4 @@

Seasonal Tag Definitions

- + \ No newline at end of file diff --git a/Jellyfin.Plugin.CinemaMode/Jellyfin.Plugin.CinemaMode.csproj b/Jellyfin.Plugin.CinemaMode/Jellyfin.Plugin.CinemaMode.csproj index 26d2681..2565115 100644 --- a/Jellyfin.Plugin.CinemaMode/Jellyfin.Plugin.CinemaMode.csproj +++ b/Jellyfin.Plugin.CinemaMode/Jellyfin.Plugin.CinemaMode.csproj @@ -10,6 +10,8 @@ + + diff --git a/Jellyfin.Plugin.CinemaMode/TrailerDownloader/MediaItem.cs b/Jellyfin.Plugin.CinemaMode/TrailerDownloader/MediaItem.cs new file mode 100644 index 0000000..5420afc --- /dev/null +++ b/Jellyfin.Plugin.CinemaMode/TrailerDownloader/MediaItem.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.CinemaMode.TrailerDownloader; + +public class MediaItem +{ + public string Id { get; internal set; } + public string Title { get; internal set; } + public IEnumerable Files { get; internal set; } + public string TmdbId { get; internal set; } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.CinemaMode/TrailerDownloader/TrailerDownloader.cs b/Jellyfin.Plugin.CinemaMode/TrailerDownloader/TrailerDownloader.cs new file mode 100644 index 0000000..89cf6c7 --- /dev/null +++ b/Jellyfin.Plugin.CinemaMode/TrailerDownloader/TrailerDownloader.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TMDbLib.Client; +using TMDbLib.Objects.Search; + +namespace Jellyfin.Plugin.CinemaMode.TrailerDownloader; + +public class TrailerDownloader +{ + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + private readonly TMDbClient _tmdbClient; + private readonly YoutubeDownloader _youtubeDownloader; + private readonly string[] _libraryIds; + private readonly string _downloadTempFolder; + private readonly ILibraryManager _libraryManager; + public TrailerDownloader( + string[] libraryIds, + string downloadTempFolder, + string tmdbApiKey, + ILibraryManager libraryManager, + ILogger logger, + IServiceProvider serviceProvider) + { + _logger = logger; + _serviceProvider = serviceProvider; + + _tmdbClient = new TMDbClient(tmdbApiKey); + + _youtubeDownloader = new YoutubeDownloader(_serviceProvider.GetService>()); + + if (libraryIds.Contains("*") && libraryIds.Length > 1) + { + throw new ArgumentOutOfRangeException(nameof(libraryIds), "LibraryIds must not be '*' and include ids at the same time."); + } + _libraryIds = libraryIds; + + _downloadTempFolder = downloadTempFolder ?? throw new ArgumentNullException(nameof(downloadTempFolder)); + _libraryManager = libraryManager; + } + + public async Task RunAsync( + IProgress progress = null, + CancellationToken cancellationToken = default) + { + var downloadStats = new DownloadStats(); + + _logger.LogDebug("Starting trailer download RunAsync..."); + + _logger.LogDebug("Temp folder is: {Folder}", _downloadTempFolder); + + Directory.CreateDirectory(_downloadTempFolder); + + // Cleaning up temp folder + _logger.LogDebug("Cleaning up temp folder"); + foreach (var file in Directory.GetFiles(_downloadTempFolder, "*-trailer.mp4")) + { + _logger.LogDebug("Deleting file {file}", file); + File.Delete(file); + } + + _logger.LogDebug("Initializing Youtube Downloader"); + await _youtubeDownloader.Init(); + + List jellyfinMovies = []; + + if (_libraryIds.Length == 0) + { + _logger.LogWarning("No library selected in configuration. Skipping."); + + // return empty stats and finish + return downloadStats; + } + else if (_libraryIds.Length == 1 && _libraryIds.Contains("*")) + { + _logger.LogInformation("Fetching movies with TMDb ID from all libraries"); + // leaving guids as an empty array --> means ALL + + jellyfinMovies.AddRange(await GetItemsWithTmdbIdAsync(null)); + } + else + { + var guids = _libraryIds.Select(id => Guid.ParseExact(id, "N")); + _logger.LogInformation("Fetching movies with TMDb ID from selected libraries: {libs}", string.Join(',', guids)); + + foreach (var g in guids) + { + jellyfinMovies.AddRange(await GetItemsWithTmdbIdAsync(g)); + } + } + + _logger.LogInformation("Found {Count} movies with TMDb ID in library", jellyfinMovies.Count); + + int movieIdx = 0; + + downloadStats.Total = jellyfinMovies.Count; + + foreach (var movie in jellyfinMovies) + { + movieIdx++; + progress?.Report(100.0 / jellyfinMovies.Count * movieIdx); + + if (cancellationToken != default && cancellationToken.IsCancellationRequested) + { + _logger.LogInformation("Trailer download cancelled."); + return downloadStats; + } + + _logger.LogInformation("Processing movie {Index}/{Count}: {Movie} [TMDbId: {Id}]", + movieIdx, jellyfinMovies.Count, + movie.Title, + movie.TmdbId); + + if (movie.Files == null || !movie.Files.Any()) + { + _logger.LogError("No files found for movie: {Movie}. Skipping.", movie.Title); + downloadStats.Error++; + continue; + } + if (!File.Exists(movie.Files.First())) + { + _logger.LogError("Movie file does not exist: {File}. Skipping.", movie.Files.First()); + downloadStats.Error++; + continue; + } + + string movieFolder = Path.TrimEndingDirectorySeparator(Path.GetDirectoryName(movie.Files.FirstOrDefault())); + if (string.IsNullOrEmpty(movieFolder) || !Directory.Exists(movieFolder)) + { + _logger.LogError("No existing movie folder found for {Movie}. Skipping.", movie.Title); + downloadStats.Error++; + continue; + } + + var movieFilename = Path.GetFileNameWithoutExtension(movie.Files.First()); + + _logger.LogDebug("File for movie: {Movie} [TMDbId: {Id}] - {file}", + movie.Title, + movie.TmdbId, + movie.Files.First()); + + string trailerFileName = $"{movieFilename}-trailer.mp4"; + + string trailerFullPath = ""; + if (movieFolder.EndsWith(movieFilename)) + { + // If the movie folder already ends with the movie filename, we assume it's already in a subfolder + trailerFullPath = Path.Combine(movieFolder, trailerFileName); + } + else + { + // Otherwise, we create a subfolder for the movie + trailerFullPath = Path.Combine(movieFolder, movieFilename, trailerFileName); + } + + _logger.LogDebug("Trailer file name: {Trailerfile}", trailerFileName); + _logger.LogDebug("Trailer full path: {Trailerfile}", trailerFullPath); + + if (File.Exists(trailerFullPath)) + { + _logger.LogInformation("Trailer already exists, skipping."); + downloadStats.Existing++; + continue; + } + + if (Directory.Exists(Path.GetDirectoryName(trailerFullPath))) + { + _logger.LogWarning("Subfolder already exists: {Folder}", Path.GetDirectoryName(trailerFullPath)); + // downloadStats.Error++; + // continue; + } + + var tmdbMovie = await _tmdbClient.GetMovieAsync(movie.TmdbId, TMDbLib.Objects.Movies.MovieMethods.Videos, cancellationToken); + if (tmdbMovie?.Videos?.Results == null) + { + _logger.LogWarning("No trailer found in TMDb for movie: {Movie}. Skipping.", movie.Title); + downloadStats.NoTrailer++; + continue; + } + + var trailerKey = tmdbMovie.Videos.Results + .Where(v => v.Type == "Trailer" && v.Site == "YouTube") + .Select(v => v.Key) + .FirstOrDefault(); + + if (string.IsNullOrEmpty(trailerKey)) + { + _logger.LogWarning("No trailer found in TMDb for movie: {Movie}. Skipping.", movie.Title); + downloadStats.NoTrailer++; + continue; + } + + var trailerUrl = $"https://www.youtube.com/watch?v={trailerKey}"; + + _logger.LogInformation("Downloading trailer for {Movie}: {Url}", movie.Title, trailerUrl); + + bool success = await _youtubeDownloader.DownloadAsync( + trailerUrl, + Path.GetFileNameWithoutExtension(trailerFileName), + _downloadTempFolder); + + if (!success) + { + _logger.LogWarning("Download failed for {Movie}: {Url}. Skipping.", movie.Title, trailerUrl); + downloadStats.DownloadError++; + continue; + } + + _logger.LogInformation("Trailer downloaded for {Movie}: {Url}", movie.Title, trailerUrl); + + string finalFolder = Path.GetDirectoryName(trailerFullPath)!; + Directory.CreateDirectory(finalFolder); + + _logger.LogDebug("Moving trailer to {Path}", trailerFullPath); + + File.Move(Path.Combine(_downloadTempFolder, trailerFileName), trailerFullPath); + _logger.LogDebug("Trailer moved to: {Path}", trailerFullPath); + + var movieFiles = Directory.GetFiles(movieFolder, $"{movieFilename}*.*"); + foreach (var file in movieFiles) + { + var dest = Path.Combine(finalFolder, Path.GetFileName(file)); + + if (!File.Exists(dest) && file != dest) + { + _logger.LogDebug("Moving movie file {File} to {dest}", file, dest); + File.Move(file, dest); + } + } + + _logger.LogInformation("Downloaded trailer for {Movie} to {Folder}", movie.Title, finalFolder); + downloadStats.Downloaded++; + } + + return downloadStats; + } + + private Task> GetItemsWithTmdbIdAsync(Guid? parentId) + { + var query = new MediaBrowser.Controller.Entities.InternalItemsQuery + { + EnableTotalRecordCount = true, + IsVirtualItem = false, + HasTmdbId = true, + Recursive = true, + IncludeItemTypes = [BaseItemKind.Movie], + }; + + // if there is a parentId passed, only fetch for specific parent + if (parentId.HasValue) + { + query.ParentId = parentId.Value; + } + + var itemsWithTmdb = _libraryManager.GetItemList(query).OfType() + .Where(item => item.HasProviderId(MetadataProvider.Tmdb)) + .Select(item => new MediaItem + { + Id = item.Id.ToString(), + Title = item.Name, + Files = item.GetMediaSources(false).Select(file => file.Path).ToList(), + TmdbId = item.GetProviderId("Tmdb") + }); + + return Task.FromResult(itemsWithTmdb); + } + + public class DownloadStats + { + public int Total { get; set; } + public int Downloaded { get; set; } + public int Error { get; set; } + public int Existing { get; set; } + public int NoTrailer { get; set; } + public int DownloadError { get; set; } + } +} diff --git a/Jellyfin.Plugin.CinemaMode/TrailerDownloader/YoutubeDownloader.cs b/Jellyfin.Plugin.CinemaMode/TrailerDownloader/YoutubeDownloader.cs new file mode 100644 index 0000000..fc12754 --- /dev/null +++ b/Jellyfin.Plugin.CinemaMode/TrailerDownloader/YoutubeDownloader.cs @@ -0,0 +1,68 @@ +namespace Jellyfin.Plugin.CinemaMode.TrailerDownloader; + +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using System; +using System.IO; +using System.Linq; + +public class YoutubeDownloader +{ + private readonly ILogger _logger; + public YoutubeDownloader(ILogger logger) + { + _logger = logger; + } + + public async Task Init() + { + _logger.LogDebug("Downloading binaries..."); + + await YoutubeDLSharp.Utils.DownloadBinaries(); + + _logger.LogDebug("Binaries downloaded successfully."); + + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + _logger.LogDebug("Setting executable permissions for yt-dlp on Linux..."); + + // Fix a bug in YoutubeDlSharp here + File.SetUnixFileMode("yt-dlp", UnixFileMode.UserExecute | UnixFileMode.UserRead | UnixFileMode.UserWrite | + UnixFileMode.GroupExecute | UnixFileMode.GroupRead | + UnixFileMode.OtherExecute | UnixFileMode.OtherRead); + + _logger.LogDebug("Executable permissions set for yt-dlp on Linux."); + } + } + + public async Task DownloadAsync(string url, string title, string outputFolder) + { + var youtubeDl = new YoutubeDLSharp.YoutubeDL + { + // YoutubeDLPath = "yt-dlp", + // FFmpegPath = "ffmpeg", + OutputFolder = outputFolder + }; + + _logger.LogDebug("Starting download for '{Title}' from {Url}", title, url); + var options = new YoutubeDLSharp.Options.OptionSet() { Output = $"{youtubeDl.OutputFolder}/{title}.%(ext)s" }; + + var result = await youtubeDl.RunVideoDownload(url, format: "mp4", overrideOptions: options); + + if (!result.Success) + { + _logger.LogError("Youtube download failed for {Title}: {Error}", title, result.ErrorOutput); + + if (result.ErrorOutput.Contains("/usr/bin/env: ‘python3’: No such file or directory")) + { + _logger.LogCritical("Python3 not found on the machine, cannot download trailers!"); + throw new("Python3 not found on the machine, cannot download trailers!"); + } + + return false; + } + + _logger.LogDebug("Download succeeded: {Title}", title); + return true; + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.CinemaMode/TrailerDownloaderScheduledTask.cs b/Jellyfin.Plugin.CinemaMode/TrailerDownloaderScheduledTask.cs new file mode 100644 index 0000000..757c456 --- /dev/null +++ b/Jellyfin.Plugin.CinemaMode/TrailerDownloaderScheduledTask.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using MediaBrowser.Common; +using MediaBrowser.Controller; +using System.IO; + +namespace Jellyfin.Plugin.CinemaMode; + +public class TrailerDownloadScheduledTask : IScheduledTask +{ + private readonly ILibraryManager _libraryManager; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly string _tempPath; + + public string Name => "Download Trailers"; + public string Key => "TrailerDownloaderScheduledTask"; + public string Description => "Downloads trailers for movies in the library and puts them on the local folder."; + public string Category => "Cinema Mode"; + + public TrailerDownloadScheduledTask( + ILibraryManager libraryManager, + IServiceProvider serviceProvider, + ILogger logger) + { + _libraryManager = libraryManager; + _serviceProvider = serviceProvider; + _logger = logger; + + _tempPath = Path.Combine(Plugin.Instance.DataFolderPath, "downloads"); + + _logger.LogDebug("Using temp path: {TempPath}", _tempPath); + } + + public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + _logger.LogInformation("Starting TrailerDownload Scheduled Task..."); + + if (string.IsNullOrEmpty(Plugin.Instance.Configuration.TmdbApiKey)) + { + _logger.LogError("TMDB API Key is not configured. Please set the TMDB API Key in the plugin configuration. Trailer download will not proceed."); + return; + } + + var trailerDownloader = new TrailerDownloader.TrailerDownloader( + libraryIds: Plugin.Instance.Configuration.TrailerDownloadLibraries, + downloadTempFolder: _tempPath, + tmdbApiKey: Plugin.Instance.Configuration.TmdbApiKey, + libraryManager: _libraryManager, + logger: _serviceProvider.GetService>(), + serviceProvider: _serviceProvider + ); + + var stats = await trailerDownloader.RunAsync(progress, cancellationToken); + + _logger.LogInformation("TrailerDownload Scheduled Task completed successfully."); + _logger.LogInformation(" {s} movies with TMDb ID processed", stats.Total); + _logger.LogInformation(" {s} movies with existing trailers", stats.Existing); + _logger.LogInformation(" {s} trailers downloaded", stats.Downloaded); + _logger.LogInformation(" {s} movies with no trailer", stats.NoTrailer); + _logger.LogInformation(" {s} errors while downloading trailers", stats.DownloadError); + _logger.LogInformation(" {s} errors", stats.Error); + } + + public IEnumerable GetDefaultTriggers() + { + return + [ + new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerDaily, + TimeOfDayTicks = TimeSpan.FromHours(7).Add(TimeSpan.FromMinutes(30)).Ticks, // Run daily at 7:30 AM + } + ]; + } +} diff --git a/README.md b/README.md index 799d534..21a1176 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ addition, pre-roll videos can be played before and after the block of trailers. level by turning off 'Cinema Mode' in the users Playback settings. Pro-tip: Skip any pre-roll or trailer by pressing the next button in the player. For more details see the [User Guide](#user-guide) +NEW: It is also possible to [automatically download trailers] (#auto-trailer-download) from TMDb / Youtube on a daily schedule as a task. The trailers will be stored locally, alongside the movies as "xyz-trailer.mp4". + ## Installation To install this plugin, you will first need to add the repository in Jellyfin. Under 'Repositories' in the 'Plugin' @@ -33,6 +35,23 @@ More information about installing plugins can be found in the official docs [here](https://jellyfin.org/docs/general/server/plugins/index.html#installing). A quick web search will also turn up plenty of great tutorial videos for setting up Jellyfin, including how to install 3rd party plugins. +### Python 3 requirement (for auto trailer download only) + +To automatically download trailers from YouTube **Python 3** is required on the host or inside the Docker container. +This is because the feature uses [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) under the hood to fetch and download trailers. + +#### 📦 Example: Dockerfile with Jellyfin + Python 3 + +Here is an example Dockerfile for running Jellyfin w. latest Python3. + +```Dockerfile +FROM jellyfin/jellyfin:latest + +# Install Python 3 and pip +RUN apt-get update && \ + apt-get install -y python3 python3-pip && \ + rm -rf /var/lib/apt/lists/* +``` ## User Guide

@@ -100,10 +119,12 @@ ratings (`Unrated` or left `blank`) are considered suitable for all audiences. E ### Trailers -The plugin will automatically find any trailers you have stored alongside the movies in your Jellyfin library. The +The plugin will find any trailers you have stored alongside the movies in your Jellyfin library. The plugin does not support playback of remote trailers. For information on how to add local trailers to Jellyfin, follow [this guide](https://jellyfin.org/docs/general/server/media/movies/#movie-extras). +There's now a new functionality built-in to [download trailers automatically](#auto-trailer-download) to the local library. + #### Number of Trailers This setting will set the number of trailers to play. Setting this to '0' will disable the section. Set to '2' by @@ -147,12 +168,52 @@ Year will be ignored by the plugin. - **End Date** - The day the season ends. Included as part of the season. Only the Month and Day are important, the Year will be ignored by the plugin. +### Auto Trailer Download + +A scheduled task automatically downloads movie trailers from YouTube using data from [The Movie Database (TMDb)](https://www.themoviedb.org/). + +#### How it works + +- The scheduled task scans your configured movie libraries for items with a TMDb ID. +- For each match, it queries TMDb for trailer metadata. +- If a trailer is found, it is downloaded from YouTube using [`yt-dlp`](https://github.com/yt-dlp/yt-dlp). +- Trailers are saved next to the movie file, named as `-trailer.mp4`, following [Jellyfin’s local trailer naming convention](https://jellyfin.org/docs/general/server/media/videos/#trailers). + +These trailers are available for playback in Jellyfin and can be used in the Cinema Mode Trailer section. + +#### Configuration + +Go to the Plugin's configuration page to enable and control trailer downloading: + +- **TMDb API Key** + Required to access TMDb trailer metadata. + You can generate an API key from [developer.themoviedb.org](https://developer.themoviedb.org/docs/getting-started). + +- **Include All Libraries** + When enabled, all movie libraries are scanned during the trailer download task. + When disabled, you must select specific libraries manually. + +- **Selected Libraries** + Only shown if "Include All Libraries" is unchecked. + Select one or more movie libraries to limit trailer downloading to specific content. + ⚠️ If no libraries are selected and "Include All Libraries" is off, **no trailers will be downloaded**. + +#### Scheduled Task + +- A Jellyfin scheduled task called **Download Trailers** will appear under scheduled tasks. +- You can run it manually or let it run automatically on a schedule. +- Make sure to configure your TMDb API key, the libraries that should be scanned and have Python3 installed before running the task. + ### Troubleshooting If the plugin is not providing intros check the following: - "Cinema Mode" is enabled in your users playback settings. - You have some trailers stored alongside your media following the naming conventions given [here](https://jellyfin.org/docs/general/server/media/movies/#movie-extras). +- Python3 is installed for the automatic trailer downloads. +- Check the log files for any errors from Cinema Mode + +#### Configuration ## Build Process From d23b76f815a0558ba4f79734163568452bfa20a5 Mon Sep 17 00:00:00 2001 From: knom Date: Mon, 27 Oct 2025 16:03:56 +0100 Subject: [PATCH 2/2] Updated Task Trigger --- Jellyfin.Plugin.CinemaMode/TrailerDownloaderScheduledTask.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jellyfin.Plugin.CinemaMode/TrailerDownloaderScheduledTask.cs b/Jellyfin.Plugin.CinemaMode/TrailerDownloaderScheduledTask.cs index 757c456..d5776e1 100644 --- a/Jellyfin.Plugin.CinemaMode/TrailerDownloaderScheduledTask.cs +++ b/Jellyfin.Plugin.CinemaMode/TrailerDownloaderScheduledTask.cs @@ -74,7 +74,7 @@ public IEnumerable GetDefaultTriggers() [ new TaskTriggerInfo { - Type = TaskTriggerInfo.TriggerDaily, + Type = TaskTriggerInfoType.DailyTrigger, TimeOfDayTicks = TimeSpan.FromHours(7).Add(TimeSpan.FromMinutes(30)).Ticks, // Run daily at 7:30 AM } ];