2026-05-22 14:29:22 +08:00
|
|
|
|
using FileShare_Common.Core;
|
|
|
|
|
|
using FileShare_EFCore.Database;
|
|
|
|
|
|
using FileShare_EFCore.Models;
|
|
|
|
|
|
using FileShare_Services.Core;
|
2026-05-21 16:45:56 +08:00
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
|
|
using System.Text;
|
|
|
|
|
|
|
2026-05-22 14:29:22 +08:00
|
|
|
|
namespace FileShare_Services.Services.FileLibrary
|
2026-05-21 16:45:56 +08:00
|
|
|
|
{
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 文件库核心业务服务,实现磁盘枚举、目录管理、文件扫描与检索。
|
|
|
|
|
|
/// </summary>
|
2026-05-22 17:01:49 +08:00
|
|
|
|
public sealed class FileLibraryService(AppDataContext db, IVideoThumbnailService thumbnailService) : IFileLibraryService
|
2026-05-21 16:45:56 +08:00
|
|
|
|
{
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 默认扫描间隔(分钟),当请求未指定间隔时使用。
|
|
|
|
|
|
/// </summary>
|
2026-05-21 16:45:56 +08:00
|
|
|
|
private const int DefaultScanIntervalMinutes = 5;
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 文本预览最大读取字节数(1 MB)。
|
|
|
|
|
|
/// </summary>
|
2026-05-21 16:45:56 +08:00
|
|
|
|
private const int MaxTextPreviewBytes = 1024 * 1024;
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <inheritdoc />
|
2026-05-21 16:45:56 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <inheritdoc />
|
2026-05-21 16:45:56 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <inheritdoc />
|
2026-05-21 16:45:56 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <inheritdoc />
|
2026-05-21 16:45:56 +08:00
|
|
|
|
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);
|
2026-05-22 17:01:49 +08:00
|
|
|
|
var existingCount = await db.ManagedFileRecords
|
|
|
|
|
|
.CountAsync(file => file.LibraryRootId == existing.Id && file.Exists, cancellationToken);
|
|
|
|
|
|
return ToRootDto(existing, existingCount);
|
2026-05-21 16:45:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
2026-05-22 17:01:49 +08:00
|
|
|
|
return ToRootDto(root, 0);
|
2026-05-21 16:45:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <inheritdoc />
|
2026-05-21 16:45:56 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <inheritdoc />
|
2026-05-21 16:45:56 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <inheritdoc />
|
2026-05-21 16:45:56 +08:00
|
|
|
|
public async Task<LibraryRootDto> ScanRootAsync(int rootId, CancellationToken cancellationToken = default)
|
|
|
|
|
|
{
|
2026-06-12 15:45:06 +08:00
|
|
|
|
// ── 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)
|
2026-05-21 16:45:56 +08:00
|
|
|
|
?? throw new InvalidOperationException("文件库目录不存在。");
|
|
|
|
|
|
|
2026-05-22 20:09:22 +08:00
|
|
|
|
Serilog.Log.Information("开始扫描文件库根目录 RootId={RootId} Path={Path}", root.Id, root.Path);
|
2026-05-21 16:45:56 +08:00
|
|
|
|
|
2026-05-22 20:09:22 +08:00
|
|
|
|
var staleFileCount = 0;
|
|
|
|
|
|
var staleThumbnailPaths = new List<string>();
|
2026-06-12 15:45:06 +08:00
|
|
|
|
var pendingVideos = new List<ManagedFileRecord>();
|
2026-05-22 20:09:22 +08:00
|
|
|
|
|
2026-05-21 16:45:56 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-06-12 15:45:06 +08:00
|
|
|
|
// ── 3. 快速文件枚举(只更新元数据,不生成缩略图) ──
|
2026-05-21 16:45:56 +08:00
|
|
|
|
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;
|
2026-05-23 11:03:51 +08:00
|
|
|
|
record.FileCreationTimeUtc = info.CreationTimeUtc;
|
2026-05-21 16:45:56 +08:00
|
|
|
|
record.MediaType = mediaType;
|
|
|
|
|
|
record.ContentType = contentType;
|
|
|
|
|
|
record.Exists = true;
|
|
|
|
|
|
record.LastSeenAt = DateTime.UtcNow;
|
2026-05-22 17:01:49 +08:00
|
|
|
|
|
2026-06-12 15:45:06 +08:00
|
|
|
|
// 收集缺少缩略图的视频,稍后批量生成(不在此处阻塞扫描)
|
2026-05-22 17:01:49 +08:00
|
|
|
|
if (mediaType == "video" && record.ThumbnailId is null)
|
|
|
|
|
|
{
|
2026-06-12 15:45:06 +08:00
|
|
|
|
pendingVideos.Add(record);
|
2026-05-22 17:01:49 +08:00
|
|
|
|
}
|
2026-05-21 16:45:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 15:45:06 +08:00
|
|
|
|
// ── 4. 清理过期文件记录 ──
|
2026-05-22 20:09:22 +08:00
|
|
|
|
var staleFiles = existing.Values
|
|
|
|
|
|
.Where(file => !seen.Contains(file.AbsolutePath))
|
|
|
|
|
|
.ToList();
|
|
|
|
|
|
staleFileCount = staleFiles.Count;
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var stale in staleFiles)
|
2026-05-21 16:45:56 +08:00
|
|
|
|
{
|
|
|
|
|
|
stale.Exists = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 15:45:06 +08:00
|
|
|
|
// ── 5. 清理过期缩略图 ──
|
2026-05-22 20:09:22 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 15:45:06 +08:00
|
|
|
|
// ── 6. 保存文件元数据(快速阶段结束) ──
|
2026-05-21 16:45:56 +08:00
|
|
|
|
root.LastScanCompletedAt = DateTime.UtcNow;
|
|
|
|
|
|
await db.SaveChangesAsync(cancellationToken);
|
2026-05-22 20:09:22 +08:00
|
|
|
|
DeleteThumbnailFiles(staleThumbnailPaths);
|
2026-06-12 15:45:06 +08:00
|
|
|
|
|
|
|
|
|
|
// ── 7. 批量生成缩略图和视频时长(与扫描解耦,有限并发) ──
|
|
|
|
|
|
if (pendingVideos.Count > 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
await GenerateThumbnailsBatchAsync(root.Id, pendingVideos, cancellationToken);
|
|
|
|
|
|
}
|
2026-05-21 16:45:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
|
|
|
|
|
root.IsAvailable = false;
|
|
|
|
|
|
root.IsEnabled = false;
|
|
|
|
|
|
root.LastScanError = ex.Message;
|
|
|
|
|
|
root.LastScanCompletedAt = DateTime.UtcNow;
|
|
|
|
|
|
await db.SaveChangesAsync(CancellationToken.None);
|
|
|
|
|
|
throw;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 15:45:06 +08:00
|
|
|
|
var count = await db.ManagedFileRecords
|
|
|
|
|
|
.CountAsync(file => file.LibraryRootId == root.Id && file.Exists, cancellationToken);
|
2026-05-22 20:09:22 +08:00
|
|
|
|
Serilog.Log.Information(
|
2026-06-12 15:45:06 +08:00
|
|
|
|
"扫描文件库根目录完成 RootId={RootId} FileCount={FileCount} StaleFiles={StaleFiles} RemovedThumbnails={RemovedThumbnails} PendingThumbnails={PendingThumbnails}",
|
2026-05-22 20:09:22 +08:00
|
|
|
|
root.Id,
|
|
|
|
|
|
count,
|
|
|
|
|
|
staleFileCount,
|
2026-06-12 15:45:06 +08:00
|
|
|
|
staleThumbnailPaths.Count,
|
|
|
|
|
|
pendingVideos.Count);
|
2026-05-21 16:45:56 +08:00
|
|
|
|
return ToRootDto(root, count);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <inheritdoc />
|
2026-05-21 16:45:56 +08:00
|
|
|
|
public async Task ScanDueRootsAsync(CancellationToken cancellationToken = default)
|
|
|
|
|
|
{
|
|
|
|
|
|
var now = DateTime.UtcNow;
|
|
|
|
|
|
var roots = await db.ManagedLibraryRoots
|
|
|
|
|
|
.Where(root => root.IsEnabled && root.IsAvailable)
|
|
|
|
|
|
.ToListAsync(cancellationToken);
|
2026-05-22 20:09:22 +08:00
|
|
|
|
var dueRoots = roots
|
|
|
|
|
|
.Where(root =>
|
2026-05-21 16:45:56 +08:00
|
|
|
|
{
|
2026-06-12 15:45:06 +08:00
|
|
|
|
// 跳过正在扫描中的目录(StartedAt 存在且晚于 CompletedAt)
|
|
|
|
|
|
if (root.LastScanStartedAt is not null
|
|
|
|
|
|
&& (root.LastScanCompletedAt is null || root.LastScanStartedAt > root.LastScanCompletedAt))
|
|
|
|
|
|
{
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 20:09:22 +08:00
|
|
|
|
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);
|
2026-05-21 16:45:56 +08:00
|
|
|
|
|
2026-05-22 20:09:22 +08:00
|
|
|
|
foreach (var root in dueRoots)
|
|
|
|
|
|
{
|
2026-05-21 16:45:56 +08:00
|
|
|
|
try
|
|
|
|
|
|
{
|
2026-05-22 20:09:22 +08:00
|
|
|
|
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);
|
2026-05-21 16:45:56 +08:00
|
|
|
|
}
|
2026-05-22 15:17:59 +08:00
|
|
|
|
catch (Exception ex)
|
2026-05-21 16:45:56 +08:00
|
|
|
|
{
|
2026-05-22 15:17:59 +08:00
|
|
|
|
Serilog.Log.Warning(ex, "扫描文件库根目录失败 RootId={RootId}", root.Id);
|
2026-05-21 16:45:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <inheritdoc />
|
2026-05-22 11:18:47 +08:00
|
|
|
|
public async Task<PagedResponse<FileRecordDto>> SearchFilesAsync(SearchFilesRequest request, CancellationToken cancellationToken = default)
|
2026-05-21 16:45:56 +08:00
|
|
|
|
{
|
2026-05-22 11:18:47 +08:00
|
|
|
|
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);
|
2026-05-21 16:45:56 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-05-23 11:03:51 +08:00
|
|
|
|
var items = await ApplyFileSort(query, request.SortBy, request.SortDirection)
|
2026-05-21 16:45:56 +08:00
|
|
|
|
.Skip((page - 1) * pageSize)
|
|
|
|
|
|
.Take(pageSize)
|
|
|
|
|
|
.Select(file => ToFileDto(file))
|
|
|
|
|
|
.ToListAsync(cancellationToken);
|
|
|
|
|
|
|
|
|
|
|
|
return PagedResponse<FileRecordDto>.From(items, total, page, pageSize);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <inheritdoc />
|
2026-05-21 16:45:56 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <inheritdoc />
|
2026-05-22 11:59:45 +08:00
|
|
|
|
public async Task<BrowseDirectoryResponse> BrowseDirectoryAsync(BrowseDirectoryRequest request, CancellationToken cancellationToken = default)
|
|
|
|
|
|
{
|
|
|
|
|
|
var rootId = request.RootId;
|
2026-05-23 11:03:51 +08:00
|
|
|
|
var page = Math.Clamp(request.Page, 1, 100000);
|
|
|
|
|
|
var pageSize = Math.Clamp(request.PageSize, 1, 100);
|
|
|
|
|
|
var mediaType = request.MediaType?.Trim();
|
2026-06-12 15:45:06 +08:00
|
|
|
|
// URL 友好的正斜杠,用于响应
|
2026-05-22 11:59:45 +08:00
|
|
|
|
var prefix = (request.Path ?? "").Trim().Replace('\\', '/').Trim('/');
|
|
|
|
|
|
// Windows 反斜杠,用于数据库查询
|
|
|
|
|
|
var dbPrefix = prefix.Replace('/', '\\');
|
|
|
|
|
|
|
2026-06-12 15:45:06 +08:00
|
|
|
|
// ── 基础查询:根目录下存在且可用的所有文件 ──
|
|
|
|
|
|
var baseQuery = db.ManagedFileRecords
|
2026-05-22 11:59:45 +08:00
|
|
|
|
.AsNoTracking()
|
|
|
|
|
|
.Where(f => f.LibraryRootId == rootId && f.Exists
|
|
|
|
|
|
&& f.LibraryRoot != null && f.LibraryRoot.IsAvailable);
|
|
|
|
|
|
|
2026-06-12 15:45:06 +08:00
|
|
|
|
// ── 当前目录的文件查询(下推到数据库) ──
|
|
|
|
|
|
IQueryable<ManagedFileRecord> currentFilesQuery;
|
|
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrEmpty(dbPrefix))
|
|
|
|
|
|
{
|
|
|
|
|
|
// 根层级:RelativePath 中不含 '\' 的就是当前目录文件
|
|
|
|
|
|
currentFilesQuery = baseQuery.Where(f => !f.RelativePath.Contains("\\"));
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
2026-05-22 11:59:45 +08:00
|
|
|
|
{
|
2026-06-12 15:45:06 +08:00
|
|
|
|
// 子目录层级:RelativePath 以 "prefix\" 开头,且去掉前缀后不含 '\'
|
2026-05-22 11:59:45 +08:00
|
|
|
|
var dbPrefixWithSlash = dbPrefix + "\\";
|
2026-06-12 15:45:06 +08:00
|
|
|
|
var prefixLen = dbPrefixWithSlash.Length;
|
|
|
|
|
|
currentFilesQuery = baseQuery
|
|
|
|
|
|
.Where(f => f.RelativePath.StartsWith(dbPrefixWithSlash)
|
|
|
|
|
|
&& !f.RelativePath.Substring(prefixLen).Contains("\\"));
|
2026-05-22 11:59:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 15:45:06 +08:00
|
|
|
|
// ── 媒体类型过滤(数据库层面) ──
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(mediaType) && !mediaType.Equals("all", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
|
{
|
|
|
|
|
|
currentFilesQuery = currentFilesQuery.Where(f => f.MediaType == mediaType);
|
|
|
|
|
|
}
|
2026-05-22 11:59:45 +08:00
|
|
|
|
|
2026-06-12 15:45:06 +08:00
|
|
|
|
// ── 计数 + 分页文件查询(全部在数据库完成) ──
|
|
|
|
|
|
var total = await currentFilesQuery.CountAsync(cancellationToken);
|
2026-05-22 11:59:45 +08:00
|
|
|
|
|
2026-06-12 15:45:06 +08:00
|
|
|
|
var files = await ApplyFileSort(currentFilesQuery, request.SortBy, request.SortDirection)
|
|
|
|
|
|
.Skip((page - 1) * pageSize)
|
|
|
|
|
|
.Take(pageSize)
|
|
|
|
|
|
.Select(file => ToFileDto(file))
|
|
|
|
|
|
.ToListAsync(cancellationToken);
|
2026-05-22 11:59:45 +08:00
|
|
|
|
|
2026-06-12 15:45:06 +08:00
|
|
|
|
// ── 子目录提取 ──
|
|
|
|
|
|
List<string> subdirs;
|
2026-05-22 11:59:45 +08:00
|
|
|
|
|
2026-06-12 15:45:06 +08:00
|
|
|
|
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
|
2026-05-23 11:03:51 +08:00
|
|
|
|
{
|
2026-06-12 15:45:06 +08:00
|
|
|
|
// 非根层级:只加载路径字符串(单列,无实体追踪开销),内存中提取第二层子目录名
|
|
|
|
|
|
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)
|
2026-05-23 11:03:51 +08:00
|
|
|
|
.ToList();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 15:45:06 +08:00
|
|
|
|
subdirs.Sort(StringComparer.OrdinalIgnoreCase);
|
2026-05-23 11:03:51 +08:00
|
|
|
|
|
2026-05-22 11:59:45 +08:00
|
|
|
|
return new BrowseDirectoryResponse(
|
|
|
|
|
|
prefix,
|
2026-06-12 15:45:06 +08:00
|
|
|
|
subdirs,
|
2026-05-23 11:03:51 +08:00
|
|
|
|
files,
|
|
|
|
|
|
total,
|
|
|
|
|
|
page,
|
|
|
|
|
|
pageSize,
|
2026-06-12 15:45:06 +08:00
|
|
|
|
(int)Math.Ceiling((double)total / pageSize));
|
2026-05-22 11:59:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <inheritdoc />
|
2026-05-21 16:45:56 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 17:01:49 +08:00
|
|
|
|
/// <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
|
2026-05-24 15:43:31 +08:00
|
|
|
|
.Take(Math.Clamp(count, 1, 10))
|
2026-05-22 17:01:49 +08:00
|
|
|
|
.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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 17:44:04 +08:00
|
|
|
|
/// <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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 深度优先遍历目录树,枚举所有被 <see cref="MediaFileTypes"/> 支持的媒体文件路径。
|
|
|
|
|
|
/// 遇到无权限的目录时跳过该分支继续遍历。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="rootPath">根目录路径。</param>
|
|
|
|
|
|
/// <returns>支持的文件完整路径枚举。</returns>
|
2026-05-21 16:45:56 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-22 15:17:59 +08:00
|
|
|
|
catch (Exception ex)
|
2026-05-21 16:45:56 +08:00
|
|
|
|
{
|
2026-05-22 15:17:59 +08:00
|
|
|
|
Serilog.Log.Information(ex, "无法枚举目录 {Directory},已跳过", current);
|
2026-05-21 16:45:56 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 规范化目录路径并验证目录存在。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="path">原始路径。</param>
|
|
|
|
|
|
/// <returns>规范化后的完整路径。</returns>
|
|
|
|
|
|
/// <exception cref="InvalidOperationException">路径为空时抛出。</exception>
|
|
|
|
|
|
/// <exception cref="DirectoryNotFoundException">目录不存在时抛出。</exception>
|
2026-05-21 16:45:56 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 解析根目录的显示名称,优先使用用户指定名称,否则使用目录名。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="path">目录路径。</param>
|
|
|
|
|
|
/// <param name="displayName">用户指定的显示名称。</param>
|
|
|
|
|
|
/// <returns>最终显示名称。</returns>
|
2026-05-21 16:45:56 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 20:09:22 +08:00
|
|
|
|
/// <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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 15:45:06 +08:00
|
|
|
|
/// <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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 规范化扫描间隔,限制在 1 到 1440 分钟范围内,未指定时使用默认值。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="interval">用户指定的间隔。</param>
|
|
|
|
|
|
/// <returns>规范化后的间隔分钟数。</returns>
|
2026-05-21 16:45:56 +08:00
|
|
|
|
private static int NormalizeInterval(int? interval)
|
|
|
|
|
|
{
|
|
|
|
|
|
return Math.Clamp(interval ?? DefaultScanIntervalMinutes, 1, 1440);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 11:03:51 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 安全获取驱动器属性值,驱动器未就绪或访问异常时返回 null。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="drive">驱动器信息。</param>
|
|
|
|
|
|
/// <param name="selector">属性选择器。</param>
|
|
|
|
|
|
/// <returns>属性值,不可用时返回 null。</returns>
|
2026-05-21 16:45:56 +08:00
|
|
|
|
private static long? SafeDriveValue(DriveInfo drive, Func<DriveInfo, long> selector)
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
return drive.IsReady ? selector(drive) : null;
|
|
|
|
|
|
}
|
2026-05-22 15:17:59 +08:00
|
|
|
|
catch (Exception ex)
|
2026-05-21 16:45:56 +08:00
|
|
|
|
{
|
2026-05-22 15:17:59 +08:00
|
|
|
|
Serilog.Log.Information(ex, "获取驱动器属性失败,已跳过");
|
2026-05-21 16:45:56 +08:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 将 <see cref="ManagedLibraryRoot"/> 实体映射为 <see cref="LibraryRootDto"/>。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="root">数据库实体。</param>
|
|
|
|
|
|
/// <param name="fileCount">关联的文件记录数。</param>
|
|
|
|
|
|
/// <returns>DTO 对象。</returns>
|
2026-05-21 16:45:56 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 14:45:07 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 将 <see cref="ManagedFileRecord"/> 实体映射为 <see cref="FileRecordDto"/>,并生成流式访问和文本预览 URL。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="file">数据库实体。</param>
|
|
|
|
|
|
/// <returns>DTO 对象。</returns>
|
2026-05-21 16:45:56 +08:00
|
|
|
|
private static FileRecordDto ToFileDto(ManagedFileRecord file)
|
|
|
|
|
|
{
|
|
|
|
|
|
return new FileRecordDto(
|
|
|
|
|
|
file.Id,
|
|
|
|
|
|
file.LibraryRootId,
|
|
|
|
|
|
file.FileName,
|
|
|
|
|
|
file.RelativePath,
|
|
|
|
|
|
file.Extension,
|
|
|
|
|
|
file.SizeBytes,
|
|
|
|
|
|
file.LastWriteTimeUtc,
|
2026-05-23 11:03:51 +08:00
|
|
|
|
file.FileCreationTimeUtc,
|
2026-05-21 16:45:56 +08:00
|
|
|
|
file.MediaType,
|
|
|
|
|
|
file.ContentType,
|
|
|
|
|
|
$"/api/files/{file.Id}/stream",
|
|
|
|
|
|
file.MediaType == "text" ? $"/api/files/text?id={file.Id}" : null,
|
2026-05-22 17:01:49 +08:00
|
|
|
|
MediaFileTypes.IsBrowserPlayable(file.Extension),
|
|
|
|
|
|
file.ThumbnailId is null ? null : $"/api/thumbnails/{file.ThumbnailId}",
|
|
|
|
|
|
file.VideoDuration,
|
2026-05-22 17:44:04 +08:00
|
|
|
|
file.LastPlayedAt,
|
|
|
|
|
|
file.PlaybackPosition);
|
2026-05-21 16:45:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|