Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e43e3f7245 | ||
|
|
4145e1f1dd | ||
|
|
6002ce7b46 | ||
|
|
d850e12964 | ||
|
|
205d64dc6f | ||
|
|
322d6cc65d | ||
|
|
c208a81b75 | ||
|
|
5176582391 | ||
|
|
e518b79c36 | ||
|
|
6bb23dc9c4 | ||
|
|
5f7a76feb5 | ||
|
|
adfa570569 | ||
|
|
99798b5174 | ||
|
|
c76ddbc352 | ||
|
|
e9d9543941 |
@@ -3,8 +3,8 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.CinemaTrailers4Jellyfins</RootNamespace>
|
||||
<AssemblyVersion>1.0.0.6</AssemblyVersion>
|
||||
<FileVersion>1.0.0.6</FileVersion>
|
||||
<AssemblyVersion>1.0.0.13</AssemblyVersion>
|
||||
<FileVersion>1.0.0.13</FileVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
@@ -13,7 +13,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.0" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.11.0" />
|
||||
<PackageReference Include="YoutubeExplode" Version="6.*" />
|
||||
<PackageReference Include="YoutubeExplode" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -171,7 +171,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
|
||||
|
||||
var paths = BuildMediaPaths(movie.Title, movie.Year, config);
|
||||
|
||||
if (config.SkipAlreadyDownloaded && Directory.Exists(paths.MediaFolder))
|
||||
if (config.SkipAlreadyDownloaded && File.Exists(paths.TrailerPath))
|
||||
{
|
||||
_logger.LogDebug("|CinemaTrailers4Jellyfins| Already downloaded: {Path}", paths.MediaFolder);
|
||||
continue;
|
||||
@@ -197,7 +197,12 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
// Don't leave an empty folder behind: it would count toward MaxTotalTrailers
|
||||
// (pushing out real trailers) and would never be retried.
|
||||
TryDeleteFolder(paths.MediaFolder);
|
||||
continue;
|
||||
}
|
||||
|
||||
var fakeMovieReady = await _fakeMovieService.CopyFakeMovieAsync(paths.FakeMoviePath, cancellationToken).ConfigureAwait(false);
|
||||
if (!fakeMovieReady)
|
||||
@@ -212,7 +217,8 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
|
||||
var metadata = await _tmdbService.GetMediaMetadataAsync(
|
||||
"movie", movie.Id.ToString(), config.TmdbApiKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_fakeMovieService.WriteNfo(paths.NfoPath, movie.Title, movie.Year, metadata.Genres, metadata.Certification);
|
||||
_fakeMovieService.WriteNfo(paths.NfoPath, movie.Title, movie.Year, metadata.Genres, metadata.Certification,
|
||||
metadata.IsAnime ? new[] { TrailerTags.Anime } : null);
|
||||
|
||||
downloaded++;
|
||||
_logger.LogInformation(
|
||||
@@ -272,7 +278,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
|
||||
|
||||
var paths = BuildMediaPaths(show.Title, show.Year, config);
|
||||
|
||||
if (config.SkipAlreadyDownloaded && Directory.Exists(paths.MediaFolder))
|
||||
if (config.SkipAlreadyDownloaded && File.Exists(paths.TrailerPath))
|
||||
{
|
||||
_logger.LogDebug("|CinemaTrailers4Jellyfins| Already downloaded: {Path}", paths.MediaFolder);
|
||||
continue;
|
||||
@@ -298,7 +304,12 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
// Don't leave an empty folder behind: it would count toward MaxTotalTrailers
|
||||
// (pushing out real trailers) and would never be retried.
|
||||
TryDeleteFolder(paths.MediaFolder);
|
||||
continue;
|
||||
}
|
||||
|
||||
var fakeMovieReady = await _fakeMovieService.CopyFakeMovieAsync(paths.FakeMoviePath, cancellationToken).ConfigureAwait(false);
|
||||
if (!fakeMovieReady)
|
||||
@@ -313,7 +324,8 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
|
||||
var metadata = await _tmdbService.GetMediaMetadataAsync(
|
||||
"tv", show.Id.ToString(), config.TmdbApiKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_fakeMovieService.WriteNfo(paths.NfoPath, show.Title, show.Year, metadata.Genres, metadata.Certification, new[] { TrailerTags.TvShow });
|
||||
_fakeMovieService.WriteNfo(paths.NfoPath, show.Title, show.Year, metadata.Genres, metadata.Certification,
|
||||
metadata.IsAnime ? new[] { TrailerTags.TvShow, TrailerTags.Anime } : new[] { TrailerTags.TvShow });
|
||||
|
||||
downloaded++;
|
||||
_logger.LogInformation(
|
||||
|
||||
@@ -109,16 +109,11 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
"-f mp4",
|
||||
$"\"{tempPath}\"");
|
||||
|
||||
// Prefer Jellyfin's bundled ffmpeg; fall back to whatever is on PATH.
|
||||
var ffmpegPath = File.Exists("/usr/lib/jellyfin-ffmpeg/ffmpeg")
|
||||
? "/usr/lib/jellyfin-ffmpeg/ffmpeg"
|
||||
: "ffmpeg";
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
FileName = FfmpegLocator.Path,
|
||||
Arguments = args,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.IO;
|
||||
|
||||
namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
{
|
||||
internal static class FfmpegLocator
|
||||
{
|
||||
private const string JellyfinFfmpeg = "/usr/lib/jellyfin-ffmpeg/ffmpeg";
|
||||
|
||||
// Prefer Jellyfin's bundled ffmpeg; fall back to whatever is on PATH.
|
||||
public static string Path => File.Exists(JellyfinFfmpeg) ? JellyfinFfmpeg : "ffmpeg";
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
public int? Year => DateTime.TryParse(ReleaseDate, out var d) ? d.Year : (int?)null;
|
||||
}
|
||||
|
||||
public record TmdbMediaMetadata(IReadOnlyList<string> Genres, string? Certification);
|
||||
public record TmdbMediaMetadata(IReadOnlyList<string> Genres, string? Certification, bool IsAnime);
|
||||
|
||||
public class TmdbService : IDisposable
|
||||
{
|
||||
@@ -236,7 +236,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
try
|
||||
{
|
||||
var appendField = mediaType == "movie" ? "release_dates" : "content_ratings";
|
||||
var url = $"{BaseUrl}/{mediaType}/{tmdbId}?append_to_response={appendField}&language=en-US";
|
||||
var url = $"{BaseUrl}/{mediaType}/{tmdbId}?append_to_response={appendField},keywords&language=en-US";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
ApplyAuth(request, apiKey);
|
||||
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
|
||||
@@ -262,16 +262,40 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
? GetMovieCertification(doc)
|
||||
: GetTvCertification(doc);
|
||||
|
||||
return new TmdbMediaMetadata(genres.AsReadOnly(), certification);
|
||||
return new TmdbMediaMetadata(genres.AsReadOnly(), certification, IsAnime(doc, mediaType, genres));
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| GetMediaMetadata failed for {MediaType} TMDB ID {Id}", mediaType, tmdbId);
|
||||
return new TmdbMediaMetadata(Array.Empty<string>(), null);
|
||||
return new TmdbMediaMetadata(Array.Empty<string>(), null, false);
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly string[] AnimeKeywords = { "anime", "based on manga" };
|
||||
private static readonly string[] AnimeLanguages = { "ja", "zh", "ko" };
|
||||
|
||||
// Anime vs. Western animation: both carry TMDB's "Animation" genre, so use the "anime" /
|
||||
// "based on manga" keywords, or Animation made in Japanese, Chinese or Korean.
|
||||
private static bool IsAnime(JsonDocument doc, string mediaType, List<string> genres)
|
||||
{
|
||||
// Movie keywords live under keywords.keywords, TV keywords under keywords.results.
|
||||
var listName = mediaType == "movie" ? "keywords" : "results";
|
||||
if (doc.RootElement.TryGetProperty("keywords", out var kw)
|
||||
&& kw.TryGetProperty(listName, out var list)
|
||||
&& list.ValueKind == JsonValueKind.Array
|
||||
&& list.EnumerateArray().Any(k =>
|
||||
k.TryGetProperty("name", out var n)
|
||||
&& AnimeKeywords.Contains(n.GetString(), StringComparer.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return genres.Contains("Animation", StringComparer.OrdinalIgnoreCase)
|
||||
&& doc.RootElement.TryGetProperty("original_language", out var lang)
|
||||
&& AnimeLanguages.Contains(lang.GetString(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Finds the US theatrical certification (G, PG, PG-13, R, NC-17) from release_dates.
|
||||
private static string? GetMovieCertification(JsonDocument doc)
|
||||
{
|
||||
|
||||
@@ -63,8 +63,10 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ytDlpPath) && File.Exists(ytDlpPath))
|
||||
{
|
||||
return await DownloadWithYtDlpAsync(youtubeKey, outputPath, preferredHeight, ytDlpPath, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (await DownloadWithYtDlpAsync(youtubeKey, outputPath, preferredHeight, ytDlpPath, ct).ConfigureAwait(false))
|
||||
return true;
|
||||
|
||||
_logger.LogWarning("|CinemaTrailers4Jellyfins| yt-dlp failed for {Key}; falling back to the built-in downloader.", youtubeKey);
|
||||
}
|
||||
|
||||
return await DownloadWithYoutubeExplodeAsync(youtubeKey, outputPath, preferredHeight, ct)
|
||||
@@ -77,6 +79,10 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
int preferredHeight,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Separate video/audio downloads land next to the output and are always removed,
|
||||
// whether the merge succeeds or not.
|
||||
var videoTemp = outputPath + ".video.tmp";
|
||||
var audioTemp = outputPath + ".audio.tmp";
|
||||
try
|
||||
{
|
||||
var youtube = new YoutubeClient(_httpClient);
|
||||
@@ -84,30 +90,42 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
.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.
|
||||
// Muxed streams include audio+video in one file. YouTube rarely serves them
|
||||
// anymore, but use one when it exists since it needs no merge.
|
||||
var muxedStreams = manifest.GetMuxedStreams().ToList();
|
||||
if (muxedStreams.Count == 0)
|
||||
if (muxedStreams.Count > 0)
|
||||
{
|
||||
_logger.LogWarning("|CinemaTrailers4Jellyfins| No muxed streams available for {Key}. Consider configuring yt-dlp for 1080p support.", key);
|
||||
var muxed = PickByHeight(muxedStreams, preferredHeight);
|
||||
_logger.LogInformation(
|
||||
"|CinemaTrailers4Jellyfins| Downloading {Key} at {Quality} (muxed) to {Path}",
|
||||
key, muxed.VideoQuality.Label, outputPath);
|
||||
await youtube.Videos.Streams.DownloadAsync(muxed, outputPath, cancellationToken: ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise download the best video-only and audio-only streams and merge them.
|
||||
// MP4 (H.264/AAC) is preferred for client compatibility; any container is the fallback.
|
||||
var videoStreams = manifest.GetVideoOnlyStreams().ToList();
|
||||
var audioStreams = manifest.GetAudioOnlyStreams().ToList();
|
||||
if (videoStreams.Count == 0 || audioStreams.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("|CinemaTrailers4Jellyfins| No usable video/audio streams for {Key}.", 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();
|
||||
var mp4Video = videoStreams.Where(s => s.Container == Container.Mp4).ToList();
|
||||
var video = PickByHeight(mp4Video.Count > 0 ? mp4Video : videoStreams, preferredHeight);
|
||||
var mp4Audio = audioStreams.Where(s => s.Container == Container.Mp4).ToList();
|
||||
var audio = (mp4Audio.Count > 0 ? mp4Audio : audioStreams).GetWithHighestBitrate();
|
||||
|
||||
_logger.LogInformation(
|
||||
"|CinemaTrailers4Jellyfins| Downloading {Key} at {Quality} to {Path}",
|
||||
key, stream.VideoQuality.Label, outputPath);
|
||||
"|CinemaTrailers4Jellyfins| Downloading {Key} at {Quality} (video + audio, merged with ffmpeg) to {Path}",
|
||||
key, video.VideoQuality.Label, outputPath);
|
||||
|
||||
await youtube.Videos.Streams
|
||||
.DownloadAsync(stream, outputPath, cancellationToken: ct)
|
||||
.ConfigureAwait(false);
|
||||
await youtube.Videos.Streams.DownloadAsync(video, videoTemp, cancellationToken: ct).ConfigureAwait(false);
|
||||
await youtube.Videos.Streams.DownloadAsync(audio, audioTemp, cancellationToken: ct).ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
return await MergeAsync(key, videoTemp, audioTemp, outputPath, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -118,6 +136,85 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| YoutubeExplode download failed for {Key}", key);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteFile(videoTemp);
|
||||
TryDeleteFile(audioTemp);
|
||||
}
|
||||
}
|
||||
|
||||
// Highest quality at or below the configured height limit, else the lowest available.
|
||||
private static T PickByHeight<T>(System.Collections.Generic.IReadOnlyList<T> streams, int preferredHeight)
|
||||
where T : IVideoStreamInfo
|
||||
=> streams
|
||||
.Where(s => s.VideoQuality.MaxHeight <= preferredHeight)
|
||||
.OrderByDescending(s => s.VideoQuality.MaxHeight)
|
||||
.FirstOrDefault()
|
||||
?? streams.OrderBy(s => s.VideoQuality.MaxHeight).First();
|
||||
|
||||
// Stream-copies (no re-encode) the video and audio tracks into one MP4.
|
||||
// Writes to a temp file first so a failed merge never leaves a broken trailer.
|
||||
private async Task<bool> MergeAsync(string key, string videoPath, string audioPath, string outputPath, CancellationToken ct)
|
||||
{
|
||||
var mergeTemp = outputPath + ".merge.tmp";
|
||||
try
|
||||
{
|
||||
var args = string.Join(" ",
|
||||
"-y -loglevel error",
|
||||
$"-i \"{videoPath}\"",
|
||||
$"-i \"{audioPath}\"",
|
||||
"-map 0:v:0 -map 1:a:0 -c copy -movflags +faststart",
|
||||
"-f mp4",
|
||||
$"\"{mergeTemp}\"");
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = FfmpegLocator.Path,
|
||||
Arguments = args,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
// Drain both pipes while ffmpeg runs so a full buffer can never block it.
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync(ct);
|
||||
var stderrTask = process.StandardError.ReadToEndAsync(ct);
|
||||
await process.WaitForExitAsync(ct).ConfigureAwait(false);
|
||||
await stdoutTask.ConfigureAwait(false);
|
||||
var stderr = await stderrTask.ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode != 0 || !File.Exists(mergeTemp))
|
||||
{
|
||||
_logger.LogError("|CinemaTrailers4Jellyfins| ffmpeg merge failed for {Key} (exit code {Code}): {Error}",
|
||||
key, process.ExitCode, stderr);
|
||||
return false;
|
||||
}
|
||||
|
||||
File.Move(mergeTemp, outputPath, overwrite: true);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteFile(mergeTemp);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDeleteFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "|CinemaTrailers4Jellyfins| Could not delete temporary file {Path}", path);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> DownloadWithYtDlpAsync(
|
||||
@@ -129,13 +226,16 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
{
|
||||
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.
|
||||
// Format selects the best video at or below preferredHeight merged with the best audio,
|
||||
// preferring H.264 + AAC (plays or hardware-transcodes on every client; VP9/AV1 often
|
||||
// need a CPU transcode). --merge-output-format mp4 ensures the output is always an mp4.
|
||||
var args = string.Join(" ",
|
||||
$"-f \"bestvideo[height<={preferredHeight}]+bestaudio/best[height<={preferredHeight}]\"",
|
||||
$"-f \"bestvideo[height<={preferredHeight}][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[height<={preferredHeight}]+bestaudio/best[height<={preferredHeight}]\"",
|
||||
"--merge-output-format mp4",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--no-progress",
|
||||
$"--ffmpeg-location \"{FfmpegLocator.Path}\"",
|
||||
$"-o \"{outputPath}\"",
|
||||
$"\"https://www.youtube.com/watch?v={key}\"");
|
||||
|
||||
@@ -157,11 +257,15 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
};
|
||||
|
||||
process.Start();
|
||||
// Drain both pipes while yt-dlp runs so a full buffer can never block it.
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync(ct);
|
||||
var stderrTask = process.StandardError.ReadToEndAsync(ct);
|
||||
await process.WaitForExitAsync(ct).ConfigureAwait(false);
|
||||
await stdoutTask.ConfigureAwait(false);
|
||||
var stderr = await stderrTask.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;
|
||||
|
||||
@@ -48,6 +48,16 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
var preRollEnabled = !string.IsNullOrEmpty(config.TrailerPreRollLibraryId);
|
||||
var featurePreRollEnabled = !string.IsNullOrEmpty(config.FeaturePreRollLibraryId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"|CinemaTrailers4Jellyfins| GetIntros called for {Type} '{Name}' (Path={Path}). "
|
||||
+ "trailersEnabled={Trailers} preRollEnabled={PreRoll} featurePreRollEnabled={FeaturePreRoll}",
|
||||
item.GetType().Name,
|
||||
item.Name,
|
||||
item.Path,
|
||||
trailersEnabled,
|
||||
preRollEnabled,
|
||||
featurePreRollEnabled);
|
||||
|
||||
if (!trailersEnabled && !preRollEnabled && !featurePreRollEnabled)
|
||||
return Task.FromResult(Enumerable.Empty<IntroInfo>());
|
||||
|
||||
@@ -65,16 +75,23 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
|
||||
case Episode episode:
|
||||
if (!config.TrailersForEpisodes)
|
||||
{
|
||||
_logger.LogInformation("|CinemaTrailers4Jellyfins| Skipping: TrailersForEpisodes is disabled.");
|
||||
return Task.FromResult(Enumerable.Empty<IntroInfo>());
|
||||
}
|
||||
|
||||
if (!IsFirstEpisodeOfDay(user.Id))
|
||||
{
|
||||
_logger.LogInformation("|CinemaTrailers4Jellyfins| Skipping: not the first episode of the day for this user.");
|
||||
return Task.FromResult(Enumerable.Empty<IntroInfo>());
|
||||
}
|
||||
|
||||
feature = episode.Series ?? (BaseItem)episode;
|
||||
isEpisode = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.LogInformation("|CinemaTrailers4Jellyfins| Skipping: item type {Type} is not a Movie or Episode.", item.GetType().Name);
|
||||
return Task.FromResult(Enumerable.Empty<IntroInfo>());
|
||||
}
|
||||
|
||||
@@ -85,13 +102,16 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
// (covers both the fake-movie files and their trailer extras).
|
||||
if (!string.IsNullOrEmpty(outputFolder)
|
||||
&& item.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
_logger.LogInformation("|CinemaTrailers4Jellyfins| Skipping: item path is inside the output folder ({Folder}).", outputFolder);
|
||||
return Task.FromResult(Enumerable.Empty<IntroInfo>());
|
||||
}
|
||||
|
||||
var intros = new List<IntroInfo>();
|
||||
|
||||
if (preRollEnabled)
|
||||
{
|
||||
var preRoll = GetRandomLibraryMovieIntro("Trailer Pre-Roll", config.TrailerPreRollLibraryId, item.Id);
|
||||
var preRoll = GetRandomLibraryMovieIntro("Trailer Pre-Roll", config.TrailerPreRollLibraryId, item.Id, user);
|
||||
if (preRoll != null)
|
||||
intros.Add(preRoll);
|
||||
}
|
||||
@@ -109,6 +129,9 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
.Where(m =>
|
||||
m.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true
|
||||
&& m.LocalTrailers.Count > 0
|
||||
// Skip items the user can't actually play (e.g. library not in their access list) —
|
||||
// some clients hang indefinitely trying to play an inaccessible item.
|
||||
&& m.IsVisibleStandalone(user)
|
||||
// Keep TV show trailers for episodes and movie trailers for movies separate.
|
||||
&& m.Tags.Contains(TrailerTags.TvShow, StringComparer.OrdinalIgnoreCase) == isEpisode)
|
||||
.SelectMany(m => m.LocalTrailers.Select(t => (Movie: m, Trailer: t)))
|
||||
@@ -124,9 +147,16 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
}
|
||||
else
|
||||
{
|
||||
// Apply enabled filters. If nothing survives, fall back to the full pool.
|
||||
var filtered = ApplyFilters(pairs, feature, config);
|
||||
var pool = filtered.Count > 0 ? filtered : pairs;
|
||||
// Anime trailers only play before anime. Applied before the fallback below, so
|
||||
// non-anime content never gets one — it gets no trailer instead. Western animation
|
||||
// is not affected and follows the normal filters.
|
||||
var eligible = IsAnimeFeature(feature)
|
||||
? pairs
|
||||
: pairs.Where(p => !IsAnimeTrailer(p.Movie)).ToList();
|
||||
|
||||
// Apply enabled filters. If nothing survives, fall back to the eligible pool.
|
||||
var filtered = ApplyFilters(eligible, feature, config);
|
||||
var pool = filtered.Count > 0 ? filtered : eligible;
|
||||
|
||||
// Prefer unseen trailers within the pool; reset the cycle when all have been shown.
|
||||
pool = ApplyAvoidRepeats(pool, user.Id, config.AvoidRepeats);
|
||||
@@ -145,11 +175,16 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
|
||||
if (featurePreRollEnabled)
|
||||
{
|
||||
var featureRoll = GetRandomLibraryMovieIntro("Feature Pre-Roll", config.FeaturePreRollLibraryId, item.Id);
|
||||
var featureRoll = GetRandomLibraryMovieIntro("Feature Pre-Roll", config.FeaturePreRollLibraryId, item.Id, user);
|
||||
if (featureRoll != null)
|
||||
intros.Add(featureRoll);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"|CinemaTrailers4Jellyfins| Returning {Count} intro(s): {Paths}",
|
||||
intros.Count,
|
||||
string.Join(", ", intros.Select(i => i.Path)));
|
||||
|
||||
return Task.FromResult<IEnumerable<IntroInfo>>(intros);
|
||||
}
|
||||
|
||||
@@ -157,7 +192,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
/// Picks a random Movie from the given Jellyfin library (VirtualFolder ItemId) to use as a
|
||||
/// pre-roll/post-roll bumper, excluding the item currently being played.
|
||||
/// </summary>
|
||||
private IntroInfo? GetRandomLibraryMovieIntro(string label, string libraryId, Guid excludeId)
|
||||
private IntroInfo? GetRandomLibraryMovieIntro(string label, string libraryId, Guid excludeId, User user)
|
||||
{
|
||||
if (!Guid.TryParse(libraryId, out var parsedId))
|
||||
{
|
||||
@@ -168,29 +203,32 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
return null;
|
||||
}
|
||||
|
||||
var movies = _libraryManager
|
||||
.GetItemList(new InternalItemsQuery
|
||||
if (_libraryManager.GetItemById(parsedId) is not Folder folder)
|
||||
{
|
||||
IncludeItemTypes = new[] { BaseItemKind.Movie },
|
||||
TopParentIds = new[] { parsedId },
|
||||
Recursive = true,
|
||||
})
|
||||
_logger.LogWarning(
|
||||
"|CinemaTrailers4Jellyfins| {Label} library {LibraryId} could not be found.",
|
||||
label,
|
||||
parsedId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var movies = folder.GetRecursiveChildren()
|
||||
.OfType<Movie>()
|
||||
.Where(m => m.Id != excludeId)
|
||||
.Where(m => m.Id != excludeId && m.IsVisibleStandalone(user))
|
||||
.ToList();
|
||||
|
||||
if (movies.Count == 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"|CinemaTrailers4Jellyfins| {Label} library {LibraryId} has no eligible Movie items. "
|
||||
+ "Ensure the library has been scanned and contains at least one other Movie.",
|
||||
_logger.LogInformation(
|
||||
"|CinemaTrailers4Jellyfins| {Label} library {LibraryId} has no eligible Movie items visible to this user. "
|
||||
+ "Ensure the library has been scanned, contains at least one other Movie, and is in this user's library access list.",
|
||||
label,
|
||||
parsedId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var movie = movies[_rng.Next(movies.Count)];
|
||||
_logger.LogDebug(
|
||||
_logger.LogInformation(
|
||||
"|CinemaTrailers4Jellyfins| {Label}: picked '{Title}' ({Path}).",
|
||||
label,
|
||||
movie.Name,
|
||||
@@ -198,6 +236,20 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
return new IntroInfo { ItemId = movie.Id, Path = movie.Path };
|
||||
}
|
||||
|
||||
// Library items: anime providers (e.g. AniDB) set the "Anime" genre; TMDB-scraped items
|
||||
// carry TMDB keywords as tags ("anime", "based on manga"). "Animation" alone is not enough —
|
||||
// it also covers Western animation.
|
||||
private static readonly string[] AnimeFeatureTags = { "anime", "based on manga" };
|
||||
|
||||
private static bool IsAnimeFeature(BaseItem item)
|
||||
=> item.Genres.Contains("Anime", StringComparer.OrdinalIgnoreCase)
|
||||
|| item.Tags.Any(t => AnimeFeatureTags.Contains(t, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
// Trailers are tagged at download time from TMDB data (see TmdbService.IsAnime).
|
||||
private static bool IsAnimeTrailer(BaseItem item)
|
||||
=> item.Tags.Contains(TrailerTags.Anime, StringComparer.OrdinalIgnoreCase)
|
||||
|| item.Genres.Contains("Anime", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static List<(Movie Movie, BaseItem Trailer)> ApplyFilters(
|
||||
List<(Movie Movie, BaseItem Trailer)> pairs,
|
||||
BaseItem feature,
|
||||
|
||||
@@ -2,11 +2,14 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Tags written into each downloaded trailer's NFO so <see cref="TrailerIntroProvider"/>
|
||||
/// can tell movie trailers and TV show trailers apart within the shared output folder.
|
||||
/// can tell movie trailers, TV show trailers and anime trailers apart within the shared output folder.
|
||||
/// </summary>
|
||||
public static class TrailerTags
|
||||
{
|
||||
/// <summary>Marks a fake-movie folder as containing a TV show trailer.</summary>
|
||||
public const string TvShow = "ct4j-tvshow";
|
||||
|
||||
/// <summary>Marks a trailer as anime, so it only plays before anime content.</summary>
|
||||
public const string Anime = "ct4j-anime";
|
||||
}
|
||||
}
|
||||
|
||||
12
build.yaml
12
build.yaml
@@ -1,5 +1,5 @@
|
||||
---
|
||||
version: 1.0.0.6
|
||||
version: 1.0.0.13
|
||||
name: CinemaTrailers4Jellyfins
|
||||
guid: b581493e-1046-40ed-b6dc-cb8027624984
|
||||
description: >
|
||||
@@ -12,10 +12,12 @@ category: General
|
||||
owner: 514mart
|
||||
targetAbi: 10.11.0.0
|
||||
changelog:
|
||||
- Add diagnostic logging for Trailer Pre-Roll and Feature Pre-Roll bumpers —
|
||||
logs a warning if the configured library ID is invalid, and a debug message
|
||||
when no eligible movie is found or which movie was picked, to help
|
||||
troubleshoot why a bumper isn't playing
|
||||
- yt-dlp now prefers H.264 + AAC (plays or hardware-transcodes on every
|
||||
client; VP9/AV1 often needed a CPU transcode) and uses Jellyfin's own
|
||||
ffmpeg for the merge
|
||||
- If yt-dlp fails, the built-in downloader is tried instead
|
||||
- Fix a possible hang. yt-dlp's output is now read while it runs, so a
|
||||
full output buffer can no longer block it
|
||||
|
||||
dotnetProjects:
|
||||
- name: Jellyfin.Plugin.CinemaTrailers4Jellyfins
|
||||
|
||||
@@ -8,6 +8,62 @@
|
||||
"owner": "514mart",
|
||||
"imageUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/raw/branch/main/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Images/logo.svg",
|
||||
"versions": [
|
||||
{
|
||||
"checksum": "d7a565b0e4d47effb9db40fb44847920",
|
||||
"changelog": "- Anime trailers now only play before anime. Western animation (Pixar, Disney, ...) is no longer blocked and follows the normal genre filter again. A trailer counts as anime when TMDB tags it \"anime\" or \"based on manga\", or it is animation in Japanese, Chinese or Korean; it is marked with a ct4j-anime tag in its NFO at download time. Your anime is recognised by the \"Anime\" genre or the same TMDB keywords\n",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.12/cinematrailers4jellyfins_1.0.0.12.zip",
|
||||
"timestamp": "2026-10-05T21:57:24Z",
|
||||
"version": "1.0.0.12"
|
||||
},
|
||||
{
|
||||
"checksum": "ccb45ef4bda23b245e47f6226e3c06fc",
|
||||
"changelog": "- Animated trailers (anime included) now only play before animated content. Before, an anime trailer could play before a live-action movie or show when they shared a genre like Drama or Action, or when no trailer matched the genre filter. A live-action feature now gets no trailer rather than an animated one\n",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.11/cinematrailers4jellyfins_1.0.0.11.zip",
|
||||
"timestamp": "2026-10-05T21:52:47Z",
|
||||
"version": "1.0.0.11"
|
||||
},
|
||||
{
|
||||
"checksum": "13e6467ce568b1abe987ef5a8a0ee95d",
|
||||
"changelog": "- Fix trailer downloads, broken by a YouTube change. YouTube no longer serves combined video+audio files, so the built-in downloader now fetches the video and audio separately, merges them with ffmpeg (no re-encode) and deletes the separate files afterwards\n- A failed download no longer leaves an empty trailer folder behind. Empty folders counted toward \"Max trailers to keep\" (pushing out real trailers) and were never retried\n",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.10/cinematrailers4jellyfins_1.0.0.10.zip",
|
||||
"timestamp": "2026-10-05T21:42:55Z",
|
||||
"version": "1.0.0.10"
|
||||
},
|
||||
{
|
||||
"checksum": "9594d14a5fed94ee5eea55d72c71728c",
|
||||
"changelog": "- Skip trailers and pre-roll/feature pre-roll bumpers the current user can't actually access (e.g. a library not in their library access list) \u2014 some Cinema Mode clients hang indefinitely trying to play an inaccessible item, so these are now filtered out per-user instead\n",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.9/cinematrailers4jellyfins_1.0.0.9.zip",
|
||||
"timestamp": "2026-06-10T17:07:24Z",
|
||||
"version": "1.0.0.9"
|
||||
},
|
||||
{
|
||||
"checksum": "89c66caa789da56ead7d60e01d3207a5",
|
||||
"changelog": "- Fix Trailer Pre-Roll / Feature Pre-Roll bumpers not being found \u2014 the library lookup now walks the configured library folder's children directly instead of relying on TopParentIds matching, which could fail to match movies even when they appear correctly in the library\n",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.8/cinematrailers4jellyfins_1.0.0.8.zip",
|
||||
"timestamp": "2026-06-10T15:28:02Z",
|
||||
"version": "1.0.0.8"
|
||||
},
|
||||
{
|
||||
"checksum": "a52f1354c9737cf9ec92f588bb4e750e",
|
||||
"changelog": "- Add Information-level diagnostic logging to IIntroProvider.GetIntros \u2014 logs every call (item, path, and which features are enabled), why an item is skipped, and the outcome of Trailer/Feature Pre-Roll lookups, to help troubleshoot why a pre-roll bumper isn't playing\n",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.7/cinematrailers4jellyfins_1.0.0.7.zip",
|
||||
"timestamp": "2026-06-10T15:10:56Z",
|
||||
"version": "1.0.0.7"
|
||||
},
|
||||
{
|
||||
"checksum": "96e0624e5173e5e6bf829d47118e1b40",
|
||||
"changelog": "- Add diagnostic logging for Trailer Pre-Roll and Feature Pre-Roll bumpers \u2014 logs a warning if the configured library ID is invalid, and a debug message when no eligible movie is found or which movie was picked, to help troubleshoot why a bumper isn't playing\n",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.6/cinematrailers4jellyfins_1.0.0.6.zip",
|
||||
"timestamp": "2026-06-10T14:35:52Z",
|
||||
"version": "1.0.0.6"
|
||||
},
|
||||
{
|
||||
"checksum": "6c88aaeb38023b327e9b042181dc0708",
|
||||
"changelog": "- Add Trailer Pre-Roll and Feature Pre-Roll bumpers via IIntroProvider \u2014 pick existing Jellyfin Movie libraries in settings and a random movie from each is played before the trailer block (\"Now Playing\") and/or right before the feature (\"Feature Presentation\"); both are independent and disabled by default\n",
|
||||
|
||||
Reference in New Issue
Block a user