后端: - 新增 ManagedLibraryRoot / ManagedFileRecord 数据模型及 SQLite 迁移 - 新增文件库服务、端点服务及定时扫描后台任务 - 新增 REST API: drives、directories、roots CRUD、files 分页搜索、文本预览 - 新增文件流端点支持视频/音频流式传输 - 数据库切换为 SQLite,Kestrel 绑定 0.0.0.0 支持局域网访问 前端: - 管理端:磁盘浏览、目录选择、根目录添加/启用/删除/扫描 - 客户端:根目录选择、文件搜索/筛选/分页、音视频播放、文本预览 - 全新响应式 UI(桌面+移动端),CSS 变量设计系统 - HTTP 客户端支持 Vite 开发代理与生产同源自动切换 - 移除 HTTPS 强制重定向以提升移动端视频流兼容性
410 lines
16 KiB
C#
410 lines
16 KiB
C#
using Avalonia_Common.Core;
|
|
using Avalonia_EFCore.Database;
|
|
using Avalonia_EFCore.Models;
|
|
using Avalonia_Services.Core;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Text;
|
|
|
|
namespace Avalonia_Services.Services.FileLibrary
|
|
{
|
|
public sealed class FileLibraryService(AppDataContext db) : IFileLibraryService
|
|
{
|
|
private const int DefaultScanIntervalMinutes = 5;
|
|
private const int MaxTextPreviewBytes = 1024 * 1024;
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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);
|
|
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<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);
|
|
}
|
|
|
|
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<LibraryRootDto> 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<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.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<PagedResponse<FileRecordDto>> SearchFilesAsync(ServiceEndpointContext ctx, CancellationToken cancellationToken = default)
|
|
{
|
|
var page = ParseInt(ctx.Query.GetValueOrDefault("page"), 1, 1, 100000);
|
|
var pageSize = ParseInt(ctx.Query.GetValueOrDefault("pageSize"), 24, 1, 100);
|
|
var mediaType = ctx.Query.GetValueOrDefault("mediaType")?.Trim();
|
|
var keyword = ctx.Query.GetValueOrDefault("keyword")?.Trim();
|
|
var rootId = ParseInt(ctx.Query.GetValueOrDefault("rootId"), 0, 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<FileRecordDto>.From(items, total, page, pageSize);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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
|
|
{
|
|
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<DriveInfo, long> 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));
|
|
}
|
|
|
|
private static int ParseInt(string? value, int fallback, int min, int max)
|
|
{
|
|
return int.TryParse(value, out var parsed)
|
|
? Math.Clamp(parsed, min, max)
|
|
: fallback;
|
|
}
|
|
}
|
|
}
|