refactor(1): move Detection (MediaDetector/SocialSiteDetector/DetectionContext) App->Core

This commit is contained in:
Yun Chan 2026-08-15 14:49:02 +09:00
parent b6b7fc341b
commit 1aab8729fa
6 changed files with 6 additions and 6 deletions

View file

@ -0,0 +1,6 @@
namespace VideoDownloader.Core.Detection;
public enum MediaKind { Hls, Dash, Direct, Social }
/// <summary>감지된 미디어. HLS(m3u8) / DASH(.mpd) / 직링크(Direct mp4/webm/...) / Social(yt-dlp).</summary>
public record DetectionContext(string Url, string PageUrl, MediaKind Kind, string? ContentType = null, string? Title = null, string? Tag = null);

View file

@ -0,0 +1,81 @@
using VideoDownloader.Core.Config;
namespace VideoDownloader.Core.Detection;
/// <summary>요청(URL + content-type + 길이)과 설정으로 미디어 종류를 판정.</summary>
public static class MediaDetector
{
/// <returns>HLS 또는 Direct, 감지 대상이 아니면 null</returns>
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);
/// <summary>형식 키 반환: mp4/webm/mkv/m4v/mov/avi/audio (미매칭 null)</summary>
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;
}
/// <summary>감지 후보(사람에게 보여줄 만한) 판정 - DASH/MSE/세그먼트 등</summary>
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");
/// <summary>#15 본문 선두 magic byte 로 실제 포맷 판별 (위장 image/* 등 검증용).
/// 이미지 포맷(GIF/PNG/JPEG/WebP)은 null 반환 — 위장 트래킹 픽셀이 미디어로 오탐되는 것 방지.
/// (GIF 'G'=0x47 가 MPEG-TS sync byte 와 겹쳐 ts 로 오탐되던 문제 수정)</summary>
public static string? ProbeFormat(ReadOnlySpan<byte> 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;
}
}

View file

@ -0,0 +1,136 @@
using System.Text.RegularExpressions;
namespace VideoDownloader.Core.Detection;
/// <summary>
/// 소셜 영상 페이지(YouTube/Shorts/Instagram/X/TikTok/Facebook) 판별 + URL 정규화.
/// yt-dlp 로 처리 가능한 단일 영상 URL 인지 검사하고, 같은 영상의 여러 URL 형태를
/// 하나의 정규화된 키로 통일(중복 등록 방지). 홈/검색/채널 페이지는 거짓.
/// </summary>
public static class SocialSiteDetector
{
private static readonly Regex YtId = new(@"^[A-Za-z0-9_-]{11}$", RegexOptions.Compiled);
/// <summary>소셜 영상 URL 이면 정규화된 URL 과 사이트 라벨을 반환.</summary>
public static bool TryMatch(Uri url, out string normalized, out string label)
{
normalized = "";
label = "";
var host = (url.Host ?? "").ToLowerInvariant();
var path = url.AbsolutePath ?? "";
// ===== YouTube / Shorts =====
if (host.EndsWith("youtube.com") || host == "m.youtube.com" || host == "youtu.be")
{
string? id = ExtractYouTubeId(host, url, path);
if (id != null && YtId.IsMatch(id))
{
normalized = $"https://www.youtube.com/watch?v={id}";
label = "YOUTUBE";
return true;
}
return false;
}
// ===== Instagram =====
if (host.EndsWith("instagram.com"))
{
// /reel/<id>/ , /reels/<id>/ , /p/<id>/ , /tv/<id>/ , /stories/<u>/<id>/
var m = Regex.Match(path, "/(?:reels?|p|tv)/([^/?#]+)");
if (!m.Success) m = Regex.Match(path, "/stories/([^/]+)/([^/?#]+)");
if (m.Success)
{
normalized = Normalize(host, m.Value); // 매칭된 경로만 보존
label = "INSTAGRAM";
return true;
}
return false;
}
// ===== X / Twitter =====
if (host == "x.com" || host == "twitter.com" || host.EndsWith(".x.com") || host.EndsWith(".twitter.com"))
{
var m = Regex.Match(path, "/[^/]+/status/([0-9]+)");
if (m.Success)
{
normalized = Normalize(host, m.Value);
label = "X";
return true;
}
return false;
}
// ===== TikTok =====
if (host.EndsWith("tiktok.com"))
{
var m = Regex.Match(path, "/@[^/]+/video/([0-9]+)");
if (m.Success)
{
normalized = Normalize(host, m.Value);
label = "TIKTOK";
return true;
}
return false;
}
// ===== Facebook =====
if (host.EndsWith("facebook.com") || host == "fb.watch")
{
var m = Regex.Match(path, "/(?:reel|videos)/([^/?#]+)");
string? watchId = GetQuery(url, "v");
if (m.Success)
{
normalized = Normalize(host == "fb.watch" ? "www.facebook.com" : host, m.Value);
label = "FACEBOOK";
return true;
}
if (!string.IsNullOrEmpty(watchId))
{
normalized = $"https://www.facebook.com/watch/?v={watchId}";
label = "FACEBOOK";
return true;
}
return false;
}
return false;
}
private static string? ExtractYouTubeId(string host, Uri url, string path)
{
if (host == "youtu.be")
{
var seg = path.Trim('/');
return string.IsNullOrEmpty(seg) ? null : seg;
}
var v = GetQuery(url, "v");
if (!string.IsNullOrEmpty(v)) return v;
var m = Regex.Match(path, "/(?:shorts|embed|live)/([A-Za-z0-9_-]{11})");
if (m.Success) return m.Groups[1].Value;
return null;
}
/// <summary>쿼리스트링에서 단일 키 값 추출(? 제외 후 & 와 = 로 분해).</summary>
private static string? GetQuery(Uri url, string key)
{
var query = url.Query ?? "";
if (query.StartsWith("?")) query = query[1..];
if (string.IsNullOrEmpty(query)) return null;
foreach (var pair in query.Split('&'))
{
var eq = pair.IndexOf('=');
if (eq <= 0) continue;
var k = pair[..eq];
if (k.Equals(key, StringComparison.Ordinal))
return Uri.UnescapeDataString(pair[(eq + 1)..]);
}
return null;
}
/// <summary>scheme://host + 매칭된 경로(쿼리/트래킹 제거). host 에 www 보정.</summary>
private static string Normalize(string host, string path)
{
var h = host.StartsWith("www.") ? host : "www." + host;
return $"https://{h}{path}";
}
}