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