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