ow
This commit is contained in:
1 parent
90bb20e75f
commit
bcf82d0f45
8 files changed
+339
-34
No files matched your search
@@ -10,7 +10,8 @@ namespace WinFormsApp3
|
|||||||
/// <param name="Url">可直接 HTTP 访问的视频地址;PlaylistText 不为空时作为相对地址基准。</param>
|
/// <param name="Url">可直接 HTTP 访问的视频地址;PlaylistText 不为空时作为相对地址基准。</param>
|
||||||
/// <param name="PlaylistText">只在内存里拿到 m3u8 文本时使用(抖音这类 MSE 播放)。</param>
|
/// <param name="PlaylistText">只在内存里拿到 m3u8 文本时使用(抖音这类 MSE 播放)。</param>
|
||||||
/// <param name="Referer">发出这个请求的文档地址;跨域 iframe 播放器必须用它,不然会被防盗链拒掉。</param>
|
/// <param name="Referer">发出这个请求的文档地址;跨域 iframe 播放器必须用它,不然会被防盗链拒掉。</param>
|
||||||
public record VideoCandidate(string Url, string? PlaylistText = null, string? Referer = null);
|
/// <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
|
public partial class Form1 : Form
|
||||||
{
|
{
|
||||||
@@ -503,16 +504,19 @@ namespace WinFormsApp3
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 只取当前正在播放的那一个视频地址,按三层递降:
|
/// 只取当前正在播放的那一个视频地址,按四层递降:
|
||||||
/// 1. video 标签自己的播放源(最准确,就是正在播的这个);
|
/// 1. video 标签自己的播放源(最准确,就是正在播的这个);
|
||||||
/// 2. 抖音这类 blob+MSE 播放、或播放器在 iframe 里取不到源时,
|
/// 2. DASH 分离轨(抖音这类 blob+MSE):视频轨 + 音频轨配对,下载后合并;
|
||||||
/// 退回用本页最近嗅探到的那一个整片地址(m3u8 或直链,分片不算);
|
/// 3. 播放器在 iframe 里取不到源时,退回用本页最近嗅探到的那一个整片地址(m3u8 或直链,分片不算);
|
||||||
/// 3. 只在内存里拿到播放列表文本时,用页面地址做分片相对地址的基准。
|
/// 4. 只在内存里拿到播放列表文本时,用页面地址做分片相对地址的基准。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private VideoCandidate? CollectCurrentCandidate(string grabResultJson)
|
private VideoCandidate? CollectCurrentCandidate(string grabResultJson)
|
||||||
{
|
{
|
||||||
string current = string.Empty;
|
string current = string.Empty;
|
||||||
string pageUrl = string.Empty;
|
string pageUrl = string.Empty;
|
||||||
|
string dashVideo = string.Empty;
|
||||||
|
string dashAudio = string.Empty;
|
||||||
|
string videoId = string.Empty;
|
||||||
List<string> sniffed = new List<string>();
|
List<string> sniffed = new List<string>();
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -524,6 +528,9 @@ namespace WinFormsApp3
|
|||||||
JsonNode? node = JsonNode.Parse(inner);
|
JsonNode? node = JsonNode.Parse(inner);
|
||||||
current = node?["current"]?.GetValue<string>() ?? string.Empty;
|
current = node?["current"]?.GetValue<string>() ?? string.Empty;
|
||||||
pageUrl = node?["pageUrl"]?.GetValue<string>() ?? string.Empty;
|
pageUrl = node?["pageUrl"]?.GetValue<string>() ?? string.Empty;
|
||||||
|
dashVideo = node?["dashVideo"]?.GetValue<string>() ?? string.Empty;
|
||||||
|
dashAudio = node?["dashAudio"]?.GetValue<string>() ?? string.Empty;
|
||||||
|
videoId = node?["videoId"]?.GetValue<string>() ?? string.Empty;
|
||||||
|
|
||||||
if (node?["sniffed"] is JsonArray arr)
|
if (node?["sniffed"] is JsonArray arr)
|
||||||
{
|
{
|
||||||
@@ -540,23 +547,44 @@ namespace WinFormsApp3
|
|||||||
// 页面没有返回可解析结果时,只用宿主侧嗅探到的地址
|
// 页面没有返回可解析结果时,只用宿主侧嗅探到的地址
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 第一层:video 标签自己的播放源
|
||||||
if (VideoDownloader.LooksLikeMedia(current) && !VideoDownloader.IsSegmentUrl(current))
|
if (VideoDownloader.LooksLikeMedia(current) && !VideoDownloader.IsSegmentUrl(current))
|
||||||
{
|
{
|
||||||
return new VideoCandidate(current, null, pageUrl);
|
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())
|
List<string> pool = sniffed.Concat(SnapshotSniffedUrls())
|
||||||
.Where(VideoDownloader.LooksLikeMedia)
|
.Where(VideoDownloader.LooksLikeMedia)
|
||||||
|
.Where(u => !VideoDownloader.IsDashAudioOnly(u)) // 纯音频轨不能单独当视频下
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
int index = pool.FindLastIndex(u => !VideoDownloader.IsSegmentUrl(u));
|
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)
|
if (index >= 0)
|
||||||
{
|
{
|
||||||
string url = pool[index];
|
string url = pool[index];
|
||||||
return new VideoCandidate(url, null, RefererFor(url, pageUrl));
|
// 看看能不能从嗅探记录里配到同组音频轨
|
||||||
|
string? pairedAudio = FindPairedAudio(url, videoId);
|
||||||
|
return new VideoCandidate(url, null, RefererFor(url, pageUrl), pairedAudio);
|
||||||
}
|
}
|
||||||
|
|
||||||
string? text = sniffedPlaylists.Concat(ReadBlobPlaylists(grabResultJson))
|
string? text = sniffedPlaylists.Concat(ReadBlobPlaylists(grabResultJson))
|
||||||
@@ -566,6 +594,34 @@ namespace WinFormsApp3
|
|||||||
return text == null ? null : new VideoCandidate(pageUrl, text, pageUrl);
|
return text == null ? null : new VideoCandidate(pageUrl, text, pageUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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)
|
private static List<string> ReadBlobPlaylists(string grabResultJson)
|
||||||
{
|
{
|
||||||
List<string> result = new List<string>();
|
List<string> result = new List<string>();
|
||||||
@@ -634,9 +690,19 @@ namespace WinFormsApp3
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DownloadResult result = candidate.PlaylistText != null
|
DownloadResult result;
|
||||||
? await downloader.DownloadFromPlaylistTextAsync(candidate.PlaylistText, candidate.Url, filePath, progress, downloadCts.Token)
|
if (candidate.PlaylistText != null)
|
||||||
: await downloader.DownloadAsync(candidate.Url, filePath, progress, downloadCts.Token);
|
{
|
||||||
|
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)})");
|
SetStatus($"已下载 {Path.GetFileName(result.FilePath)}({VideoDownloader.FormatSize(result.Bytes)})");
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,12 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- _hostest 是独立测试项目,不能被主项目编译 -->
|
||||||
|
<Compile Remove="_hostest/**/*.cs" />
|
||||||
|
<None Remove="_hostest/**/*" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.ML" Version="5.0.0" />
|
<PackageReference Include="Microsoft.ML" Version="5.0.0" />
|
||||||
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.26.0" />
|
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.26.0" />
|
||||||
@@ -27,6 +33,9 @@
|
|||||||
<None Update="Resources\video_grab.js">
|
<None Update="Resources\video_grab.js">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
|
<None Update="ffmpeg.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ namespace WinFormsApp3
|
|||||||
[STAThread] // ← 加上这个,99% 立刻解决你的错误
|
[STAThread] // ← 加上这个,99% 立刻解决你的错误
|
||||||
static int Main(string[] args)
|
static int Main(string[] args)
|
||||||
{
|
{
|
||||||
|
// 全局未处理异常兜底:写日志而不是直接闪退,放到最前面
|
||||||
|
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
|
||||||
|
{
|
||||||
|
try { File.WriteAllText("crash.log", $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {(e.IsTerminating ? "FATAL" : "NON-FATAL")}\n{e.ExceptionObject}"); }
|
||||||
|
catch { }
|
||||||
|
};
|
||||||
|
|
||||||
// 命令行下载:WinFormsApp3.exe --download <视频地址> [--out 保存路径] [--referer 页面地址]
|
// 命令行下载:WinFormsApp3.exe --download <视频地址> [--out 保存路径] [--referer 页面地址]
|
||||||
if (args.Length > 0 && string.Equals(args[0], "--download", StringComparison.OrdinalIgnoreCase))
|
if (args.Length > 0 && string.Equals(args[0], "--download", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -27,10 +34,15 @@ namespace WinFormsApp3
|
|||||||
}
|
}
|
||||||
|
|
||||||
ApplicationConfiguration.Initialize();
|
ApplicationConfiguration.Initialize();
|
||||||
|
try
|
||||||
// 这里不能提前 form.Show():那会让 Form.Load 在消息循环开始之前执行,
|
{
|
||||||
// WebView2 在没有消息泵时初始化会报 RPC_E_CHANGED_MODE,交给 Run 自己显示窗体
|
|
||||||
Application.Run(new Form1());
|
Application.Run(new Form1());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
File.WriteAllText("crash.log", $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] Application.Run\n{ex}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,72 @@
|
|||||||
// 点击"下载视频"时执行:读取当前 <video> 的播放源,并把嗅探到的候选地址一起返回给宿主。
|
// 点击"下载视频"时执行:优先从页面内嵌数据 RENDER_DATA 精确提取当前视频地址,
|
||||||
|
// 其次才用嗅探列表兜底。
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
|
var pageUrl = location.href;
|
||||||
|
|
||||||
|
// 从 URL 提取视频 ID
|
||||||
|
var videoId = "";
|
||||||
|
var m = pageUrl.match(/[?&]modal_id=(\d+)/);
|
||||||
|
if (m) videoId = m[1];
|
||||||
|
if (!videoId) {
|
||||||
|
var m0 = pageUrl.match(/[?&]aweme_id=(\d+)/);
|
||||||
|
if (m0) videoId = m0[1];
|
||||||
|
}
|
||||||
|
if (!videoId) {
|
||||||
|
var m2 = pageUrl.match(/\/(?:video|note)\/(\d+)/);
|
||||||
|
if (m2) videoId = m2[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 方案一:从 RENDER_DATA 里精确找当前视频的播放地址 ===
|
||||||
|
var renderVideoUrl = "";
|
||||||
|
var renderAudioUrl = "";
|
||||||
|
try {
|
||||||
|
var rd = document.getElementById("RENDER_DATA");
|
||||||
|
if (rd && rd.textContent) {
|
||||||
|
var decoded = decodeURIComponent(rd.textContent);
|
||||||
|
// 在 JSON 里找 aweme_id 等于 videoId 的节点,提取其 video.play_addr 或 play_addr 列表
|
||||||
|
// 用正则粗匹配,避免完整 JSON 解析的复杂性
|
||||||
|
var idx = decoded.indexOf(videoId);
|
||||||
|
while (idx >= 0) {
|
||||||
|
// 在这个 ID 附近找 playApi / play_addr / url_list
|
||||||
|
var chunk = decoded.substring(Math.max(0, idx - 200), idx + 5000);
|
||||||
|
// 找 DASH 格式的视频轨和音频轨
|
||||||
|
var vMatches = chunk.match(/https?:\/\/[^"\\]+media-video[^"\\]*/gi);
|
||||||
|
var aMatches = chunk.match(/https?:\/\/[^"\\]+media-audio[^"\\]*/gi);
|
||||||
|
if (vMatches && vMatches.length) {
|
||||||
|
renderVideoUrl = vMatches[vMatches.length - 1];
|
||||||
|
if (aMatches && aMatches.length) {
|
||||||
|
renderAudioUrl = aMatches[aMatches.length - 1];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 找普通 mp4 直链
|
||||||
|
var mp4Matches = chunk.match(/https?:\/\/[^"\\]*\.mp4[^"\\]*/gi);
|
||||||
|
if (mp4Matches && mp4Matches.length) {
|
||||||
|
renderVideoUrl = mp4Matches[mp4Matches.length - 1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
idx = decoded.indexOf(videoId, idx + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* RENDER_DATA 解析失败就走嗅探兜底 */ }
|
||||||
|
|
||||||
|
// === 方案二:选正在播放的 <video> 元素 ===
|
||||||
var vids = Array.prototype.slice.call(document.querySelectorAll("video"));
|
var vids = Array.prototype.slice.call(document.querySelectorAll("video"));
|
||||||
var best = null;
|
var best = null;
|
||||||
for (var i = 0; i < vids.length; i++) {
|
for (var i = 0; i < vids.length; i++) {
|
||||||
var v = vids[i];
|
var v = vids[i];
|
||||||
if (v.videoWidth > 0 || !isNaN(v.duration)) {
|
if (!v.paused && v.currentTime > 0) { best = v; break; }
|
||||||
best = v;
|
}
|
||||||
|
if (!best) {
|
||||||
|
for (var j = 0; j < vids.length; j++) {
|
||||||
|
var v2 = vids[j];
|
||||||
|
if (v2.videoWidth > 0 || (!isNaN(v2.duration) && v2.duration > 0 && v2.currentTime > 0)) {
|
||||||
|
best = v2;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!best) best = vids[0];
|
if (!best) best = vids[0];
|
||||||
|
|
||||||
var current = "";
|
var current = "";
|
||||||
@@ -20,33 +77,48 @@
|
|||||||
var s = best.querySelector && best.querySelector("source");
|
var s = best.querySelector && best.querySelector("source");
|
||||||
if (s) current = s.src || s.getAttribute("src") || "";
|
if (s) current = s.src || s.getAttribute("src") || "";
|
||||||
}
|
}
|
||||||
if (best.networkState === 0 || (best.msKeySystemLoaded && !best.src))
|
if (best.networkState === 0) playable = false;
|
||||||
playable = false;
|
}
|
||||||
|
|
||||||
|
// === 方案三:嗅探列表兜底 ===
|
||||||
|
var all = window.__mediaUrls || [];
|
||||||
|
var videoTracks = [];
|
||||||
|
var audioTracks = [];
|
||||||
|
var otherMedia = [];
|
||||||
|
for (var k = 0; k < all.length; k++) {
|
||||||
|
var u = all[k];
|
||||||
|
if (/media-video/i.test(u)) videoTracks.push(u);
|
||||||
|
else if (/media-audio/i.test(u)) audioTracks.push(u);
|
||||||
|
else otherMedia.push(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLast(arr) {
|
||||||
|
return arr.length ? arr[arr.length - 1] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var dashVideo = pickLast(videoTracks);
|
||||||
|
var dashAudio = pickLast(audioTracks);
|
||||||
|
|
||||||
|
// 如果 RENDER_DATA 找到了,优先用它
|
||||||
|
if (renderVideoUrl) {
|
||||||
|
dashVideo = renderVideoUrl;
|
||||||
|
if (renderAudioUrl) dashAudio = renderAudioUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
var title = document.title || "";
|
var title = document.title || "";
|
||||||
var h1 = document.querySelector("h1");
|
var h1 = document.querySelector("h1");
|
||||||
if (h1 && h1.innerText) title = h1.innerText;
|
if (h1 && h1.innerText) title = h1.innerText;
|
||||||
|
|
||||||
var anchors = [];
|
|
||||||
var links = document.querySelectorAll("a[href]");
|
|
||||||
for (var j = 0; j < links.length; j++) {
|
|
||||||
var h = links[j].href;
|
|
||||||
if (
|
|
||||||
window.__reportMedia &&
|
|
||||||
/\.(mp4|flv|m3u8|mov|mkv|webm|ts)(\?|#|$)/i.test(h)
|
|
||||||
)
|
|
||||||
anchors.push(h);
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
current: current || "",
|
current: current || "",
|
||||||
playable: playable,
|
playable: playable,
|
||||||
usesMse: !!window.__usesMse || /^blob:/i.test(current),
|
usesMse: !!window.__usesMse || /^blob:/i.test(current),
|
||||||
title: title,
|
title: title,
|
||||||
pageUrl: location.href,
|
pageUrl: pageUrl,
|
||||||
sniffed: (window.__mediaUrls || []).slice(-60),
|
videoId: videoId,
|
||||||
anchors: anchors.slice(-20),
|
sniffed: otherMedia.slice(-60),
|
||||||
|
dashVideo: dashVideo,
|
||||||
|
dashAudio: dashAudio,
|
||||||
m3u8Blobs: (window.__m3u8Blobs || []).slice(-1),
|
m3u8Blobs: (window.__m3u8Blobs || []).slice(-1),
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace WinFormsApp3
|
namespace WinFormsApp3
|
||||||
@@ -59,6 +60,20 @@ namespace WinFormsApp3
|
|||||||
public static bool IsHlsUrl(string url) =>
|
public static bool IsHlsUrl(string url) =>
|
||||||
!string.IsNullOrEmpty(url) && Regex.IsMatch(url, @"\.m3u8(\?|#|$)", RegexOptions.IgnoreCase);
|
!string.IsNullOrEmpty(url) && Regex.IsMatch(url, @"\.m3u8(\?|#|$)", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断是否是 DASH 纯音频轨地址(抖音 .../media-audio-xxx/...)。
|
||||||
|
/// 这种地址单独下载出来没有画面,必须和视频轨合并。
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsDashAudioOnly(string? url) =>
|
||||||
|
!string.IsNullOrEmpty(url) && Regex.IsMatch(url, @"/media-audio[/-]", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断是否是 DASH 纯视频轨地址(抖音 .../media-video-xxx/...)。
|
||||||
|
/// 这种地址单独下载出来没有声音,需要配音频轨。
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsDashVideoOnly(string? url) =>
|
||||||
|
!string.IsNullOrEmpty(url) && Regex.IsMatch(url, @"/media-video[/-]", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 判断是否是 HLS 分片(合并播放列表可用,但单独下载没有意义)。
|
/// 判断是否是 HLS 分片(合并播放列表可用,但单独下载没有意义)。
|
||||||
/// 只认分片特有的后缀与编号形式,不能拿路径里的 video / seg 字样当依据,
|
/// 只认分片特有的后缀与编号形式,不能拿路径里的 video / seg 字样当依据,
|
||||||
@@ -177,6 +192,105 @@ namespace WinFormsApp3
|
|||||||
return await WriteHlsAsync(client, playlist, filePath, progress, ct);
|
return await WriteHlsAsync(client, playlist, filePath, progress, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下载 DASH 分离的视频轨和音频轨,再用 ffmpeg 合并成单个 mp4。
|
||||||
|
/// 抖音等站点把视频和音频分成两个独立的 mp4 轨道返回,必须合并才能有声有画。
|
||||||
|
/// </summary>
|
||||||
|
public async Task<DownloadResult> DownloadDashPairAsync(string videoUrl, string audioUrl, string filePath, IProgress<DownloadProgress>? 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 { /* 临时目录删不掉不影响结果 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下载单个文件到指定路径(不做 .part 重命名,内部用)。
|
||||||
|
/// </summary>
|
||||||
|
private async Task DownloadSingleToFileAsync(HttpClient client, string url, string filePath, CancellationToken ct, Action<long>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用 ffmpeg 把 DASH 视频轨和音频轨合并成 mp4(直接 copy 不重编码)。
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<bool> 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()
|
private HttpClient CreateClient()
|
||||||
{
|
{
|
||||||
var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
|
var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
|
||||||
@@ -529,13 +643,35 @@ namespace WinFormsApp3
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找 ffmpeg:先看程序目录,再看 PATH。找到就能把 HLS 转成 mp4,找不到则只合并 ts。
|
/// 查找 ffmpeg:顺序为 1) ffmpeg.json 配置文件指定的路径;2) 程序目录 ffmpeg.exe;3) PATH。
|
||||||
|
/// 配置文件格式:{"ffmpegPath": "D:\\NET8\\Exe\\ffmpeg\\bin\\ffmpeg.exe"}
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string? FindFfmpeg()
|
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");
|
var local = Path.Combine(AppContext.BaseDirectory, "ffmpeg.exe");
|
||||||
if (File.Exists(local)) return local;
|
if (File.Exists(local)) return local;
|
||||||
|
|
||||||
|
// 3. PATH
|
||||||
foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator))
|
foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator))
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(dir)) continue;
|
if (string.IsNullOrWhiteSpace(dir)) continue;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"v": "https://v26-web.douyinvod.com/cde0dd644a3f3fdd8b51ce0d8ac53eea/6aed51d1/video/tos/cn/tos-cn-v-694b40/oUqAC2l4TBfIhPFJ2AcgFisAA41dgvfDqX8DEW/media-video-hvc1/?a=6383&ch=0&cr=8&dr=0&er=1&lr=default&cd=0%7C0%7C0%7C3&cv=1&br=346&bt=346&cs=4&ds=6&mime_type=video_mp4&qs=14&rc=O2RpN2doPDxnNjk1Njs1Z0BpM3l0M3U5cm40ZTYzNDVpM0A2Y2MvNDAuXmAxXmMtNjAxYSNfcGliMmRzX2VhLS1kMi9zcw%3D%3D&btag=c0000e00038000&cquery=100w_100o&dy_q=1793840674&l=20261105090434E2510E54DA3AC7D64180", "a": "https://v26-web.douyinvod.com/e6943c243c43eed40ed2797458968f85/6aed51d1/video/tos/cn/tos-cn-v-694b40/okTEuLCmAKneHtxwXVYxfsIN8ARqAA56GCfeIA/media-audio-und-mp4a/?a=6383&ch=0&cr=8&dr=0&er=1&lr=default&cd=0%7C0%7C0%7C3&cv=1&br=50&bt=50&cs=0&mime_type=video_mp4&qs=1&rc=OTVpOTw6Njo2O2U6ZWQ2NUBpM3l0M3U5cm40ZTYzNDVpM0A1MS80LWBfYy4wLTQxMjQvYSNfcGliMmRzX2VhLS1kMi9zcw%3D%3D&btag=c0000e00038000&cquery=100o_100w&dy_q=1793840674&l=20261105090434E2510E54DA3AC7D64180"}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"ffmpegPath": "D:\\NET8\\Exe\\ffmpeg\\bin\\ffmpeg.exe"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "8.0.100",
|
||||||
|
"rollForward": "latestMajor"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user