luoqian 8c92d2fbac perf: 优化文件浏览性能、扫描流程和前端 ref 冲突
- BrowseDirectoryAsync: 将分组/过滤/排序/分页从内存推到数据库,
    避免每次翻页加载全量文件实体(20万文件场景从 ~200MB 降到 KB 级)
  - ScanRootAsync: 使用 ExecuteUpdateAsync 原子抢占扫描锁防止并发扫描;
    缩略图/视频时长从扫描循环内同步生成改为扫描完成后 SemaphoreSlim(4) 批量生成,
    扫描耗时从数小时降到数十秒
  - ScanDueRootsAsync: 增加 LastScanStartedAt 判断,跳过正在执行中的扫描
  - ClientPage: 6 个重复的 ref="mediaPlayer" 改为 :ref="setMediaPlayer" 函数引用,
    修复 Vue 3 v-for 内字符串 ref 互相覆盖导致的播放器事件指向错误实例问题
2026-06-12 15:45:06 +08:00

874 lines
38 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using FileShare_Common.Core;
using FileShare_EFCore.Database;
using FileShare_EFCore.Models;
using FileShare_Services.Core;
using Microsoft.EntityFrameworkCore;
using System.Text;
namespace FileShare_Services.Services.FileLibrary
{
/// <summary>
/// 文件库核心业务服务,实现磁盘枚举、目录管理、文件扫描与检索。
/// </summary>
public sealed class FileLibraryService(AppDataContext db, IVideoThumbnailService thumbnailService) : IFileLibraryService
{
/// <summary>
/// 默认扫描间隔(分钟),当请求未指定间隔时使用。
/// </summary>
private const int DefaultScanIntervalMinutes = 5;
/// <summary>
/// 文本预览最大读取字节数1 MB
/// </summary>
private const int MaxTextPreviewBytes = 1024 * 1024;
/// <inheritdoc />
public Task<List<DriveDto>> GetDrivesAsync(CancellationToken cancellationToken = default)
{
var drives = DriveInfo.GetDrives()
.Select(drive => new DriveDto(
drive.Name,
drive.IsReady ? $"{drive.Name} ({drive.VolumeLabel})" : drive.Name,
drive.RootDirectory.FullName,
drive.DriveType.ToString(),
SafeDriveValue(drive, d => d.TotalSize),
SafeDriveValue(drive, d => d.AvailableFreeSpace),
drive.IsReady))
.OrderBy(drive => drive.Name)
.ToList();
return Task.FromResult(drives);
}
/// <inheritdoc />
public Task<List<DirectoryDto>> GetDirectoriesAsync(string? path, CancellationToken cancellationToken = default)
{
var normalized = NormalizeExistingDirectory(path);
var directories = Directory.EnumerateDirectories(normalized)
.Select(directory => new DirectoryInfo(directory))
.OrderBy(directory => directory.Name)
.Select(directory => new DirectoryDto(directory.Name, directory.FullName))
.ToList();
return Task.FromResult(directories);
}
/// <inheritdoc />
public async Task<List<LibraryRootDto>> GetRootsAsync(CancellationToken cancellationToken = default)
{
var counts = await db.ManagedFileRecords
.Where(file => file.Exists)
.GroupBy(file => file.LibraryRootId)
.Select(group => new { RootId = group.Key, Count = group.Count() })
.ToDictionaryAsync(item => item.RootId, item => item.Count, cancellationToken);
var roots = await db.ManagedLibraryRoots
.OrderBy(root => root.Path)
.ToListAsync(cancellationToken);
return roots.Select(root => ToRootDto(root, counts.GetValueOrDefault(root.Id))).ToList();
}
/// <inheritdoc />
public async Task<LibraryRootDto> AddRootAsync(AddLibraryRootRequest request, CancellationToken cancellationToken = default)
{
var normalized = NormalizeExistingDirectory(request.Path);
var existing = await db.ManagedLibraryRoots.FirstOrDefaultAsync(root => root.Path == normalized, cancellationToken);
if (existing is not null)
{
existing.IsEnabled = true;
existing.IsAvailable = true;
existing.DisplayName = ResolveDisplayName(normalized, request.DisplayName);
existing.ScanIntervalMinutes = NormalizeInterval(request.ScanIntervalMinutes);
await db.SaveChangesAsync(cancellationToken);
var existingCount = await db.ManagedFileRecords
.CountAsync(file => file.LibraryRootId == existing.Id && file.Exists, cancellationToken);
return ToRootDto(existing, existingCount);
}
var root = new ManagedLibraryRoot
{
Path = normalized,
DisplayName = ResolveDisplayName(normalized, request.DisplayName),
ScanIntervalMinutes = NormalizeInterval(request.ScanIntervalMinutes),
IsEnabled = true,
IsAvailable = true,
};
db.ManagedLibraryRoots.Add(root);
await db.SaveChangesAsync(cancellationToken);
return ToRootDto(root, 0);
}
/// <inheritdoc />
public async Task<LibraryRootDto> SetRootEnabledAsync(UpdateLibraryRootRequest request, CancellationToken cancellationToken = default)
{
var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == request.Id, cancellationToken)
?? throw new InvalidOperationException("文件库目录不存在。");
root.IsEnabled = request.IsEnabled;
await db.SaveChangesAsync(cancellationToken);
var count = await db.ManagedFileRecords.CountAsync(file => file.LibraryRootId == root.Id && file.Exists, cancellationToken);
return ToRootDto(root, count);
}
/// <inheritdoc />
public async Task DeleteRootAsync(DeleteLibraryRootRequest request, CancellationToken cancellationToken = default)
{
var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == request.Id, cancellationToken)
?? throw new InvalidOperationException("文件库目录不存在。");
db.ManagedLibraryRoots.Remove(root);
await db.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<LibraryRootDto> ScanRootAsync(int rootId, CancellationToken cancellationToken = default)
{
// ── 1. 原子抢占扫描锁:仅在没有正在执行的扫描时才允许开始新的扫描 ──
var now = DateTime.UtcNow;
var claimed = await db.ManagedLibraryRoots
.Where(r => r.Id == rootId
&& (r.LastScanStartedAt == null || r.LastScanCompletedAt >= r.LastScanStartedAt))
.ExecuteUpdateAsync(
setters => setters
.SetProperty(r => r.LastScanStartedAt, now)
.SetProperty(r => r.LastScanError, (string?)null),
cancellationToken);
if (claimed == 0)
{
// 扫描已在执行中:返回当前状态,不报错
var currentRoot = await db.ManagedLibraryRoots
.FirstOrDefaultAsync(r => r.Id == rootId, cancellationToken);
if (currentRoot is null)
throw new InvalidOperationException("文件库目录不存在。");
var currentCount = await db.ManagedFileRecords
.CountAsync(f => f.LibraryRootId == rootId && f.Exists, cancellationToken);
Serilog.Log.Information("扫描已在执行中,跳过重复扫描 RootId={RootId}", rootId);
return ToRootDto(currentRoot, currentCount);
}
// ── 2. 加载根目录实体ExecuteUpdateAsync 已直接写入 DB需重新加载以获取最新值 ──
var root = await db.ManagedLibraryRoots
.FirstOrDefaultAsync(r => r.Id == rootId, cancellationToken)
?? throw new InvalidOperationException("文件库目录不存在。");
Serilog.Log.Information("开始扫描文件库根目录 RootId={RootId} Path={Path}", root.Id, root.Path);
var staleFileCount = 0;
var staleThumbnailPaths = new List<string>();
var pendingVideos = new List<ManagedFileRecord>();
try
{
if (!Directory.Exists(root.Path))
{
throw new DirectoryNotFoundException($"目录不存在:{root.Path}");
}
root.IsAvailable = true;
root.IsEnabled = true;
var existing = await db.ManagedFileRecords
.Where(file => file.LibraryRootId == root.Id)
.ToDictionaryAsync(file => file.AbsolutePath, StringComparer.OrdinalIgnoreCase, cancellationToken);
// ── 3. 快速文件枚举(只更新元数据,不生成缩略图) ──
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in EnumerateSupportedFiles(root.Path))
{
cancellationToken.ThrowIfCancellationRequested();
var info = new FileInfo(path);
if (!info.Exists || !MediaFileTypes.TryGet(info.Extension.ToLowerInvariant(), out var mediaType, out var contentType, out _))
{
continue;
}
var absolutePath = info.FullName;
seen.Add(absolutePath);
if (!existing.TryGetValue(absolutePath, out var record))
{
record = new ManagedFileRecord
{
LibraryRootId = root.Id,
AbsolutePath = absolutePath,
};
db.ManagedFileRecords.Add(record);
}
record.FileName = info.Name;
record.RelativePath = Path.GetRelativePath(root.Path, absolutePath);
record.Extension = info.Extension.ToLowerInvariant();
record.SizeBytes = info.Length;
record.LastWriteTimeUtc = info.LastWriteTimeUtc;
record.FileCreationTimeUtc = info.CreationTimeUtc;
record.MediaType = mediaType;
record.ContentType = contentType;
record.Exists = true;
record.LastSeenAt = DateTime.UtcNow;
// 收集缺少缩略图的视频,稍后批量生成(不在此处阻塞扫描)
if (mediaType == "video" && record.ThumbnailId is null)
{
pendingVideos.Add(record);
}
}
// ── 4. 清理过期文件记录 ──
var staleFiles = existing.Values
.Where(file => !seen.Contains(file.AbsolutePath))
.ToList();
staleFileCount = staleFiles.Count;
foreach (var stale in staleFiles)
{
stale.Exists = false;
}
// ── 5. 清理过期缩略图 ──
var activeThumbnailIds = existing.Values
.Where(file => file.Exists && file.ThumbnailId is not null)
.Select(file => file.ThumbnailId!.Value)
.ToHashSet();
var staleThumbnailIds = staleFiles
.Where(file => file.ThumbnailId is not null && !activeThumbnailIds.Contains(file.ThumbnailId.Value))
.Select(file => file.ThumbnailId!.Value)
.Distinct()
.ToList();
if (staleThumbnailIds.Count > 0)
{
var staleThumbnails = await db.ManagedThumbnailMaps
.Where(thumbnail => thumbnail.LibraryRootId == root.Id && staleThumbnailIds.Contains(thumbnail.Id))
.ToListAsync(cancellationToken);
staleThumbnailPaths = staleThumbnails
.Select(thumbnail => thumbnail.RelativePath)
.ToList();
foreach (var stale in staleFiles.Where(file => file.ThumbnailId is not null && staleThumbnailIds.Contains(file.ThumbnailId.Value)))
{
stale.ThumbnailId = null;
stale.Thumbnail = null;
}
db.ManagedThumbnailMaps.RemoveRange(staleThumbnails);
}
// ── 6. 保存文件元数据(快速阶段结束) ──
root.LastScanCompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
DeleteThumbnailFiles(staleThumbnailPaths);
// ── 7. 批量生成缩略图和视频时长(与扫描解耦,有限并发) ──
if (pendingVideos.Count > 0)
{
await GenerateThumbnailsBatchAsync(root.Id, pendingVideos, cancellationToken);
}
}
catch (Exception ex)
{
root.IsAvailable = false;
root.IsEnabled = false;
root.LastScanError = ex.Message;
root.LastScanCompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
throw;
}
var count = await db.ManagedFileRecords
.CountAsync(file => file.LibraryRootId == root.Id && file.Exists, cancellationToken);
Serilog.Log.Information(
"扫描文件库根目录完成 RootId={RootId} FileCount={FileCount} StaleFiles={StaleFiles} RemovedThumbnails={RemovedThumbnails} PendingThumbnails={PendingThumbnails}",
root.Id,
count,
staleFileCount,
staleThumbnailPaths.Count,
pendingVideos.Count);
return ToRootDto(root, count);
}
/// <inheritdoc />
public async Task ScanDueRootsAsync(CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
var roots = await db.ManagedLibraryRoots
.Where(root => root.IsEnabled && root.IsAvailable)
.ToListAsync(cancellationToken);
var dueRoots = roots
.Where(root =>
{
// 跳过正在扫描中的目录StartedAt 存在且晚于 CompletedAt
if (root.LastScanStartedAt is not null
&& (root.LastScanCompletedAt is null || root.LastScanStartedAt > root.LastScanCompletedAt))
{
return false;
}
var interval = Math.Max(1, root.ScanIntervalMinutes);
return root.LastScanCompletedAt is null || root.LastScanCompletedAt.Value.AddMinutes(interval) <= now;
})
.ToList();
Serilog.Log.Information(
"文件库定时扫描检查完成 CheckedRoots={CheckedRoots} DueRoots={DueRoots}",
roots.Count,
dueRoots.Count);
foreach (var root in dueRoots)
{
try
{
Serilog.Log.Information("文件库定时扫描开始 RootId={RootId} Path={Path}", root.Id, root.Path);
var scannedRoot = await ScanRootAsync(root.Id, cancellationToken);
Serilog.Log.Information(
"文件库定时扫描完成 RootId={RootId} FileCount={FileCount}",
scannedRoot.Id,
scannedRoot.FileCount);
}
catch (Exception ex)
{
Serilog.Log.Warning(ex, "扫描文件库根目录失败 RootId={RootId}", root.Id);
}
}
}
/// <inheritdoc />
public async Task<PagedResponse<FileRecordDto>> SearchFilesAsync(SearchFilesRequest request, CancellationToken cancellationToken = default)
{
var page = Math.Clamp(request.Page, 1, 100000);
var pageSize = Math.Clamp(request.PageSize, 1, 100);
var mediaType = request.MediaType?.Trim();
var keyword = request.Keyword?.Trim();
var rootId = Math.Clamp(request.RootId, 0, int.MaxValue);
var query = db.ManagedFileRecords
.AsNoTracking()
.Where(file => file.Exists && file.LibraryRoot != null && file.LibraryRoot.IsAvailable);
if (!string.IsNullOrWhiteSpace(mediaType) && !mediaType.Equals("all", StringComparison.OrdinalIgnoreCase))
{
query = query.Where(file => file.MediaType == mediaType);
}
if (rootId > 0)
{
query = query.Where(file => file.LibraryRootId == rootId);
}
if (!string.IsNullOrWhiteSpace(keyword))
{
query = query.Where(file => file.FileName.Contains(keyword) || file.RelativePath.Contains(keyword));
}
var total = await query.CountAsync(cancellationToken);
var items = await ApplyFileSort(query, request.SortBy, request.SortDirection)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(file => ToFileDto(file))
.ToListAsync(cancellationToken);
return PagedResponse<FileRecordDto>.From(items, total, page, pageSize);
}
/// <inheritdoc />
public async Task<FileRecordDto?> GetFileAsync(int id, CancellationToken cancellationToken = default)
{
return await db.ManagedFileRecords
.AsNoTracking()
.Where(file => file.Id == id && file.Exists && file.LibraryRoot != null && file.LibraryRoot.IsAvailable)
.Select(file => ToFileDto(file))
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<BrowseDirectoryResponse> BrowseDirectoryAsync(BrowseDirectoryRequest request, CancellationToken cancellationToken = default)
{
var rootId = request.RootId;
var page = Math.Clamp(request.Page, 1, 100000);
var pageSize = Math.Clamp(request.PageSize, 1, 100);
var mediaType = request.MediaType?.Trim();
// URL 友好的正斜杠,用于响应
var prefix = (request.Path ?? "").Trim().Replace('\\', '/').Trim('/');
// Windows 反斜杠,用于数据库查询
var dbPrefix = prefix.Replace('/', '\\');
// ── 基础查询:根目录下存在且可用的所有文件 ──
var baseQuery = db.ManagedFileRecords
.AsNoTracking()
.Where(f => f.LibraryRootId == rootId && f.Exists
&& f.LibraryRoot != null && f.LibraryRoot.IsAvailable);
// ── 当前目录的文件查询(下推到数据库) ──
IQueryable<ManagedFileRecord> currentFilesQuery;
if (string.IsNullOrEmpty(dbPrefix))
{
// 根层级RelativePath 中不含 '\' 的就是当前目录文件
currentFilesQuery = baseQuery.Where(f => !f.RelativePath.Contains("\\"));
}
else
{
// 子目录层级RelativePath 以 "prefix\" 开头,且去掉前缀后不含 '\'
var dbPrefixWithSlash = dbPrefix + "\\";
var prefixLen = dbPrefixWithSlash.Length;
currentFilesQuery = baseQuery
.Where(f => f.RelativePath.StartsWith(dbPrefixWithSlash)
&& !f.RelativePath.Substring(prefixLen).Contains("\\"));
}
// ── 媒体类型过滤(数据库层面) ──
if (!string.IsNullOrWhiteSpace(mediaType) && !mediaType.Equals("all", StringComparison.OrdinalIgnoreCase))
{
currentFilesQuery = currentFilesQuery.Where(f => f.MediaType == mediaType);
}
// ── 计数 + 分页文件查询(全部在数据库完成) ──
var total = await currentFilesQuery.CountAsync(cancellationToken);
var files = await ApplyFileSort(currentFilesQuery, request.SortBy, request.SortDirection)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(file => ToFileDto(file))
.ToListAsync(cancellationToken);
// ── 子目录提取 ──
List<string> subdirs;
if (string.IsNullOrEmpty(dbPrefix))
{
// 根层级SQL 层面提取第一段目录名Substring+IndexOf+Distinct 跨数据库可翻译)
subdirs = await baseQuery
.Where(f => f.RelativePath.Contains("\\"))
.Select(f => f.RelativePath.Substring(0, f.RelativePath.IndexOf("\\")))
.Distinct()
.ToListAsync(cancellationToken);
}
else
{
// 非根层级:只加载路径字符串(单列,无实体追踪开销),内存中提取第二层子目录名
var dbPrefixWithSlash = dbPrefix + "\\";
var prefixLen = dbPrefixWithSlash.Length;
var paths = await baseQuery
.Where(f => f.RelativePath.StartsWith(dbPrefixWithSlash))
.Select(f => f.RelativePath)
.ToListAsync(cancellationToken);
subdirs = paths
.Select(p => p.Substring(prefixLen))
.Where(remaining => remaining.Contains('\\'))
.Select(remaining => remaining.Substring(0, remaining.IndexOf('\\')))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
subdirs.Sort(StringComparer.OrdinalIgnoreCase);
return new BrowseDirectoryResponse(
prefix,
subdirs,
files,
total,
page,
pageSize,
(int)Math.Ceiling((double)total / pageSize));
}
/// <inheritdoc />
public async Task<TextPreviewDto?> GetTextPreviewAsync(int id, CancellationToken cancellationToken = default)
{
var file = await db.ManagedFileRecords
.AsNoTracking()
.Include(item => item.LibraryRoot)
.FirstOrDefaultAsync(item =>
item.Id == id
&& item.Exists
&& item.MediaType == "text"
&& item.LibraryRoot != null
&& item.LibraryRoot.IsAvailable,
cancellationToken);
if (file is null || !File.Exists(file.AbsolutePath))
{
return null;
}
await using var stream = File.OpenRead(file.AbsolutePath);
var limit = (int)Math.Min(stream.Length, MaxTextPreviewBytes);
var buffer = new byte[limit];
var read = await stream.ReadAsync(buffer.AsMemory(0, limit), cancellationToken);
var content = Encoding.UTF8.GetString(buffer, 0, read);
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, 10))
.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);
}
}
/// <inheritdoc />
public async Task SaveFileProgressAsync(int id, double position, CancellationToken cancellationToken = default)
{
var record = await db.ManagedFileRecords.FindAsync([id], cancellationToken);
if (record is not null)
{
record.PlaybackPosition = Math.Max(0, position);
await db.SaveChangesAsync(cancellationToken);
}
}
/// <summary>
/// 深度优先遍历目录树,枚举所有被 <see cref="MediaFileTypes"/> 支持的媒体文件路径。
/// 遇到无权限的目录时跳过该分支继续遍历。
/// </summary>
/// <param name="rootPath">根目录路径。</param>
/// <returns>支持的文件完整路径枚举。</returns>
private static IEnumerable<string> EnumerateSupportedFiles(string rootPath)
{
var pending = new Stack<string>();
pending.Push(rootPath);
while (pending.Count > 0)
{
var current = pending.Pop();
IEnumerable<string> directories;
IEnumerable<string> files;
try
{
directories = Directory.EnumerateDirectories(current);
files = Directory.EnumerateFiles(current);
}
catch (Exception ex)
{
Serilog.Log.Information(ex, "无法枚举目录 {Directory},已跳过", current);
continue;
}
foreach (var directory in directories)
{
pending.Push(directory);
}
foreach (var file in files)
{
if (MediaFileTypes.TryGet(Path.GetExtension(file), out _, out _, out _))
{
yield return file;
}
}
}
}
/// <summary>
/// 规范化目录路径并验证目录存在。
/// </summary>
/// <param name="path">原始路径。</param>
/// <returns>规范化后的完整路径。</returns>
/// <exception cref="InvalidOperationException">路径为空时抛出。</exception>
/// <exception cref="DirectoryNotFoundException">目录不存在时抛出。</exception>
private static string NormalizeExistingDirectory(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new InvalidOperationException("目录路径不能为空。");
}
var fullPath = Path.GetFullPath(path.Trim());
if (!Directory.Exists(fullPath))
{
throw new DirectoryNotFoundException($"目录不存在:{fullPath}");
}
return new DirectoryInfo(fullPath).FullName;
}
/// <summary>
/// 解析根目录的显示名称,优先使用用户指定名称,否则使用目录名。
/// </summary>
/// <param name="path">目录路径。</param>
/// <param name="displayName">用户指定的显示名称。</param>
/// <returns>最终显示名称。</returns>
private static string ResolveDisplayName(string path, string? displayName)
{
if (!string.IsNullOrWhiteSpace(displayName))
{
return displayName.Trim();
}
var directory = new DirectoryInfo(path);
return string.IsNullOrWhiteSpace(directory.Name) ? directory.FullName : directory.Name;
}
/// <summary>
/// 删除数据库中已释放的缩略图文件,失败时保留扫描结果并记录告警。
/// </summary>
/// <param name="relativePaths">待删除缩略图相对路径。</param>
private void DeleteThumbnailFiles(IEnumerable<string> relativePaths)
{
foreach (var relativePath in relativePaths)
{
try
{
var absolutePath = thumbnailService.GetAbsolutePath(relativePath);
if (File.Exists(absolutePath))
{
File.Delete(absolutePath);
}
}
catch (Exception ex)
{
Serilog.Log.Warning(ex, "清理失效缩略图文件失败 ThumbnailPath={ThumbnailPath}", relativePath);
}
}
}
/// <summary>
/// 批量生成视频缩略图和提取时长。与文件扫描解耦,使用有限并发控制 ffmpeg 进程数,
/// 通过 <see cref="SemaphoreSlim"/> 限制并发数,通过 lock 保证 <see cref="AppDataContext"/> 的线程安全。
/// </summary>
/// <param name="rootId">库根目录 ID。</param>
/// <param name="videos">缺少缩略图的视频文件记录。</param>
/// <param name="ct">取消令牌。</param>
private async Task GenerateThumbnailsBatchAsync(int rootId, List<ManagedFileRecord> videos, CancellationToken ct)
{
// 限制并发 ffmpeg 进程数,避免占满 CPU / 内存
using var semaphore = new SemaphoreSlim(Math.Min(4, Environment.ProcessorCount));
var dbLock = new object();
var generated = 0;
var failed = 0;
async Task ProcessVideo(ManagedFileRecord video)
{
await semaphore.WaitAsync(ct);
try
{
// 耗时操作ffmpeg / ffprobe 调用(可并发,不涉及 DbContext
var thumbnail = await thumbnailService.GenerateThumbnailAsync(rootId, video.AbsolutePath, ct);
var duration = thumbnailService.GetVideoDuration(video.AbsolutePath);
// DbContext 操作需串行化EF Core DbContext 非线程安全)
lock (dbLock)
{
if (thumbnail is not null)
{
var map = new ManagedThumbnailMap
{
LibraryRootId = rootId,
RelativePath = thumbnail.RelativePath,
ContentType = thumbnail.ContentType,
};
db.ManagedThumbnailMaps.Add(map);
video.Thumbnail = map;
}
video.VideoDuration ??= duration;
generated++;
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// 取消时不再处理
}
catch (Exception ex)
{
Serilog.Log.Warning(ex, "生成缩略图失败 File={FilePath}", video.AbsolutePath);
Interlocked.Increment(ref failed);
}
finally
{
semaphore.Release();
}
}
var tasks = videos.Select(ProcessVideo);
await Task.WhenAll(tasks);
if (generated > 0)
{
await db.SaveChangesAsync(ct);
}
Serilog.Log.Information(
"缩略图批量生成完成 RootId={RootId} Generated={Generated} Failed={Failed} Total={Total}",
rootId, generated, failed, videos.Count);
}
/// <summary>
/// 规范化扫描间隔,限制在 1 到 1440 分钟范围内,未指定时使用默认值。
/// </summary>
/// <param name="interval">用户指定的间隔。</param>
/// <returns>规范化后的间隔分钟数。</returns>
private static int NormalizeInterval(int? interval)
{
return Math.Clamp(interval ?? DefaultScanIntervalMinutes, 1, 1440);
}
private static IQueryable<ManagedFileRecord> ApplyFileSort(
IQueryable<ManagedFileRecord> query,
string? sortBy,
string? sortDirection)
{
var descending = IsDescending(sortDirection);
return NormalizeSortBy(sortBy) switch
{
"size" => descending
? query.OrderByDescending(file => file.SizeBytes).ThenBy(file => file.FileName)
: query.OrderBy(file => file.SizeBytes).ThenBy(file => file.FileName),
"created" => descending
? query.OrderByDescending(file => file.FileCreationTimeUtc ?? file.LastWriteTimeUtc).ThenBy(file => file.FileName)
: query.OrderBy(file => file.FileCreationTimeUtc ?? file.LastWriteTimeUtc).ThenBy(file => file.FileName),
"modified" => descending
? query.OrderByDescending(file => file.LastWriteTimeUtc).ThenBy(file => file.FileName)
: query.OrderBy(file => file.LastWriteTimeUtc).ThenBy(file => file.FileName),
"type" => descending
? query.OrderByDescending(file => file.MediaType).ThenByDescending(file => file.Extension).ThenBy(file => file.FileName)
: query.OrderBy(file => file.MediaType).ThenBy(file => file.Extension).ThenBy(file => file.FileName),
_ => descending
? query.OrderByDescending(file => file.FileName).ThenBy(file => file.RelativePath)
: query.OrderBy(file => file.FileName).ThenBy(file => file.RelativePath),
};
}
private static IOrderedEnumerable<ManagedFileRecord> ApplyFileSort(
IEnumerable<ManagedFileRecord> files,
string? sortBy,
string? sortDirection)
{
var descending = IsDescending(sortDirection);
return NormalizeSortBy(sortBy) switch
{
"size" => descending
? files.OrderByDescending(file => file.SizeBytes).ThenBy(file => file.FileName)
: files.OrderBy(file => file.SizeBytes).ThenBy(file => file.FileName),
"created" => descending
? files.OrderByDescending(file => file.FileCreationTimeUtc ?? file.LastWriteTimeUtc).ThenBy(file => file.FileName)
: files.OrderBy(file => file.FileCreationTimeUtc ?? file.LastWriteTimeUtc).ThenBy(file => file.FileName),
"modified" => descending
? files.OrderByDescending(file => file.LastWriteTimeUtc).ThenBy(file => file.FileName)
: files.OrderBy(file => file.LastWriteTimeUtc).ThenBy(file => file.FileName),
"type" => descending
? files.OrderByDescending(file => file.MediaType).ThenByDescending(file => file.Extension).ThenBy(file => file.FileName)
: files.OrderBy(file => file.MediaType).ThenBy(file => file.Extension).ThenBy(file => file.FileName),
_ => descending
? files.OrderByDescending(file => file.FileName).ThenBy(file => file.RelativePath)
: files.OrderBy(file => file.FileName).ThenBy(file => file.RelativePath),
};
}
private static string NormalizeSortBy(string? sortBy)
{
return sortBy?.Trim().ToLowerInvariant() switch
{
"size" or "sizebytes" => "size",
"created" or "createdat" or "createtime" or "creationtime" => "created",
"modified" or "lastwrite" or "lastwritetime" or "lastwritetimeutc" or "time" or "date" => "modified",
"type" or "mediatype" or "extension" or "ext" => "type",
_ => "name",
};
}
private static bool IsDescending(string? sortDirection)
{
return sortDirection?.Trim().Equals("desc", StringComparison.OrdinalIgnoreCase) == true
|| sortDirection?.Trim().Equals("descending", StringComparison.OrdinalIgnoreCase) == true;
}
/// <summary>
/// 安全获取驱动器属性值,驱动器未就绪或访问异常时返回 null。
/// </summary>
/// <param name="drive">驱动器信息。</param>
/// <param name="selector">属性选择器。</param>
/// <returns>属性值,不可用时返回 null。</returns>
private static long? SafeDriveValue(DriveInfo drive, Func<DriveInfo, long> selector)
{
try
{
return drive.IsReady ? selector(drive) : null;
}
catch (Exception ex)
{
Serilog.Log.Information(ex, "获取驱动器属性失败,已跳过");
return null;
}
}
/// <summary>
/// 将 <see cref="ManagedLibraryRoot"/> 实体映射为 <see cref="LibraryRootDto"/>。
/// </summary>
/// <param name="root">数据库实体。</param>
/// <param name="fileCount">关联的文件记录数。</param>
/// <returns>DTO 对象。</returns>
private static LibraryRootDto ToRootDto(ManagedLibraryRoot root, int fileCount)
{
return new LibraryRootDto(
root.Id,
root.Path,
root.DisplayName,
root.IsEnabled,
root.IsAvailable,
root.ScanIntervalMinutes,
root.LastScanStartedAt,
root.LastScanCompletedAt,
root.LastScanError,
fileCount);
}
/// <summary>
/// 将 <see cref="ManagedFileRecord"/> 实体映射为 <see cref="FileRecordDto"/>,并生成流式访问和文本预览 URL。
/// </summary>
/// <param name="file">数据库实体。</param>
/// <returns>DTO 对象。</returns>
private static FileRecordDto ToFileDto(ManagedFileRecord file)
{
return new FileRecordDto(
file.Id,
file.LibraryRootId,
file.FileName,
file.RelativePath,
file.Extension,
file.SizeBytes,
file.LastWriteTimeUtc,
file.FileCreationTimeUtc,
file.MediaType,
file.ContentType,
$"/api/files/{file.Id}/stream",
file.MediaType == "text" ? $"/api/files/text?id={file.Id}" : null,
MediaFileTypes.IsBrowserPlayable(file.Extension),
file.ThumbnailId is null ? null : $"/api/thumbnails/{file.ThumbnailId}",
file.VideoDuration,
file.LastPlayedAt,
file.PlaybackPosition);
}
}
}