feat: 视频缩略图生成、最近文件面板与前端视图重构
- 新增 VideoThumbnailService,基于 ffmpeg 截取视频缩略图,ffprobe 提取时长
- 新增 ManagedThumbnailMap 模型及多数据库迁移,存储缩略图元数据
- 新增 /api/thumbnails/{id} 缩略图流端点
- 新增最近添加/最近播放 API 与前端面板,支持列表/网格双视图切换
- FileRecordDto 扩展 thumbnailUrl、videoDuration、lastPlayedAt 字段
- 前端新增文件库 Tab 导航、卡片网格视图、视频海报与时长信息栏
- 添加文件库目录不再同步全量扫描,改为后台异步自动扫描
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user