Files
Docker/uniapp/Google Chrome/VideoDownloader.cs
T
2026-11-03 09:19:50 +08:00

574 lines
25 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.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>
/// 真正的视频容器不会以 "&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:先看程序目录,再看 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";
}
}
}