using System.Text.RegularExpressions; using Paca.Core.Models; namespace Paca.Core.Hls; /// m3u8(마스터/미디어) 재생목록 파서. Python parse_m3u8 의 C# 포팅. public static class M3u8Parser { // KEY="VAL",KEY2=VAL 형태 속성 파싱 (소문자/언더스코어 포함) private static readonly Regex AttrRe = new(@"(?[A-Za-z0-9_\-]+)=(\""(?[^""]*)\"")|(?[A-Za-z0-9_\-]+)=(?[^,]*)", RegexOptions.Compiled); private static Dictionary ParseAttrs(string s) { var d = new Dictionary(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 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(); var segments = new List(); Uri? init = null; long? initByteRangeLen = null; long? initByteRangeOff = null; bool isMaster = false; if (baseUrl == null || string.IsNullOrWhiteSpace(text) || !text.Contains("#EXTM3U", StringComparison.OrdinalIgnoreCase)) { return new ParsedPlaylist { Type = PlaylistType.Media, Variants = variants, Segments = segments, InitSegment = init, InitByteRangeLength = initByteRangeLen, InitByteRangeOffset = initByteRangeOff, }; } Dictionary? pendingVariant = null; KeyInfo? curKey = null; long seq = 0; int idx = 0; long? pendingByteRangeLength = null; long? pendingByteRangeOffset = null; long prevByteRangeOffset = 0; long prevByteRangeLength = 0; using var reader = new StringReader(text); string? raw; while ((raw = reader.ReadLine()) != null) { var line = raw.Trim(); if (string.IsNullOrEmpty(line)) continue; if (line.StartsWith("#EXT-X-STREAM-INF:", StringComparison.OrdinalIgnoreCase)) { pendingVariant = ParseAttrs(line.Substring("#EXT-X-STREAM-INF:".Length)); continue; } if (line.StartsWith("#EXT-X-MEDIA-SEQUENCE:", StringComparison.OrdinalIgnoreCase)) { var parts = line.Split(':', 2); if (parts.Length > 1 && long.TryParse(parts[1].Trim(), out var s) && s >= 0) seq = s; continue; } if (line.StartsWith("#EXT-X-KEY:", StringComparison.OrdinalIgnoreCase)) { 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) && Uri.TryCreate(baseUrl, u.Trim('"'), out var ku) ? ku : null, a.GetValue("IV")) : null; continue; } if (line.StartsWith("#EXT-X-BYTERANGE:", StringComparison.OrdinalIgnoreCase)) { var val = line.Substring("#EXT-X-BYTERANGE:".Length).Trim(); var parts = val.Split('@', 2); if (long.TryParse(parts[0].Trim(), out var len) && len > 0) { pendingByteRangeLength = len; if (parts.Length > 1 && long.TryParse(parts[1].Trim(), out var off) && off >= 0) { pendingByteRangeOffset = off; prevByteRangeOffset = off; } else { pendingByteRangeOffset = prevByteRangeOffset + prevByteRangeLength; prevByteRangeOffset = pendingByteRangeOffset.Value; } prevByteRangeLength = len; } continue; } if (line.StartsWith("#EXT-X-MAP:", StringComparison.OrdinalIgnoreCase)) { var a = ParseAttrs(line.Substring("#EXT-X-MAP:".Length)); if (a.TryGetValue("URI", out var u) && !string.IsNullOrEmpty(u) && Uri.TryCreate(baseUrl, u.Trim('"'), out var mu)) init = mu; if (a.TryGetValue("BYTERANGE", out var br) && !string.IsNullOrEmpty(br)) { var cleanBr = br.Trim('"'); var brParts = cleanBr.Split('@', 2); if (long.TryParse(brParts[0].Trim(), out var bLen) && bLen > 0) { initByteRangeLen = bLen; if (brParts.Length > 1 && long.TryParse(brParts[1].Trim(), out var bOff) && bOff >= 0) { initByteRangeOff = bOff; } } } continue; } if (line.StartsWith("#")) continue; // URL 라인 if (!Uri.TryCreate(baseUrl, line, out var abs)) continue; if (pendingVariant != null) { int.TryParse(pendingVariant.GetValue("BANDWIDTH", "0"), out var bw); variants.Add(new Variant(abs, Math.Max(0, bw), pendingVariant.GetValue("RESOLUTION"), pendingVariant.GetValue("CODECS"))); pendingVariant = null; isMaster = true; } else { segments.Add(new Segment(abs, idx, seq, curKey, pendingByteRangeLength, pendingByteRangeOffset)); pendingByteRangeLength = null; pendingByteRangeOffset = null; idx++; if (seq < long.MaxValue) seq++; } } return new ParsedPlaylist { Type = isMaster ? PlaylistType.Master : PlaylistType.Media, Variants = variants, Segments = segments, InitSegment = init, InitByteRangeLength = initByteRangeLen, InitByteRangeOffset = initByteRangeOff, }; } }