1275 lines
50 KiB
C#
1275 lines
50 KiB
C#
using System.Text.Json;
|
||
using System.Text.Json.Nodes;
|
||
using System.Text.RegularExpressions;
|
||
using Microsoft.Web.WebView2.Core;
|
||
|
||
namespace WinFormsApp3
|
||
{
|
||
/// <summary>
|
||
/// 当前正在播放的视频地址。
|
||
/// </summary>
|
||
/// <param name="Url">可直接 HTTP 访问的视频地址;PlaylistText 不为空时作为相对地址基准。</param>
|
||
/// <param name="PlaylistText">只在内存里拿到 m3u8 文本时使用(抖音这类 MSE 播放)。</param>
|
||
/// <param name="Referer">发出这个请求的文档地址;跨域 iframe 播放器必须用它,不然会被防盗链拒掉。</param>
|
||
/// <param name="AudioUrl">DASH 分离音轨地址;不为空时需要和 Url 一起下载后用 ffmpeg 合并。</param>
|
||
public record VideoCandidate(string Url, string? PlaylistText = null, string? Referer = null, string? AudioUrl = null);
|
||
|
||
public partial class Form1 : Form
|
||
{
|
||
private const string SnifferScript = @"Resources\media_sniffer.js";
|
||
private const string ButtonScript = @"Resources\video_button.js";
|
||
private const string GrabScript = @"Resources\video_grab.js";
|
||
|
||
private double currentZoomFactor = 1.3;
|
||
private readonly List<string> sniffedUrls = new();
|
||
private readonly List<string> sniffedPlaylists = new();
|
||
|
||
/// <summary>视频地址 → 发出请求的文档地址,用作下载时的 Referer。</summary>
|
||
private readonly Dictionary<string, string> urlReferers = new();
|
||
private readonly Dictionary<string, string> injectedScripts = new();
|
||
private CancellationTokenSource? downloadCts;
|
||
private bool hooksAttached;
|
||
|
||
/// <summary>批量下载时用于识别“用户按了取消”,直接中止后续条目。</summary>
|
||
private bool downloadWasCanceled;
|
||
|
||
public Form1()
|
||
{
|
||
InitializeComponent();
|
||
|
||
string css = @"
|
||
.pt-top-nav { display: none !important; }
|
||
.pt-head-bg { display: none !important; }
|
||
.pt-navbg { display: none !important; }
|
||
.erwm { display: none !important; }
|
||
.pt-right { display: none !important; }
|
||
.pt-copyright { display: none !important; }
|
||
.pt-crumb { display: none !important; }
|
||
.txtinfo { display: none !important; }
|
||
.mys-wrapper { display: none !important; }
|
||
.tools { display: none !important; }
|
||
.headbox { display: none !important; }
|
||
.setbox { display: none !important; }
|
||
.foot { display: none !important; }
|
||
";
|
||
|
||
if (File.Exists("css.txt"))
|
||
{
|
||
css = File.ReadAllText("css.txt");
|
||
}
|
||
|
||
webView21.NavigationCompleted += async (sender, e) =>
|
||
{
|
||
if (e.IsSuccess)
|
||
{
|
||
await InjectCssAsync(css);
|
||
}
|
||
};
|
||
|
||
urlTxt.Text = "http://www.luoqiuzww.cc";
|
||
}
|
||
|
||
private async Task InjectCssAsync(string css)
|
||
{
|
||
if (File.Exists("css.txt"))
|
||
{
|
||
css = File.ReadAllText("css.txt");
|
||
}
|
||
|
||
string script = $@"
|
||
var style = document.createElement('style');
|
||
style.type = 'text/css';
|
||
style.innerHTML = `{css}`;
|
||
document.head.appendChild(style);
|
||
";
|
||
|
||
await webView21.CoreWebView2.ExecuteScriptAsync(script);
|
||
}
|
||
|
||
private void OnNavigationCompleted(object sender, CoreWebView2NavigationCompletedEventArgs e)
|
||
{
|
||
if (e.IsSuccess)
|
||
{
|
||
urlTxt.Text = ((Microsoft.Web.WebView2.Core.CoreWebView2)sender).Source;
|
||
// 小延迟确保DOM完全加载
|
||
Task.Delay(100).ContinueWith(_ =>
|
||
{
|
||
this.Invoke((MethodInvoker)delegate
|
||
{
|
||
if (Math.Abs(webView21.ZoomFactor - currentZoomFactor) > 0.01f)
|
||
{
|
||
webView21.ZoomFactor = currentZoomFactor;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 顶层页面导航完成后重置嗅探结果:只保留当前页面里播过的地址,
|
||
/// 免得长时间浏览后下载地址越攒越多。
|
||
/// </summary>
|
||
private void OnMainNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e)
|
||
{
|
||
if (!e.IsSuccess)
|
||
{
|
||
return;
|
||
}
|
||
|
||
lock (sniffedUrls) sniffedUrls.Clear();
|
||
lock (urlReferers) urlReferers.Clear();
|
||
sniffedPlaylists.Clear();
|
||
}
|
||
|
||
private async Task EnsureCoreAsync()
|
||
{
|
||
if (webView21.CoreWebView2 != null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
// 初始化 CoreWebView2
|
||
await webView21.EnsureCoreWebView2Async(null);
|
||
|
||
if (!hooksAttached && webView21.CoreWebView2 is { } core)
|
||
{
|
||
hooksAttached = true;
|
||
|
||
core.FrameNavigationCompleted += OnNavigationCompleted;
|
||
core.NavigationCompleted += OnNavigationCompleted;
|
||
// 换了页面就把上一页嗅探到的地址清空,下载时只会拿到当前这页在播的视频
|
||
core.NavigationCompleted += OnMainNavigationCompleted;
|
||
core.DOMContentLoaded += (sender, e) => webView21.ZoomFactor = currentZoomFactor;
|
||
webView21.ZoomFactorChanged += (sender, e) => currentZoomFactor = webView21.ZoomFactor;
|
||
|
||
// 页面里的嗅探脚本靠这两个事件把视频地址回传
|
||
core.WebMessageReceived += OnWebMessageReceived;
|
||
// 站点自带的"下载"按钮(如抖音)也统一走另存为,避免存到看不见的默认目录
|
||
core.DownloadStarting += OnDownloadStarting;
|
||
// 新窗口就地导航,保证嗅探脚本仍然在跑
|
||
core.NewWindowRequested += OnNewWindowRequested;
|
||
}
|
||
|
||
await InjectScriptsAsync();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
MessageBox.Show($"初始化 WebView2 失败: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 在文档创建阶段注入嗅探与悬浮按钮脚本,保证早于播放器脚本运行(抖音视频是翻页加载的,尤其需要早注入)。
|
||
/// </summary>
|
||
private async Task InjectScriptsAsync()
|
||
{
|
||
var core = webView21.CoreWebView2;
|
||
if (core == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
foreach (var file in new[] { SnifferScript, ButtonScript })
|
||
{
|
||
if (injectedScripts.ContainsKey(file))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
string code = ReadScript(file);
|
||
if (string.IsNullOrEmpty(code))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
string id = await core.AddScriptToExecuteOnDocumentCreatedAsync(code);
|
||
if (!string.IsNullOrEmpty(id))
|
||
{
|
||
injectedScripts[file] = id;
|
||
}
|
||
}
|
||
}
|
||
|
||
private async void InitializeWebView()
|
||
{
|
||
await EnsureCoreAsync();
|
||
|
||
if (webView21.CoreWebView2 == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
// 加载网页
|
||
webView21.CoreWebView2.Navigate(urlTxt.Text);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
MessageBox.Show($"加载页面失败: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
private void Form1_Load(object sender, EventArgs e)
|
||
{
|
||
if (File.Exists("currentLink.txt"))
|
||
{
|
||
urlTxt.Text = File.ReadAllText("currentLink.txt");
|
||
}
|
||
|
||
// 打开就直接加载页面,不用先点一次“转到”才能看视频
|
||
InitializeWebView();
|
||
}
|
||
|
||
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
|
||
{
|
||
File.WriteAllText("currentLink.txt", urlTxt.Text);
|
||
}
|
||
|
||
private void cssBut_Click(object sender, EventArgs e)
|
||
{
|
||
ShowDialog("css.txt");
|
||
}
|
||
|
||
private void addLinkBut_Click(object sender, EventArgs e)
|
||
{
|
||
File.AppendAllText("link.txt", "\r\n" + urlTxt.Text);
|
||
}
|
||
|
||
private void linkBut_Click(object sender, EventArgs e)
|
||
{
|
||
ShowDialog("link.txt");
|
||
}
|
||
|
||
private void ShowDialog(string path)
|
||
{
|
||
Form2 form2 = new Form2();
|
||
form2.Path = path;
|
||
form2.ShowDialog();
|
||
}
|
||
|
||
private void displayBut_Click(object sender, EventArgs e)
|
||
{
|
||
urlTxt.Visible = !urlTxt.Visible;
|
||
}
|
||
|
||
private void goBut_Click(object sender, EventArgs e)
|
||
{
|
||
InitializeWebView();
|
||
}
|
||
|
||
#region 视频下载
|
||
|
||
private static string ReadScript(string file)
|
||
{
|
||
try
|
||
{
|
||
string path = Path.IsPathRooted(file) ? file : Path.Combine(Application.StartupPath, file);
|
||
return File.Exists(path) ? File.ReadAllText(path) : string.Empty;
|
||
}
|
||
catch
|
||
{
|
||
return string.Empty;
|
||
}
|
||
}
|
||
|
||
private void SetStatus(string text)
|
||
{
|
||
if (IsHandleCreated && InvokeRequired)
|
||
{
|
||
BeginInvoke(new Action(() => statusLbl.Text = text));
|
||
}
|
||
else
|
||
{
|
||
statusLbl.Text = text;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 把 WebView2 调用封送回 UI 线程执行:CoreWebView2 是 COM 接口,
|
||
/// 从线程池线程(如截图预览的后台任务)直接调会永久卡死,必须先封送。
|
||
/// </summary>
|
||
private async Task<T> OnUiAsync<T>(Func<Task<T>> action)
|
||
{
|
||
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
PreviewLog.Write($"OnUiAsync 排队 {action.Method.Name}");
|
||
try
|
||
{
|
||
BeginInvoke(new Action(async () =>
|
||
{
|
||
try
|
||
{
|
||
T r = await action();
|
||
PreviewLog.Write($"OnUiAsync 完成 {action.Method.Name}");
|
||
tcs.SetResult(r);
|
||
}
|
||
catch (Exception ex) { tcs.SetException(ex); }
|
||
}));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
tcs.SetException(ex);
|
||
}
|
||
return await tcs.Task;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 嗅探到的地址可能来自不同 iframe,列表要加锁并限制长度,
|
||
/// 只留最近若干个,供“当前播放地址取不到时”兜底用。
|
||
/// </summary>
|
||
private void AddSniffedUrl(string url, string? referer)
|
||
{
|
||
lock (sniffedUrls)
|
||
{
|
||
if (!sniffedUrls.Contains(url))
|
||
{
|
||
sniffedUrls.Add(url);
|
||
}
|
||
|
||
while (sniffedUrls.Count > 50) sniffedUrls.RemoveAt(0);
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(referer))
|
||
{
|
||
lock (urlReferers) urlReferers[url] = referer;
|
||
}
|
||
}
|
||
|
||
private string? RefererFor(string url, string? fallback)
|
||
{
|
||
lock (urlReferers)
|
||
{
|
||
if (urlReferers.TryGetValue(url, out var referer)) return referer;
|
||
}
|
||
|
||
return string.IsNullOrWhiteSpace(fallback) ? null : fallback;
|
||
}
|
||
|
||
private List<string> SnapshotSniffedUrls()
|
||
{
|
||
lock (sniffedUrls) return sniffedUrls.ToList();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 接收页面嗅探脚本回传的视频地址与播放列表文本。
|
||
/// </summary>
|
||
private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
|
||
{
|
||
try
|
||
{
|
||
string json = e.TryGetWebMessageAsString();
|
||
JsonNode? node = JsonNode.Parse(json);
|
||
string? type = node?["type"]?.GetValue<string>();
|
||
|
||
switch (type)
|
||
{
|
||
case "media":
|
||
{
|
||
string? url = node?["url"]?.GetValue<string>();
|
||
string? referer = node?["base"]?.GetValue<string>();
|
||
if (!string.IsNullOrEmpty(url) && VideoDownloader.LooksLikeMedia(url))
|
||
{
|
||
AddSniffedUrl(url, referer);
|
||
if (VideoDownloader.IsHlsUrl(url))
|
||
{
|
||
SetStatus("已嗅探到 m3u8,可点击下载");
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
case "hlsText":
|
||
{
|
||
string? text = node?["text"]?.GetValue<string>();
|
||
if (!string.IsNullOrEmpty(text) && text.Contains("#EXTM3U") && !sniffedPlaylists.Contains(text))
|
||
{
|
||
sniffedPlaylists.Add(text);
|
||
// 只留最近几份播放列表,够用又不会越积越多
|
||
while (sniffedPlaylists.Count > 10) sniffedPlaylists.RemoveAt(0);
|
||
SetStatus("已抓到播放列表,可点击下载");
|
||
}
|
||
break;
|
||
}
|
||
case "downloadClick":
|
||
// 播放器右下角的悬浮按钮
|
||
_ = DownloadCurrentVideoAsync();
|
||
break;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// 非本程序约定的消息直接忽略
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 站点自己发起的下载(例如抖音自带的下载入口)改为用户选择保存位置。
|
||
/// </summary>
|
||
private void OnDownloadStarting(object? sender, CoreWebView2DownloadStartingEventArgs e)
|
||
{
|
||
string suggested = Path.GetFileName(e.DownloadOperation.ResultFilePath);
|
||
if (string.IsNullOrWhiteSpace(suggested))
|
||
{
|
||
suggested = "video.mp4";
|
||
}
|
||
|
||
using SaveFileDialog dlg = new SaveFileDialog
|
||
{
|
||
Title = "保存视频",
|
||
FileName = suggested,
|
||
Filter = "视频文件|*.mp4;*.ts;*.mkv;*.flv;*.mov|所有文件|*.*"
|
||
};
|
||
|
||
if (dlg.ShowDialog(this) != DialogResult.OK)
|
||
{
|
||
e.Cancel = true;
|
||
SetStatus("已取消下载");
|
||
return;
|
||
}
|
||
|
||
string target = dlg.FileName;
|
||
e.Handled = true;
|
||
e.ResultFilePath = target;
|
||
SetStatus($"正在下载 {Path.GetFileName(target)}");
|
||
|
||
CoreWebView2DownloadOperation operation = e.DownloadOperation;
|
||
operation.BytesReceivedChanged += (s, ev) =>
|
||
{
|
||
ulong? total = operation.TotalBytesToReceive;
|
||
string totalText = total > 0 ? $" / {VideoDownloader.FormatSize((long)total!.Value)}" : string.Empty;
|
||
SetStatus($"下载中 {VideoDownloader.FormatSize(operation.BytesReceived)}{totalText}");
|
||
};
|
||
operation.StateChanged += (s, ev) =>
|
||
{
|
||
switch (operation.State)
|
||
{
|
||
case CoreWebView2DownloadState.Completed:
|
||
SetStatus($"已保存 {target}");
|
||
break;
|
||
case CoreWebView2DownloadState.Interrupted:
|
||
SetStatus($"下载中断:{operation.InterruptReason}");
|
||
break;
|
||
}
|
||
};
|
||
}
|
||
|
||
private void OnNewWindowRequested(object? sender, CoreWebView2NewWindowRequestedEventArgs e)
|
||
{
|
||
// 抖音等站点的 window.open 在这里就地导航,避免新窗口没有嗅探脚本
|
||
e.Handled = true;
|
||
webView21.CoreWebView2?.Navigate(e.Uri);
|
||
}
|
||
|
||
private async void dlBut_Click(object sender, EventArgs e)
|
||
{
|
||
await DownloadCurrentVideoAsync();
|
||
}
|
||
|
||
private async void listBut_Click(object sender, EventArgs e)
|
||
{
|
||
await ShowVideoListAsync();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 打开“本页视频列表”:列出全部候选并生成截图预览(每个视频 3 张、间隔 5 秒),
|
||
/// 用户对比确认哪个是当前视频后勾选,选文件夹批量下载。
|
||
/// </summary>
|
||
private async Task ShowVideoListAsync()
|
||
{
|
||
if (webView21.CoreWebView2 == null)
|
||
{
|
||
SetStatus("页面还没加载好,请稍候");
|
||
return;
|
||
}
|
||
|
||
if (downloadCts != null)
|
||
{
|
||
SetStatus("已有下载在进行中,可先点“取消下载”");
|
||
return;
|
||
}
|
||
|
||
listBut.Enabled = false;
|
||
try
|
||
{
|
||
SetStatus("正在识别本页视频…");
|
||
string raw = await webView21.CoreWebView2.ExecuteScriptAsync(BuildGrabScript());
|
||
(List<VideoCandidate> items, HashSet<string> currentUrls) = CollectCandidates(raw);
|
||
|
||
if (items.Count == 0)
|
||
{
|
||
SetStatus("没识别到视频地址");
|
||
MessageBox.Show(this,
|
||
"本页没有识别到可下载的视频地址。\n\n" +
|
||
"常见原因:\r\n1. 视频还没真正开始播放,请先点播放再点本按钮;\r\n"
|
||
+ "2. 站点用了加密流(DRM),无法直接下载;\r\n"
|
||
+ "3. 视频在 iframe 里且尚未加载完,稍等一两秒重试。",
|
||
"下载列表", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||
return;
|
||
}
|
||
|
||
// 关键:列表窗口是模态对话框,实测对话框存在期间 WebView2 的异步回调
|
||
// (ExecuteScriptAsync / GetCookiesAsync)不会送达,要等窗口关闭才一次性返回。
|
||
// 所以所有依赖 WebView2 的数据必须在 ShowDialog 之前预取完毕,
|
||
// 后台预览任务只做 ffmpeg 抽帧,不再碰 WebView2。
|
||
SetStatus("正在取登录态与播放器画面…");
|
||
Dictionary<string, string?> cookies = new Dictionary<string, string?>();
|
||
foreach (VideoCandidate item in items)
|
||
{
|
||
if (!cookies.ContainsKey(item.Url))
|
||
{
|
||
cookies[item.Url] = await GetCookieHeaderAsync(item.Url);
|
||
}
|
||
}
|
||
List<byte[]> canvasFrames = await CaptureCurrentFramesAsync(CancellationToken.None);
|
||
Image? currentStrip = VideoListForm.ComposeFrames(canvasFrames);
|
||
PreviewLog.Write($"预取完成 cookies={cookies.Count} canvas帧={canvasFrames.Count} strip={(currentStrip != null ? "ok" : "null")}");
|
||
|
||
SetStatus($"识别到 {items.Count} 个视频,正在生成截图…");
|
||
Func<VideoCandidate, CancellationToken, Task<(Image? Image, string? Error)>> factory =
|
||
(c, ct) => BuildPreviewAsync(c, currentUrls.Contains(c.Url), currentStrip, cookies, ct);
|
||
using VideoListForm form = new VideoListForm(items, currentUrls, factory);
|
||
if (form.ShowDialog(this) != DialogResult.OK || form.SelectedCandidates.Count == 0)
|
||
{
|
||
SetStatus("已取消");
|
||
return;
|
||
}
|
||
|
||
using FolderBrowserDialog folderDlg = new FolderBrowserDialog
|
||
{
|
||
Description = $"选择保存文件夹(将下载 {form.SelectedCandidates.Count} 个视频)"
|
||
};
|
||
if (folderDlg.ShowDialog(this) != DialogResult.OK)
|
||
{
|
||
SetStatus("已取消");
|
||
return;
|
||
}
|
||
|
||
await DownloadCandidatesBatchAsync(form.SelectedCandidates, folderDlg.SelectedPath);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
SetStatus("已取消");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
SetStatus($"识别失败:{ex.Message}");
|
||
MessageBox.Show(this, ex.Message, "下载列表", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
}
|
||
finally
|
||
{
|
||
listBut.Enabled = true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 给列表行生成截图预览。当前正在播的视频直接用开窗前从播放器画面预取好的截图条
|
||
/// (不受防盗链和地址过期影响);其余候选用 ffmpeg 带预取的 Referer/Cookie 读远端地址抽帧。
|
||
/// 这个方法在后台线程上跑,绝不能调 WebView2 API(模态对话框期间回调不会送达)。
|
||
/// </summary>
|
||
private async Task<(Image? Image, string? Error)> BuildPreviewAsync(
|
||
VideoCandidate candidate, bool isCurrent, Image? currentStrip,
|
||
IReadOnlyDictionary<string, string?> cookies, CancellationToken ct)
|
||
{
|
||
string shortUrl = candidate.Url.Length > 70 ? candidate.Url[..70] : candidate.Url;
|
||
PreviewLog.Write($"预览开始 isCurrent={isCurrent} url={shortUrl}");
|
||
|
||
// 只在内存里的播放列表文本没有可 seek 的地址,不抽帧
|
||
if (candidate.PlaylistText != null)
|
||
{
|
||
return (null, "内存播放列表,无法抽帧预览");
|
||
}
|
||
|
||
if (isCurrent && currentStrip != null)
|
||
{
|
||
PreviewLog.Write("预览成功:播放器画面预取帧");
|
||
return (currentStrip, null);
|
||
}
|
||
|
||
try
|
||
{
|
||
string? referer = candidate.Referer ?? RefererFor(candidate.Url, null);
|
||
cookies.TryGetValue(candidate.Url, out string? cookie);
|
||
VideoDownloader downloader = new VideoDownloader(referer, cookie);
|
||
PreviewLog.Write("ffmpeg 抽帧开始");
|
||
VideoDownloader.FrameExtraction result = await downloader.ExtractFramesAsync(candidate.Url, ct);
|
||
Image? img = VideoListForm.ComposeFrames(result.Frames);
|
||
PreviewLog.Write($"ffmpeg 抽帧结束 frames={result.Frames.Count} error={result.Error ?? "-"}");
|
||
return img != null ? (img, null) : (null, result.Error ?? "没抽到帧");
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
PreviewLog.Write("预览被取消");
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
PreviewLog.Write($"预览异常 {ex.GetType().Name}: {ex.Message}");
|
||
return (null, ex.Message);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从正在播放的 <video> 元素里截 3 帧(2/7/12 秒,对应“每 5 秒 3 张”):
|
||
/// 临时暂停→逐帧 seek+canvas 绘制→恢复原进度与播放状态;单次 seek 限 3 秒,
|
||
/// 遇到 tainted canvas(跨域污染)等异常就放弃,退回 ffmpeg 远程抽帧。
|
||
/// </summary>
|
||
private async Task<List<byte[]>> CaptureCurrentFramesAsync(CancellationToken ct)
|
||
{
|
||
List<byte[]> frames = new List<byte[]>();
|
||
try
|
||
{
|
||
if (webView21.CoreWebView2 == null) return frames;
|
||
|
||
// 后台线程不能直接碰 ExecuteScriptAsync,封送回 UI 线程;
|
||
// 整条脚本再加 20 秒硬超时,页面播放器抽风时不能把预览任务永久挂住
|
||
using var overall = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||
overall.CancelAfter(TimeSpan.FromSeconds(20));
|
||
string raw;
|
||
try
|
||
{
|
||
raw = await OnUiAsync(() => webView21.CoreWebView2!.ExecuteScriptAsync(CurrentFramesScript))
|
||
.WaitAsync(overall.Token);
|
||
}
|
||
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
|
||
{
|
||
PreviewLog.Write($"播放器取帧脚本超时/取消:{ex.GetType().Name}");
|
||
return frames;
|
||
}
|
||
ct.ThrowIfCancellationRequested();
|
||
PreviewLog.Write($"播放器取帧脚本返回 {raw.Length} 字符");
|
||
string? inner = JsonSerializer.Deserialize<string>(raw);
|
||
if (string.IsNullOrEmpty(inner)) return frames;
|
||
|
||
if (JsonNode.Parse(inner)?["frames"] is JsonArray arr)
|
||
{
|
||
foreach (JsonNode? item in arr)
|
||
{
|
||
string? dataUrl = item?.GetValue<string>();
|
||
int comma = dataUrl?.IndexOf(',') ?? -1;
|
||
if (dataUrl == null || comma < 0) continue;
|
||
try { frames.Add(Convert.FromBase64String(dataUrl[(comma + 1)..])); }
|
||
catch { /* 坏帧跳过 */ }
|
||
}
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
throw;
|
||
}
|
||
catch
|
||
{
|
||
// 播放器截图失败就退回 ffmpeg 远程抽帧
|
||
}
|
||
return frames;
|
||
}
|
||
|
||
private const string CurrentFramesScript = @"
|
||
(async function(){
|
||
function findVideo(){
|
||
var vids=Array.prototype.slice.call(document.querySelectorAll('video'));
|
||
for(var i=0;i<vids.length;i++){ if(!vids[i].paused && vids[i].currentTime>0) return vids[i]; }
|
||
for(var j=0;j<vids.length;j++){ if(vids[j].videoWidth>0) return vids[j]; }
|
||
return vids[0]||null;
|
||
}
|
||
var v=findVideo();
|
||
if(!v) return JSON.stringify({frames:[]});
|
||
var times=[2,7,12], frames=[], wasPaused=v.paused, t0=v.currentTime;
|
||
try{
|
||
v.pause();
|
||
for(var k=0;k<times.length;k++){
|
||
var t=times[k];
|
||
if(isFinite(v.duration) && t>v.duration-0.2) t=v.duration>1?v.duration-0.5:0;
|
||
var ok=true;
|
||
try{
|
||
await new Promise(function(res,rej){
|
||
var done=false;
|
||
var to=setTimeout(function(){ if(!done){done=true;rej('t');} },3000);
|
||
v.onseeked=function(){ if(!done){done=true;clearTimeout(to);res();} };
|
||
try{ v.currentTime=t; }catch(e){ if(!done){done=true;clearTimeout(to);rej('s');} }
|
||
});
|
||
}catch(e){ ok=false; }
|
||
if(!ok) break;
|
||
try{
|
||
var c=document.createElement('canvas');
|
||
var w=v.videoWidth||480, h=v.videoHeight||270;
|
||
var sc=Math.min(1,480/w);
|
||
c.width=Math.round(w*sc); c.height=Math.round(h*sc);
|
||
c.getContext('2d').drawImage(v,0,0,c.width,c.height);
|
||
frames.push(c.toDataURL('image/jpeg',0.85));
|
||
}catch(e){ frames=[]; break; }
|
||
}
|
||
} finally {
|
||
// 注意:这里绝不能 await v.play()——抖音播放器会干预 play 的 Promise,
|
||
// 一旦 pending 不结束整段脚本永不返回,截图任务会永久挂死
|
||
try{
|
||
v.currentTime=t0;
|
||
if(!wasPaused){ try{ v.play().catch(function(){}); }catch(e){} }
|
||
}catch(e){}
|
||
}
|
||
return JSON.stringify({frames:frames});
|
||
})()";
|
||
|
||
/// <summary>
|
||
/// 批量下载:逐个存进所选文件夹,音视频分离的轨对仍走精确合并;
|
||
/// 某条失败时询问是否继续,用户点取消则中止剩余条目。
|
||
/// </summary>
|
||
private async Task DownloadCandidatesBatchAsync(List<VideoCandidate> items, string folder)
|
||
{
|
||
int succeeded = 0;
|
||
List<string> failures = new List<string>();
|
||
VideoDownloader namer = new VideoDownloader(RefererFor(items[0].Url, webView21.CoreWebView2?.Source));
|
||
|
||
for (int i = 0; i < items.Count; i++)
|
||
{
|
||
VideoCandidate item = items[i];
|
||
string fileName = namer.SuggestedFileName(item.Url, PageTitle);
|
||
string filePath = UniqueFilePath(folder, fileName);
|
||
|
||
SetStatus($"批量下载 {i + 1}/{items.Count}:{fileName}");
|
||
downloadWasCanceled = false;
|
||
string? failure = await TryDownloadAsync(item, filePath, promptOpenFolder: false);
|
||
|
||
if (downloadWasCanceled) break; // 用户取消整个批次
|
||
|
||
if (failure == null)
|
||
{
|
||
succeeded++;
|
||
}
|
||
else
|
||
{
|
||
failures.Add($"{fileName}:{failure}");
|
||
if (i < items.Count - 1 && MessageBox.Show(this,
|
||
$"第 {i + 1} 个下载失败:\n{failure}\n\n跳过它继续下载剩下的吗?",
|
||
"批量下载", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes)
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
string summary = $"成功 {succeeded} 个,失败 {failures.Count} 个(共 {items.Count})。";
|
||
SetStatus($"批量下载完成:{summary}");
|
||
if (failures.Count > 0)
|
||
{
|
||
summary += "\n\n" + string.Join("\n", failures.Take(8)) + (failures.Count > 8 ? "\n…" : string.Empty);
|
||
}
|
||
MessageBox.Show(this, summary, "批量下载", MessageBoxButtons.OK,
|
||
failures.Count == 0 ? MessageBoxIcon.Information : MessageBoxIcon.Warning);
|
||
|
||
if (succeeded > 0) OpenFolder(folder);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 只下载当前正在播放的那一个视频地址,不再列出一堆候选让用户挑。
|
||
/// </summary>
|
||
private async Task DownloadCurrentVideoAsync()
|
||
{
|
||
if (webView21.CoreWebView2 == null)
|
||
{
|
||
SetStatus("页面还没加载好,请稍候");
|
||
return;
|
||
}
|
||
|
||
if (downloadCts != null)
|
||
{
|
||
SetStatus("已有下载在进行中,可先点“取消下载”");
|
||
return;
|
||
}
|
||
|
||
dlBut.Enabled = false;
|
||
try
|
||
{
|
||
SetStatus("正在识别当前视频…");
|
||
string raw = await webView21.CoreWebView2.ExecuteScriptAsync(BuildGrabScript());
|
||
VideoCandidate? candidate = CollectCurrentCandidate(raw);
|
||
|
||
if (candidate == null)
|
||
{
|
||
SetStatus("没识别到视频地址");
|
||
MessageBox.Show(this,
|
||
"没有抓到当前正在播放的视频地址。\n\n" +
|
||
"常见原因:\r\n1. 视频还没真正开始播放,请先点播放再点本按钮;\r\n" +
|
||
"2. 站点用了加密流(DRM),无法直接下载;\r\n" +
|
||
"3. 视频在 iframe 里且尚未加载完,稍等一两秒重试。",
|
||
"下载视频", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||
return;
|
||
}
|
||
|
||
await DownloadVideoAsync(candidate);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
SetStatus("已取消");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
SetStatus($"识别失败:{ex.Message}");
|
||
MessageBox.Show(this, ex.Message, "下载视频", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
}
|
||
finally
|
||
{
|
||
dlBut.Enabled = true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 抓取脚本兜底版本:资源文件丢失时仍能取到当前 video 标签的播放地址。
|
||
/// </summary>
|
||
private static string BuildGrabScript()
|
||
{
|
||
string code = ReadScript(GrabScript);
|
||
if (!string.IsNullOrEmpty(code))
|
||
{
|
||
return code;
|
||
}
|
||
|
||
return "(function(){var v=document.querySelector('video');"
|
||
+ "return JSON.stringify({current:v?(v.currentSrc||v.src):'',title:document.title,pageUrl:location.href,"
|
||
+ "sniffed:(window.__mediaUrls||[]).slice(-60),m3u8Blobs:(window.__m3u8Blobs||[]).slice(-1)});})()";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 只取当前正在播放的那一个视频地址,按四层递降:
|
||
/// 1. video 标签自己的播放源(最准确,就是正在播的这个);
|
||
/// 2. DASH 分离轨(抖音这类 blob+MSE):视频轨 + 音频轨配对,下载后合并;
|
||
/// 3. 播放器在 iframe 里取不到源时,退回用本页最近嗅探到的那一个整片地址(m3u8 或直链,分片不算);
|
||
/// 4. 只在内存里拿到播放列表文本时,用页面地址做分片相对地址的基准。
|
||
/// </summary>
|
||
private VideoCandidate? CollectCurrentCandidate(string grabResultJson)
|
||
{
|
||
GrabInfo g = ParseGrab(grabResultJson);
|
||
string pageUrl = g.PageUrl;
|
||
List<string> sniffed = g.Sniffed;
|
||
string dashVideo = g.DashVideo;
|
||
string dashAudio = g.DashAudio;
|
||
string videoId = g.VideoId;
|
||
string current = g.Current;
|
||
|
||
// 第一层:video 标签自己的播放源
|
||
if (VideoDownloader.LooksLikeMedia(current) && !VideoDownloader.IsSegmentUrl(current))
|
||
{
|
||
return new VideoCandidate(current, null, pageUrl);
|
||
}
|
||
|
||
// 第二层:DASH 分离轨配对(抖音)—— JS 端已按 videoId 过滤
|
||
if (!string.IsNullOrEmpty(dashVideo) && VideoDownloader.LooksLikeMedia(dashVideo))
|
||
{
|
||
return new VideoCandidate(dashVideo, null, RefererFor(dashVideo, pageUrl),
|
||
string.IsNullOrEmpty(dashAudio) ? null : dashAudio);
|
||
}
|
||
|
||
// 第三层:从嗅探结果里找当前视频的地址
|
||
List<string> pool = sniffed.Concat(SnapshotSniffedUrls())
|
||
.Where(VideoDownloader.LooksLikeMedia)
|
||
.Where(u => !VideoDownloader.IsDashAudioOnly(u)) // 纯音频轨不能单独当视频下
|
||
.Distinct()
|
||
.ToList();
|
||
|
||
int index;
|
||
if (!string.IsNullOrEmpty(videoId))
|
||
{
|
||
// 有视频 ID 时,优先找 URL 里包含该 ID 的整片地址
|
||
index = pool.FindLastIndex(u => !VideoDownloader.IsSegmentUrl(u) && u.Contains(videoId));
|
||
}
|
||
else
|
||
{
|
||
// 没有 ID 就退回最后一个整片地址
|
||
index = pool.FindLastIndex(u => !VideoDownloader.IsSegmentUrl(u));
|
||
}
|
||
|
||
if (index >= 0)
|
||
{
|
||
string url = pool[index];
|
||
// 看看能不能从嗅探记录里配到同组音频轨
|
||
string? pairedAudio = FindPairedAudio(url, videoId);
|
||
return new VideoCandidate(url, null, RefererFor(url, pageUrl), pairedAudio);
|
||
}
|
||
|
||
string? text = sniffedPlaylists.Concat(ReadBlobPlaylists(grabResultJson))
|
||
.Distinct()
|
||
.LastOrDefault(t => t.Contains("#EXTM3U"));
|
||
|
||
return text == null ? null : new VideoCandidate(pageUrl, text, pageUrl);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 抓取脚本回包的解析结果,当前下载与列表两条路径共用。
|
||
/// </summary>
|
||
private sealed class GrabInfo
|
||
{
|
||
public string Current = string.Empty;
|
||
public string PageUrl = string.Empty;
|
||
public string DashVideo = string.Empty;
|
||
public string DashAudio = string.Empty;
|
||
public string VideoId = string.Empty;
|
||
public List<string> Sniffed = new();
|
||
}
|
||
|
||
private static GrabInfo ParseGrab(string grabResultJson)
|
||
{
|
||
GrabInfo g = new GrabInfo();
|
||
try
|
||
{
|
||
// ExecuteScriptAsync 返回的是 JSON 编码后的字符串,需要先脱一层引号
|
||
string? inner = JsonSerializer.Deserialize<string>(grabResultJson);
|
||
if (!string.IsNullOrEmpty(inner))
|
||
{
|
||
JsonNode? node = JsonNode.Parse(inner);
|
||
g.Current = node?["current"]?.GetValue<string>() ?? string.Empty;
|
||
g.PageUrl = node?["pageUrl"]?.GetValue<string>() ?? string.Empty;
|
||
g.DashVideo = node?["dashVideo"]?.GetValue<string>() ?? string.Empty;
|
||
g.DashAudio = node?["dashAudio"]?.GetValue<string>() ?? string.Empty;
|
||
g.VideoId = node?["videoId"]?.GetValue<string>() ?? string.Empty;
|
||
|
||
if (node?["sniffed"] is JsonArray arr)
|
||
{
|
||
foreach (JsonNode? item in arr)
|
||
{
|
||
string? u = item?.GetValue<string>();
|
||
if (!string.IsNullOrEmpty(u)) g.Sniffed.Add(u!);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// 页面没有返回可解析结果时,只用宿主侧嗅探到的地址
|
||
}
|
||
return g;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 给“列表”按钮收集本页全部候选:当前播放源、DASH 视频轨排最前并标记为当前,
|
||
/// 其余嗅探到的整片地址按新旧倒序追加;每条都尝试配对同组音频轨,下载时精确合并。
|
||
/// </summary>
|
||
private (List<VideoCandidate> Items, HashSet<string> CurrentUrls) CollectCandidates(string grabResultJson)
|
||
{
|
||
GrabInfo g = ParseGrab(grabResultJson);
|
||
List<VideoCandidate> items = new List<VideoCandidate>();
|
||
HashSet<string> currentUrls = new HashSet<string>();
|
||
|
||
void Add(string? url, string? audio, bool isCurrent)
|
||
{
|
||
if (string.IsNullOrEmpty(url)
|
||
|| !VideoDownloader.LooksLikeMedia(url)
|
||
|| VideoDownloader.IsSegmentUrl(url)
|
||
|| VideoDownloader.IsDashAudioOnly(url))
|
||
{
|
||
return;
|
||
}
|
||
if (items.Any(i => i.Url == url))
|
||
{
|
||
if (isCurrent) currentUrls.Add(url!);
|
||
return;
|
||
}
|
||
|
||
// 只有 DASH 纯视频轨才需要配音频轨;普通整片直链自带音轨,乱配反而会把别人的声音合进去
|
||
string? pairedAudio = audio ?? (VideoDownloader.IsDashVideoOnly(url)
|
||
? FindPairedAudio(url!, isCurrent ? g.VideoId : IdFromUrl(url!))
|
||
: null);
|
||
items.Add(new VideoCandidate(url, null, RefererFor(url, g.PageUrl), pairedAudio));
|
||
if (isCurrent) currentUrls.Add(url!);
|
||
}
|
||
|
||
// 当前播放的两条源排最前,方便和下面的嗅探条目肉眼对比
|
||
Add(g.Current, null, true);
|
||
Add(g.DashVideo, string.IsNullOrEmpty(g.DashAudio) ? null : g.DashAudio, true);
|
||
|
||
List<string> pool = g.Sniffed.Concat(SnapshotSniffedUrls())
|
||
.Distinct()
|
||
.ToList();
|
||
|
||
// 有视频 ID 时先把同 ID 的地址排前面,它们多半就是当前视频
|
||
if (!string.IsNullOrEmpty(g.VideoId))
|
||
{
|
||
pool = pool.OrderByDescending(u => u.Contains(g.VideoId)).ToList();
|
||
}
|
||
|
||
// 嗅探列表里越新的越接近当前视频,倒序追加
|
||
for (int i = pool.Count - 1; i >= 0; i--)
|
||
{
|
||
string url = pool[i];
|
||
Add(url, null, !string.IsNullOrEmpty(g.VideoId) && url.Contains(g.VideoId));
|
||
}
|
||
|
||
return (items, currentUrls);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从地址里提视频 ID(抖音这类地址路径里带 10 位以上纯数字),用于音频轨配对。
|
||
/// </summary>
|
||
private static string IdFromUrl(string url)
|
||
{
|
||
try
|
||
{
|
||
Match m = Regex.Match(new Uri(url).AbsolutePath, @"\d{10,}");
|
||
return m.Success ? m.Value : string.Empty;
|
||
}
|
||
catch
|
||
{
|
||
return string.Empty;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从已嗅探地址里找和视频轨同组的 DASH 音频轨。
|
||
/// 有 videoId 时优先选 URL 里包含该 ID 的音频轨,否则取最后一条 media-audio。
|
||
/// </summary>
|
||
private string? FindPairedAudio(string videoUrl, string videoId = "")
|
||
{
|
||
lock (sniffedUrls)
|
||
{
|
||
// 有视频 ID 时,优先找同 ID 的音频轨
|
||
if (!string.IsNullOrEmpty(videoId))
|
||
{
|
||
for (int i = sniffedUrls.Count - 1; i >= 0; i--)
|
||
{
|
||
if (VideoDownloader.IsDashAudioOnly(sniffedUrls[i]) &&
|
||
sniffedUrls[i].Contains(videoId))
|
||
return sniffedUrls[i];
|
||
}
|
||
}
|
||
// 退回:从后往前找最后一条音频轨
|
||
for (int i = sniffedUrls.Count - 1; i >= 0; i--)
|
||
{
|
||
if (VideoDownloader.IsDashAudioOnly(sniffedUrls[i]))
|
||
return sniffedUrls[i];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static List<string> ReadBlobPlaylists(string grabResultJson)
|
||
{
|
||
List<string> result = new List<string>();
|
||
try
|
||
{
|
||
string? inner = JsonSerializer.Deserialize<string>(grabResultJson);
|
||
if (inner != null && JsonNode.Parse(inner)?["m3u8Blobs"] is JsonArray arr)
|
||
{
|
||
foreach (JsonNode? item in arr)
|
||
{
|
||
string? text = item?.GetValue<string>();
|
||
if (!string.IsNullOrWhiteSpace(text) && text.Contains("#EXTM3U")) result.Add(text);
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// 忽略
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 下载当前播放的这个地址:先让用户选保存位置,再流式落盘。
|
||
/// 地址只有一个,失败就直接报错误信息,不再翻别的地址重试。
|
||
/// </summary>
|
||
private async Task DownloadVideoAsync(VideoCandidate candidate)
|
||
{
|
||
string url = candidate.Url;
|
||
VideoDownloader namer = new VideoDownloader(RefererFor(url, webView21.CoreWebView2?.Source));
|
||
|
||
using SaveFileDialog dlg = new SaveFileDialog
|
||
{
|
||
Title = "下载视频",
|
||
FileName = namer.SuggestedFileName(url, PageTitle),
|
||
Filter = "视频文件|*.mp4;*.ts;*.mkv;*.flv;*.mov|所有文件|*.*",
|
||
AddExtension = true
|
||
};
|
||
|
||
if (dlg.ShowDialog(this) != DialogResult.OK)
|
||
{
|
||
SetStatus("已取消");
|
||
return;
|
||
}
|
||
|
||
string? failure = await TryDownloadAsync(candidate, dlg.FileName);
|
||
if (failure == null) return;
|
||
|
||
SetStatus($"下载失败:{failure}");
|
||
MessageBox.Show(this, failure, "下载视频", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 执行一次下载。返回 null 表示成功或用户主动取消,否则返回错误信息给上层提示。
|
||
/// 批量下载时 promptOpenFolder 传 false,避免每条都弹“打开文件夹”。
|
||
/// </summary>
|
||
private async Task<string?> TryDownloadAsync(VideoCandidate candidate, string filePath, bool promptOpenFolder = true)
|
||
{
|
||
VideoDownloader downloader = await CreateDownloaderAsync(candidate);
|
||
|
||
downloadCts = new CancellationTokenSource();
|
||
Progress<DownloadProgress> progress = new Progress<DownloadProgress>(ReportProgress);
|
||
progressBar.Visible = true;
|
||
progressBar.Value = 0;
|
||
cancelBut.Enabled = true;
|
||
SetStatus($"准备下载 {Path.GetFileName(filePath)}");
|
||
|
||
try
|
||
{
|
||
DownloadResult result;
|
||
if (candidate.PlaylistText != null)
|
||
{
|
||
result = await downloader.DownloadFromPlaylistTextAsync(candidate.PlaylistText, candidate.Url, filePath, progress, downloadCts.Token);
|
||
}
|
||
else if (!string.IsNullOrEmpty(candidate.AudioUrl))
|
||
{
|
||
result = await downloader.DownloadDashPairAsync(candidate.Url, candidate.AudioUrl, filePath, progress, downloadCts.Token);
|
||
}
|
||
else
|
||
{
|
||
result = await downloader.DownloadAsync(candidate.Url, filePath, progress, downloadCts.Token);
|
||
}
|
||
|
||
SetStatus($"已下载 {Path.GetFileName(result.FilePath)}({VideoDownloader.FormatSize(result.Bytes)})");
|
||
|
||
if (promptOpenFolder && MessageBox.Show(this, $"视频已保存到:\n{result.FilePath}\n\n要打开所在文件夹吗?",
|
||
"下载完成", MessageBoxButtons.YesNo, MessageBoxIcon.Information) == DialogResult.Yes)
|
||
{
|
||
OpenFolder(result.FilePath);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// 用户主动取消就别再换地址重试了;批量路径靠这个标志中止剩余条目
|
||
downloadWasCanceled = true;
|
||
SetStatus("已取消下载");
|
||
return null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
DeletePartFiles(filePath);
|
||
return ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
downloadCts?.Dispose();
|
||
downloadCts = null;
|
||
progressBar.Visible = false;
|
||
cancelBut.Enabled = false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 用候选地址自己的 Referer 和浏览器里的 Cookie 建下载器:
|
||
/// 播放器在跨域 iframe 里时,拿顶层页面地址当 Referer 会被防盗链拒掉。
|
||
/// </summary>
|
||
private async Task<VideoDownloader> CreateDownloaderAsync(VideoCandidate candidate)
|
||
{
|
||
string? referer = candidate.Referer ?? RefererFor(candidate.Url, webView21.CoreWebView2?.Source);
|
||
string? cookie = await GetCookieHeaderAsync(candidate.Url);
|
||
return new VideoDownloader(referer, cookie);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 取 Cookie 也是 CoreWebView2 COM 调用,同样封送回 UI 线程,
|
||
/// 否则截图预览的后台任务会在这里永久卡死。
|
||
/// </summary>
|
||
private Task<string?> GetCookieHeaderAsync(string url) => OnUiAsync(async () =>
|
||
{
|
||
CoreWebView2? core = webView21.CoreWebView2;
|
||
if (core == null || !Uri.TryCreate(url, UriKind.Absolute, out Uri? uri)) return null;
|
||
|
||
try
|
||
{
|
||
var cookies = await core.CookieManager.GetCookiesAsync(uri.ToString());
|
||
string header = string.Join("; ", cookies
|
||
.Where(c => !string.IsNullOrEmpty(c.Name))
|
||
.Select(c => $"{c.Name}={c.Value}"));
|
||
return header.Length == 0 ? null : header;
|
||
}
|
||
catch
|
||
{
|
||
// 拿不到 Cookie 就按匿名请求下载
|
||
return null;
|
||
}
|
||
});
|
||
|
||
private void ReportProgress(DownloadProgress value)
|
||
{
|
||
SetStatus(value.Message);
|
||
|
||
if (value.Ratio != null)
|
||
{
|
||
progressBar.Value = (int)Math.Clamp(value.Ratio.Value * 100, 0, 100);
|
||
}
|
||
}
|
||
|
||
private void cancelBut_Click(object sender, EventArgs e)
|
||
{
|
||
try
|
||
{
|
||
downloadCts?.Cancel();
|
||
SetStatus("正在取消…");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
SetStatus($"取消失败:{ex.Message}");
|
||
}
|
||
}
|
||
|
||
private static void DeletePartFiles(string target)
|
||
{
|
||
try
|
||
{
|
||
string part = target + ".part";
|
||
if (File.Exists(part)) File.Delete(part);
|
||
}
|
||
catch
|
||
{
|
||
// 残留的 .part 交给下次覆盖
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 批量下载时同页多个候选可能推断出同名文件,重名时加序号避免互相覆盖。
|
||
/// </summary>
|
||
private static string UniqueFilePath(string folder, string fileName)
|
||
{
|
||
string path = Path.Combine(folder, fileName);
|
||
if (!File.Exists(path)) return path;
|
||
|
||
string name = Path.GetFileNameWithoutExtension(fileName);
|
||
string ext = Path.GetExtension(fileName);
|
||
for (int i = 2; ; i++)
|
||
{
|
||
path = Path.Combine(folder, $"{name} ({i}){ext}");
|
||
if (!File.Exists(path)) return path;
|
||
}
|
||
}
|
||
|
||
private static void OpenFolder(string filePath)
|
||
{
|
||
try
|
||
{
|
||
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\"");
|
||
}
|
||
catch
|
||
{
|
||
// 打不开文件夹不影响下载结果
|
||
}
|
||
}
|
||
|
||
private string PageTitle
|
||
{
|
||
get
|
||
{
|
||
try
|
||
{
|
||
return webView21.CoreWebView2?.DocumentTitle ?? string.Empty;
|
||
}
|
||
catch
|
||
{
|
||
return string.Empty;
|
||
}
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
}
|
||
}
|