574 lines
25 KiB
C#
574 lines
25 KiB
C#
using System.Diagnostics;
|
||
using System.Net.Http;
|
||
using System.Text;
|
||
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>
|
||
/// 判断是否是 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);
|
||
}
|
||
|
||
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>
|
||
/// 真正的视频容器不会以 "<" 或 "{" 开头;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:先看程序目录,再看 PATH。找到就能把 HLS 转成 mp4,找不到则只合并 ts。
|
||
/// </summary>
|
||
public static string? FindFfmpeg()
|
||
{
|
||
var local = Path.Combine(AppContext.BaseDirectory, "ffmpeg.exe");
|
||
if (File.Exists(local)) return local;
|
||
|
||
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";
|
||
}
|
||
}
|
||
}
|