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>
279 lines
12 KiB
C#
279 lines
12 KiB
C#
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<TrailerDownloadService> _logger;
|
|
private readonly HttpClient _httpClient;
|
|
|
|
public TrailerDownloadService(ILogger<TrailerDownloadService> 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();
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public async Task<bool> DownloadAsync(
|
|
string youtubeKey,
|
|
string outputPath,
|
|
int preferredHeight,
|
|
string ytDlpPath,
|
|
CancellationToken ct)
|
|
{
|
|
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
|
|
|
|
if (!string.IsNullOrWhiteSpace(ytDlpPath) && File.Exists(ytDlpPath))
|
|
{
|
|
return await DownloadWithYtDlpAsync(youtubeKey, outputPath, preferredHeight, ytDlpPath, ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
return await DownloadWithYoutubeExplodeAsync(youtubeKey, outputPath, preferredHeight, ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task<bool> 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<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(
|
|
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.
|
|
// --merge-output-format mp4 ensures the output is always an mp4.
|
|
var args = string.Join(" ",
|
|
$"-f \"bestvideo[height<={preferredHeight}]+bestaudio/best[height<={preferredHeight}]\"",
|
|
"--merge-output-format mp4",
|
|
"--no-playlist",
|
|
"--no-warnings",
|
|
$"-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();
|
|
await process.WaitForExitAsync(ct).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;
|
|
}
|
|
|
|
return File.Exists(outputPath);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| yt-dlp download failed for {Key}", key);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|