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 { public sealed class FileLibraryService(AppDataContext db) : IFileLibraryService { private const int DefaultScanIntervalMinutes = 5; private const int MaxTextPreviewBytes = 1024 * 1024; public Task> 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); } public Task> 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); } public async Task> 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(); } public async Task 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); return await ScanRootAsync(existing.Id, cancellationToken); } 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 await ScanRootAsync(root.Id, cancellationToken); } public async Task 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); } 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); } public async Task ScanRootAsync(int rootId, CancellationToken cancellationToken = default) { var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == rootId, cancellationToken) ?? throw new InvalidOperationException("文件库目录不存在。"); root.LastScanStartedAt = DateTime.UtcNow; root.LastScanError = null; await db.SaveChangesAsync(cancellationToken); 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); var seen = new HashSet(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.MediaType = mediaType; record.ContentType = contentType; record.Exists = true; record.LastSeenAt = DateTime.UtcNow; } foreach (var stale in existing.Values.Where(file => !seen.Contains(file.AbsolutePath))) { stale.Exists = false; } root.LastScanCompletedAt = DateTime.UtcNow; await db.SaveChangesAsync(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); return ToRootDto(root, count); } public async Task ScanDueRootsAsync(CancellationToken cancellationToken = default) { var now = DateTime.UtcNow; var roots = await db.ManagedLibraryRoots .Where(root => root.IsEnabled && root.IsAvailable) .ToListAsync(cancellationToken); foreach (var root in roots) { var interval = Math.Max(1, root.ScanIntervalMinutes); var isDue = root.LastScanCompletedAt is null || root.LastScanCompletedAt.Value.AddMinutes(interval) <= now; if (!isDue) { continue; } try { await ScanRootAsync(root.Id, cancellationToken); } catch { // ScanRootAsync records the error on the root. Continue scanning other roots. } } } public async Task> 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 query .OrderBy(file => file.MediaType) .ThenBy(file => file.RelativePath) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(file => ToFileDto(file)) .ToListAsync(cancellationToken); return PagedResponse.From(items, total, page, pageSize); } public async Task 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); } public async Task BrowseDirectoryAsync(BrowseDirectoryRequest request, CancellationToken cancellationToken = default) { var rootId = request.RootId; // URL 友好的正斜杠,用于响应和内存处理 var prefix = (request.Path ?? "").Trim().Replace('\\', '/').Trim('/'); // Windows 反斜杠,用于数据库查询 var dbPrefix = prefix.Replace('/', '\\'); var query = db.ManagedFileRecords .AsNoTracking() .Where(f => f.LibraryRootId == rootId && f.Exists && f.LibraryRoot != null && f.LibraryRoot.IsAvailable); if (!string.IsNullOrEmpty(dbPrefix)) { 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(ToFileDto(file)); } else { subdirs.Add(remaining[..slashIndex]); } } return new BrowseDirectoryResponse( prefix, subdirs.OrderBy(d => d, StringComparer.OrdinalIgnoreCase).ToList(), currentFiles); } public async Task 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); } private static IEnumerable EnumerateSupportedFiles(string rootPath) { var pending = new Stack(); pending.Push(rootPath); while (pending.Count > 0) { var current = pending.Pop(); IEnumerable directories; IEnumerable files; try { directories = Directory.EnumerateDirectories(current); files = Directory.EnumerateFiles(current); } catch { 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; } } } } 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; } 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; } private static int NormalizeInterval(int? interval) { return Math.Clamp(interval ?? DefaultScanIntervalMinutes, 1, 1440); } private static long? SafeDriveValue(DriveInfo drive, Func selector) { try { return drive.IsReady ? selector(drive) : null; } catch { return null; } } 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); } private static FileRecordDto ToFileDto(ManagedFileRecord file) { return new FileRecordDto( file.Id, file.LibraryRootId, file.FileName, file.RelativePath, file.Extension, file.SizeBytes, file.LastWriteTimeUtc, file.MediaType, file.ContentType, $"/api/files/{file.Id}/stream", file.MediaType == "text" ? $"/api/files/text?id={file.Id}" : null, MediaFileTypes.IsBrowserPlayable(file.Extension)); } } }