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 不重编码)。
///
private static async Task MuxDashAsync(string ffmpeg, string videoFile, string audioFile, string outputFile, CancellationToken ct)
{
var args = $"-hide_banner -loglevel error -y -i \"{videoFile}\" -i \"{audioFile}\" -c:v copy -c:a aac -strict experimental -movflags +faststart \"{outputFile}\"";
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;
}
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";
}
}
}