21 Commits

Author SHA1 Message Date
Martin
d6280f3ce8 fix: built-in downloader prefers separate streams over 360p muxed (v1.0.0.16)
All checks were successful
Publish Release / release (push) Successful in 24s
When YouTube offered a muxed stream, it was always taken, but muxed streams
are 360p only. Use muxed only when the separate video+audio streams are
missing or not better.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:39:49 -04:00
gitea-actions
43e002fcdd chore: update manifest.json for v1.0.0.15 2026-10-05 22:28:03 +00:00
Martin
fb75fa5a07 fix: language filter also checks the title's original language (v1.0.0.15)
All checks were successful
Publish Release / release (push) Successful in 9s
AllowedLanguages only filtered on each video's iso_639_1 tag, and TMDB
often tags subtitled foreign trailers as "en". With en,fr selected, 6 of
27 downloaded trailers were Korean, Chinese or Zulu titles. Candidates
now also need an allowed original_language (from the list results, so
no extra API calls).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:27:50 -04:00
gitea-actions
2664ab4d5a chore: update manifest.json for v1.0.0.14 2026-10-05 22:19:38 +00:00
Martin
86d8e2a05b fix: delete yt-dlp partial files when it fails (v1.0.0.14)
All checks were successful
Publish Release / release (push) Successful in 11s
yt-dlp removes its separate video/audio files after a successful merge, but
a failed run (e.g. HTTP 403 mid-download) left "<name>.f137.mp4.part" etc.
in the trailer folder, even when the built-in fallback then succeeded.
Always remove files sharing the output's "<name>." prefix after yt-dlp runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:19:24 -04:00
gitea-actions
c013416473 chore: update manifest.json for v1.0.0.13 2026-10-05 22:11:03 +00:00
Martin
e43e3f7245 fix(build): quote-safe changelog line for v1.0.0.13
All checks were successful
Publish Release / release (push) Successful in 11s
"Fix a possible hang: ..." has ": " inside a plain YAML scalar, which
PyYAML reads as a mapping key, so update_manifest.py failed in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:10:49 -04:00
Martin
4145e1f1dd feat: yt-dlp prefers H.264, falls back to built-in, no pipe hang (v1.0.0.13)
Some checks failed
Publish Release / release (push) Failing after 8s
- Format string prefers avc1 video + m4a audio, then any codec. The RTX 2070
  host can hardware-transcode H.264 but not AV1.
- Pass Jellyfin's ffmpeg via --ffmpeg-location instead of relying on PATH.
- If yt-dlp fails, retry with the built-in YoutubeExplode downloader.
- Read stdout/stderr while yt-dlp runs (and add --no-progress); before,
  stdout was never read, so a full pipe buffer could block yt-dlp forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:01:33 -04:00
gitea-actions
6002ce7b46 chore: update manifest.json for v1.0.0.12 2026-10-05 21:57:24 +00:00
Martin
d850e12964 feat: restrict anime trailers, not all animation (v1.0.0.12)
All checks were successful
Publish Release / release (push) Successful in 9s
v1.0.0.11 kept every animated trailer away from non-animated content, which
also blocked Western animation (Pixar, Disney...). Only anime should be
restricted.

At download, a trailer is tagged ct4j-anime when TMDB has the "anime" or
"based on manga" keyword, or it is Animation in ja/zh/ko (keywords are now
appended to the details request). At playback, anime trailers are dropped
unless the feature is anime (genre "Anime", or those keywords as tags).
Western animation follows the normal genre/rating filters again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:57:12 -04:00
gitea-actions
205d64dc6f chore: update manifest.json for v1.0.0.11 2026-10-05 21:52:47 +00:00
Martin
322d6cc65d feat: only play animated trailers before animated content (v1.0.0.11)
All checks were successful
Publish Release / release (push) Successful in 10s
An anime trailer could play before live-action content: the genre filter
accepts any single shared genre (anime is tagged Animation + Drama/Action/...),
and when nothing matched it fell back to the whole pool. Trailers tagged
Animation or Anime are now dropped for non-animated features before the
filters and the fallback run, so a live-action feature gets no trailer
rather than an animated one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:52:33 -04:00
gitea-actions
c208a81b75 chore: update manifest.json for v1.0.0.10 2026-10-05 21:42:55 +00:00
Martin
5176582391 fix: download trailers as separate video+audio and merge with ffmpeg (v1.0.0.10)
All checks were successful
Publish Release / release (push) Successful in 26s
YouTube stopped serving muxed streams and YoutubeExplode 6.6.0 failed on
every video ("Video is not available"), so no trailer downloaded. Bump
YoutubeExplode to 6.6.2, download the best video-only + audio-only streams
and stream-copy them into one MP4 with ffmpeg. The separate files are
always deleted afterwards, merge or not.

Also delete the trailer folder when a download fails. Empty folders were
treated as "already downloaded" (never retried) and counted toward
MaxTotalTrailers, so over time they pushed out every real trailer. The
skip check now requires the trailer file itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:42:28 -04:00
gitea-actions
e518b79c36 chore: update manifest.json for v1.0.0.9 2026-06-10 17:07:24 +00:00
Martin
6bb23dc9c4 feat: skip trailers/pre-rolls inaccessible to the current user (v1.0.0.9)
All checks were successful
Publish Release / release (push) Successful in 12s
Filters trailer pool items and pre-roll/feature pre-roll bumpers using
BaseItem.IsVisibleStandalone(user), so users without access to the
relevant library are never handed an item they can't actually play —
some Cinema Mode clients hang indefinitely in that case.
2026-06-10 13:07:11 -04:00
gitea-actions
5f7a76feb5 chore: update manifest.json for v1.0.0.8 2026-06-10 15:28:02 +00:00
Martin
adfa570569 fix: pre-roll/feature pre-roll bumpers not found in library (v1.0.0.8)
All checks were successful
Publish Release / release (push) Successful in 8s
TopParentIds-based queries could fail to match Movie items in the
configured pre-roll library even when they appear correctly in the
Jellyfin UI. Look up the library Folder by ID and walk its recursive
children directly instead, matching the path-based approach already
used for the trailer pool.
2026-06-10 11:27:52 -04:00
gitea-actions
99798b5174 chore: update manifest.json for v1.0.0.7 2026-06-10 15:10:56 +00:00
Martin
c76ddbc352 chore: trace GetIntros calls and outcomes at Information level (v1.0.0.7)
All checks were successful
Publish Release / release (push) Successful in 10s
Logs every GetIntros call (item, path, enabled features), why an item
is skipped, and pre-roll/post-roll lookup outcomes, without depending
on a Debug logging.json override.
2026-06-10 11:09:48 -04:00
gitea-actions
e9d9543941 chore: update manifest.json for v1.0.0.6 2026-06-10 14:35:52 +00:00
11 changed files with 398 additions and 66 deletions

View File

@@ -45,7 +45,9 @@
<fieldset class="verticalSection verticalSection-extrabottompadding"> <fieldset class="verticalSection verticalSection-extrabottompadding">
<legend><h3 class="sectionTitle">Trailer Languages</h3></legend> <legend><h3 class="sectionTitle">Trailer Languages</h3></legend>
<p class="fieldDescription"> <p class="fieldDescription">
Only download trailers in the selected languages. Only download trailers in the selected languages. Both the trailer and the
movie/show itself (its original language) must be in one of them, so a
subtitled foreign trailer is skipped.
Leave everything unchecked to allow all languages. Leave everything unchecked to allow all languages.
</p> </p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 2em;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:0 2em;">

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.6</AssemblyVersion> <AssemblyVersion>1.0.0.16</AssemblyVersion>
<FileVersion>1.0.0.6</FileVersion> <FileVersion>1.0.0.16</FileVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateDocumentationFile>false</GenerateDocumentationFile> <GenerateDocumentationFile>false</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>false</TreatWarningsAsErrors>
@@ -13,7 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.0" /> <PackageReference Include="Jellyfin.Controller" Version="10.11.0" />
<PackageReference Include="Jellyfin.Model" 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>
<ItemGroup> <ItemGroup>

View File

@@ -171,12 +171,20 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
var paths = BuildMediaPaths(movie.Title, movie.Year, config); 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); _logger.LogDebug("|CinemaTrailers4Jellyfins| Already downloaded: {Path}", paths.MediaFolder);
continue; continue;
} }
// A trailer's own language tag is often "en" just because it has English
// subtitles, so also require the title itself to be in an allowed language.
if (allowedLanguages != null && !allowedLanguages.Contains(movie.OriginalLanguage))
{
_logger.LogDebug("|CinemaTrailers4Jellyfins| Skipping '{Title}': original language '{Lang}' is not allowed", movie.Title, movie.OriginalLanguage);
continue;
}
var trailers = await _tmdbService.GetTrailersAsync( var trailers = await _tmdbService.GetTrailersAsync(
"movie", movie.Id.ToString(), config.TmdbApiKey, allowedLanguages, cancellationToken).ConfigureAwait(false); "movie", movie.Id.ToString(), config.TmdbApiKey, allowedLanguages, cancellationToken).ConfigureAwait(false);
@@ -197,7 +205,12 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
if (!success) 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; continue;
}
var fakeMovieReady = await _fakeMovieService.CopyFakeMovieAsync(paths.FakeMoviePath, cancellationToken).ConfigureAwait(false); var fakeMovieReady = await _fakeMovieService.CopyFakeMovieAsync(paths.FakeMoviePath, cancellationToken).ConfigureAwait(false);
if (!fakeMovieReady) if (!fakeMovieReady)
@@ -212,7 +225,8 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
var metadata = await _tmdbService.GetMediaMetadataAsync( var metadata = await _tmdbService.GetMediaMetadataAsync(
"movie", movie.Id.ToString(), config.TmdbApiKey, cancellationToken).ConfigureAwait(false); "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++; downloaded++;
_logger.LogInformation( _logger.LogInformation(
@@ -272,12 +286,20 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
var paths = BuildMediaPaths(show.Title, show.Year, config); 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); _logger.LogDebug("|CinemaTrailers4Jellyfins| Already downloaded: {Path}", paths.MediaFolder);
continue; continue;
} }
// A trailer's own language tag is often "en" just because it has English
// subtitles, so also require the title itself to be in an allowed language.
if (allowedLanguages != null && !allowedLanguages.Contains(show.OriginalLanguage))
{
_logger.LogDebug("|CinemaTrailers4Jellyfins| Skipping '{Title}': original language '{Lang}' is not allowed", show.Title, show.OriginalLanguage);
continue;
}
var trailers = await _tmdbService.GetTrailersAsync( var trailers = await _tmdbService.GetTrailersAsync(
"tv", show.Id.ToString(), config.TmdbApiKey, allowedLanguages, cancellationToken).ConfigureAwait(false); "tv", show.Id.ToString(), config.TmdbApiKey, allowedLanguages, cancellationToken).ConfigureAwait(false);
@@ -298,7 +320,12 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
if (!success) 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; continue;
}
var fakeMovieReady = await _fakeMovieService.CopyFakeMovieAsync(paths.FakeMoviePath, cancellationToken).ConfigureAwait(false); var fakeMovieReady = await _fakeMovieService.CopyFakeMovieAsync(paths.FakeMoviePath, cancellationToken).ConfigureAwait(false);
if (!fakeMovieReady) if (!fakeMovieReady)
@@ -313,7 +340,8 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
var metadata = await _tmdbService.GetMediaMetadataAsync( var metadata = await _tmdbService.GetMediaMetadataAsync(
"tv", show.Id.ToString(), config.TmdbApiKey, cancellationToken).ConfigureAwait(false); "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++; downloaded++;
_logger.LogInformation( _logger.LogInformation(

View File

@@ -109,16 +109,11 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
"-f mp4", "-f mp4",
$"\"{tempPath}\""); $"\"{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 using var process = new Process
{ {
StartInfo = new ProcessStartInfo StartInfo = new ProcessStartInfo
{ {
FileName = ffmpegPath, FileName = FfmpegLocator.Path,
Arguments = args, Arguments = args,
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,

View File

@@ -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";
}
}

View File

@@ -14,12 +14,12 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
{ {
public record TmdbVideo(string Key, string Name, string Language, bool Official, int Size); public record TmdbVideo(string Key, string Name, string Language, bool Official, int Size);
public record TmdbMediaResult(int Id, string Title, string ReleaseDate) public record TmdbMediaResult(int Id, string Title, string ReleaseDate, string OriginalLanguage)
{ {
public int? Year => DateTime.TryParse(ReleaseDate, out var d) ? d.Year : (int?)null; 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 public class TmdbService : IDisposable
{ {
@@ -170,6 +170,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
var releaseDate = entry.TryGetProperty(dateField, out var rd) ? rd.GetString() ?? string.Empty : string.Empty; var releaseDate = entry.TryGetProperty(dateField, out var rd) ? rd.GetString() ?? string.Empty : string.Empty;
var title = entry.TryGetProperty(titleField, out var t) ? t.GetString() ?? string.Empty : string.Empty; var title = entry.TryGetProperty(titleField, out var t) ? t.GetString() ?? string.Empty : string.Empty;
var id = entry.GetProperty("id").GetInt32(); var id = entry.GetProperty("id").GetInt32();
var originalLanguage = entry.TryGetProperty("original_language", out var ol) ? ol.GetString() ?? string.Empty : string.Empty;
if (releasedAfter.HasValue && DateTime.TryParse(releaseDate, out var parsed)) if (releasedAfter.HasValue && DateTime.TryParse(releaseDate, out var parsed))
{ {
@@ -177,7 +178,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
} }
anyInRange = true; anyInRange = true;
results.Add(new TmdbMediaResult(id, title, releaseDate)); results.Add(new TmdbMediaResult(id, title, releaseDate, originalLanguage));
} }
if (page >= totalPages || (releasedAfter.HasValue && !anyInRange)) if (page >= totalPages || (releasedAfter.HasValue && !anyInRange))
@@ -236,7 +237,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
try try
{ {
var appendField = mediaType == "movie" ? "release_dates" : "content_ratings"; 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); using var request = new HttpRequestMessage(HttpMethod.Get, url);
ApplyAuth(request, apiKey); ApplyAuth(request, apiKey);
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
@@ -262,16 +263,40 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
? GetMovieCertification(doc) ? GetMovieCertification(doc)
: GetTvCertification(doc); : GetTvCertification(doc);
return new TmdbMediaMetadata(genres.AsReadOnly(), certification); return new TmdbMediaMetadata(genres.AsReadOnly(), certification, IsAnime(doc, mediaType, genres));
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| GetMediaMetadata failed for {MediaType} TMDB ID {Id}", mediaType, tmdbId); _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. // Finds the US theatrical certification (G, PG, PG-13, R, NC-17) from release_dates.
private static string? GetMovieCertification(JsonDocument doc) private static string? GetMovieCertification(JsonDocument doc)
{ {

View File

@@ -63,8 +63,10 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
if (!string.IsNullOrWhiteSpace(ytDlpPath) && File.Exists(ytDlpPath)) if (!string.IsNullOrWhiteSpace(ytDlpPath) && File.Exists(ytDlpPath))
{ {
return await DownloadWithYtDlpAsync(youtubeKey, outputPath, preferredHeight, ytDlpPath, ct) if (await DownloadWithYtDlpAsync(youtubeKey, outputPath, preferredHeight, ytDlpPath, ct).ConfigureAwait(false))
.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) return await DownloadWithYoutubeExplodeAsync(youtubeKey, outputPath, preferredHeight, ct)
@@ -77,6 +79,10 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
int preferredHeight, int preferredHeight,
CancellationToken ct) 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 try
{ {
var youtube = new YoutubeClient(_httpClient); var youtube = new YoutubeClient(_httpClient);
@@ -84,30 +90,48 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
.GetManifestAsync($"https://www.youtube.com/watch?v={key}", ct) .GetManifestAsync($"https://www.youtube.com/watch?v={key}", ct)
.ConfigureAwait(false); .ConfigureAwait(false);
// Muxed streams include audio+video in one file. Quality is capped at 720p by YouTube. // Best video-only stream at or below the height limit. MP4 (H.264) is preferred for
// client compatibility; any container is the fallback.
var videoStreams = manifest.GetVideoOnlyStreams().ToList();
var audioStreams = manifest.GetAudioOnlyStreams().ToList();
var mp4Video = videoStreams.Where(s => s.Container == Container.Mp4).ToList();
var video = videoStreams.Count > 0
? PickByHeight(mp4Video.Count > 0 ? mp4Video : videoStreams, preferredHeight)
: null;
// Muxed streams (audio+video in one file) need no merge, but YouTube only serves
// them at 360p. Use one only when the separate streams can't do better.
var muxedStreams = manifest.GetMuxedStreams().ToList(); 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);
return false; if (video == null || audioStreams.Count == 0
|| muxed.VideoQuality.MaxHeight >= video.VideoQuality.MaxHeight)
{
_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;
}
} }
// Prefer the highest quality at or below the configured height limit. if (video == null || audioStreams.Count == 0)
var stream = muxedStreams {
.Where(s => s.VideoQuality.MaxHeight <= preferredHeight) _logger.LogWarning("|CinemaTrailers4Jellyfins| No usable video/audio streams for {Key}.", key);
.OrderByDescending(s => s.VideoQuality.MaxHeight) return false;
.FirstOrDefault() }
?? muxedStreams.OrderByDescending(s => s.VideoQuality.MaxHeight).First(); var mp4Audio = audioStreams.Where(s => s.Container == Container.Mp4).ToList();
var audio = (mp4Audio.Count > 0 ? mp4Audio : audioStreams).GetWithHighestBitrate();
_logger.LogInformation( _logger.LogInformation(
"|CinemaTrailers4Jellyfins| Downloading {Key} at {Quality} to {Path}", "|CinemaTrailers4Jellyfins| Downloading {Key} at {Quality} (video + audio, merged with ffmpeg) to {Path}",
key, stream.VideoQuality.Label, outputPath); key, video.VideoQuality.Label, outputPath);
await youtube.Videos.Streams await youtube.Videos.Streams.DownloadAsync(video, videoTemp, cancellationToken: ct).ConfigureAwait(false);
.DownloadAsync(stream, outputPath, cancellationToken: ct) await youtube.Videos.Streams.DownloadAsync(audio, audioTemp, cancellationToken: ct).ConfigureAwait(false);
.ConfigureAwait(false);
return true; return await MergeAsync(key, videoTemp, audioTemp, outputPath, ct).ConfigureAwait(false);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -118,6 +142,85 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| YoutubeExplode download failed for {Key}", key); _logger.LogError(ex, "|CinemaTrailers4Jellyfins| YoutubeExplode download failed for {Key}", key);
return false; 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( private async Task<bool> DownloadWithYtDlpAsync(
@@ -129,13 +232,16 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
{ {
try try
{ {
// Format selects the best video at or below preferredHeight merged with the best audio. // 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. // 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(" ", 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", "--merge-output-format mp4",
"--no-playlist", "--no-playlist",
"--no-warnings", "--no-warnings",
"--no-progress",
$"--ffmpeg-location \"{FfmpegLocator.Path}\"",
$"-o \"{outputPath}\"", $"-o \"{outputPath}\"",
$"\"https://www.youtube.com/watch?v={key}\""); $"\"https://www.youtube.com/watch?v={key}\"");
@@ -157,11 +263,15 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
}; };
process.Start(); 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 process.WaitForExitAsync(ct).ConfigureAwait(false);
await stdoutTask.ConfigureAwait(false);
var stderr = await stderrTask.ConfigureAwait(false);
if (process.ExitCode != 0) 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}", _logger.LogError("|CinemaTrailers4Jellyfins| yt-dlp exited with code {Code} for {Key}: {Error}",
process.ExitCode, key, stderr); process.ExitCode, key, stderr);
return false; return false;
@@ -178,6 +288,32 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| yt-dlp download failed for {Key}", key); _logger.LogError(ex, "|CinemaTrailers4Jellyfins| yt-dlp download failed for {Key}", key);
return false; return false;
} }
finally
{
DeleteYtDlpLeftovers(outputPath);
}
}
// yt-dlp names its separate/partial downloads "<output name>.f137.mp4.part", ".f140.m4a",
// ".ytdl", ... It removes them after a successful merge, but not when it fails. Everything
// sharing the output's "<name>." prefix (other than the output itself) is one of those.
private void DeleteYtDlpLeftovers(string outputPath)
{
var folder = Path.GetDirectoryName(outputPath);
if (folder == null || !Directory.Exists(folder))
return;
var outputName = Path.GetFileName(outputPath);
var prefix = Path.GetFileNameWithoutExtension(outputPath) + ".";
foreach (var file in Directory.EnumerateFiles(folder))
{
var name = Path.GetFileName(file);
if (name.StartsWith(prefix, StringComparison.Ordinal)
&& !string.Equals(name, outputName, StringComparison.Ordinal))
{
TryDeleteFile(file);
}
}
} }
} }
} }

View File

@@ -48,6 +48,16 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
var preRollEnabled = !string.IsNullOrEmpty(config.TrailerPreRollLibraryId); var preRollEnabled = !string.IsNullOrEmpty(config.TrailerPreRollLibraryId);
var featurePreRollEnabled = !string.IsNullOrEmpty(config.FeaturePreRollLibraryId); 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) if (!trailersEnabled && !preRollEnabled && !featurePreRollEnabled)
return Task.FromResult(Enumerable.Empty<IntroInfo>()); return Task.FromResult(Enumerable.Empty<IntroInfo>());
@@ -65,16 +75,23 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
case Episode episode: case Episode episode:
if (!config.TrailersForEpisodes) if (!config.TrailersForEpisodes)
{
_logger.LogInformation("|CinemaTrailers4Jellyfins| Skipping: TrailersForEpisodes is disabled.");
return Task.FromResult(Enumerable.Empty<IntroInfo>()); return Task.FromResult(Enumerable.Empty<IntroInfo>());
}
if (!IsFirstEpisodeOfDay(user.Id)) if (!IsFirstEpisodeOfDay(user.Id))
{
_logger.LogInformation("|CinemaTrailers4Jellyfins| Skipping: not the first episode of the day for this user.");
return Task.FromResult(Enumerable.Empty<IntroInfo>()); return Task.FromResult(Enumerable.Empty<IntroInfo>());
}
feature = episode.Series ?? (BaseItem)episode; feature = episode.Series ?? (BaseItem)episode;
isEpisode = true; isEpisode = true;
break; break;
default: default:
_logger.LogInformation("|CinemaTrailers4Jellyfins| Skipping: item type {Type} is not a Movie or Episode.", item.GetType().Name);
return Task.FromResult(Enumerable.Empty<IntroInfo>()); 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). // (covers both the fake-movie files and their trailer extras).
if (!string.IsNullOrEmpty(outputFolder) if (!string.IsNullOrEmpty(outputFolder)
&& item.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true) && 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>()); return Task.FromResult(Enumerable.Empty<IntroInfo>());
}
var intros = new List<IntroInfo>(); var intros = new List<IntroInfo>();
if (preRollEnabled) 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) if (preRoll != null)
intros.Add(preRoll); intros.Add(preRoll);
} }
@@ -109,6 +129,9 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
.Where(m => .Where(m =>
m.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true m.Path?.StartsWith(outputFolder, StringComparison.OrdinalIgnoreCase) == true
&& m.LocalTrailers.Count > 0 && 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. // Keep TV show trailers for episodes and movie trailers for movies separate.
&& m.Tags.Contains(TrailerTags.TvShow, StringComparer.OrdinalIgnoreCase) == isEpisode) && m.Tags.Contains(TrailerTags.TvShow, StringComparer.OrdinalIgnoreCase) == isEpisode)
.SelectMany(m => m.LocalTrailers.Select(t => (Movie: m, Trailer: t))) .SelectMany(m => m.LocalTrailers.Select(t => (Movie: m, Trailer: t)))
@@ -124,9 +147,16 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
} }
else else
{ {
// Apply enabled filters. If nothing survives, fall back to the full pool. // Anime trailers only play before anime. Applied before the fallback below, so
var filtered = ApplyFilters(pairs, feature, config); // non-anime content never gets one — it gets no trailer instead. Western animation
var pool = filtered.Count > 0 ? filtered : pairs; // 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. // Prefer unseen trailers within the pool; reset the cycle when all have been shown.
pool = ApplyAvoidRepeats(pool, user.Id, config.AvoidRepeats); pool = ApplyAvoidRepeats(pool, user.Id, config.AvoidRepeats);
@@ -145,11 +175,16 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
if (featurePreRollEnabled) 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) if (featureRoll != null)
intros.Add(featureRoll); 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); 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 /// 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. /// pre-roll/post-roll bumper, excluding the item currently being played.
/// </summary> /// </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)) if (!Guid.TryParse(libraryId, out var parsedId))
{ {
@@ -168,29 +203,32 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
return null; return null;
} }
var movies = _libraryManager if (_libraryManager.GetItemById(parsedId) is not Folder folder)
.GetItemList(new InternalItemsQuery {
{ _logger.LogWarning(
IncludeItemTypes = new[] { BaseItemKind.Movie }, "|CinemaTrailers4Jellyfins| {Label} library {LibraryId} could not be found.",
TopParentIds = new[] { parsedId }, label,
Recursive = true, parsedId);
}) return null;
}
var movies = folder.GetRecursiveChildren()
.OfType<Movie>() .OfType<Movie>()
.Where(m => m.Id != excludeId) .Where(m => m.Id != excludeId && m.IsVisibleStandalone(user))
.ToList(); .ToList();
if (movies.Count == 0) if (movies.Count == 0)
{ {
_logger.LogDebug( _logger.LogInformation(
"|CinemaTrailers4Jellyfins| {Label} library {LibraryId} has no eligible Movie items. " "|CinemaTrailers4Jellyfins| {Label} library {LibraryId} has no eligible Movie items visible to this user. "
+ "Ensure the library has been scanned and contains at least one other Movie.", + "Ensure the library has been scanned, contains at least one other Movie, and is in this user's library access list.",
label, label,
parsedId); parsedId);
return null; return null;
} }
var movie = movies[_rng.Next(movies.Count)]; var movie = movies[_rng.Next(movies.Count)];
_logger.LogDebug( _logger.LogInformation(
"|CinemaTrailers4Jellyfins| {Label}: picked '{Title}' ({Path}).", "|CinemaTrailers4Jellyfins| {Label}: picked '{Title}' ({Path}).",
label, label,
movie.Name, movie.Name,
@@ -198,6 +236,20 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
return new IntroInfo { ItemId = movie.Id, Path = movie.Path }; 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( private static List<(Movie Movie, BaseItem Trailer)> ApplyFilters(
List<(Movie Movie, BaseItem Trailer)> pairs, List<(Movie Movie, BaseItem Trailer)> pairs,
BaseItem feature, BaseItem feature,

View File

@@ -2,11 +2,14 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
{ {
/// <summary> /// <summary>
/// Tags written into each downloaded trailer's NFO so <see cref="TrailerIntroProvider"/> /// 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> /// </summary>
public static class TrailerTags public static class TrailerTags
{ {
/// <summary>Marks a fake-movie folder as containing a TV show trailer.</summary> /// <summary>Marks a fake-movie folder as containing a TV show trailer.</summary>
public const string TvShow = "ct4j-tvshow"; 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";
} }
} }

View File

@@ -1,5 +1,5 @@
--- ---
version: 1.0.0.6 version: 1.0.0.16
name: CinemaTrailers4Jellyfins name: CinemaTrailers4Jellyfins
guid: b581493e-1046-40ed-b6dc-cb8027624984 guid: b581493e-1046-40ed-b6dc-cb8027624984
description: > description: >
@@ -12,10 +12,9 @@ category: General
owner: 514mart owner: 514mart
targetAbi: 10.11.0.0 targetAbi: 10.11.0.0
changelog: changelog:
- Add diagnostic logging for Trailer Pre-Roll and Feature Pre-Roll bumpers — - The built-in downloader no longer picks YouTube's 360p combined file
logs a warning if the configured library ID is invalid, and a debug message when separate video and audio streams give a better picture. This
when no eligible movie is found or which movie was picked, to help mostly showed up when yt-dlp failed and the built-in downloader took over
troubleshoot why a bumper isn't playing
dotnetProjects: dotnetProjects:
- name: Jellyfin.Plugin.CinemaTrailers4Jellyfins - name: Jellyfin.Plugin.CinemaTrailers4Jellyfins

View File

@@ -8,6 +8,86 @@
"owner": "514mart", "owner": "514mart",
"imageUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/raw/branch/main/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Images/logo.svg", "imageUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/raw/branch/main/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Images/logo.svg",
"versions": [ "versions": [
{
"checksum": "211f58d992857f9e91263293306fbd0d",
"changelog": "- Trailer Languages now also checks the original language of the movie or show. Before, only the trailer's own language tag was checked, and TMDB often tags subtitled foreign trailers as English, so Korean, Chinese or Japanese trailers got through\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.15/cinematrailers4jellyfins_1.0.0.15.zip",
"timestamp": "2026-10-05T22:28:03Z",
"version": "1.0.0.15"
},
{
"checksum": "10ecbc35b84e98bade61576bbfc41864",
"changelog": "- When yt-dlp fails part-way, its partial download files (.part, .ytdl, separate video/audio) are now deleted instead of being left in the trailer folder\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.14/cinematrailers4jellyfins_1.0.0.14.zip",
"timestamp": "2026-10-05T22:19:38Z",
"version": "1.0.0.14"
},
{
"checksum": "f1fe201e7b0238fa432ae7fb5dbb1844",
"changelog": "- 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\n- If yt-dlp fails, the built-in downloader is tried instead\n- Fix a possible hang. yt-dlp's output is now read while it runs, so a full output buffer can no longer block it\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.13/cinematrailers4jellyfins_1.0.0.13.zip",
"timestamp": "2026-10-05T22:11:03Z",
"version": "1.0.0.13"
},
{
"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", "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", "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",