using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using YoutubeExplode; using YoutubeExplode.Videos.Streams; namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services { public class TrailerDownloadService : IDisposable { private readonly ILogger _logger; private readonly HttpClient _httpClient; public TrailerDownloadService(ILogger logger) { _logger = logger; // Force IPv4 to avoid ~80s delay when IPv6 is unreachable (Happy Eyeballs fallback). var handler = new SocketsHttpHandler { ConnectCallback = async (ctx, ct) => { var entry = await Dns.GetHostEntryAsync(ctx.DnsEndPoint.Host, AddressFamily.InterNetwork, ct).ConfigureAwait(false); var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); socket.NoDelay = true; try { await socket.ConnectAsync(entry.AddressList[0], ctx.DnsEndPoint.Port, ct).ConfigureAwait(false); return new NetworkStream(socket, ownsSocket: true); } catch { socket.Dispose(); throw; } } }; _httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(10) }; } public void Dispose() => _httpClient.Dispose(); /// /// Downloads a YouTube video by key to outputPath. /// Uses yt-dlp when a valid path is configured (supports 1080p+, requires ffmpeg on PATH). /// Falls back to YoutubeExplode for built-in download (max 720p, no external tools needed). /// public async Task DownloadAsync( string youtubeKey, string outputPath, int preferredHeight, string ytDlpPath, CancellationToken ct) { Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); if (!string.IsNullOrWhiteSpace(ytDlpPath) && File.Exists(ytDlpPath)) { 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) .ConfigureAwait(false); } private async Task DownloadWithYoutubeExplodeAsync( string key, string outputPath, 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); var manifest = await youtube.Videos.Streams .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. 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; } // 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; } 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} (video + audio, merged with ffmpeg) to {Path}", key, video.VideoQuality.Label, outputPath); await youtube.Videos.Streams.DownloadAsync(video, videoTemp, cancellationToken: ct).ConfigureAwait(false); await youtube.Videos.Streams.DownloadAsync(audio, audioTemp, cancellationToken: ct).ConfigureAwait(false); return await MergeAsync(key, videoTemp, audioTemp, outputPath, ct).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch (Exception ex) { _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( string key, string outputPath, int preferredHeight, string ytDlpPath, CancellationToken ct) { try { // 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}][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}\""); _logger.LogInformation( "|CinemaTrailers4Jellyfins| Downloading {Key} via yt-dlp at max {Height}p to {Path}", key, preferredHeight, outputPath); using var process = new Process { StartInfo = new ProcessStartInfo { FileName = ytDlpPath, Arguments = args, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, } }; 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) { _logger.LogError("|CinemaTrailers4Jellyfins| yt-dlp exited with code {Code} for {Key}: {Error}", process.ExitCode, key, stderr); return false; } return File.Exists(outputPath); } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.LogError(ex, "|CinemaTrailers4Jellyfins| yt-dlp download failed for {Key}", key); return false; } finally { DeleteYtDlpLeftovers(outputPath); } } // yt-dlp names its separate/partial downloads ".f137.mp4.part", ".f140.m4a", // ".ytdl", ... It removes them after a successful merge, but not when it fails. Everything // sharing the output's "." 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); } } } } }