using System.Diagnostics; using System.Net.Http; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; namespace WinFormsApp3 { /// /// 下载进度:文字说明 + 可选的百分比(0~1)。 /// public record DownloadProgress(string Message, double? Ratio); /// /// 下载结果。 /// /// 最终落盘的文件路径(HLS 转封装后可能改变扩展名)。 /// 写出的字节数。 /// 是否由 ffmpeg 把 ts 转封装为 mp4。 public record DownloadResult(string FilePath, long Bytes, bool Converted); /// /// 视频下载器:支持直链(mp4/flv/ts…)与 HLS(m3u8)分片合并下载。 /// 请求时带上当前页面 Referer,抖音等站点校验 Referer 后才允许访问播放地址。 /// 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"]; /// /// 整片容器格式。这些后缀永远不能当作 HLS 分片,否则 /video/xxx.flv 这类地址会被误删。 /// 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; /// 播放器所在页面的地址,不是顶层页面地址(播放器常在跨域 iframe 里)。 /// 从 WebView2 配置文件里取出的 Cookie,部分站点没 Cookie 直接给空文件。 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); /// /// 判断是否是 DASH 纯音频轨地址(抖音 .../media-audio-xxx/...)。 /// 这种地址单独下载出来没有画面,必须和视频轨合并。 /// public static bool IsDashAudioOnly(string? url) => !string.IsNullOrEmpty(url) && Regex.IsMatch(url, @"/media-audio[/-]", RegexOptions.IgnoreCase); /// /// 判断是否是 DASH 纯视频轨地址(抖音 .../media-video-xxx/...)。 /// 这种地址单独下载出来没有声音,需要配音频轨。 /// public static bool IsDashVideoOnly(string? url) => !string.IsNullOrEmpty(url) && Regex.IsMatch(url, @"/media-video[/-]", RegexOptions.IgnoreCase); /// /// 判断是否是 HLS 分片(合并播放列表可用,但单独下载没有意义)。 /// 只认分片特有的后缀与编号形式,不能拿路径里的 video / seg 字样当依据, /// 否则 /video/xxx.flv 这种整片地址会被当成分片删掉。 /// 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)[/-]"); } /// /// 根据地址推断保存扩展名;m3u8 会合并成单个文件,有 ffmpeg 时转 mp4,否则输出 ts。 /// 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 ""; } } /// /// 生成建议的文件名:优先用页面标题,其次用地址末段。 /// 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; } /// /// 下载视频。m3u8 会自动挑选最高清晰度并顺序合并分片。 /// public async Task DownloadAsync(string url, string filePath, IProgress? 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); } /// /// 站点只用 MSE 播放、抓不到 m3u8 地址时,直接拿页面里嗅探到的播放列表文本下载。 /// public async Task DownloadFromPlaylistTextAsync(string playlistText, string baseUrl, string filePath, IProgress? 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); } /// /// 下载 DASH 分离的视频轨和音频轨,再用 ffmpeg 合并成单个 mp4。 /// 抖音等站点把视频和音频分成两个独立的 mp4 轨道返回,必须合并才能有声有画。 /// public async Task DownloadDashPairAsync(string videoUrl, string audioUrl, string filePath, IProgress? 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 { /* 临时目录删不掉不影响结果 */ } } } /// /// 下载单个文件到指定路径(不做 .part 重命名,内部用)。 /// private async Task DownloadSingleToFileAsync(HttpClient client, string url, string filePath, CancellationToken ct, Action? 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); } } /// /// 用 ffmpeg 把 DASH 视频轨和音频轨合并成 mp4。 /// 优先两条流直接 copy:不重编码、时间戳原样保留,音画对齐最准确; /// 只有音频轨格式装不进 mp4 容器时才回退到 AAC 转码。 /// private static async Task 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 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; } /// /// 抽帧结果:成功时 Frames 非空;失败时 Error 给界面显示原因,不让用户干等。 /// public record FrameExtraction(List Frames, string? Error); /// /// 抽取预览截图:每 intervalSeconds 秒取一张、共取 frameCount 张(默认 5 秒 3 张), /// 供下载列表肉眼对比哪个是当前视频。用 ffmpeg 直接读远端地址,带 Referer/Cookie 过防盗链。 /// 单次尝试限 25 秒,超时不再重试第二次,避免地址失效时列表长时间卡在“生成中”。 /// public async Task ExtractFramesAsync(string url, CancellationToken ct, int intervalSeconds = 5, int frameCount = 3) { var ffmpeg = FindFfmpeg(); if (ffmpeg == null) { return new FrameExtraction(new List(), "未找到 ffmpeg,无法生成截图"); } if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) { return new FrameExtraction(new List(), "该地址无法直接抽帧"); } 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 { "-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(), lastError ?? "没抽到帧"); } finally { try { Directory.Delete(tmpDir, true); } catch { /* 临时目录删不掉不影响结果 */ } } } /// /// ffmpeg 走网络也要过防盗链:把下载器身上的 UA / Referer / Cookie 拼成 -headers 参数。 /// 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(); } /// ffmpeg 单次运行结果:TimedOut 表示被外部杀掉,Error 给界面显示原因。 private record FfmpegRun(bool TimedOut, string? Error); private static async Task RunFfmpegArgsAsync(string ffmpeg, List 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); } /// 取 ffmpeg 报错里最有信息量的一行,太长截断,直接丢给界面显示。 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 DownloadSingleAsync(HttpClient client, string url, string filePath, IProgress? 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); } /// /// 把常见 HTTP 错误码翻译成能行动的原因,只说“403”用户不知道下一步该做什么。 /// 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}"); } /// /// 真正的视频容器不会以 "<" 或 "{" 开头;m3u8 / mpd 本身是文本,不参与检查。 /// 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 WriteHlsAsync(HttpClient client, HlsPlaylist playlist, string filePath, IProgress? 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 Segments, string? KeyLine); /// /// 解析 m3u8:主播放列表按带宽挑最高清晰度,最后返回绝对地址的分片列表。 /// private async Task 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 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 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); } /// /// m3u8 常用 GBK 编码,直接按 UTF8 读会让中文路径的分片地址出错。 /// private static async Task 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 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; } /// /// 查找 ffmpeg:顺序为 1) ffmpeg.json 配置文件指定的路径;2) 程序目录 ffmpeg.exe;3) PATH。 /// 配置文件格式:{"ffmpegPath": "D:\\NET8\\Exe\\ffmpeg\\bin\\ffmpeg.exe"} /// 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"; } } }