Files
Docker/uniapp/Google Chrome/VideoDownloader.cs
T
2026-11-06 16:14:51 +08:00

887 lines
39 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace WinFormsApp3
{
/// <summary>
/// 下载进度:文字说明 + 可选的百分比(0~1)。
/// </summary>
public record DownloadProgress(string Message, double? Ratio);
/// <summary>
/// 下载结果。
/// </summary>
/// <param name="FilePath">最终落盘的文件路径(HLS 转封装后可能改变扩展名)。</param>
/// <param name="Bytes">写出的字节数。</param>
/// <param name="Converted">是否由 ffmpeg 把 ts 转封装为 mp4。</param>
public record DownloadResult(string FilePath, long Bytes, bool Converted);
/// <summary>
/// 视频下载器:支持直链(mp4/flv/ts…)与 HLS(m3u8)分片合并下载。
/// 请求时带上当前页面 Referer,抖音等站点校验 Referer 后才允许访问播放地址。
/// </summary>
public class VideoDownloader
{
private const string UserAgent =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0";
private static readonly Regex CharsetRx = new(@"charset\s*=\s*""?([\w-]+)""?", RegexOptions.IgnoreCase);
private static readonly string[] MediaExtensions =
[".mp4", ".flv", ".m3u8", ".mpd", ".mkv", ".mov", ".m4v", ".webm", ".f4v", ".3gp", ".ts", ".mpg", ".mpeg"];
/// <summary>
/// 整片容器格式。这些后缀永远不能当作 HLS 分片,否则 /video/xxx.flv 这类地址会被误删。
/// </summary>
private static readonly string[] WholeVideoExtensions =
[".mp4", ".flv", ".mkv", ".mov", ".webm", ".m4v", ".f4v", ".3gp", ".avi", ".rmvb", ".mpg", ".mpeg", ".m3u8", ".mpd"];
private readonly string? _referer;
private readonly string? _cookie;
/// <param name="referer">播放器所在页面的地址,不是顶层页面地址(播放器常在跨域 iframe 里)。</param>
/// <param name="cookie">从 WebView2 配置文件里取出的 Cookie,部分站点没 Cookie 直接给空文件。</param>
public VideoDownloader(string? referer, string? cookie = null)
{
if (!string.IsNullOrWhiteSpace(referer) && Uri.TryCreate(referer, UriKind.Absolute, out var u)
&& !string.IsNullOrEmpty(u.Host))
{
// 防盗链一般只校来源站点,保留完整路径反而更容易被判定为非法来源;
// 用 Authority 而不是 Host,带端口的站点把端口去掉后来源就对不上了
_referer = $"{u.Scheme}://{u.Authority}/";
}
if (!string.IsNullOrWhiteSpace(cookie)) _cookie = cookie;
}
public static bool IsHlsUrl(string url) =>
!string.IsNullOrEmpty(url) && Regex.IsMatch(url, @"\.m3u8(\?|#|$)", RegexOptions.IgnoreCase);
/// <summary>
/// 判断是否是 DASH 纯音频轨地址(抖音 .../media-audio-xxx/...)。
/// 这种地址单独下载出来没有画面,必须和视频轨合并。
/// </summary>
public static bool IsDashAudioOnly(string? url) =>
!string.IsNullOrEmpty(url) && Regex.IsMatch(url, @"/media-audio[/-]", RegexOptions.IgnoreCase);
/// <summary>
/// 判断是否是 DASH 纯视频轨地址(抖音 .../media-video-xxx/...)。
/// 这种地址单独下载出来没有声音,需要配音频轨。
/// </summary>
public static bool IsDashVideoOnly(string? url) =>
!string.IsNullOrEmpty(url) && Regex.IsMatch(url, @"/media-video[/-]", RegexOptions.IgnoreCase);
/// <summary>
/// 判断是否是 HLS 分片(合并播放列表可用,但单独下载没有意义)。
/// 只认分片特有的后缀与编号形式,不能拿路径里的 video / seg 字样当依据,
/// 否则 /video/xxx.flv 这种整片地址会被当成分片删掉。
/// </summary>
public static bool IsSegmentUrl(string url)
{
if (string.IsNullOrEmpty(url)) return false;
var path = Uri.TryCreate(url, UriKind.Absolute, out var u) ? u.AbsolutePath.ToLowerInvariant() : url.ToLowerInvariant();
var extension = Path.GetExtension(path);
// 没有扩展名的地址多为接口型直链(如抖音),当作整片
if (extension.Length == 0) return false;
if (WholeVideoExtensions.Any(e => extension == e)) return false;
return extension is ".ts" or ".m4s" or ".init" or ".m2ts" or ".aac" or ".mp3"
|| Regex.IsMatch(path, @"(seg|chunk|part|fragment|f)[-_.]?\d+");
}
public static bool LooksLikeMedia(string? url)
{
if (string.IsNullOrWhiteSpace(url)) return false;
if (url.StartsWith("blob:", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) return false;
if (!Uri.TryCreate(url, UriKind.Absolute, out var u)) return false;
if (u.Scheme != Uri.UriSchemeHttp && u.Scheme != Uri.UriSchemeHttps) return false;
var path = u.AbsolutePath.ToLowerInvariant();
if (Regex.IsMatch(path, @"\.(jpg|jpeg|png|gif|webp|ico|svg|css|js|json|woff2?|ttf|map)$")) return false;
if (MediaExtensions.Any(e => path.EndsWith(e))) return true;
return Regex.IsMatch(path, @"/(video|vod|playback|media)[/-]");
}
/// <summary>
/// 根据地址推断保存扩展名;m3u8 会合并成单个文件,有 ffmpeg 时转 mp4,否则输出 ts。
/// </summary>
public static string ExtensionFor(string url)
{
var raw = RawExtension(url);
if (raw == ".m3u8" || (raw.Length == 0 && IsHlsUrl(url))) return HasFfmpeg() ? ".mp4" : ".ts";
if (raw.Length == 0) return ".mp4";
return raw;
}
public static string RawExtension(string url)
{
try
{
return Path.GetExtension(new Uri(url).AbsolutePath).ToLowerInvariant();
}
catch
{
return "";
}
}
/// <summary>
/// 生成建议的文件名:优先用页面标题,其次用地址末段。
/// </summary>
public string SuggestedFileName(string url, string? title)
{
var name = CleanFileName(title);
if (string.IsNullOrWhiteSpace(name))
{
try
{
name = CleanFileName(Path.GetFileNameWithoutExtension(new Uri(url).AbsolutePath));
}
catch
{
// 忽略,使用兜底名
}
}
if (string.IsNullOrWhiteSpace(name)) name = "video";
if (name.Length > 60) name = name[..60];
return name + ExtensionFor(url);
}
private static string? CleanFileName(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return null;
var cleaned = Regex.Replace(text, @"[\\/:*?""<>|\r\n\t]", " ").Trim();
cleaned = Regex.Replace(cleaned, @"\s+", " ");
return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned;
}
/// <summary>
/// 下载视频。m3u8 会自动挑选最高清晰度并顺序合并分片。
/// </summary>
public async Task<DownloadResult> DownloadAsync(string url, string filePath, IProgress<DownloadProgress>? progress, CancellationToken ct)
{
using var client = CreateClient();
if (IsHlsUrl(url))
{
progress?.Report(new DownloadProgress("解析播放列表…", null));
var playlist = await ResolvePlaylistAsync(client, url, ct);
return await WriteHlsAsync(client, playlist, filePath, progress, ct);
}
return await DownloadSingleAsync(client, url, filePath, progress, ct);
}
/// <summary>
/// 站点只用 MSE 播放、抓不到 m3u8 地址时,直接拿页面里嗅探到的播放列表文本下载。
/// </summary>
public async Task<DownloadResult> DownloadFromPlaylistTextAsync(string playlistText, string baseUrl, string filePath, IProgress<DownloadProgress>? progress, CancellationToken ct)
{
using var client = CreateClient();
progress?.Report(new DownloadProgress("解析播放列表…", null));
var playlist = await ParsePlaylistAsync(playlistText, baseUrl, client, ct, 0);
return await WriteHlsAsync(client, playlist, filePath, progress, ct);
}
/// <summary>
/// 下载 DASH 分离的视频轨和音频轨,再用 ffmpeg 合并成单个 mp4。
/// 抖音等站点把视频和音频分成两个独立的 mp4 轨道返回,必须合并才能有声有画。
/// </summary>
public async Task<DownloadResult> DownloadDashPairAsync(string videoUrl, string audioUrl, string filePath, IProgress<DownloadProgress>? progress, CancellationToken ct)
{
var ffmpeg = FindFfmpeg();
if (ffmpeg == null)
{
throw new NotSupportedException("下载抖音 DASH 分离视频轨需要 ffmpeg 合并音视频,但未找到 ffmpeg.exe。请把 ffmpeg.exe 放到程序目录下。");
}
using var client = CreateClient();
// 临时文件
var tmpDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tmpDir);
var videoTmp = Path.Combine(tmpDir, "video.mp4");
var audioTmp = Path.Combine(tmpDir, "audio.mp4");
try
{
// 下载视频轨(占总进度 0~50%)
progress?.Report(new DownloadProgress("下载视频轨…", 0));
await DownloadSingleToFileAsync(client, videoUrl, videoTmp, ct,
p => progress?.Report(new DownloadProgress($"下载视频轨 {FormatSize(p)}", null)));
var videoSize = new FileInfo(videoTmp).Length;
// 下载音频轨(占总进度 50~100%)
progress?.Report(new DownloadProgress("下载音频轨…", 0.5));
await DownloadSingleToFileAsync(client, audioUrl, audioTmp, ct,
p => progress?.Report(new DownloadProgress($"下载音频轨 {FormatSize(p)}", null)));
var audioSize = new FileInfo(audioTmp).Length;
// 合并
progress?.Report(new DownloadProgress("合并音视频…", 0.95));
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(filePath))!);
var ok = await MuxDashAsync(ffmpeg, videoTmp, audioTmp, filePath, ct);
if (!ok)
throw new InvalidOperationException("ffmpeg 合并音视频失败");
var finalSize = new FileInfo(filePath).Length;
progress?.Report(new DownloadProgress($"完成 {FormatSize(finalSize)}", 1));
return new DownloadResult(filePath, finalSize, true);
}
finally
{
try { Directory.Delete(tmpDir, true); } catch { /* 临时目录删不掉不影响结果 */ }
}
}
/// <summary>
/// 下载单个文件到指定路径(不做 .part 重命名,内部用)。
/// </summary>
private async Task DownloadSingleToFileAsync(HttpClient client, string url, string filePath, CancellationToken ct, Action<long>? onBytes = null)
{
using var resp = await GetFollowRedirectAsync(client, url, ct);
EnsureVideoResponse(resp);
await using var input = await resp.Content.ReadAsStreamAsync(ct);
await using var output = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 64, true);
var buffer = new byte[1024 * 64];
long total = 0;
int read;
while ((read = await input.ReadAsync(buffer, ct)) > 0)
{
await output.WriteAsync(buffer.AsMemory(0, read), ct);
total += read;
onBytes?.Invoke(total);
}
}
/// <summary>
/// 用 ffmpeg 把 DASH 视频轨和音频轨合并成 mp4。
/// 优先两条流直接 copy:不重编码、时间戳原样保留,音画对齐最准确;
/// 只有音频轨格式装不进 mp4 容器时才回退到 AAC 转码。
/// </summary>
private static async Task<bool> MuxDashAsync(string ffmpeg, string videoFile, string audioFile, string outputFile, CancellationToken ct)
{
// 只取视频轨的视频流 + 音频轨的音频流(没音频流也照常出片),-shortest 把两条轨截齐到同一时长
var copyArgs = $"-hide_banner -loglevel error -y -fflags +genpts -i \"{videoFile}\" -i \"{audioFile}\" -map 0:v:0 -map 1:a:0? -c copy -shortest -movflags +faststart \"{outputFile}\"";
if (await RunFfmpegAsync(ffmpeg, copyArgs, outputFile, ct)) return true;
TryDelete(outputFile);
var aacArgs = $"-hide_banner -loglevel error -y -fflags +genpts -i \"{videoFile}\" -i \"{audioFile}\" -map 0:v:0 -map 1:a:0? -c:v copy -c:a aac -strict experimental -shortest -movflags +faststart \"{outputFile}\"";
return await RunFfmpegAsync(ffmpeg, aacArgs, outputFile, ct);
}
private static async Task<bool> RunFfmpegAsync(string ffmpeg, string args, string outputFile, CancellationToken ct)
{
var psi = new ProcessStartInfo
{
FileName = ffmpeg,
Arguments = args,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true
};
using var proc = Process.Start(psi);
if (proc == null) return false;
using (ct.Register(() => { try { if (!proc.HasExited) proc.Kill(true); } catch { } }))
{
await proc.WaitForExitAsync(ct);
}
return proc.ExitCode == 0 && File.Exists(outputFile) && new FileInfo(outputFile).Length > 0;
}
/// <summary>
/// 抽帧结果:成功时 Frames 非空;失败时 Error 给界面显示原因,不让用户干等。
/// </summary>
public record FrameExtraction(List<byte[]> Frames, string? Error);
/// <summary>
/// 抽取预览截图:每 intervalSeconds 秒取一张、共取 frameCount 张(默认 5 秒 3 张),
/// 供下载列表肉眼对比哪个是当前视频。用 ffmpeg 直接读远端地址,带 Referer/Cookie 过防盗链。
/// 单次尝试限 25 秒,超时不再重试第二次,避免地址失效时列表长时间卡在“生成中”。
/// </summary>
public async Task<FrameExtraction> ExtractFramesAsync(string url, CancellationToken ct, int intervalSeconds = 5, int frameCount = 3)
{
var ffmpeg = FindFfmpeg();
if (ffmpeg == null)
{
return new FrameExtraction(new List<byte[]>(), "未找到 ffmpeg,无法生成截图");
}
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
return new FrameExtraction(new List<byte[]>(), "该地址无法直接抽帧");
}
var tmpDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tmpDir);
try
{
string? lastError = null;
// 先试从第 2 秒开始抽(片头常有黑帧),只有上一次正常退出且没拿到帧才退回 0 秒再试
foreach (var startAt in new[] { 2, 0 })
{
ct.ThrowIfCancellationRequested();
var args = new List<string>
{
"-hide_banner", "-loglevel", "error", "-nostdin", "-y",
// 远端卡住时快速失败:单次 I/O 12 秒,探测上限 2MB/2秒,不靠外部杀进程兑底
"-rw_timeout", "12000000",
"-probesize", "2000000", "-analyzeduration", "2000000",
"-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "2"
};
var headers = BuildFfmpegHeaders();
if (headers.Length > 0)
{
args.Add("-headers");
args.Add(headers);
}
if (IsHlsUrl(url))
{
args.Add("-protocol_whitelist");
args.Add("file,http,https,tcp,tls,crypto");
}
args.AddRange(new[]
{
"-ss", startAt.ToString(), "-i", url,
"-t", (intervalSeconds * frameCount).ToString(),
"-vf", $"fps=1/{intervalSeconds},scale=160:-2",
"-frames:v", frameCount.ToString(), "-an",
Path.Combine(tmpDir, "f_%d.jpg")
});
var run = await RunFfmpegArgsAsync(ffmpeg, args, ct);
var files = Directory.GetFiles(tmpDir, "f_*.jpg").OrderBy(f => f, StringComparer.Ordinal).ToList();
if (files.Count > 0)
{
return new FrameExtraction(files.Select(File.ReadAllBytes).ToList(), null);
}
lastError = run.Error ?? "没抽到帧";
if (run.TimedOut)
{
break; // 地址多半已失效或被防盗链卡住,再试一次只会多等半分钟
}
}
return new FrameExtraction(new List<byte[]>(), lastError ?? "没抽到帧");
}
finally
{
try { Directory.Delete(tmpDir, true); } catch { /* 临时目录删不掉不影响结果 */ }
}
}
/// <summary>
/// ffmpeg 走网络也要过防盗链:把下载器身上的 UA / Referer / Cookie 拼成 -headers 参数。
/// </summary>
private string BuildFfmpegHeaders()
{
var sb = new StringBuilder();
sb.Append($"User-Agent: {UserAgent}\r\n");
if (_referer != null) sb.Append($"Referer: {_referer}\r\n");
if (_cookie != null) sb.Append($"Cookie: {_cookie}\r\n");
return sb.ToString();
}
/// <summary>ffmpeg 单次运行结果:TimedOut 表示被外部杀掉,Error 给界面显示原因。</summary>
private record FfmpegRun(bool TimedOut, string? Error);
private static async Task<FfmpegRun> RunFfmpegArgsAsync(string ffmpeg, List<string> args, CancellationToken ct)
{
var psi = new ProcessStartInfo
{
FileName = ffmpeg,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true
};
foreach (var a in args) psi.ArgumentList.Add(a);
using var proc = Process.Start(psi);
if (proc == null) return new FfmpegRun(false, "ffmpeg 启动失败");
// 就算 -rw_timeout 失效(DNS 卡住、TLS 握手挂起等),25 秒也必须杀掉
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(25));
var errRead = proc.StandardError.ReadToEndAsync(); // 不读干 stderr 会把进程堵死
bool timedOut = false;
using (timeout.Token.Register(() => { try { if (!proc.HasExited) proc.Kill(true); } catch { } }))
{
try
{
await proc.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // 用户取消要往上抛,超时杀掉进程则返回原因
}
catch (OperationCanceledException)
{
timedOut = true;
}
}
string stderr = string.Empty;
try { stderr = await errRead; } catch { /* 进程被杀时读流可能报错,不影响判断 */ }
if (timedOut)
{
return new FfmpegRun(true, "抽帧超时(25秒):地址可能已失效或被防盗链卡住");
}
if (proc.ExitCode != 0)
{
return new FfmpegRun(false, SummarizeFfmpegError(stderr));
}
return new FfmpegRun(false, null);
}
/// <summary>取 ffmpeg 报错里最有信息量的一行,太长截断,直接丢给界面显示。</summary>
private static string SummarizeFfmpegError(string stderr)
{
var line = (stderr ?? string.Empty)
.Split('\n')
.Select(l => l.Trim('\r', ' '))
.FirstOrDefault(l => l.Length > 0 && !l.StartsWith("[h264", StringComparison.OrdinalIgnoreCase)
&& !l.StartsWith("[aac", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrEmpty(line)) return "ffmpeg 抽帧失败";
return line.Length > 90 ? line[..90] + "…" : line;
}
private HttpClient CreateClient()
{
var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{
Timeout = TimeSpan.FromMinutes(15)
};
client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent);
client.DefaultRequestHeaders.Accept.ParseAdd("*/*");
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("zh-CN,zh;q=0.9,en;q=0.8");
if (_referer != null) client.DefaultRequestHeaders.Referrer = new Uri(_referer);
if (_cookie != null) client.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", _cookie);
return client;
}
private async Task<DownloadResult> DownloadSingleAsync(HttpClient client, string url, string filePath, IProgress<DownloadProgress>? progress, CancellationToken ct)
{
using var resp = await GetFollowRedirectAsync(client, url, ct);
EnsureVideoResponse(resp);
var total = resp.Content.Headers.ContentLength ?? -1L;
var part = filePath + ".part";
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(filePath))!);
long done = 0;
long lastReport = 0;
await using var input = await resp.Content.ReadAsStreamAsync(ct);
try
{
var buffer = new byte[1024 * 64];
// 先读一小块验货:防盗链站点经常不报 403,而是给一段 200 的 HTML 错误页
int read = await input.ReadAsync(buffer.AsMemory(0, 4096), ct);
if (read > 0) EnsureRealVideoContent(buffer, read, resp, url);
await using var output = new FileStream(part, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 64, true);
while (read > 0)
{
await output.WriteAsync(buffer.AsMemory(0, read), ct);
done += read;
if (done - lastReport >= 256 * 1024)
{
lastReport = done;
progress?.Report(total > 0
? new DownloadProgress($"下载中 {FormatSize(done)} / {FormatSize(total)}", done / (double)total)
: new DownloadProgress($"下载中 {FormatSize(done)}(总大小未知,可点取消结束)", null));
}
read = await input.ReadAsync(buffer, ct);
}
}
catch
{
TryDelete(part);
throw;
}
MoveIntoPlace(part, filePath);
progress?.Report(new DownloadProgress($"完成 {FormatSize(done)}", 1));
return new DownloadResult(filePath, done, false);
}
/// <summary>
/// 把常见 HTTP 错误码翻译成能行动的原因,只说“403”用户不知道下一步该做什么。
/// </summary>
private static void EnsureVideoResponse(HttpResponseMessage resp)
{
if (resp.IsSuccessStatusCode) return;
string hint = (int)resp.StatusCode switch
{
403 or 401 => ",防盗链或需要登录:先在页面里把视频播起来再点下载,或换一条线路",
404 => ",地址已失效(这类直链通常只有特别短的有效期)",
416 => ",服务器不接受当前请求范围,重试即可",
_ => string.Empty
};
throw new HttpRequestException($"服务器返回 {(int)resp.StatusCode} {resp.StatusCode}{hint}");
}
/// <summary>
/// 真正的视频容器不会以 "&lt;" 或 "{" 开头;m3u8 / mpd 本身是文本,不参与检查。
/// </summary>
private static void EnsureRealVideoContent(byte[] head, int length, HttpResponseMessage resp, string url)
{
if (IsHlsUrl(url) || RawExtension(url) == ".mpd") return;
var type = resp.Content.Headers.ContentType?.MediaType ?? string.Empty;
if (type.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)
|| type.StartsWith("text/plain", StringComparison.OrdinalIgnoreCase)
|| type.Contains("json", StringComparison.OrdinalIgnoreCase))
{
throw new NotSupportedException($"站点返回的是 {type} 而不是视频,多半是防盗链或需要先登录/先播放");
}
int i = 0;
while (i < length && (head[i] is 0xEF or 0xBB or 0xBF or 0x20 or 0x0D or 0x0A or 0x09)) i++;
if (i < length && (head[i] == (byte)'<' || head[i] == (byte)'{'))
{
throw new NotSupportedException("站点返回的是网页内容而不是视频(防盗链或地址已过期),换一条线路重试");
}
}
private static void TryDelete(string path)
{
try { if (File.Exists(path)) File.Delete(path); } catch { /* 删不掉不影响报错 */ }
}
private async Task<DownloadResult> WriteHlsAsync(HttpClient client, HlsPlaylist playlist, string filePath, IProgress<DownloadProgress>? progress, CancellationToken ct)
{
if (playlist.Segments.Count == 0)
throw new InvalidOperationException("播放列表里没有解析到视频分片,可能还在加载中,请稍后重试");
if (playlist.KeyLine != null && playlist.KeyLine.Contains("METHOD=", StringComparison.OrdinalIgnoreCase)
&& !playlist.KeyLine.Contains("METHOD=NONE", StringComparison.OrdinalIgnoreCase))
{
throw new NotSupportedException($"该视频是加密 HLS({playlist.KeyLine}),需要解密 Key,暂不支持自动下载");
}
// 分片直接拼接出来的是 .ts;装了 ffmpeg 时顺手无损转封装成 mp4,播放更通用
var wantsMp4 = string.Equals(Path.GetExtension(filePath), ".mp4", StringComparison.OrdinalIgnoreCase);
var ffmpeg = wantsMp4 ? FindFfmpeg() : null;
if (wantsMp4 && ffmpeg == null)
{
// 没有 ffmpeg 就不能把 ts 流写成 .mp4 后缀,否则很多播放器认不出来
filePath = Path.ChangeExtension(filePath, ".ts");
progress?.Report(new DownloadProgress("未找到 ffmpeg,改存为 .ts(播放器可以直接播)", null));
}
var mergedPath = ffmpeg != null
? Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".ts")
: filePath;
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(mergedPath))!);
long total = 0;
int index = 0;
var part = mergedPath + ".part";
await using (var output = new FileStream(part, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024, true))
{
foreach (var segment in playlist.Segments)
{
ct.ThrowIfCancellationRequested();
using var resp = await GetFollowRedirectAsync(client, segment, ct);
resp.EnsureSuccessStatusCode();
await using var input = await resp.Content.ReadAsStreamAsync(ct);
await input.CopyToAsync(output, ct);
total += resp.Content.Headers.ContentLength ?? 0;
index++;
if (index % 5 == 0 || index == playlist.Segments.Count)
progress?.Report(new DownloadProgress($"下载分片 {index}/{playlist.Segments.Count},已下载 {FormatSize(total)}", index / (double)playlist.Segments.Count));
}
}
if (ffmpeg == null)
{
MoveIntoPlace(part, mergedPath);
progress?.Report(new DownloadProgress($"完成 {FormatSize(total)}", 1));
return new DownloadResult(mergedPath, new FileInfo(mergedPath).Length, false);
}
progress?.Report(new DownloadProgress("用 ffmpeg 转封装为 mp4…", null));
MoveIntoPlace(part, mergedPath);
try
{
var ok = await ConvertToMp4Async(ffmpeg, mergedPath, filePath, ct);
if (ok)
{
try { File.Delete(mergedPath); } catch { /* 临时文件删不掉不影响结果 */ }
var size = new FileInfo(filePath).Length;
progress?.Report(new DownloadProgress($"完成 {FormatSize(size)}", 1));
return new DownloadResult(filePath, size, true);
}
}
catch
{
// 转封装失败时保留 ts 文件,避免用户什么都拿不到
}
var tsSize = new FileInfo(mergedPath).Length;
progress?.Report(new DownloadProgress($"完成(未能转成 mp4,已保存 ts){FormatSize(tsSize)}", 1));
return new DownloadResult(mergedPath, tsSize, false);
}
private record HlsPlaylist(List<string> Segments, string? KeyLine);
/// <summary>
/// 解析 m3u8:主播放列表按带宽挑最高清晰度,最后返回绝对地址的分片列表。
/// </summary>
private async Task<HlsPlaylist> ResolvePlaylistAsync(HttpClient client, string url, CancellationToken ct, int depth = 0)
{
if (depth > 3) throw new InvalidOperationException("播放列表嵌套层级过深");
using var resp = await GetFollowRedirectAsync(client, url, ct);
resp.EnsureSuccessStatusCode();
var text = await ReadTextAsync(resp, ct);
return await ParsePlaylistAsync(text, url, client, ct, depth);
}
private async Task<HlsPlaylist> ParsePlaylistAsync(string text, string url, HttpClient client, CancellationToken ct, int depth)
{
if (!text.Contains("#EXTM3U"))
throw new InvalidOperationException("返回的内容不是 m3u8 播放列表,请改用列表里的 mp4 直链下载");
var lines = text.Split('\n').Select(l => l.Trim('\r', ' ', '\t')).ToList();
// 主播放列表:#EXT-X-STREAM-INF 后面紧跟子列表地址
var variants = new List<(string Uri, int Bandwidth)>();
for (int i = 0; i < lines.Count; i++)
{
if (!lines[i].StartsWith("#EXT-X-STREAM-INF", StringComparison.OrdinalIgnoreCase)) continue;
var bandwidth = 0;
var m = Regex.Match(lines[i], @"BANDWIDTH=(\d+)", RegexOptions.IgnoreCase);
if (m.Success) bandwidth = int.Parse(m.Groups[1].Value);
for (int j = i + 1; j < lines.Count; j++)
{
if (lines[j].Length == 0 || lines[j].StartsWith("#")) continue;
variants.Add((lines[j], bandwidth));
break;
}
}
if (variants.Count > 0)
{
var best = variants[^1];
foreach (var v in variants)
if (v.Bandwidth > best.Bandwidth) best = v;
ct.ThrowIfCancellationRequested();
return await ResolvePlaylistAsync(client, Resolve(url, best.Uri), ct, depth + 1);
}
var segments = lines
.Where(l => l.Length > 0 && !l.StartsWith("#"))
.Select(l => Resolve(url, l))
.ToList();
var keyLine = lines.FirstOrDefault(l => l.StartsWith("#EXT-X-KEY", StringComparison.OrdinalIgnoreCase));
return new HlsPlaylist(segments, keyLine);
}
private static string Resolve(string baseUrl, string relative)
{
if (Path.IsPathRooted(relative) || relative.StartsWith("//", StringComparison.Ordinal))
{
try
{
var u = new Uri(baseUrl);
return $"{u.Scheme}:{relative}";
}
catch
{
return relative;
}
}
if (Uri.TryCreate(relative, UriKind.Absolute, out _)) return relative;
try
{
return new Uri(new Uri(baseUrl), relative).ToString();
}
catch
{
return relative;
}
}
private static async Task<HttpResponseMessage> GetFollowRedirectAsync(HttpClient client, string url, CancellationToken ct)
{
var current = url;
HttpResponseMessage? previous = null;
for (int i = 0; i < 6; i++)
{
var resp = await client.GetAsync(current, HttpCompletionOption.ResponseHeadersRead, ct);
if (resp.IsSuccessStatusCode) return resp;
var status = (int)resp.StatusCode;
var location = resp.Headers.Location;
if (status < 300 || status > 399 || location == null) return resp;
previous?.Dispose();
previous = resp;
current = location.IsAbsoluteUri ? location.ToString() : new Uri(new Uri(current), location).ToString();
}
return previous ?? await client.GetAsync(current, HttpCompletionOption.ResponseHeadersRead, ct);
}
/// <summary>
/// m3u8 常用 GBK 编码,直接按 UTF8 读会让中文路径的分片地址出错。
/// </summary>
private static async Task<string> ReadTextAsync(HttpResponseMessage resp, CancellationToken ct)
{
using var ms = new MemoryStream();
await resp.Content.CopyToAsync(ms, ct);
var encoding = Encoding.UTF8;
var match = CharsetRx.Match(resp.Content.Headers.ContentType?.ToString() ?? "");
if (match.Success)
{
try { encoding = Encoding.GetEncoding(match.Groups[1].Value); }
catch { /* 未知编码退回 UTF8 */ }
}
var text = encoding.GetString(ms.ToArray());
// 去掉可能存在的 BOM
if (text.Length > 0 && text[0] == '\uFEFF') text = text[1..];
return text;
}
private static async Task<bool> ConvertToMp4Async(string ffmpeg, string tsFile, string mp4File, CancellationToken ct)
{
var args = $"-hide_banner -loglevel error -y -fflags +genpts -i \"{tsFile}\" -c copy -bsf:a aac_adtstoasc -movflags +faststart \"{mp4File}\"";
var psi = new ProcessStartInfo
{
FileName = ffmpeg,
Arguments = args,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true
};
using var proc = Process.Start(psi);
if (proc == null) return false;
using (ct.Register(() => { try { if (!proc.HasExited) proc.Kill(true); } catch { } }))
{
await proc.WaitForExitAsync(ct);
}
return proc.ExitCode == 0 && File.Exists(mp4File) && new FileInfo(mp4File).Length > 0;
}
/// <summary>
/// 查找 ffmpeg:顺序为 1) ffmpeg.json 配置文件指定的路径;2) 程序目录 ffmpeg.exe;3) PATH。
/// 配置文件格式:{"ffmpegPath": "D:\\NET8\\Exe\\ffmpeg\\bin\\ffmpeg.exe"}
/// </summary>
public static string? FindFfmpeg()
{
// 1. 配置文件指定的路径(优先级最高)
var configPath = Path.Combine(AppContext.BaseDirectory, "ffmpeg.json");
if (File.Exists(configPath))
{
try
{
var json = File.ReadAllText(configPath);
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("ffmpegPath", out var prop) &&
prop.ValueKind == JsonValueKind.String)
{
var configured = prop.GetString();
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
return configured;
}
}
catch { /* 配置文件损坏就跳过,继续找别的 */ }
}
// 2. 程序目录
var local = Path.Combine(AppContext.BaseDirectory, "ffmpeg.exe");
if (File.Exists(local)) return local;
// 3. PATH
foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator))
{
if (string.IsNullOrWhiteSpace(dir)) continue;
try
{
var candidate = Path.Combine(dir.Trim().Trim('"'), "ffmpeg.exe");
if (File.Exists(candidate)) return candidate;
}
catch
{
// PATH 里存在非法字符时跳过该项
}
}
return null;
}
public static bool HasFfmpeg() => FindFfmpeg() != null;
private static void MoveIntoPlace(string partFile, string targetFile)
{
if (File.Exists(targetFile)) File.Delete(targetFile);
File.Move(partFile, targetFile);
}
public static string FormatSize(long bytes)
{
if (bytes <= 0) return "0 B";
if (bytes >= 1L << 30) return $"{bytes / (double)(1L << 30):0.00} GB";
if (bytes >= 1L << 20) return $"{bytes / (double)(1L << 20):0.0} MB";
if (bytes >= 1L << 10) return $"{bytes / (double)(1L << 10):0.0} KB";
return $"{bytes} B";
}
}
}