diff --git a/FileShare-Services/Services/FileLibrary/FileLibraryService.cs b/FileShare-Services/Services/FileLibrary/FileLibraryService.cs index fdae511..728e1d1 100644 --- a/FileShare-Services/Services/FileLibrary/FileLibraryService.cs +++ b/FileShare-Services/Services/FileLibrary/FileLibraryService.cs @@ -126,16 +126,40 @@ namespace FileShare_Services.Services.FileLibrary /// public async Task ScanRootAsync(int rootId, CancellationToken cancellationToken = default) { - var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == rootId, cancellationToken) + // ── 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); - root.LastScanStartedAt = DateTime.UtcNow; - root.LastScanError = null; - await db.SaveChangesAsync(cancellationToken); var staleFileCount = 0; var staleThumbnailPaths = new List(); + var pendingVideos = new List(); try { @@ -151,6 +175,7 @@ namespace FileShare_Services.Services.FileLibrary .Where(file => file.LibraryRootId == root.Id) .ToDictionaryAsync(file => file.AbsolutePath, StringComparer.OrdinalIgnoreCase, cancellationToken); + // ── 3. 快速文件枚举(只更新元数据,不生成缩略图) ── var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var path in EnumerateSupportedFiles(root.Path)) { @@ -186,25 +211,14 @@ namespace FileShare_Services.Services.FileLibrary 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); + pendingVideos.Add(record); } } + // ── 4. 清理过期文件记录 ── var staleFiles = existing.Values .Where(file => !seen.Contains(file.AbsolutePath)) .ToList(); @@ -215,6 +229,7 @@ namespace FileShare_Services.Services.FileLibrary stale.Exists = false; } + // ── 5. 清理过期缩略图 ── var activeThumbnailIds = existing.Values .Where(file => file.Exists && file.ThumbnailId is not null) .Select(file => file.ThumbnailId!.Value) @@ -244,9 +259,16 @@ namespace FileShare_Services.Services.FileLibrary 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) { @@ -258,13 +280,15 @@ namespace FileShare_Services.Services.FileLibrary throw; } - var count = await db.ManagedFileRecords.CountAsync(file => file.LibraryRootId == root.Id && file.Exists, cancellationToken); + 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}", + "扫描文件库根目录完成 RootId={RootId} FileCount={FileCount} StaleFiles={StaleFiles} RemovedThumbnails={RemovedThumbnails} PendingThumbnails={PendingThumbnails}", root.Id, count, staleFileCount, - staleThumbnailPaths.Count); + staleThumbnailPaths.Count, + pendingVideos.Count); return ToRootDto(root, count); } @@ -278,6 +302,13 @@ namespace FileShare_Services.Services.FileLibrary 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; }) @@ -361,67 +392,91 @@ namespace FileShare_Services.Services.FileLibrary var page = Math.Clamp(request.Page, 1, 100000); var pageSize = Math.Clamp(request.PageSize, 1, 100); var mediaType = request.MediaType?.Trim(); - // URL 友好的正斜杠,用于响应和内存处理 + // URL 友好的正斜杠,用于响应 var prefix = (request.Path ?? "").Trim().Replace('\\', '/').Trim('/'); // Windows 反斜杠,用于数据库查询 var dbPrefix = prefix.Replace('/', '\\'); - var query = db.ManagedFileRecords + // ── 基础查询:根目录下存在且可用的所有文件 ── + var baseQuery = db.ManagedFileRecords .AsNoTracking() .Where(f => f.LibraryRootId == rootId && f.Exists && f.LibraryRoot != null && f.LibraryRoot.IsAvailable); - if (!string.IsNullOrEmpty(dbPrefix)) + // ── 当前目录的文件查询(下推到数据库) ── + IQueryable currentFilesQuery; + + if (string.IsNullOrEmpty(dbPrefix)) { + // 根层级:RelativePath 中不含 '\' 的就是当前目录文件 + currentFilesQuery = baseQuery.Where(f => !f.RelativePath.Contains("\\")); + } + else + { + // 子目录层级:RelativePath 以 "prefix\" 开头,且去掉前缀后不含 '\' var dbPrefixWithSlash = dbPrefix + "\\"; - query = query.Where(f => f.RelativePath.StartsWith(dbPrefixWithSlash)); - } - - var allFiles = await query.ToListAsync(cancellationToken); - - var subdirs = new HashSet(StringComparer.OrdinalIgnoreCase); - var currentFiles = new List(); - - foreach (var file in allFiles) - { - var relativePath = file.RelativePath.Replace('\\', '/'); - var remaining = string.IsNullOrEmpty(prefix) - ? relativePath - : relativePath[(prefix.Length + 1)..]; - - var slashIndex = remaining.IndexOf('/'); - if (slashIndex < 0) - { - currentFiles.Add(file); - } - else - { - subdirs.Add(remaining[..slashIndex]); - } + 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)) { - currentFiles = currentFiles - .Where(file => file.MediaType.Equals(mediaType, 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 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(); } - var total = currentFiles.Count; - var files = ApplyFileSort(currentFiles, request.SortBy, request.SortDirection) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(ToFileDto) - .ToList(); + subdirs.Sort(StringComparer.OrdinalIgnoreCase); return new BrowseDirectoryResponse( prefix, - subdirs.OrderBy(d => d, StringComparer.OrdinalIgnoreCase).ToList(), + subdirs, files, total, page, pageSize, - pageSize > 0 ? (int)Math.Ceiling((double)total / pageSize) : 0); + (int)Math.Ceiling((double)total / pageSize)); } /// @@ -596,6 +651,77 @@ namespace FileShare_Services.Services.FileLibrary } } + /// + /// 批量生成视频缩略图和提取时长。与文件扫描解耦,使用有限并发控制 ffmpeg 进程数, + /// 通过 限制并发数,通过 lock 保证 的线程安全。 + /// + /// 库根目录 ID。 + /// 缺少缩略图的视频文件记录。 + /// 取消令牌。 + private async Task GenerateThumbnailsBatchAsync(int rootId, List 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); + } + /// /// 规范化扫描间隔,限制在 1 到 1440 分钟范围内,未指定时使用默认值。 /// diff --git a/FileShare-Web-VUE/src/components/ClientPage.vue b/FileShare-Web-VUE/src/components/ClientPage.vue index 6b044ed..133dc81 100644 --- a/FileShare-Web-VUE/src/components/ClientPage.vue +++ b/FileShare-Web-VUE/src/components/ClientPage.vue @@ -36,7 +36,11 @@ const recentLoading = ref(false) const viewMode = ref<'list' | 'grid'>('list') const qrModal = ref | null>(null) -const mediaPlayer = ref(null) +const mediaPlayer = ref(null) + +function setMediaPlayer(el: unknown) { + mediaPlayer.value = (el as MediaPlayerHandle) ?? null +} const searchQuery = ref('') const searchResults = ref([]) @@ -91,12 +95,11 @@ const clientTitle = computed(() => { }) function getVideoElement() { - const player = Array.isArray(mediaPlayer.value) ? mediaPlayer.value[0] : mediaPlayer.value - return player?.getVideoElement() ?? null + return mediaPlayer.value?.getVideoElement() ?? null } function getMediaPlayer() { - return Array.isArray(mediaPlayer.value) ? mediaPlayer.value[0] : mediaPlayer.value + return mediaPlayer.value } function setError(error: unknown) { @@ -461,51 +464,51 @@ onBeforeUnmount(() => { - + - + { - + @@ -559,26 +562,26 @@ onBeforeUnmount(() => { :show-last-played="activeTab === 'recent-played'" @select="selectFile" /> - + @@ -628,26 +631,26 @@ onBeforeUnmount(() => { show-created-time @select="selectFile" /> - + @@ -659,26 +662,26 @@ onBeforeUnmount(() => { show-created-time @select="selectFile" /> - + @@ -698,6 +701,7 @@ onBeforeUnmount(() => { 加载中... +
加载中...