From 5176582391b15ce114d3aa5c81c38ac24cba053c Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 5 Oct 2026 17:42:28 -0400 Subject: [PATCH] fix: download trailers as separate video+audio and merge with ffmpeg (v1.0.0.10) 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 --- ...fin.Plugin.CinemaTrailers4Jellyfins.csproj | 6 +- .../ScheduledTasks/DownloadTrailersTask.cs | 14 +- .../Services/FakeMovieService.cs | 7 +- .../Services/FfmpegLocator.cs | 12 ++ .../Services/TrailerDownloadService.cs | 125 +++++++++++++++--- build.yaml | 13 +- 6 files changed, 146 insertions(+), 31 deletions(-) create mode 100644 Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FfmpegLocator.cs diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Jellyfin.Plugin.CinemaTrailers4Jellyfins.csproj b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Jellyfin.Plugin.CinemaTrailers4Jellyfins.csproj index a3ff21c..2701628 100644 --- a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Jellyfin.Plugin.CinemaTrailers4Jellyfins.csproj +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Jellyfin.Plugin.CinemaTrailers4Jellyfins.csproj @@ -3,8 +3,8 @@ net9.0 Jellyfin.Plugin.CinemaTrailers4Jellyfins - 1.0.0.9 - 1.0.0.9 + 1.0.0.10 + 1.0.0.10 enable false false @@ -13,7 +13,7 @@ - + diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/ScheduledTasks/DownloadTrailersTask.cs b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/ScheduledTasks/DownloadTrailersTask.cs index e0a8e65..c2b6ec9 100644 --- a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/ScheduledTasks/DownloadTrailersTask.cs +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/ScheduledTasks/DownloadTrailersTask.cs @@ -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) @@ -272,7 +277,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 +303,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) diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FakeMovieService.cs b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FakeMovieService.cs index 3dc20ec..0946b2b 100644 --- a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FakeMovieService.cs +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FakeMovieService.cs @@ -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, diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FfmpegLocator.cs b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FfmpegLocator.cs new file mode 100644 index 0000000..efe15b4 --- /dev/null +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FfmpegLocator.cs @@ -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"; + } +} diff --git a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TrailerDownloadService.cs b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TrailerDownloadService.cs index 7c7b987..c6d7aa1 100644 --- a/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TrailerDownloadService.cs +++ b/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/TrailerDownloadService.cs @@ -77,6 +77,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 +88,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 +134,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(System.Collections.Generic.IReadOnlyList 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 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 DownloadWithYtDlpAsync( diff --git a/build.yaml b/build.yaml index bbf6424..f676b16 100644 --- a/build.yaml +++ b/build.yaml @@ -1,5 +1,5 @@ --- -version: 1.0.0.9 +version: 1.0.0.10 name: CinemaTrailers4Jellyfins guid: b581493e-1046-40ed-b6dc-cb8027624984 description: > @@ -12,10 +12,13 @@ category: General owner: 514mart targetAbi: 10.11.0.0 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) - — some Cinema Mode clients hang indefinitely trying to play an - inaccessible item, so these are now filtered out per-user instead + - 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 + - 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 dotnetProjects: - name: Jellyfin.Plugin.CinemaTrailers4Jellyfins