9 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
7 changed files with 122 additions and 33 deletions

View File

@@ -45,7 +45,9 @@
<fieldset class="verticalSection verticalSection-extrabottompadding">
<legend><h3 class="sectionTitle">Trailer Languages</h3></legend>
<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.
</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 2em;">

View File

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

View File

@@ -177,6 +177,14 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
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(
"movie", movie.Id.ToString(), config.TmdbApiKey, allowedLanguages, cancellationToken).ConfigureAwait(false);
@@ -284,6 +292,14 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
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(
"tv", show.Id.ToString(), config.TmdbApiKey, allowedLanguages, cancellationToken).ConfigureAwait(false);

View File

@@ -14,7 +14,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
{
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;
}
@@ -170,6 +170,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
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 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))
{
@@ -177,7 +178,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
}
anyInRange = true;
results.Add(new TmdbMediaResult(id, title, releaseDate));
results.Add(new TmdbMediaResult(id, title, releaseDate, originalLanguage));
}
if (page >= totalPages || (releasedAfter.HasValue && !anyInRange))

View File

@@ -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)
@@ -88,31 +90,37 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
.GetManifestAsync($"https://www.youtube.com/watch?v={key}", ct)
.ConfigureAwait(false);
// Muxed streams include audio+video in one file. YouTube rarely serves them
// anymore, but use one when it exists since it needs no merge.
// 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();
if (muxedStreams.Count > 0)
{
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;
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;
}
}
// 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)
if (video == null || audioStreams.Count == 0)
{
_logger.LogWarning("|CinemaTrailers4Jellyfins| No usable video/audio streams for {Key}.", key);
return false;
}
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();
@@ -224,13 +232,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}\"");
@@ -252,11 +263,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;
@@ -273,6 +288,32 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| yt-dlp download failed for {Key}", key);
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

@@ -1,5 +1,5 @@
---
version: 1.0.0.12
version: 1.0.0.16
name: CinemaTrailers4Jellyfins
guid: b581493e-1046-40ed-b6dc-cb8027624984
description: >
@@ -12,12 +12,9 @@ category: General
owner: 514mart
targetAbi: 10.11.0.0
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
- The built-in downloader no longer picks YouTube's 360p combined file
when separate video and audio streams give a better picture. This
mostly showed up when yt-dlp failed and the built-in downloader took over
dotnetProjects:
- name: Jellyfin.Plugin.CinemaTrailers4Jellyfins

View File

@@ -8,6 +8,38 @@
"owner": "514mart",
"imageUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/raw/branch/main/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Images/logo.svg",
"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",