using System.Text.RegularExpressions; using VideoDownloader.Core.Config; using VideoDownloader.Core.Platform; namespace VideoDownloader.Server; /// /// M2 라이브러리 메타데이터 — ffmpeg 기반 썸네일(.thumb.jpg)·해상도(.meta.json) 사이드카 캐시. /// IProcessRunner 주입(테스트는 Fake, 호스트는 DesktopProcessRunner). /// public sealed partial class Thumbnailer { private readonly IProcessRunner _runner; public Thumbnailer(IProcessRunner runner) => _runner = runner; /// 1초 지점 프레임 썸네일(320px 폭 JPEG). 실패 시 null. 캐시 히트 시 프로세스 없음. public async Task GetThumbnailAsync(string ffmpeg, string mediaPath, CancellationToken ct = default) { var cache = mediaPath + ".thumb.jpg"; try { if (File.Exists(cache)) return await File.ReadAllBytesAsync(cache, ct); var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, new[] { "-y", "-hide_banner", "-loglevel", "error", "-ss", "1", "-i", mediaPath, "-frames:v", "1", "-vf", "scale=320:-2", cache, }), null, ct); if (res.ExitCode != 0 || !File.Exists(cache)) return null; return await File.ReadAllBytesAsync(cache, ct); } catch { return null; } } /// 해상도("1920x1080"). ffmpeg -i stderr 의 Stream 라인에서 추출, .meta.json 캐시. public async Task GetResolutionAsync(string ffmpeg, string mediaPath, CancellationToken ct = default) { var cache = mediaPath + ".meta.json"; try { if (File.Exists(cache)) { var cached = await File.ReadAllTextAsync(cache, ct); return cached.Length >= 3 ? cached.Trim('"') : null; } var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, new[] { "-hide_banner", "-i", mediaPath }), null, ct); var m = ResolutionRegex().Match(res.Stderr); var value = m.Success ? m.Groups[1].Value : null; await File.WriteAllTextAsync(cache, value == null ? "-" : $"\"{value}\"", ct); return value; } catch { return null; } } [GeneratedRegex(@"Video:.*?,\s(\d{2,5}x\d{2,5})")] private static partial Regex ResolutionRegex(); }