157 lines
6.6 KiB
C#
157 lines
6.6 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Threading;
|
|
using VideoDownloader.Core.Config;
|
|
using VideoDownloader.Core.Models;
|
|
|
|
namespace VideoDownloader.Core.Hls;
|
|
|
|
/// <summary>
|
|
/// 직링크 미디어(mp4/webm/mkv/m4v/mov/m4a/mp3/ogg...) 다운로드.
|
|
/// - .part 임시파일에 쓰고 완료 시 이동
|
|
/// - Range 헤더로 이어받기 (서버 지원 시)
|
|
/// - 일시 오류 재시도 + 백오프 (영구 4xx 제외)
|
|
/// </summary>
|
|
public class DirectDownloader
|
|
{
|
|
private readonly HttpClient _http;
|
|
private readonly AppConfig _config;
|
|
|
|
public DirectDownloader(HttpClient http, AppConfig config)
|
|
{
|
|
_http = http;
|
|
_config = config;
|
|
}
|
|
|
|
public async Task<string> DownloadAsync(
|
|
Uri url, string outputBase,
|
|
IProgress<DownloadProgress>? progress = null, CancellationToken ct = default)
|
|
{
|
|
string? dir = Path.GetDirectoryName(outputBase);
|
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
|
|
|
var part = outputBase + ".part";
|
|
Exception? last = null;
|
|
string ext = GuessExt("", url);
|
|
long total = -1;
|
|
|
|
for (int attempt = 1; attempt <= _config.Retries; attempt++)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
long written = File.Exists(part) ? new FileInfo(part).Length : 0;
|
|
TimeSpan? retryAfter = null; // 서버가 Retry-After 를 주면 그만큼 대기(#12)
|
|
|
|
try
|
|
{
|
|
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
|
if (written > 0) req.Headers.Range = new RangeHeaderValue(written, null);
|
|
|
|
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
|
|
|
|
// 이미 완전히 받은 경우
|
|
if (resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable)
|
|
return Finalize(part, outputBase, ext);
|
|
|
|
// 영구 4xx (408/429 제외) → 재시도 의미 없음
|
|
var code = (int)resp.StatusCode;
|
|
bool permanent = code >= 400 && code < 500
|
|
&& resp.StatusCode != HttpStatusCode.RequestTimeout
|
|
&& resp.StatusCode != HttpStatusCode.TooManyRequests;
|
|
// #12 429/503 은 서버가 정한 쿨다운(Retry-After) 존중 — resp 가 dispose 되기 전에 캡처
|
|
if ((code == 429 || code == 503) && resp.Headers.RetryAfter is { } ra)
|
|
retryAfter = ra.Delta ?? (ra.Date is { } d ? d - DateTimeOffset.Now : null);
|
|
resp.EnsureSuccessStatusCode();
|
|
|
|
bool partial = resp.StatusCode == HttpStatusCode.PartialContent;
|
|
if (!partial) written = 0; // 서버가 range 무시 → 처음부터
|
|
|
|
var mediaType = resp.Content.Headers.ContentType?.MediaType ?? "";
|
|
ext = GuessExt(mediaType, url);
|
|
if (resp.Content.Headers.ContentLength is long cl)
|
|
total = partial ? written + cl : cl;
|
|
|
|
await using var stream = await resp.Content.ReadAsStreamAsync(ct);
|
|
using var fs = new FileStream(part, partial ? FileMode.Append : FileMode.Create,
|
|
FileAccess.Write, FileShare.None, bufferSize: 65536, useAsync: true);
|
|
var buf = new byte[65536];
|
|
int n;
|
|
while ((n = await stream.ReadAsync(buf, ct)) > 0)
|
|
{
|
|
fs.Write(buf, 0, n);
|
|
written += n;
|
|
double pct = total > 0 ? written * 100.0 / total : -1;
|
|
string detail = total > 0
|
|
? $"{written / 1048576.0:F1}/{total / 1048576.0:F1} MB"
|
|
: $"{written / 1048576.0:F1} MB";
|
|
progress?.Report(new DownloadProgress(0, 0, "downloading", pct, detail,
|
|
ReceivedBytes: written, TotalBytes: total));
|
|
}
|
|
await fs.FlushAsync(ct);
|
|
return Finalize(part, outputBase, ext);
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch (HttpRequestException hre)
|
|
{
|
|
last = hre;
|
|
var c = hre.StatusCode is { } s ? (int)s : 0;
|
|
bool perm = c >= 400 && c < 500 && c != 408 && c != 429;
|
|
if (perm || attempt == _config.Retries) break;
|
|
await DelayAsync(retryAfter, attempt, ct);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
last = e;
|
|
if (attempt == _config.Retries) break;
|
|
await DelayAsync(retryAfter, attempt, ct);
|
|
}
|
|
}
|
|
throw last!;
|
|
}
|
|
|
|
private static string Finalize(string part, string outputBase, string ext)
|
|
{
|
|
var final = outputBase + ext;
|
|
// 대상 파일이 탐색기(썸네일)/백신에 의해 잠깐 잠겨있으면 File.Move 가
|
|
// "being used by another process" 로 실패하므로 잠시 대기하며 재시도.
|
|
for (int i = 0; ; i++)
|
|
{
|
|
try { File.Move(part, final, overwrite: true); return final; }
|
|
catch (IOException) when (i < 10) { Thread.Sleep(250); }
|
|
}
|
|
}
|
|
|
|
/// <summary>#12 재시도 대기: 서버 Retry-After(있으면, 최대 60s 캡) 를 우선, 없으면 지수 백오프.</summary>
|
|
private static async Task DelayAsync(TimeSpan? retryAfter, int attempt, CancellationToken ct)
|
|
{
|
|
var delay = retryAfter ?? TimeSpan.FromMilliseconds(Math.Min(1500 * attempt, 10000));
|
|
if (delay > TimeSpan.FromSeconds(60)) delay = TimeSpan.FromSeconds(60);
|
|
if (delay < TimeSpan.Zero) delay = TimeSpan.Zero;
|
|
try { await Task.Delay(delay, ct); } catch { }
|
|
}
|
|
|
|
private static readonly string[] KnownExts =
|
|
{ ".mp4", ".webm", ".mkv", ".m4v", ".mov", ".avi", ".m4a", ".mp3", ".ogg", ".opus", ".flac", ".wav", ".ts" };
|
|
|
|
private static string GuessExt(string mediaType, Uri url)
|
|
{
|
|
var path = url.AbsolutePath.ToLowerInvariant();
|
|
foreach (var k in KnownExts)
|
|
if (path.EndsWith(k)) return k;
|
|
|
|
return mediaType.ToLowerInvariant() switch
|
|
{
|
|
"video/mp4" or "video/mp4v-es" => ".mp4",
|
|
"video/webm" => ".webm",
|
|
"video/x-matroska" => ".mkv",
|
|
"video/quicktime" => ".mov",
|
|
"video/x-msvideo" => ".avi",
|
|
"audio/mp4" or "audio/x-m4a" => ".m4a",
|
|
"audio/mpeg" => ".mp3",
|
|
"audio/ogg" or "application/ogg" => ".ogg",
|
|
"audio/webm" => ".webm",
|
|
"audio/wav" or "audio/x-wav" => ".wav",
|
|
"audio/flac" => ".flac",
|
|
_ => ".mp4",
|
|
};
|
|
}
|
|
}
|