luoqian 2c20f9bb54 feat: 视频缩略图生成、最近文件面板与前端视图重构
- 新增 VideoThumbnailService,基于 ffmpeg 截取视频缩略图,ffprobe 提取时长
  - 新增 ManagedThumbnailMap 模型及多数据库迁移,存储缩略图元数据
  - 新增 /api/thumbnails/{id} 缩略图流端点
  - 新增最近添加/最近播放 API 与前端面板,支持列表/网格双视图切换
  - FileRecordDto 扩展 thumbnailUrl、videoDuration、lastPlayedAt 字段
  - 前端新增文件库 Tab 导航、卡片网格视图、视频海报与时长信息栏
  - 添加文件库目录不再同步全量扫描,改为后台异步自动扫描
2026-05-22 17:01:49 +08:00

137 lines
5.1 KiB
C#

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<GeneratedThumbnail?> 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<GeneratedThumbnail?>(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<GeneratedThumbnail?>(new GeneratedThumbnail(relativePath, "image/jpeg"));
return Task.FromResult<GeneratedThumbnail?>(null);
}
catch (Exception ex)
{
Serilog.Log.Warning(ex, "生成视频缩略图失败 {VideoPath}", videoPath);
return Task.FromResult<GeneratedThumbnail?>(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();
}
}