feat: add IIntroProvider for Wholphin/cinema-mode compatibility
All checks were successful
Publish Release / release (push) Successful in 1m34s

Registers TrailerIntroProvider as IIntroProvider. Queries fake-movie
items in the output folder and returns their local trailer extras
(LocalTrailers) as IntroInfo — mirroring jellyfin-plugin-cinemamode's
proven pattern so Wholphin plays the actual trailer, not the 3-second
black placeholder.

Recursion guard: items whose path starts with the output folder are
excluded from intro injection, so Wholphin's getIntros call on the
intro item itself returns empty. Server-scoped query bypasses library
visibility restrictions so hidden trailer libraries still work.

Adds TrailersPerMovie config option (default 1, 0 = disabled).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-06-09 13:03:34 -04:00
parent 3868876401
commit e84a897c27
6 changed files with 121 additions and 7 deletions

View File

@@ -38,5 +38,10 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Configuration
/// <summary>Maximum trailers to keep on disk. Oldest are deleted first when exceeded. 0 = unlimited.</summary> /// <summary>Maximum trailers to keep on disk. Oldest are deleted first when exceeded. 0 = unlimited.</summary>
public int MaxTotalTrailers { get; set; } = 50; public int MaxTotalTrailers { get; set; } = 50;
// ── IIntroProvider (Cinema Mode / Wholphin) ───────────────────────────
/// <summary>Number of trailers to inject before each movie via IIntroProvider. 0 = disabled.</summary>
public int TrailersPerMovie { get; set; } = 1;
} }
} }

View File

@@ -217,6 +217,26 @@
</div> </div>
</fieldset> </fieldset>
<!-- Cinema Mode Integration -->
<fieldset class="verticalSection verticalSection-extrabottompadding">
<legend><h3 class="sectionTitle">Cinema Mode Integration</h3></legend>
<p class="fieldDescription">
When enabled, this plugin registers as an <strong>IIntroProvider</strong> and
injects downloaded trailers before movies — compatible with Jellyfin's built-in
cinema mode support and clients like Wholphin.
The output folder must be added as a Jellyfin <strong>Movies</strong> library
and scanned before trailers appear.
</p>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="trailers-per-movie">Trailers per movie</label>
<input type="number" id="trailers-per-movie" is="emby-input" min="0" max="10" />
<div class="fieldDescription">
Number of trailers to play before each movie. Set to 0 to disable.
Default: 1.
</div>
</div>
</fieldset>
<!-- Advanced --> <!-- Advanced -->
<fieldset class="verticalSection verticalSection-extrabottompadding"> <fieldset class="verticalSection verticalSection-extrabottompadding">
<legend><h3 class="sectionTitle">Advanced</h3></legend> <legend><h3 class="sectionTitle">Advanced</h3></legend>
@@ -264,6 +284,7 @@
document.getElementById('lang-' + code).checked = langs.length === 0 || langs.indexOf(code) !== -1; document.getElementById('lang-' + code).checked = langs.length === 0 || langs.indexOf(code) !== -1;
}); });
document.getElementById('max-total-trailers').value = config.MaxTotalTrailers ?? 50; document.getElementById('max-total-trailers').value = config.MaxTotalTrailers ?? 50;
document.getElementById('trailers-per-movie').value = config.TrailersPerMovie ?? 1;
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
}); });
}); });
@@ -291,6 +312,7 @@
// If all are checked treat it as "no preference" (empty string) // If all are checked treat it as "no preference" (empty string)
config.AllowedLanguages = checkedLangs.length === allLangCodes.length ? '' : checkedLangs.join(','); config.AllowedLanguages = checkedLangs.length === allLangCodes.length ? '' : checkedLangs.join(',');
config.MaxTotalTrailers = parseInt(document.getElementById('max-total-trailers').value, 10) || 50; config.MaxTotalTrailers = parseInt(document.getElementById('max-total-trailers').value, 10) || 50;
config.TrailersPerMovie = parseInt(document.getElementById('trailers-per-movie').value, 10) || 0;
ApiClient.updatePluginConfiguration(pluginId, config) ApiClient.updatePluginConfiguration(pluginId, config)
.then(Dashboard.processPluginConfigurationUpdateResult); .then(Dashboard.processPluginConfigurationUpdateResult);
}); });

View File

@@ -3,8 +3,8 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.CinemaTrailers4Jellyfins</RootNamespace> <RootNamespace>Jellyfin.Plugin.CinemaTrailers4Jellyfins</RootNamespace>
<AssemblyVersion>1.0.0.0</AssemblyVersion> <AssemblyVersion>1.0.0.2</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion> <FileVersion>1.0.0.2</FileVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateDocumentationFile>false</GenerateDocumentationFile> <GenerateDocumentationFile>false</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>false</TreatWarningsAsErrors>

View File

@@ -1,6 +1,7 @@
using Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks; using Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks;
using Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services; using Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services;
using MediaBrowser.Controller; using MediaBrowser.Controller;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Plugins;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -15,6 +16,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins
serviceCollection.AddSingleton<TrailerDownloadService>(); serviceCollection.AddSingleton<TrailerDownloadService>();
serviceCollection.AddSingleton<FakeMovieService>(); serviceCollection.AddSingleton<FakeMovieService>();
serviceCollection.AddTransient<IScheduledTask, DownloadTrailersTask>(); serviceCollection.AddTransient<IScheduledTask, DownloadTrailersTask>();
serviceCollection.AddTransient<IIntroProvider, TrailerIntroProvider>();
} }
} }
} }

View File

@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
{
public class TrailerIntroProvider : IIntroProvider
{
private static readonly Random _rng = new();
private readonly ILibraryManager _libraryManager;
private readonly ILogger<TrailerIntroProvider> _logger;
public string Name => "CinemaTrailers4Jellyfins";
public TrailerIntroProvider(ILibraryManager libraryManager, ILogger<TrailerIntroProvider> logger)
{
_libraryManager = libraryManager;
_logger = logger;
}
public Task<IEnumerable<IntroInfo>> GetIntros(BaseItem item, User user)
{
var config = Plugin.Instance?.Configuration;
if (config == null || config.TrailersPerMovie <= 0)
return Task.FromResult(Enumerable.Empty<IntroInfo>());
if (item is not Movie)
return Task.FromResult(Enumerable.Empty<IntroInfo>());
var outputFolder = config.DownloadFolder?.TrimEnd(
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (string.IsNullOrEmpty(outputFolder))
return Task.FromResult(Enumerable.Empty<IntroInfo>());
// Break potential recursion: don't inject intros before items that live inside
// our output folder. This covers both the fake-movie files and their trailer
// extras, so Wholphin can't trigger a getIntros loop on the intro item itself.
if (item.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true)
return Task.FromResult(Enumerable.Empty<IntroInfo>());
// Server-scoped query (no User parameter) so library visibility settings don't
// prevent trailer discovery even when the output library is hidden from users.
var fakeMovies = _libraryManager
.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Movie },
Recursive = true,
})
.OfType<Movie>()
.Where(m =>
m.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true
&& m.LocalTrailers.Count > 0)
.ToList();
if (fakeMovies.Count == 0)
{
_logger.LogDebug(
"|CinemaTrailers4Jellyfins| No indexed trailers found in {Folder}. "
+ "Ensure the output folder is added as a Jellyfin Movies library and scanned.",
outputFolder);
return Task.FromResult(Enumerable.Empty<IntroInfo>());
}
var selected = fakeMovies
.OrderBy(_ => _rng.Next())
.Take(config.TrailersPerMovie)
.Select(m =>
{
var t = m.LocalTrailers.ElementAt(_rng.Next(m.LocalTrailers.Count));
return new IntroInfo { ItemId = t.Id, Path = t.Path };
})
.ToList();
return Task.FromResult<IEnumerable<IntroInfo>>(selected);
}
}
}

View File

@@ -1,5 +1,5 @@
--- ---
version: 1.0.0.0 version: 1.0.0.2
name: CinemaTrailers4Jellyfins name: CinemaTrailers4Jellyfins
guid: b581493e-1046-40ed-b6dc-cb8027624984 guid: b581493e-1046-40ed-b6dc-cb8027624984
description: > description: >
@@ -12,10 +12,10 @@ category: General
owner: 514mart owner: 514mart
targetAbi: 10.11.0.0 targetAbi: 10.11.0.0
changelog: changelog:
- Initial release based on Trailers4Jellyfin — TMDB/YouTube trailer downloads, - Add IIntroProvider support — downloaded trailers now play as pre-rolls before
scheduled task, language/date filters, and trailer rotation, repackaged so movies in Jellyfin clients with cinema mode (Wholphin, etc.); configurable
each trailer ships inside its own fake-movie folder (placeholder video + trailers-per-movie count; recursion-safe so the intro items themselves never
locked NFO + trailer) for use with a Cinema Mode pre-roll plugin trigger a second round of intro injection
dotnetProjects: dotnetProjects:
- name: Jellyfin.Plugin.CinemaTrailers4Jellyfins - name: Jellyfin.Plugin.CinemaTrailers4Jellyfins