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
+2
View File
@@ -13,6 +13,8 @@
<Content Include="www\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\tools\ffmpeg\bin\ffmpeg.exe" Link="tools\ffmpeg\bin\ffmpeg.exe" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\tools\ffmpeg\bin\ffprobe.exe" Link="tools\ffmpeg\bin\ffprobe.exe" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
+5
View File
@@ -12,6 +12,7 @@ using FileShare_Services.Services.FileLibrary;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using System;
using System.IO;
namespace FileShare_PC
{
@@ -71,9 +72,13 @@ namespace FileShare_PC
services.AddSingleton<PcGlobalTokenService>();
services.AddSingleton<IAuthService, PcAuthService>();
services.AddSingleton<IPcAuthEndpointService, PcAuthEndpointService>();
services.AddSingleton(new ThumbnailStorageOptions());
services.AddSingleton<IVideoThumbnailService>(sp =>
new VideoThumbnailService(sp.GetRequiredService<ThumbnailStorageOptions>()));
services.AddScoped<IFileLibraryService, FileLibraryService>();
services.AddScoped<IFileLibraryEndpointService, FileLibraryEndpointService>();
services.AddScoped<IFileStreamService, FileStreamService>();
services.AddScoped<IThumbnailStreamService, ThumbnailStreamService>();
// ---- 端点注册 ----
var endpointBuilder = new ServiceEndpointBuilder();
+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>