From c2d2b1ae445a8520184fae77500e82c83a9c87fb Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 8 Jun 2026 14:24:28 -0400 Subject: [PATCH] Initial commit: CinemaTrailers4Jellyfins plugin Adapted from Trailers4Jellyfin: keeps TMDB/YouTube trailer downloading, the scheduled task, language/source/date filters, and trailer rotation, but drops cinema-mode/IIntroProvider entirely. Each trailer now ships in its own fake-movie folder (placeholder video + locked NFO + trailer) for use with a Cinema Mode / trailer pre-roll plugin. --- .github/workflows/release.yml | 103 ++++++ .gitignore | 9 + Jellyfin.Plugin.CinemaTrailers4Jellyfins.sln | 19 ++ .../Configuration/PluginConfiguration.cs | 42 +++ .../Configuration/config.html | 302 ++++++++++++++++++ .../Images/logo.svg | 60 ++++ ...fin.Plugin.CinemaTrailers4Jellyfins.csproj | 24 ++ .../Plugin.cs | 34 ++ .../PluginServiceRegistrator.cs | 20 ++ .../ScheduledTasks/DownloadTrailersTask.cs | 254 +++++++++++++++ .../Services/FakeMovieService.cs | 147 +++++++++ .../Services/TmdbService.cs | 246 ++++++++++++++ .../Services/TrailerDownloadService.cs | 183 +++++++++++ README.md | 93 ++++++ build.yaml | 22 ++ manifest.json | 12 + update_manifest.py | 67 ++++ 17 files changed, 1637 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 Jellyfin.Plugin.CinemaTrailers4Jellyfins.sln create mode 100644 Jellyfin.Plugin.CinemaTrailers4Jellyfins/Configuration/PluginConfiguration.cs create mode 100644 Jellyfin.Plugin.CinemaTrailers4Jellyfins/Configuration/config.html create mode 100644 Jellyfin.Plugin.CinemaTrailers4Jellyfins/Images/logo.svg create mode 100644 Jellyfin.Plugin.CinemaTrailers4Jellyfins/Jellyfin.Plugin.CinemaTrailers4Jellyfins.csproj create mode 100644 Jellyfin.Plugin.CinemaTrailers4Jellyfins/Plugin.cs create mode 100644 Jellyfin.Plugin.CinemaTrailers4Jellyfins/PluginServiceRegistrator.cs create mode 100644 Jellyfin.Plugin.CinemaTrailers4Jellyfins/ScheduledTasks/DownloadTrailersTask.cs create mode 100644 Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FakeMovieService.cs create mode 100644 Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TmdbService.cs create mode 100644 Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TrailerDownloadService.cs create mode 100644 README.md create mode 100644 build.yaml create mode 100644 manifest.json create mode 100644 update_manifest.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b4273c4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,103 @@ +name: Publish Release + +# NOTE: This repo lives on a private Gitea instance +# (https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins), not GitHub. +# Gitea Actions understands a compatible subset of GitHub Actions syntax, but: +# - GitHub-hosted actions (actions/checkout, actions/setup-dotnet) must be reachable +# from your Gitea runner (directly, via a mirror, or vendored) to be usable as-is. +# - softprops/action-gh-release talks to the GitHub API and will NOT work against +# Gitea — it has been replaced below with direct calls to the Gitea Releases API +# via curl, which needs a GITEA_TOKEN secret (an access token with repo write scope). +# Treat this file as a starting template: verify runner/action availability and the +# release-API calls against your instance before relying on it. + +on: + push: + tags: + - 'v*.*.*.*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITEA_TOKEN }} + fetch-depth: 0 + + - name: Setup .NET 9 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Get version from tag + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Build + run: | + dotnet publish \ + Jellyfin.Plugin.CinemaTrailers4Jellyfins/Jellyfin.Plugin.CinemaTrailers4Jellyfins.csproj \ + --configuration Release \ + --output bin + + - name: Package + run: | + VERSION="${{ steps.version.outputs.VERSION }}" + # Always include the plugin DLL itself, then all third-party dependencies. + # Exclude Jellyfin/MediaBrowser framework DLLs — the server already has those loaded. + DEPS=$(find bin -name "*.dll" \ + | grep -Ev "/(Jellyfin\.|MediaBrowser\.|Microsoft\.|System\.|netstandard|mscorlib)" \ + | tr '\n' ' ') + zip -j "cinematrailers4jellyfins_${VERSION}.zip" \ + bin/Jellyfin.Plugin.CinemaTrailers4Jellyfins.dll \ + $DEPS + + - name: Checksum + id: checksum + run: | + VERSION="${{ steps.version.outputs.VERSION }}" + echo "MD5=$(md5sum cinematrailers4jellyfins_${VERSION}.zip | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + + - name: Update manifest.json + run: | + pip install pyyaml --quiet + python3 update_manifest.py "${{ steps.version.outputs.VERSION }}" "${{ steps.checksum.outputs.MD5 }}" + + - name: Commit manifest + run: | + git config user.name "gitea-actions" + git config user.email "gitea-actions@quarantinedstudio.com" + git add manifest.json + git commit -m "chore: update manifest.json for v${{ steps.version.outputs.VERSION }}" || echo "No changes to commit" + git fetch origin main + git rebase origin/main + git push origin HEAD:main + + - name: Create Gitea release and upload asset + env: + GITEA_SERVER: https://www.git.quarantinedstudio.com + GITEA_REPO: mvezina/CinemaTrailers4Jellyfins + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + VERSION="${{ steps.version.outputs.VERSION }}" + TAG="${{ github.ref_name }}" + ZIP="cinematrailers4jellyfins_${VERSION}.zip" + BODY="See build.yaml for the changelog. To install, add ${GITEA_SERVER}/${GITEA_REPO}/raw/branch/main/manifest.json as a plugin repository in Jellyfin -> Admin -> Plugins -> Repositories." + + RELEASE_ID=$(curl -sf -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"${TAG}\", \"name\": \"Release ${TAG}\", \"body\": \"${BODY}\"}" \ + "${GITEA_SERVER}/api/v1/repos/${GITEA_REPO}/releases" \ + | python3 -c "import sys, json; print(json.load(sys.stdin)['id'])") + + curl -sf -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -F "attachment=@${ZIP}" \ + "${GITEA_SERVER}/api/v1/repos/${GITEA_REPO}/releases/${RELEASE_ID}/assets?name=${ZIP}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a04c07b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +bin/ +obj/ +*.user +.vs/ +.idea/ +*.suo +*.swp +.claude/ +.serena/ diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins.sln b/Jellyfin.Plugin.CinemaTrailers4Jellyfins.sln new file mode 100644 index 0000000..95754ce --- /dev/null +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins.sln @@ -0,0 +1,19 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.CinemaTrailers4Jellyfins", "Jellyfin.Plugin.CinemaTrailers4Jellyfins\Jellyfin.Plugin.CinemaTrailers4Jellyfins.csproj", "{88309283-2886-4B8B-9424-C1DD0B6A8C74}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {88309283-2886-4B8B-9424-C1DD0B6A8C74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {88309283-2886-4B8B-9424-C1DD0B6A8C74}.Debug|Any CPU.Build.0 = Debug|Any CPU + {88309283-2886-4B8B-9424-C1DD0B6A8C74}.Release|Any CPU.ActiveCfg = Release|Any CPU + {88309283-2886-4B8B-9424-C1DD0B6A8C74}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..0f040ef --- /dev/null +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Configuration/PluginConfiguration.cs @@ -0,0 +1,42 @@ +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Configuration +{ + public class PluginConfiguration : BasePluginConfiguration + { + // ── TMDB ────────────────────────────────────────────────────────────── + + public string TmdbApiKey { get; set; } = string.Empty; + + // ── Sources ─────────────────────────────────────────────────────────── + + public bool SourceNowPlaying { get; set; } = true; + public bool SourceUpcoming { get; set; } = true; + public bool SourcePopular { get; set; } = false; + public bool SourceTopRated { get; set; } = false; + + // ── Date Range ──────────────────────────────────────────────────────── + + public int ReleaseDateRangeMonths { get; set; } = 6; + + // ── Download Settings ───────────────────────────────────────────────── + + public string DownloadFolder { get; set; } = string.Empty; + public int MaxTrailersToDownload { get; set; } = 20; + public int MaxPagesPerSource { get; set; } = 3; + public int PreferredVideoHeight { get; set; } = 720; + public bool SkipAlreadyDownloaded { get; set; } = true; + public bool SkipMoviesInLibrary { get; set; } = true; + public string YtDlpPath { get; set; } = string.Empty; + + // ── Languages ───────────────────────────────────────────────────────── + + /// Comma-separated ISO 639-1 codes. Empty = all languages allowed. + public string AllowedLanguages { get; set; } = string.Empty; + + // ── Trailer Rotation ────────────────────────────────────────────────── + + /// Maximum trailers to keep on disk. Oldest are deleted first when exceeded. 0 = unlimited. + public int MaxTotalTrailers { get; set; } = 50; + } +} diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Configuration/config.html b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Configuration/config.html new file mode 100644 index 0000000..b072592 --- /dev/null +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Configuration/config.html @@ -0,0 +1,302 @@ + + + + CinemaTrailers4Jellyfins + + +
+
+
+
+ +
+

CinemaTrailers4Jellyfins

+ Help +
+ +
+

+ Downloads trailers for upcoming and recently released movies not in your library + from TMDB/YouTube and stores each one inside its own fake-movie folder, ready to be + picked up by a Cinema Mode / trailer pre-roll plugin. +

+

+ A free TMDB API key is required. + Get one here → +

+
+ + +
+

TMDB

+
+ + +
+ Your TMDB Read Access Token (JWT) or v3 API key from + themoviedb.org/settings/api. +
+
+
+ + +
+

Trailer Languages

+

+ Only download trailers in the selected languages. + Leave everything unchecked to allow all languages. +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+

Trailer Sources

+

+ Choose which TMDB lists to pull trailers from. Enable multiple for more variety. +

+ +
+ +
+ Movies currently in theatres. Refreshes weekly on TMDB. +
+
+ +
+ +
+ Movies coming soon to theatres. Great for seeing what's on the horizon. +
+
+ +
+ +
+ Most popular movies on TMDB right now, filtered by the date range below. +
+
+ +
+ +
+ Highest rated movies on TMDB, filtered by the date range below. +
+
+
+ + +
+

Date Range

+
+ + +
+ Applies to all sources. "Now Playing" and "Upcoming" already have tight date windows + set by TMDB, but this provides an additional filter. +
+
+
+ + +
+

Download Settings

+ +
+ + +
+ Where the fake-movie/trailer folders are created. Add this as a Jellyfin Movies + library and scan it so a Cinema Mode / trailer pre-roll plugin can use the trailers. +
+
+ +
+ + +
+ Maximum number of trailers to download each time the task runs. Default: 20. +
+
+ +
+ + +
+ How many pages to fetch from each TMDB source (20 movies per page). Default: 3. +
+
+ +
+ + +
+ +
+ +
+ Trailers for movies you already own won't be downloaded. +
+
+ +
+ +
+ If a folder already exists for a movie, don't re-download it. +
+
+
+ + +
+

Trailer Rotation

+

+ Keep your trailer library fresh by automatically removing the oldest entries + each time the download task runs. +

+ +
+ + +
+ Maximum number of trailer folders to keep on disk at once. When this limit is exceeded, + the oldest are deleted first to make room for new downloads. Set to 0 for unlimited. + Default: 50. +
+
+
+ + +
+

Advanced

+
+ + +
+ Full path to yt-dlp. + Required for 1080p quality. Also needs ffmpeg on the system PATH. + Leave blank to use the built-in downloader (720p max, zero extra tools). +
+
+
+ +
+ +
+
+
+ + +
+ + diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Images/logo.svg b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Images/logo.svg new file mode 100644 index 0000000..b958d63 --- /dev/null +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Images/logo.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CinemaTrailers4Jellyfins + + + TRAILER LIBRARY BUILDER + diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Jellyfin.Plugin.CinemaTrailers4Jellyfins.csproj b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Jellyfin.Plugin.CinemaTrailers4Jellyfins.csproj new file mode 100644 index 0000000..8197966 --- /dev/null +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Jellyfin.Plugin.CinemaTrailers4Jellyfins.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + Jellyfin.Plugin.CinemaTrailers4Jellyfins + 1.0.0.0 + 1.0.0.0 + enable + false + false + + + + + + + + + + + + + + diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Plugin.cs b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Plugin.cs new file mode 100644 index 0000000..f82c9bf --- /dev/null +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Plugin.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Plugin.CinemaTrailers4Jellyfins.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins +{ + public class Plugin : BasePlugin, IHasWebPages + { + public override string Name => "CinemaTrailers4Jellyfins"; + + public override Guid Id => Guid.Parse("b581493e-1046-40ed-b6dc-cb8027624984"); + + public static Plugin Instance { get; private set; } = null!; + + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + public IEnumerable GetPages() + { + yield return new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = GetType().Namespace + ".Configuration.config.html" + }; + } + } +} diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/PluginServiceRegistrator.cs b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/PluginServiceRegistrator.cs new file mode 100644 index 0000000..91aee9a --- /dev/null +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/PluginServiceRegistrator.cs @@ -0,0 +1,20 @@ +using Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks; +using Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Plugins; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins +{ + public class PluginServiceRegistrator : IPluginServiceRegistrator + { + public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) + { + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddTransient(); + } + } +} diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/ScheduledTasks/DownloadTrailersTask.cs b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/ScheduledTasks/DownloadTrailersTask.cs new file mode 100644 index 0000000..f3462a5 --- /dev/null +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/ScheduledTasks/DownloadTrailersTask.cs @@ -0,0 +1,254 @@ +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; + } + + _fakeMovieService.WriteNfo(paths.NfoPath, movie.Title, movie.Year); + + 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")); + } + } +} diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FakeMovieService.cs b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FakeMovieService.cs new file mode 100644 index 0000000..ec070ef --- /dev/null +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FakeMovieService.cs @@ -0,0 +1,147 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services +{ + /// + /// Produces the placeholder "fake movie" file and its NFO that sit alongside each + /// downloaded trailer. Jellyfin scans the fake movie as a normal library item (its + /// metadata locked via the NFO so it never queries TMDB) and picks up the adjacent + /// "-trailer.mp4" as that item's local trailer — exactly what a Cinema Mode / trailer + /// pre-roll plugin consumes. + /// + public class FakeMovieService + { + private const string MasterFileName = "master.mp4"; + private readonly ILogger _logger; + private readonly SemaphoreSlim _masterLock = new(1, 1); + + public FakeMovieService(ILogger logger) + { + _logger = logger; + } + + private static string MasterFilePath => + Path.Combine(Plugin.Instance.DataFolderPath, "fake-movie", MasterFileName); + + /// + /// Copies the master fake-movie file to , + /// generating the master via ffmpeg first if it doesn't exist yet. + /// + public async Task CopyFakeMovieAsync(string destinationPath, CancellationToken ct) + { + if (!await EnsureMasterFileAsync(ct).ConfigureAwait(false)) + return false; + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + File.Copy(MasterFilePath, destinationPath, overwrite: true); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "|CinemaTrailers4Jellyfins| Failed to copy fake movie file to {Path}", destinationPath); + return false; + } + } + + /// + /// Writes a minimal Jellyfin/Kodi-compatible movie NFO with <lockdata>true</lockdata> + /// so Jellyfin never tries to refresh metadata for the fake entry from TMDB. + /// + public void WriteNfo(string nfoPath, string title, int? year) + { + var settings = new XmlWriterSettings { Indent = true }; + using var writer = XmlWriter.Create(nfoPath, settings); + writer.WriteStartDocument(); + writer.WriteStartElement("movie"); + writer.WriteElementString("title", title); + if (year.HasValue) + writer.WriteElementString("year", year.Value.ToString()); + writer.WriteElementString("lockdata", "true"); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + + // Generates a few seconds of black video with silent audio — just enough for + // Jellyfin to recognize the file as a valid playable video. Built once and reused + // by copying, rather than regenerated per trailer. + private async Task EnsureMasterFileAsync(CancellationToken ct) + { + if (File.Exists(MasterFilePath)) + return true; + + await _masterLock.WaitAsync(ct).ConfigureAwait(false); + try + { + if (File.Exists(MasterFilePath)) + return true; + + Directory.CreateDirectory(Path.GetDirectoryName(MasterFilePath)!); + + var tempPath = MasterFilePath + ".tmp"; + if (File.Exists(tempPath)) File.Delete(tempPath); + + _logger.LogInformation("|CinemaTrailers4Jellyfins| Generating master fake-movie file via ffmpeg at {Path}", MasterFilePath); + + var args = string.Join(" ", + "-y", + "-f lavfi -i \"color=c=black:s=640x360:r=24:d=3\"", + "-f lavfi -i \"anullsrc=channel_layout=stereo:sample_rate=44100\"", + "-shortest", + "-c:v libx264 -tune stillimage -pix_fmt yuv420p", + "-c:a aac -b:a 64k", + $"\"{tempPath}\""); + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "ffmpeg", + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + } + }; + + process.Start(); + await process.WaitForExitAsync(ct).ConfigureAwait(false); + + if (process.ExitCode != 0 || !File.Exists(tempPath)) + { + var stderr = await process.StandardError.ReadToEndAsync(ct).ConfigureAwait(false); + _logger.LogError( + "|CinemaTrailers4Jellyfins| ffmpeg failed to generate the master fake-movie file (exit code {Code}): {Error}", + process.ExitCode, stderr); + if (File.Exists(tempPath)) File.Delete(tempPath); + return false; + } + + File.Move(tempPath, MasterFilePath, overwrite: true); + _logger.LogInformation("|CinemaTrailers4Jellyfins| Master fake-movie file generated at {Path}", MasterFilePath); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "|CinemaTrailers4Jellyfins| Failed to generate the master fake-movie file. Is ffmpeg installed and on PATH?"); + return false; + } + finally + { + _masterLock.Release(); + } + } + } +} diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TmdbService.cs b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TmdbService.cs new file mode 100644 index 0000000..1062a99 --- /dev/null +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TmdbService.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Sockets; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services +{ + public record TmdbVideo(string Key, string Name, string Language, bool Official, int Size); + + public record TmdbMovieResult(int Id, string Title, string ReleaseDate) + { + public int? Year => DateTime.TryParse(ReleaseDate, out var d) ? d.Year : (int?)null; + } + + public class TmdbService : IDisposable + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private const string BaseUrl = "https://api.themoviedb.org/3"; + + public TmdbService(ILogger logger) + { + _logger = logger; + + // Force IPv4 to avoid ~80s delay when IPv6 is unreachable (Happy Eyeballs fallback). + var handler = new SocketsHttpHandler + { + ConnectCallback = async (ctx, ct) => + { + var entry = await Dns.GetHostEntryAsync(ctx.DnsEndPoint.Host, AddressFamily.InterNetwork, ct).ConfigureAwait(false); + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + socket.NoDelay = true; + try + { + await socket.ConnectAsync(entry.AddressList[0], ctx.DnsEndPoint.Port, ct).ConfigureAwait(false); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } + }; + _httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; + } + + public void Dispose() => _httpClient.Dispose(); + + // JWT Read Access Tokens start with "eyJ"; v3 short keys (32 hex chars) use ?api_key=. + private static void ApplyAuth(HttpRequestMessage request, string apiKey) + { + if (apiKey.StartsWith("eyJ", StringComparison.Ordinal)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + } + else + { + var uri = request.RequestUri!.ToString(); + var separator = uri.Contains('?') ? "&" : "?"; + request.RequestUri = new Uri($"{uri}{separator}api_key={apiKey}"); + } + } + + /// + /// Fetches candidate movies from the configured TMDB sources, optionally filtered + /// by a minimum release date. Deduplicates across sources by TMDB ID. + /// + public async Task> GetCandidateMoviesAsync( + Configuration.PluginConfiguration config, + CancellationToken ct) + { + DateTime? releasedAfter = config.ReleaseDateRangeMonths > 0 + ? DateTime.UtcNow.AddMonths(-config.ReleaseDateRangeMonths) + : null; + + var seen = new HashSet(); + var results = new List(); + + async Task FetchSource(string endpoint) + { + var movies = await FetchSourcePagesAsync(endpoint, config.TmdbApiKey, releasedAfter, config.MaxPagesPerSource, ct) + .ConfigureAwait(false); + + foreach (var m in movies) + { + if (seen.Add(m.Id)) + results.Add(m); + } + } + + if (config.SourceNowPlaying) await FetchSource("now_playing"); + if (config.SourceUpcoming) await FetchSource("upcoming"); + if (config.SourcePopular) await FetchSource("popular"); + if (config.SourceTopRated) await FetchSource("top_rated"); + + return results; + } + + private async Task> FetchSourcePagesAsync( + string endpoint, + string apiKey, + DateTime? releasedAfter, + int maxPages, + CancellationToken ct) + { + var results = new List(); + + for (int page = 1; page <= maxPages; page++) + { + ct.ThrowIfCancellationRequested(); + + try + { + var url = $"{BaseUrl}/movie/{endpoint}?language=en-US&page={page}"; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + ApplyAuth(request, apiKey); + using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + + var pageResults = doc.RootElement.GetProperty("results"); + int totalPages = doc.RootElement.GetProperty("total_pages").GetInt32(); + bool anyInRange = false; + + foreach (var movie in pageResults.EnumerateArray()) + { + var releaseDate = movie.TryGetProperty("release_date", out var rd) ? rd.GetString() ?? string.Empty : string.Empty; + var title = movie.TryGetProperty("title", out var t) ? t.GetString() ?? string.Empty : string.Empty; + var id = movie.GetProperty("id").GetInt32(); + + if (releasedAfter.HasValue && DateTime.TryParse(releaseDate, out var parsed)) + { + if (parsed < releasedAfter.Value) continue; + } + + anyInRange = true; + results.Add(new TmdbMovieResult(id, title, releaseDate)); + } + + if (page >= totalPages || (releasedAfter.HasValue && !anyInRange)) + break; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "|CinemaTrailers4Jellyfins| Failed to fetch TMDB source '{Endpoint}' page {Page}", endpoint, page); + break; + } + } + + return results; + } + + public async Task SearchMovieAsync(string title, int? year, string apiKey, CancellationToken ct) + { + try + { + var url = $"{BaseUrl}/search/movie?query={Uri.EscapeDataString(title)}&language=en-US"; + if (year.HasValue) url += $"&year={year.Value}"; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + ApplyAuth(request, apiKey); + using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + var res = doc.RootElement.GetProperty("results"); + if (res.GetArrayLength() > 0) + return res[0].GetProperty("id").GetInt32().ToString(); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _logger.LogError(ex, "|CinemaTrailers4Jellyfins| TMDB search failed for '{Title}'", title); + } + return null; + } + + public async Task> GetTrailersAsync( + string tmdbId, + string apiKey, + IReadOnlySet? allowedLanguages, + CancellationToken ct) + { + try + { + // No language filter on the URL — we want all available trailers so we can + // filter by iso_639_1 ourselves based on the user's language preference. + var url = $"{BaseUrl}/movie/{tmdbId}/videos"; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + ApplyAuth(request, apiKey); + using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + + var videos = new List(); + foreach (var result in doc.RootElement.GetProperty("results").EnumerateArray()) + { + var type = result.GetProperty("type").GetString(); + var site = result.GetProperty("site").GetString(); + if (!string.Equals(type, "Trailer", StringComparison.OrdinalIgnoreCase) + || !string.Equals(site, "YouTube", StringComparison.OrdinalIgnoreCase)) + continue; + + var key = result.GetProperty("key").GetString(); + if (string.IsNullOrEmpty(key)) continue; + + var lang = result.TryGetProperty("iso_639_1", out var l) ? (l.GetString() ?? string.Empty) : string.Empty; + + if (allowedLanguages != null && allowedLanguages.Count > 0 && !allowedLanguages.Contains(lang)) + continue; + + videos.Add(new TmdbVideo( + key, + result.GetProperty("name").GetString() ?? "Trailer", + lang, + result.GetProperty("official").GetBoolean(), + result.GetProperty("size").GetInt32())); + } + + return videos + .OrderByDescending(v => v.Official) + .ThenByDescending(v => v.Size) + .ToList(); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _logger.LogError(ex, "|CinemaTrailers4Jellyfins| GetTrailers failed for TMDB ID {Id}", tmdbId); + return new List(); + } + } + } +} diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TrailerDownloadService.cs b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TrailerDownloadService.cs new file mode 100644 index 0000000..7c7b987 --- /dev/null +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TrailerDownloadService.cs @@ -0,0 +1,183 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using YoutubeExplode; +using YoutubeExplode.Videos.Streams; + +namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services +{ + public class TrailerDownloadService : IDisposable + { + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + + public TrailerDownloadService(ILogger logger) + { + _logger = logger; + + // Force IPv4 to avoid ~80s delay when IPv6 is unreachable (Happy Eyeballs fallback). + var handler = new SocketsHttpHandler + { + ConnectCallback = async (ctx, ct) => + { + var entry = await Dns.GetHostEntryAsync(ctx.DnsEndPoint.Host, AddressFamily.InterNetwork, ct).ConfigureAwait(false); + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + socket.NoDelay = true; + try + { + await socket.ConnectAsync(entry.AddressList[0], ctx.DnsEndPoint.Port, ct).ConfigureAwait(false); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } + }; + _httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(10) }; + } + + public void Dispose() => _httpClient.Dispose(); + + /// + /// Downloads a YouTube video by key to outputPath. + /// Uses yt-dlp when a valid path is configured (supports 1080p+, requires ffmpeg on PATH). + /// Falls back to YoutubeExplode for built-in download (max 720p, no external tools needed). + /// + public async Task DownloadAsync( + string youtubeKey, + string outputPath, + int preferredHeight, + string ytDlpPath, + CancellationToken ct) + { + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); + + if (!string.IsNullOrWhiteSpace(ytDlpPath) && File.Exists(ytDlpPath)) + { + return await DownloadWithYtDlpAsync(youtubeKey, outputPath, preferredHeight, ytDlpPath, ct) + .ConfigureAwait(false); + } + + return await DownloadWithYoutubeExplodeAsync(youtubeKey, outputPath, preferredHeight, ct) + .ConfigureAwait(false); + } + + private async Task DownloadWithYoutubeExplodeAsync( + string key, + string outputPath, + int preferredHeight, + CancellationToken ct) + { + try + { + var youtube = new YoutubeClient(_httpClient); + var manifest = await youtube.Videos.Streams + .GetManifestAsync($"https://www.youtube.com/watch?v={key}", ct) + .ConfigureAwait(false); + + // Muxed streams include audio+video in one file. Quality is capped at 720p by YouTube. + var muxedStreams = manifest.GetMuxedStreams().ToList(); + if (muxedStreams.Count == 0) + { + _logger.LogWarning("|CinemaTrailers4Jellyfins| No muxed streams available for {Key}. Consider configuring yt-dlp for 1080p support.", key); + return false; + } + + // Prefer the highest quality at or below the configured height limit. + var stream = muxedStreams + .Where(s => s.VideoQuality.MaxHeight <= preferredHeight) + .OrderByDescending(s => s.VideoQuality.MaxHeight) + .FirstOrDefault() + ?? muxedStreams.OrderByDescending(s => s.VideoQuality.MaxHeight).First(); + + _logger.LogInformation( + "|CinemaTrailers4Jellyfins| Downloading {Key} at {Quality} to {Path}", + key, stream.VideoQuality.Label, outputPath); + + await youtube.Videos.Streams + .DownloadAsync(stream, outputPath, cancellationToken: ct) + .ConfigureAwait(false); + + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "|CinemaTrailers4Jellyfins| YoutubeExplode download failed for {Key}", key); + return false; + } + } + + private async Task DownloadWithYtDlpAsync( + string key, + string outputPath, + int preferredHeight, + string ytDlpPath, + CancellationToken ct) + { + try + { + // Format selects the best video at or below preferredHeight merged with the best audio. + // --merge-output-format mp4 ensures the output is always an mp4. + var args = string.Join(" ", + $"-f \"bestvideo[height<={preferredHeight}]+bestaudio/best[height<={preferredHeight}]\"", + "--merge-output-format mp4", + "--no-playlist", + "--no-warnings", + $"-o \"{outputPath}\"", + $"\"https://www.youtube.com/watch?v={key}\""); + + _logger.LogInformation( + "|CinemaTrailers4Jellyfins| Downloading {Key} via yt-dlp at max {Height}p to {Path}", + key, preferredHeight, outputPath); + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = ytDlpPath, + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + } + }; + + process.Start(); + await process.WaitForExitAsync(ct).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + var stderr = await process.StandardError.ReadToEndAsync(ct).ConfigureAwait(false); + _logger.LogError("|CinemaTrailers4Jellyfins| yt-dlp exited with code {Code} for {Key}: {Error}", + process.ExitCode, key, stderr); + return false; + } + + return File.Exists(outputPath); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "|CinemaTrailers4Jellyfins| yt-dlp download failed for {Key}", key); + return false; + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f0c4b9 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# CinemaTrailers4Jellyfins + +A Jellyfin plugin that automatically downloads movie trailers from TMDB/YouTube and packages +each one as a self-contained "fake movie" folder, ready to be picked up by a Cinema Mode / +trailer pre-roll plugin. + +## How it works + +1. A daily scheduled task fetches candidate movies from TMDB (Now Playing, Upcoming, Popular, + Top Rated — configurable), optionally skipping movies already in your library. +2. For each candidate, it fetches the official trailer(s) from TMDB, which point to YouTube. +3. It downloads the trailer video and builds a folder for it: + ``` + {OutputFolder}/ + {Movie Title} ({Year})/ + {Movie Title} ({Year}).mp4 ← placeholder "fake movie" (copy of a master file) + {Movie Title} ({Year})-trailer.mp4 ← the actual downloaded trailer + {Movie Title} ({Year}).nfo ← minimal NFO (title, year, locked metadata) + ``` +4. Jellyfin scans the placeholder file as a normal movie (its metadata locked via the NFO so + it never queries TMDB for it) and picks up the adjacent `-trailer.mp4` as that movie's + local trailer — which a Cinema Mode / trailer pre-roll plugin can then play. + +The placeholder "fake movie" is a few seconds of black video with silent audio — just enough +for Jellyfin to treat the file as a valid video. It's generated once via `ffmpeg` and reused +by copying, not regenerated for every trailer. + +## Requirements + +- Jellyfin 10.11+ +- A free [TMDB API key](https://www.themoviedb.org/settings/api) +- `ffmpeg` available on the system PATH (used to generate the placeholder video once) +- *(Optional)* [yt-dlp](https://github.com/yt-dlp/yt-dlp) for higher quality (1080p+) trailer downloads + +## Installation + +### Via Jellyfin Plugin Catalogue + +1. In your Jellyfin dashboard go to **Admin → Plugins → Repositories**. +2. Add a new repository with this URL: + ``` + https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/raw/branch/main/manifest.json + ``` +3. Go to **Catalog**, find **CinemaTrailers4Jellyfins** under General, and click Install. +4. Restart Jellyfin. + +### Manual + +1. Download the latest `Jellyfin.Plugin.CinemaTrailers4Jellyfins.dll` (and dependencies) from Releases. +2. Copy them to your Jellyfin `plugins/` directory. +3. Restart Jellyfin. + +## Configuration + +Go to **Admin → Plugins → CinemaTrailers4Jellyfins**. + +| Setting | Description | +|---|---| +| **TMDB API Key** | Your TMDB Read Access Token (JWT) or v3 API key | +| **Trailer Languages** | Restrict downloads to specific trailer languages | +| **Trailer Sources** | Which TMDB lists to pull candidates from (Now Playing, Upcoming, Popular, Top Rated) | +| **Date Range** | Only consider movies released within the last N months | +| **Output Folder** | Where the fake-movie folders are created | +| **Max trailers per run** | How many trailers to download per task run | +| **Pages per source** | How many TMDB pages to fetch per source | +| **Video quality** | 720p / 480p (built-in) or 1080p (requires yt-dlp) | +| **Skip movies already in my Jellyfin library** | Don't download trailers for movies you already own | +| **Skip trailers already downloaded** | Don't re-download a trailer if its folder already exists | +| **Max trailers to keep** | Oldest trailer folders are deleted first once this cap is exceeded | +| **yt-dlp path** | Optional path to `yt-dlp` for 1080p+ downloads | + +## Running the task + +After configuring, go to **Admin → Scheduled Tasks → CinemaTrailers4Jellyfins** and click **Run** +to do an immediate download pass. The task then runs automatically once per day. + +After the task completes, add the output folder as a Jellyfin **Movies** library (and run a +library scan) so your Cinema Mode / trailer pre-roll plugin can use the trailers. + +## Building from source + +```sh +git clone https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins +cd CinemaTrailers4Jellyfins +dotnet publish --configuration Release --output bin +``` + +Place `Jellyfin.Plugin.CinemaTrailers4Jellyfins.dll` and its dependencies in your Jellyfin +`plugins/` directory. + +## Licence + +MIT diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..9f20386 --- /dev/null +++ b/build.yaml @@ -0,0 +1,22 @@ +--- +version: 1.0.0.0 +name: CinemaTrailers4Jellyfins +guid: b581493e-1046-40ed-b6dc-cb8027624984 +description: > + Automatically downloads trailers for upcoming and recently released movies + not in your library from TMDB/YouTube, and packages each one as a fake-movie + folder (placeholder video + NFO + trailer) ready for a Cinema Mode / trailer + pre-roll plugin. +overview: Builds a self-contained trailer library for Cinema Mode pre-roll plugins +category: General +owner: 514mart +targetAbi: 10.11.0.0 +changelog: + - Initial release based on Trailers4Jellyfin — TMDB/YouTube trailer downloads, + scheduled task, language/date filters, and trailer rotation, repackaged so + each trailer ships inside its own fake-movie folder (placeholder video + + locked NFO + trailer) for use with a Cinema Mode pre-roll plugin + +dotnetProjects: + - name: Jellyfin.Plugin.CinemaTrailers4Jellyfins + artifact: Jellyfin.Plugin.CinemaTrailers4Jellyfins.dll diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..8c27d10 --- /dev/null +++ b/manifest.json @@ -0,0 +1,12 @@ +[ + { + "category": "General", + "guid": "b581493e-1046-40ed-b6dc-cb8027624984", + "name": "CinemaTrailers4Jellyfins", + "description": "Automatically downloads trailers for upcoming and recently released movies not in your library from TMDB/YouTube, and packages each one as a fake-movie folder (placeholder video + NFO + trailer) ready for a Cinema Mode / trailer pre-roll plugin.", + "overview": "Builds a self-contained trailer library for Cinema Mode pre-roll plugins", + "owner": "514mart", + "imageUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/raw/branch/main/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Images/logo.svg", + "versions": [] + } +] diff --git a/update_manifest.py b/update_manifest.py new file mode 100644 index 0000000..699de30 --- /dev/null +++ b/update_manifest.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" +Updates manifest.json with a new version entry after a release build. +Usage: python3 update_manifest.py + version - e.g. 1.0.0.0 + checksum - MD5 hex of the release zip +""" +from __future__ import annotations + +import json +import sys +import datetime +import yaml + + +MANIFEST_FILE = "manifest.json" +BUILD_YAML = "build.yaml" +REPO = "mvezina/CinemaTrailers4Jellyfins" +REPO_BASE_URL = "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins" +ZIP_PREFIX = "cinematrailers4jellyfins" + + +def main() -> None: + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + + version = sys.argv[1] + checksum = sys.argv[2] + + with open(BUILD_YAML) as f: + build = yaml.safe_load(f) + + target_abi = build["targetAbi"] + changelog = "".join(f"- {item}\n" for item in build.get("changelog", [])) + source_url = f"{REPO_BASE_URL}/releases/download/v{version}/{ZIP_PREFIX}_{version}.zip" + timestamp = datetime.datetime.utcnow().isoformat(timespec="seconds") + "Z" + + new_entry = { + "checksum": checksum, + "changelog": changelog, + "targetAbi": target_abi, + "sourceUrl": source_url, + "timestamp": timestamp, + "version": version, + } + + with open(MANIFEST_FILE) as f: + manifest = json.load(f) + + existing_versions = [v["version"] for v in manifest[0]["versions"]] + if version in existing_versions: + print(f"Version {version} already exists in manifest. Nothing to do.") + return + + # Newest version first + manifest[0]["versions"] = [new_entry] + manifest[0]["versions"] + + with open(MANIFEST_FILE, "w") as f: + json.dump(manifest, f, indent=4) + f.write("\n") + + print(f"manifest.json updated with version {version}") + + +if __name__ == "__main__": + main()