using VideoDownloader.Core.Config; namespace VideoDownloader.App.Detection; /// 요청(URL + content-type + 길이)과 설정으로 미디어 종류를 판정. public static class MediaDetector { /// HLS 또는 Direct, 감지 대상이 아니면 null public static MediaKind? Classify(string? uri, string? contentType, long len, AppConfig config) { var l = (uri ?? "").ToLowerInvariant(); var c = (contentType ?? "").ToLowerInvariant(); bool isHls = c.Contains("mpegurl") || l.Contains(".m3u8") || l.Contains(".m3u"); if (isHls) return Enabled(config, "hls") ? MediaKind.Hls : null; bool isDash = c.Contains("dash+xml") || l.Contains(".mpd"); if (isDash) return Enabled(config, "dash") ? MediaKind.Dash : null; long min = config.MinSizeMB * 1024L * 1024L; if (len >= 0 && len <= min) return null; // 너무 작음 var fmt = MatchFormat(l, c); if (fmt == null) return null; return Enabled(config, fmt) ? MediaKind.Direct : null; } private static bool Enabled(AppConfig config, string key) => config.EnabledFormats.Contains(key); /// 형식 키 반환: mp4/webm/mkv/m4v/mov/avi/audio (미매칭 null) public static string? MatchFormat(string l, string c) { if (l.EndsWith(".mp4") || c.Contains("mp4")) return "mp4"; if (l.EndsWith(".webm") || c.Contains("webm")) return "webm"; if (l.EndsWith(".mkv") || c.Contains("matroska")) return "mkv"; if (l.EndsWith(".m4v")) return "m4v"; if (l.EndsWith(".mov") || c.Contains("quicktime")) return "mov"; if (l.EndsWith(".avi") || c.Contains("x-msvideo")) return "avi"; bool audio = c.StartsWith("audio/") || l.EndsWith(".m4a") || l.EndsWith(".mp3") || l.EndsWith(".ogg") || l.EndsWith(".opus") || l.EndsWith(".flac") || l.EndsWith(".wav") || l.EndsWith(".aac"); if (audio) return "audio"; if (c.StartsWith("video/")) return "mp4"; // 기타 video 는 mp4 취급 return null; } /// 감지 후보(사람에게 보여줄 만한) 판정 - DASH/MSE/세그먼트 등 public static bool LooksLikeCandidate(string l, string c) => l.Contains(".m4s") || l.Contains("segment") || l.Contains("/init") || l.Contains(".ts?") || l.Contains(".mpd") || c.Contains("dash+xml") || c.Contains("mp2t") || c.Contains("octet-stream"); /// #15 본문 선두 magic byte 로 실제 포맷 판별 (위장 image/* 등 검증용). /// 이미지 포맷(GIF/PNG/JPEG/WebP)은 null 반환 — 위장 트래킹 픽셀이 미디어로 오탐되는 것 방지. /// (GIF 'G'=0x47 가 MPEG-TS sync byte 와 겹쳐 ts 로 오탐되던 문제 수정) public static string? ProbeFormat(ReadOnlySpan h) { if (h.Length < 4) return null; // 이미지 포맷 → 미디어 아님 if (h.Length >= 3 && h[0] == 'G' && h[1] == 'I' && h[2] == 'F') return null; // GIF if (h.Length >= 8 && h[0] == 0x89 && h[1] == 0x50 && h[2] == 0x4E && h[3] == 0x47) return null; // PNG if (h.Length >= 3 && h[0] == 0xFF && h[1] == 0xD8 && h[2] == 0xFF) return null; // JPEG if (h.Length >= 12 && h[0] == 0x52 && h[1] == 0x49 && h[2] == 0x46 && h[3] == 0x46) // RIFF { if (h[8] == 'W' && h[9] == 'E' && h[10] == 'B' && h[11] == 'P') return null; // WebP(이미지) if (h[8] == 'W' && h[9] == 'A' && h[10] == 'V' && h[11] == 'E') return "wav"; // WAV(오디오) return null; } if (h.Length >= 12 && h[4] == 'f' && h[5] == 't' && h[6] == 'y' && h[7] == 'p') return "mp4"; // ftyp box if (h[0] == 0x47) return "ts"; // MPEG-TS sync (GIF 는 위에서 걸러짐) if (h[0] == 0x1A && h[1] == 0x45 && h[2] == 0xDF && h[3] == 0xA3) return "webm"; // EBML if (h[0] == 'I' && h[1] == 'D' && h[2] == '3') return "mp3"; // ID3 if (h[0] == 0xFF && (h[1] & 0xE0) == 0xE0) return "mp3"; // MP3 frame if (h[0] == 0x66 && h[1] == 0x4C && h[2] == 0x61 && h[3] == 0x43) return "flac"; // fLaC if (h[0] == 0x4F && h[1] == 0x67 && h[2] == 0x67 && h[3] == 0x53) return "ogg"; // OggS return null; } }