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
+65 -1
View File
@@ -554,7 +554,8 @@ namespace FileShare_PC.Views
{
try
{
if (await TryHandleLocalMediaStreamAsync(context))
if (await TryHandleLocalMediaStreamAsync(context)
|| await TryHandleLocalThumbnailAsync(context))
{
return;
}
@@ -691,6 +692,69 @@ namespace FileShare_PC.Views
return true;
}
private async Task<bool> TryHandleLocalThumbnailAsync(HttpListenerContext context)
{
var request = context.Request;
var response = context.Response;
var segments = (request.Url?.AbsolutePath ?? string.Empty)
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length != 3
|| !string.Equals(segments[0], "api", StringComparison.OrdinalIgnoreCase)
|| !string.Equals(segments[1], "thumbnails", StringComparison.OrdinalIgnoreCase))
{
return false;
}
AddLocalMediaHeaders(response);
if (!string.Equals(request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(request.HttpMethod, "HEAD", StringComparison.OrdinalIgnoreCase))
{
response.StatusCode = (int)HttpStatusCode.MethodNotAllowed;
response.Close();
return true;
}
if (!int.TryParse(segments[2], out var id) || id <= 0)
{
response.StatusCode = (int)HttpStatusCode.NotFound;
response.Close();
return true;
}
using var scope = _services.CreateScope();
var thumbnailService = scope.ServiceProvider.GetService<IThumbnailStreamService>();
var thumbnail = thumbnailService is null ? null : await thumbnailService.GetThumbnailAsync(id);
if (thumbnail is null || !File.Exists(thumbnail.FilePath))
{
response.StatusCode = (int)HttpStatusCode.NotFound;
response.Close();
return true;
}
var fileInfo = new FileInfo(thumbnail.FilePath);
response.StatusCode = (int)HttpStatusCode.OK;
response.ContentType = thumbnail.ContentType;
response.ContentLength64 = fileInfo.Length;
response.Headers["Cache-Control"] = "public, max-age=3600";
response.Headers["Content-Disposition"] =
$"inline; filename=\"{Uri.EscapeDataString(thumbnail.FileName)}\"";
response.Headers["Last-Modified"] = thumbnail.LastModified.ToUniversalTime().ToString("R");
if (string.Equals(request.HttpMethod, "HEAD", StringComparison.OrdinalIgnoreCase)
|| fileInfo.Length == 0)
{
response.Close();
return true;
}
await using var input = File.Open(thumbnail.FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
await input.CopyToAsync(response.OutputStream);
response.OutputStream.Close();
return true;
}
/// <summary>
/// 为媒体流响应添加 CORS 和 Range 请求头,允许浏览器跨域访问和分段请求。
/// </summary>