using System.Diagnostics; using System.Security.Cryptography; using System.Text; namespace FileShare_Services.Services.FileLibrary { public sealed class VideoThumbnailService : IVideoThumbnailService { private readonly string _thumbnailDir; private readonly string _ffmpegPath; private readonly string _ffprobePath; public VideoThumbnailService(ThumbnailStorageOptions options) { _thumbnailDir = Path.IsPathRooted(options.RootPath) ? options.RootPath : Path.Combine(AppContext.BaseDirectory, options.RootPath); _ffmpegPath = ResolveExecutablePath(options.FfmpegPath); _ffprobePath = ResolveExecutablePath(options.FfprobePath); Directory.CreateDirectory(_thumbnailDir); } public Task GenerateThumbnailAsync(int libraryRootId, string videoPath, CancellationToken ct = default) { var hash = ComputeHash(videoPath); var relativePath = Path.Combine($"root-{libraryRootId}", $"{hash}.jpg"); var outputPath = GetAbsolutePath(relativePath); Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); if (File.Exists(outputPath)) return Task.FromResult(new GeneratedThumbnail(relativePath, "image/jpeg")); try { var args = $"-ss 00:00:01 -i \"{videoPath}\" -vframes 1 -q:v 5 -vf \"scale=320:-2\" \"{outputPath}\" -y"; using var process = new Process { StartInfo = new ProcessStartInfo { FileName = _ffmpegPath, Arguments = args, UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, }, EnableRaisingEvents = true, }; process.Start(); using (ct.Register(() => { try { process.Kill(); } catch { /* 进程可能已退出 */ } })) { process.WaitForExit(10000); } if (File.Exists(outputPath) && new FileInfo(outputPath).Length > 0) return Task.FromResult(new GeneratedThumbnail(relativePath, "image/jpeg")); return Task.FromResult(null); } catch (Exception ex) { Serilog.Log.Warning(ex, "生成视频缩略图失败 {VideoPath}", videoPath); return Task.FromResult(null); } } public string GetAbsolutePath(string relativePath) { var root = Path.GetFullPath(_thumbnailDir); var fullPath = Path.GetFullPath(Path.Combine(root, relativePath)); if (!fullPath.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && !string.Equals(fullPath, root, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException("Thumbnail path is outside of the configured storage root."); } return fullPath; } private static string ResolveExecutablePath(string executablePath) { if (Path.IsPathRooted(executablePath)) { return executablePath; } return executablePath.Contains(Path.DirectorySeparatorChar) || executablePath.Contains(Path.AltDirectorySeparatorChar) ? Path.Combine(AppContext.BaseDirectory, executablePath) : executablePath; } public double? GetVideoDuration(string videoPath) { try { var args = $"-v error -show_entries format=duration -of csv=p=0 \"{videoPath}\""; using var process = new Process { StartInfo = new ProcessStartInfo { FileName = _ffprobePath, Arguments = args, UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, }, }; process.Start(); var output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(5000); if (double.TryParse(output, out var duration) && duration > 0) return Math.Round(duration, 1); return null; } catch (Exception ex) { Serilog.Log.Warning(ex, "获取视频时长失败 {VideoPath}", videoPath); return null; } } public string ComputeHash(string videoPath) => Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(videoPath))).ToLowerInvariant(); } }