feat: 视频缩略图生成、最近文件面板与前端视图重构

- 新增 VideoThumbnailService,基于 ffmpeg 截取视频缩略图,ffprobe 提取时长
  - 新增 ManagedThumbnailMap 模型及多数据库迁移,存储缩略图元数据
  - 新增 /api/thumbnails/{id} 缩略图流端点
  - 新增最近添加/最近播放 API 与前端面板,支持列表/网格双视图切换
  - FileRecordDto 扩展 thumbnailUrl、videoDuration、lastPlayedAt 字段
  - 前端新增文件库 Tab 导航、卡片网格视图、视频海报与时长信息栏
  - 添加文件库目录不再同步全量扫描,改为后台异步自动扫描
This commit is contained in:
2026-05-22 17:01:49 +08:00
parent 6acc92ca27
commit 2c20f9bb54
47 changed files with 5975 additions and 64 deletions
@@ -69,6 +69,14 @@ namespace FileShare_Services.Endpoints
.WithOpenApi("FileLibrary", "浏览文件库目录结构。")
.WithName("BrowseDirectory");
endpoints.MapGet<IFileLibraryEndpointService, RecentFilesRequest>("api/files/recent", (service, request, _) => service.GetRecentFilesAsync(request))
.WithOpenApi("FileLibrary", "获取最近添加或最近播放的文件。")
.WithName("GetRecentFiles");
endpoints.MapPost<IFileLibraryEndpointService, MarkFilePlayedRequest>("api/files/played", (service, request, _) => service.MarkFilePlayedAsync(request))
.WithOpenApi("FileLibrary", "标记文件已播放。")
.WithName("MarkFilePlayed");
endpoints.MapGet<IFileLibraryEndpointService, FileQueryRequest>("api/files/detail", (service, request, _) => service.GetFileAsync(request))
.WithOpenApi("FileLibrary", "查询文件详情。")
.WithName("GetFileDetail");
@@ -46,6 +46,19 @@ namespace FileShare_Services.Services.FileLibrary
public sealed record FileQueryRequest(
[property: JsonPropertyName("id")] int Id);
/// <summary>
/// 获取最近文件的请求。
/// </summary>
public sealed record RecentFilesRequest(
[property: JsonPropertyName("type")] string Type = "added",
[property: JsonPropertyName("count")] int Count = 12);
/// <summary>
/// 标记文件已播放的请求。
/// </summary>
public sealed record MarkFilePlayedRequest(
[property: JsonPropertyName("id")] int Id);
/// <summary>
/// 分页搜索已扫描文件的请求。
/// </summary>
@@ -105,7 +118,10 @@ namespace FileShare_Services.Services.FileLibrary
string ContentType,
string StreamUrl,
string? TextUrl,
bool BrowserPlayable);
bool BrowserPlayable,
string? ThumbnailUrl = null,
double? VideoDuration = null,
DateTime? LastPlayedAt = null);
/// <summary>
/// 浏览文件库目录结构的请求。
@@ -29,7 +29,7 @@ namespace FileShare_Services.Services.FileLibrary
/// <inheritdoc />
public async Task<IApiResponse> AddRootAsync(AddLibraryRootRequest request)
{
return ResponseHelper.Ok(await fileLibrary.AddRootAsync(request), "文件库目录已添加并完成扫描。");
return ResponseHelper.Ok(await fileLibrary.AddRootAsync(request), "文件库目录已添加,后续扫描将自动入库。");
}
/// <inheritdoc />
@@ -87,6 +87,20 @@ namespace FileShare_Services.Services.FileLibrary
return ResponseHelper.Ok(result);
}
/// <inheritdoc />
public async Task<IApiResponse> GetRecentFilesAsync(RecentFilesRequest request)
{
var items = await fileLibrary.GetRecentFilesAsync(request.Type, request.Count);
return ResponseHelper.Ok(items);
}
/// <inheritdoc />
public async Task<IApiResponse> MarkFilePlayedAsync(MarkFilePlayedRequest request)
{
await fileLibrary.MarkFilePlayedAsync(request.Id);
return ResponseHelper.Succeed();
}
/// <summary>
/// 验证文件 ID 是否有效,无效时抛出 <see cref="ArgumentException"/>。
/// </summary>
@@ -10,7 +10,7 @@ namespace FileShare_Services.Services.FileLibrary
/// <summary>
/// 文件库核心业务服务,实现磁盘枚举、目录管理、文件扫描与检索。
/// </summary>
public sealed class FileLibraryService(AppDataContext db) : IFileLibraryService
public sealed class FileLibraryService(AppDataContext db, IVideoThumbnailService thumbnailService) : IFileLibraryService
{
/// <summary>
/// 默认扫描间隔(分钟),当请求未指定间隔时使用。
@@ -80,7 +80,9 @@ namespace FileShare_Services.Services.FileLibrary
existing.DisplayName = ResolveDisplayName(normalized, request.DisplayName);
existing.ScanIntervalMinutes = NormalizeInterval(request.ScanIntervalMinutes);
await db.SaveChangesAsync(cancellationToken);
return await ScanRootAsync(existing.Id, cancellationToken);
var existingCount = await db.ManagedFileRecords
.CountAsync(file => file.LibraryRootId == existing.Id && file.Exists, cancellationToken);
return ToRootDto(existing, existingCount);
}
var root = new ManagedLibraryRoot
@@ -95,7 +97,7 @@ namespace FileShare_Services.Services.FileLibrary
db.ManagedLibraryRoots.Add(root);
await db.SaveChangesAsync(cancellationToken);
return await ScanRootAsync(root.Id, cancellationToken);
return ToRootDto(root, 0);
}
/// <inheritdoc />
@@ -178,6 +180,24 @@ namespace FileShare_Services.Services.FileLibrary
record.ContentType = contentType;
record.Exists = true;
record.LastSeenAt = DateTime.UtcNow;
if (mediaType == "video" && record.ThumbnailId is null)
{
var thumbnail = await thumbnailService.GenerateThumbnailAsync(root.Id, absolutePath, cancellationToken);
if (thumbnail is not null)
{
var map = new ManagedThumbnailMap
{
LibraryRootId = root.Id,
RelativePath = thumbnail.RelativePath,
ContentType = thumbnail.ContentType,
};
db.ManagedThumbnailMaps.Add(map);
record.Thumbnail = map;
}
record.VideoDuration ??= thumbnailService.GetVideoDuration(absolutePath);
}
}
foreach (var stale in existing.Values.Where(file => !seen.Contains(file.AbsolutePath)))
@@ -356,6 +376,34 @@ namespace FileShare_Services.Services.FileLibrary
return new TextPreviewDto(file.Id, file.FileName, content, stream.Length > MaxTextPreviewBytes);
}
/// <inheritdoc />
public async Task<List<FileRecordDto>> GetRecentFilesAsync(string type, int count = 12, CancellationToken cancellationToken = default)
{
var query = db.ManagedFileRecords
.AsNoTracking()
.Where(file => file.Exists && file.LibraryRoot != null && file.LibraryRoot.IsAvailable);
query = type == "played"
? query.Where(file => file.LastPlayedAt != null).OrderByDescending(file => file.LastPlayedAt)
: query.OrderByDescending(file => file.CreatedAt);
return await query
.Take(Math.Clamp(count, 1, 48))
.Select(file => ToFileDto(file))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task MarkFilePlayedAsync(int id, CancellationToken cancellationToken = default)
{
var record = await db.ManagedFileRecords.FindAsync([id], cancellationToken);
if (record is not null)
{
record.LastPlayedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}
}
/// <summary>
/// 深度优先遍历目录树,枚举所有被 <see cref="MediaFileTypes"/> 支持的媒体文件路径。
/// 遇到无权限的目录时跳过该分支继续遍历。
@@ -508,7 +556,10 @@ namespace FileShare_Services.Services.FileLibrary
file.ContentType,
$"/api/files/{file.Id}/stream",
file.MediaType == "text" ? $"/api/files/text?id={file.Id}" : null,
MediaFileTypes.IsBrowserPlayable(file.Extension));
MediaFileTypes.IsBrowserPlayable(file.Extension),
file.ThumbnailId is null ? null : $"/api/thumbnails/{file.ThumbnailId}",
file.VideoDuration,
file.LastPlayedAt);
}
}
@@ -84,5 +84,9 @@ namespace FileShare_Services.Services.FileLibrary
/// <param name="request">包含根目录 ID 和路径的请求。</param>
/// <returns>API 响应。</returns>
Task<IApiResponse> BrowseDirectoryAsync(BrowseDirectoryRequest request);
Task<IApiResponse> GetRecentFilesAsync(RecentFilesRequest request);
Task<IApiResponse> MarkFilePlayedAsync(MarkFilePlayedRequest request);
}
}
@@ -97,5 +97,9 @@ namespace FileShare_Services.Services.FileLibrary
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>目录浏览响应,包含子目录和文件列表。</returns>
Task<BrowseDirectoryResponse> BrowseDirectoryAsync(BrowseDirectoryRequest request, CancellationToken cancellationToken = default);
Task<List<FileRecordDto>> GetRecentFilesAsync(string type, int count = 12, CancellationToken cancellationToken = default);
Task MarkFilePlayedAsync(int id, CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,11 @@
namespace FileShare_Services.Services.FileLibrary
{
public interface IVideoThumbnailService
{
Task<GeneratedThumbnail?> GenerateThumbnailAsync(int libraryRootId, string videoPath, CancellationToken ct = default);
string GetAbsolutePath(string relativePath);
double? GetVideoDuration(string videoPath);
}
public sealed record GeneratedThumbnail(string RelativePath, string ContentType);
}
@@ -0,0 +1,9 @@
namespace FileShare_Services.Services.FileLibrary
{
public sealed class ThumbnailStorageOptions
{
public string RootPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "thumbnails");
public string FfmpegPath { get; set; } = Path.Combine("tools", "ffmpeg", "bin", "ffmpeg.exe");
public string FfprobePath { get; set; } = Path.Combine("tools", "ffmpeg", "bin", "ffprobe.exe");
}
}
@@ -0,0 +1,38 @@
using FileShare_EFCore.Database;
using FileShare_Services.Core;
using Microsoft.EntityFrameworkCore;
namespace FileShare_Services.Services.FileLibrary
{
public interface IThumbnailStreamService
{
Task<FileStreamResponse?> GetThumbnailAsync(int id, CancellationToken cancellationToken = default);
}
public sealed class ThumbnailStreamService(AppDataContext db, IVideoThumbnailService thumbnails) : IThumbnailStreamService
{
public async Task<FileStreamResponse?> GetThumbnailAsync(int id, CancellationToken cancellationToken = default)
{
var thumbnail = await db.ManagedThumbnailMaps
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
if (thumbnail is null)
{
return null;
}
var absolutePath = thumbnails.GetAbsolutePath(thumbnail.RelativePath);
if (!File.Exists(absolutePath))
{
return null;
}
return new FileStreamResponse(
absolutePath,
Path.GetFileName(absolutePath),
thumbnail.ContentType,
File.GetLastWriteTimeUtc(absolutePath));
}
}
}
@@ -0,0 +1,136 @@
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();
}
}