using System.Net; using System.Net.Http.Headers; using System.Threading; using VideoDownloader.Core.Config; using VideoDownloader.Core.Models; namespace VideoDownloader.Core.Hls; /// /// 직링크 미디어(mp4/webm/mkv/m4v/mov/m4a/mp3/ogg...) 다운로드. /// - .part 임시파일에 쓰고 완료 시 이동 /// - Range 헤더로 이어받기 (서버 지원 시) /// - 일시 오류 재시도 + 백오프 (영구 4xx 제외) /// public class DirectDownloader { private readonly HttpClient _http; private readonly AppConfig _config; public DirectDownloader(HttpClient http, AppConfig config) { _http = http; _config = config; } public async Task DownloadAsync( Uri url, string outputBase, IProgress? 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)); } 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); } } } /// #12 재시도 대기: 서버 Retry-After(있으면, 최대 60s 캡) 를 우선, 없으면 지수 백오프. 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", }; } }