Baseline: WPF/Avalonia/Cli/Core + plans (BROWSER_FEATURES_PLAN v2, MOBILE_PLAN v1)
This commit is contained in:
commit
21b7c4d8bd
60 changed files with 6581 additions and 0 deletions
156
VideoDownloader.Core/Hls/DirectDownloader.cs
Normal file
156
VideoDownloader.Core/Hls/DirectDownloader.cs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
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));
|
||||
}
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
261
VideoDownloader.Core/Hls/HlsDownloader.cs
Normal file
261
VideoDownloader.Core/Hls/HlsDownloader.cs
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Core.Models;
|
||||
|
||||
namespace VideoDownloader.Core.Hls;
|
||||
|
||||
/// <summary>
|
||||
/// HLS 다운로더: 마스터→최고화질 변환, 세그먼트 병렬 다운로드+이어받기,
|
||||
/// AES-128 복호화, fMP4(#EXT-X-MAP) 처리, 병합, ffmpeg 리먹스.
|
||||
/// </summary>
|
||||
public class HlsDownloader
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly AppConfig _config;
|
||||
private readonly ConcurrentDictionary<Uri, byte[]> _keys = new();
|
||||
|
||||
public HlsDownloader(HttpClient http, AppConfig config)
|
||||
{
|
||||
_http = http;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <param name="outputBase">확장자 없는 출력 경로. 최종 파일은 outputBase + ".mp4"</param>
|
||||
public async Task<string> DownloadAsync(
|
||||
Uri playlistUrl, string outputBase,
|
||||
IProgress<DownloadProgress>? progress = null, CancellationToken ct = default)
|
||||
{
|
||||
var partsDir = outputBase + ".parts";
|
||||
Directory.CreateDirectory(partsDir);
|
||||
string? dir = Path.GetDirectoryName(outputBase);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
bool success = false;
|
||||
try
|
||||
{
|
||||
// 재생목록 해석 (마스터면 최고 화질 변환 따라가기)
|
||||
var parsed = M3u8Parser.Parse(await _http.GetStringAsync(playlistUrl, ct), playlistUrl);
|
||||
int level = 0;
|
||||
while (parsed.Type == PlaylistType.Master)
|
||||
{
|
||||
if (parsed.Variants.Count == 0)
|
||||
throw new InvalidOperationException("마스터 재생목록에 변환이 없습니다.");
|
||||
var best = parsed.Variants.OrderByDescending(v => v.Bandwidth).First();
|
||||
parsed = M3u8Parser.Parse(await _http.GetStringAsync(best.Url, ct), best.Url);
|
||||
if (++level > 5) throw new InvalidOperationException("재생목록 중첩이 너무 깊습니다.");
|
||||
}
|
||||
|
||||
var segs = parsed.Segments;
|
||||
if (segs.Count == 0) throw new InvalidOperationException("다운로드할 세그먼트가 없습니다.");
|
||||
|
||||
// fMP4 초기화 세그먼트
|
||||
string? initPath = null;
|
||||
if (parsed.InitSegment != null)
|
||||
{
|
||||
initPath = Path.Combine(partsDir, "init.fmp4");
|
||||
await DownloadWithRetryAsync(parsed.InitSegment, initPath + ".tmp", ct);
|
||||
File.Move(initPath + ".tmp", initPath, overwrite: true);
|
||||
}
|
||||
|
||||
// 키 사전 로드 (merge 단계에서 동기 복호화)
|
||||
foreach (var ku in segs.Select(s => s.Key?.KeyUri).Where(u => u != null).Distinct()!)
|
||||
await GetKeyAsync(ku!, ct);
|
||||
|
||||
// 세그먼트 병렬 다운로드 (이어받기)
|
||||
progress?.Report(new DownloadProgress(0, segs.Count, "downloading"));
|
||||
int skipped = segs.Count(s =>
|
||||
File.Exists(SegmentPath(partsDir, s)) && new FileInfo(SegmentPath(partsDir, s)).Length > 0);
|
||||
var done = new StrongBox<int>(skipped);
|
||||
var opts = new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, _config.Concurrency), CancellationToken = ct };
|
||||
await Parallel.ForEachAsync(segs, opts, async (seg, token) =>
|
||||
{
|
||||
var path = SegmentPath(partsDir, seg);
|
||||
if (!(File.Exists(path) && new FileInfo(path).Length > 0))
|
||||
{
|
||||
await DownloadWithRetryAsync(seg.Url, path + ".tmp", token);
|
||||
File.Move(path + ".tmp", path, overwrite: true);
|
||||
}
|
||||
var d = Interlocked.Increment(ref done.Value);
|
||||
progress?.Report(new DownloadProgress(d, segs.Count, "downloading"));
|
||||
});
|
||||
|
||||
// 병합 (init + 세그먼트 순서대로, 필요시 복호화)
|
||||
progress?.Report(new DownloadProgress(segs.Count, segs.Count, "merging"));
|
||||
bool isFmp4 = initPath != null;
|
||||
var merged = Path.GetFullPath(outputBase + ".merged" + (isFmp4 ? ".mp4" : ".ts"));
|
||||
using (var outFs = File.Create(merged))
|
||||
{
|
||||
if (initPath != null && File.Exists(initPath))
|
||||
outFs.Write(File.ReadAllBytes(initPath));
|
||||
foreach (var seg in segs.OrderBy(s => s.Index))
|
||||
{
|
||||
var data = File.ReadAllBytes(SegmentPath(partsDir, seg));
|
||||
if (seg.Key != null) data = DecryptSegment(data, seg);
|
||||
outFs.Write(data);
|
||||
}
|
||||
}
|
||||
|
||||
var result = await RemuxAsync(merged, outputBase, isFmp4, progress, ct);
|
||||
success = true;
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// #6 성공 시 parts 디렉토리 정리 (실패/취소 시 호출자가 정리)
|
||||
try { if (success && Directory.Exists(partsDir)) Directory.Delete(partsDir, recursive: true); }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static string SegmentPath(string partsDir, Segment seg) =>
|
||||
Path.Combine(partsDir, $"{seg.Index:000000}.seg");
|
||||
|
||||
// ---- 다운로드 / 재시도 ----
|
||||
private async Task DownloadWithRetryAsync(Uri url, string path, CancellationToken ct)
|
||||
{
|
||||
Exception? last = null;
|
||||
for (int attempt = 1; attempt <= _config.Retries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
await using var fs = File.Create(path);
|
||||
await using var stream = await resp.Content.ReadAsStreamAsync(ct);
|
||||
await stream.CopyToAsync(fs, 65536, ct);
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception e)
|
||||
{
|
||||
last = e;
|
||||
if (attempt == _config.Retries) break;
|
||||
try { await Task.Delay(Math.Min(1500 * attempt, 10000), ct); } catch { }
|
||||
}
|
||||
}
|
||||
throw last!;
|
||||
}
|
||||
|
||||
// ---- AES-128 ----
|
||||
private async Task GetKeyAsync(Uri keyUri, CancellationToken ct)
|
||||
{
|
||||
if (_keys.ContainsKey(keyUri)) return;
|
||||
var key = await _http.GetByteArrayAsync(keyUri, ct);
|
||||
_keys[keyUri] = key;
|
||||
}
|
||||
|
||||
private byte[] DecryptSegment(byte[] data, Segment seg)
|
||||
{
|
||||
var key = seg.Key!;
|
||||
var keyBytes = _keys[key.KeyUri!];
|
||||
var iv = ResolveIv(key, seg.Sequence);
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = keyBytes;
|
||||
aes.IV = iv;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.None;
|
||||
using var dec = aes.CreateDecryptor();
|
||||
return dec.TransformFinalBlock(data, 0, data.Length);
|
||||
}
|
||||
|
||||
private static byte[] ResolveIv(KeyInfo key, long seq)
|
||||
{
|
||||
var iv = key.Iv;
|
||||
if (!string.IsNullOrEmpty(iv))
|
||||
{
|
||||
var hex = iv.Replace("0x", "").Replace(" ", "");
|
||||
return Convert.FromHexString(hex.Length % 2 == 1 ? "0" + hex : hex);
|
||||
}
|
||||
// IV 미지정 시 시퀀스 번호를 16바이트 big-endian 으로
|
||||
var b = new byte[16];
|
||||
var seqBytes = BitConverter.GetBytes(seq); // little-endian on x64
|
||||
if (BitConverter.IsLittleEndian) Array.Reverse(seqBytes);
|
||||
Array.Copy(seqBytes, 0, b, 16 - seqBytes.Length, seqBytes.Length);
|
||||
return b;
|
||||
}
|
||||
|
||||
// ---- 리먹스 ----
|
||||
private async Task<string> RemuxAsync(
|
||||
string merged, string outputBase, bool isFmp4,
|
||||
IProgress<DownloadProgress>? progress, CancellationToken ct)
|
||||
{
|
||||
var ffmpeg = ResolveFfmpeg();
|
||||
if (ffmpeg != null)
|
||||
{
|
||||
var final = outputBase + ".mp4";
|
||||
if (Path.GetFullPath(final) == Path.GetFullPath(merged))
|
||||
final = outputBase + ".out.mp4";
|
||||
progress?.Report(new DownloadProgress(0, 0, "remuxing"));
|
||||
try
|
||||
{
|
||||
await RunFfmpegAsync(ffmpeg, merged, final, ct);
|
||||
if (File.Exists(final) && new FileInfo(final).Length > 0)
|
||||
{
|
||||
File.Delete(merged);
|
||||
return final;
|
||||
}
|
||||
throw new InvalidOperationException("ffmpeg 출력 파일이 비었거나 없습니다.");
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch
|
||||
{
|
||||
// #2 리먹스 실패 → partial mp4 정리 후 예외 전파 (item.Error 로 사용자 노출)
|
||||
try { if (File.Exists(final)) File.Delete(final); } catch { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
// ffmpeg 없음 → 병합본을 그대로(.ts/.mp4)
|
||||
var keep = outputBase + (isFmp4 ? ".mp4" : ".ts");
|
||||
File.Move(merged, keep, overwrite: true);
|
||||
return keep;
|
||||
}
|
||||
|
||||
private string? ResolveFfmpeg()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_config.FfmpegPath) && File.Exists(_config.FfmpegPath))
|
||||
return _config.FfmpegPath;
|
||||
var name = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
|
||||
foreach (var d in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator))
|
||||
{
|
||||
var p = Path.Combine(d.Trim('"'), name);
|
||||
if (File.Exists(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// #2 stderr/stdout 파이프를 동시에 드레인(데드락 방지), 종료 보장(좀비 방지), 실패 시 예외 throw
|
||||
// #10 stdout 드레인 태스크도 관측 — 취소 시 미관측(faulted) 태스크가 되지 않도록 finally 에서 await.
|
||||
private static async Task RunFfmpegAsync(string ffmpeg, string input, string output, CancellationToken ct)
|
||||
{
|
||||
Process? proc = null;
|
||||
Task<string>? stdoutTask = null;
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo(ffmpeg)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
};
|
||||
foreach (var a in new[] { "-y", "-hide_banner", "-loglevel", "error", "-i", input, "-c", "copy", output })
|
||||
psi.ArgumentList.Add(a);
|
||||
proc = Process.Start(psi) ?? throw new InvalidOperationException("ffmpeg 시작 실패");
|
||||
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
|
||||
stdoutTask = proc.StandardOutput.ReadToEndAsync(ct); // stdout 도 드레인(데드락 방지)
|
||||
await proc.WaitForExitAsync(ct);
|
||||
var stderr = await stderrTask;
|
||||
if (proc.ExitCode != 0)
|
||||
throw new InvalidOperationException($"ffmpeg 리먹스 실패(exit {proc.ExitCode}): {stderr.Trim()}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { }
|
||||
// #10 드레인 태스크 관측 (취소/OCE/EOF 시 폭발 무시)
|
||||
try { if (stdoutTask is not null) await stdoutTask; } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
101
VideoDownloader.Core/Hls/M3u8Parser.cs
Normal file
101
VideoDownloader.Core/Hls/M3u8Parser.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using VideoDownloader.Core.Models;
|
||||
|
||||
namespace VideoDownloader.Core.Hls;
|
||||
|
||||
/// <summary>m3u8(마스터/미디어) 재생목록 파서. Python parse_m3u8 의 C# 포팅.</summary>
|
||||
public static class M3u8Parser
|
||||
{
|
||||
// KEY="VAL",KEY2=VAL 형태 속성 파싱
|
||||
private static readonly Regex AttrRe =
|
||||
new(@"(?<k>[A-Z0-9\-]+)=(\""(?<q>[^""]*)\"")|(?<k2>[A-Z0-9\-]+)=(?<v>[^,]*)",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static Dictionary<string, string> ParseAttrs(string s)
|
||||
{
|
||||
var d = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match m in AttrRe.Matches(s))
|
||||
{
|
||||
var key = m.Groups["k"].Success ? m.Groups["k"].Value : m.Groups["k2"].Value;
|
||||
var val = m.Groups["q"].Success ? m.Groups["q"].Value : m.Groups["v"].Value;
|
||||
d[key] = val;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
private static string GetValue(this Dictionary<string, string> d, string key, string def = "") =>
|
||||
d.TryGetValue(key, out var v) ? v : def;
|
||||
|
||||
public static ParsedPlaylist Parse(string text, Uri baseUrl)
|
||||
{
|
||||
var variants = new List<Variant>();
|
||||
var segments = new List<Segment>();
|
||||
Uri? init = null;
|
||||
bool isMaster = false;
|
||||
|
||||
Dictionary<string, string>? pendingVariant = null;
|
||||
KeyInfo? curKey = null;
|
||||
long seq = 0;
|
||||
int idx = 0;
|
||||
|
||||
foreach (var raw in text.Split('\n'))
|
||||
{
|
||||
var line = raw.Trim();
|
||||
if (string.IsNullOrEmpty(line)) continue;
|
||||
|
||||
if (line.StartsWith("#EXT-X-STREAM-INF:"))
|
||||
{
|
||||
pendingVariant = ParseAttrs(line.Substring("#EXT-X-STREAM-INF:".Length));
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("#EXT-X-MEDIA-SEQUENCE:"))
|
||||
{
|
||||
if (long.TryParse(line.Split(':', 2)[1].Trim(), out var s)) seq = s;
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("#EXT-X-KEY:"))
|
||||
{
|
||||
var a = ParseAttrs(line.Substring("#EXT-X-KEY:".Length));
|
||||
var method = a.GetValue("METHOD", "NONE").ToUpperInvariant();
|
||||
curKey = method == "AES-128"
|
||||
? new KeyInfo(
|
||||
a.TryGetValue("URI", out var u) && !string.IsNullOrEmpty(u) ? new Uri(baseUrl, u) : null,
|
||||
a.GetValue("IV"))
|
||||
: null;
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("#EXT-X-MAP:"))
|
||||
{
|
||||
var a = ParseAttrs(line.Substring("#EXT-X-MAP:".Length));
|
||||
if (a.TryGetValue("URI", out var u) && !string.IsNullOrEmpty(u))
|
||||
init = new Uri(baseUrl, u);
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("#")) continue;
|
||||
|
||||
// URL 라인
|
||||
var abs = new Uri(baseUrl, line);
|
||||
if (pendingVariant != null)
|
||||
{
|
||||
int.TryParse(pendingVariant.GetValue("BANDWIDTH", "0"), out var bw);
|
||||
variants.Add(new Variant(abs, bw,
|
||||
pendingVariant.GetValue("RESOLUTION"), pendingVariant.GetValue("CODECS")));
|
||||
pendingVariant = null;
|
||||
isMaster = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
segments.Add(new Segment(abs, idx, seq, curKey));
|
||||
idx++; seq++;
|
||||
}
|
||||
}
|
||||
|
||||
return new ParsedPlaylist
|
||||
{
|
||||
Type = isMaster ? PlaylistType.Master : PlaylistType.Media,
|
||||
Variants = variants,
|
||||
Segments = segments,
|
||||
InitSegment = init,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue