ow
This commit is contained in:
1 parent
90bb20e75f
commit
bcf82d0f45
8 files changed
+342
-37
No files matched your search
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace WinFormsApp3
|
||||
@@ -59,6 +60,20 @@ namespace WinFormsApp3
|
||||
public static bool IsHlsUrl(string url) =>
|
||||
!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>
|
||||
/// 判断是否是 HLS 分片(合并播放列表可用,但单独下载没有意义)。
|
||||
/// 只认分片特有的后缀与编号形式,不能拿路径里的 video / seg 字样当依据,
|
||||
@@ -177,6 +192,105 @@ namespace WinFormsApp3
|
||||
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()
|
||||
{
|
||||
var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
|
||||
@@ -529,13 +643,35 @@ namespace WinFormsApp3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找 ffmpeg:先看程序目录,再看 PATH。找到就能把 HLS 转成 mp4,找不到则只合并 ts。
|
||||
/// 查找 ffmpeg:顺序为 1) ffmpeg.json 配置文件指定的路径;2) 程序目录 ffmpeg.exe;3) PATH。
|
||||
/// 配置文件格式:{"ffmpegPath": "D:\\NET8\\Exe\\ffmpeg\\bin\\ffmpeg.exe"}
|
||||
/// </summary>
|
||||
public static string? FindFfmpeg()
|
||||
{
|
||||
// 1. 配置文件指定的路径(优先级最高)
|
||||
var configPath = Path.Combine(AppContext.BaseDirectory, "ffmpeg.json");
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(configPath);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("ffmpegPath", out var prop) &&
|
||||
prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var configured = prop.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
|
||||
return configured;
|
||||
}
|
||||
}
|
||||
catch { /* 配置文件损坏就跳过,继续找别的 */ }
|
||||
}
|
||||
|
||||
// 2. 程序目录
|
||||
var local = Path.Combine(AppContext.BaseDirectory, "ffmpeg.exe");
|
||||
if (File.Exists(local)) return local;
|
||||
|
||||
// 3. PATH
|
||||
foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dir)) continue;
|
||||
|
||||
Reference in new issue
Block a user