Add storage management features and UI components
Implemented new storage endpoints in the API and updated the dependency injection to include storage-related services. Enhanced the frontend by adding storage routes and links in the admin layout, along with new types and localization for storage management. Updated documentation to reflect the new storage features and their usage in the admin interface.
This commit is contained in:
@@ -154,6 +154,8 @@ public static class DependencyInjection
|
||||
services.AddSingleton<MediaPathResolver>();
|
||||
services.AddSingleton<IMediaStorage, FileSystemMediaStorage>();
|
||||
services.AddSingleton<IBumperTemplateStorage, BumperTemplateStorage>();
|
||||
// Синглтон: обход дерева дорогой, и кэш результата живёт в самой реализации.
|
||||
services.AddSingleton<IStorageInspector, FileSystemStorageInspector>();
|
||||
services.AddSingleton<IImageStore, ImageStore>();
|
||||
services.AddSingleton<IImageDownloader, ImageDownloader>();
|
||||
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Считает занятое место обходом дерева хранилища. Другого источника правды нет: размеры в БД
|
||||
/// разошлись бы с диском при первой же ручной правке файлов.
|
||||
///
|
||||
/// Обход дорогой — сегментов у нарезанной библиотеки сотни тысяч, — поэтому результат живёт
|
||||
/// в кэше <see cref="CacheLifetime"/> и пересчитывается по явной просьбе. Параллельные запросы
|
||||
/// не запускают второй обход: они ждут первый.
|
||||
/// </summary>
|
||||
public sealed class FileSystemStorageInspector(
|
||||
MediaPathResolver paths,
|
||||
IOptions<StorageOptions> options
|
||||
) : IStorageInspector
|
||||
{
|
||||
/// <summary>Сколько цифра считается свежей. Диск меняется медленно, а обход стоит дорого.</summary>
|
||||
private static readonly TimeSpan CacheLifetime = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly StorageOptions _storage = options.Value;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
private StorageUsage? _cached;
|
||||
|
||||
public async Task<StorageUsage> InspectAsync(
|
||||
IReadOnlySet<Guid> generatedAssetIds,
|
||||
bool refresh,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!refresh && IsFresh(_cached))
|
||||
return _cached!;
|
||||
|
||||
await _lock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Пока ждали очереди, обход мог уже пройти — второй раз ходить незачем.
|
||||
if (!refresh && IsFresh(_cached))
|
||||
return _cached!;
|
||||
|
||||
var usage = await Task.Run(
|
||||
() => Inspect(generatedAssetIds, cancellationToken),
|
||||
cancellationToken
|
||||
);
|
||||
_cached = usage;
|
||||
return usage;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsFresh(StorageUsage? usage) =>
|
||||
usage is not null && DateTimeOffset.UtcNow - usage.ComputedAt < CacheLifetime;
|
||||
|
||||
private StorageUsage Inspect(
|
||||
IReadOnlySet<Guid> generatedAssetIds,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var known = new[]
|
||||
{
|
||||
paths.AssetsDir,
|
||||
paths.OriginalsDir,
|
||||
paths.InboxDir,
|
||||
paths.ManualDir,
|
||||
paths.UploadsDir,
|
||||
paths.BumpersDir,
|
||||
paths.ImagesDir,
|
||||
};
|
||||
|
||||
var (programs, bumpers) = MeasureAssets(generatedAssetIds, cancellationToken);
|
||||
var areas = new List<StorageAreaUsage>
|
||||
{
|
||||
new(StorageArea.Assets, programs.Bytes, programs.Files),
|
||||
new(StorageArea.BumperAssets, bumpers.Bytes, bumpers.Files),
|
||||
Area(StorageArea.Originals, paths.OriginalsDir, cancellationToken),
|
||||
Area(StorageArea.Inbox, paths.InboxDir, cancellationToken),
|
||||
Area(StorageArea.ManualInbox, paths.ManualDir, cancellationToken),
|
||||
Area(StorageArea.Uploads, paths.UploadsDir, cancellationToken),
|
||||
Area(StorageArea.BumperSources, paths.BumpersDir, cancellationToken),
|
||||
Area(StorageArea.Images, paths.ImagesDir, cancellationToken),
|
||||
MeasureOther(known, cancellationToken),
|
||||
};
|
||||
|
||||
var (total, free) = Volume();
|
||||
return new StorageUsage(
|
||||
_storage.RootPath,
|
||||
total,
|
||||
free,
|
||||
areas.Sum(a => a.Bytes),
|
||||
areas.Sum(a => a.Files),
|
||||
_storage.MinFreeSpaceBytes,
|
||||
areas,
|
||||
DateTimeOffset.UtcNow
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сегменты, разложенные на программы и заставки. Каталог у них общий (<c>assets/{id}</c>),
|
||||
/// и разделить их можно только по идентификатору: имя каталога — это идентификатор ассета.
|
||||
/// </summary>
|
||||
private (Measurement Programs, Measurement Bumpers) MeasureAssets(
|
||||
IReadOnlySet<Guid> generatedAssetIds,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var programs = new Measurement();
|
||||
var bumpers = new Measurement();
|
||||
if (!Directory.Exists(paths.AssetsDir))
|
||||
return (programs, bumpers);
|
||||
|
||||
foreach (var directory in SafeEnumerateDirectories(paths.AssetsDir))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var measured = Measure(directory, cancellationToken);
|
||||
var generated =
|
||||
Guid.TryParse(Path.GetFileName(directory), out var assetId)
|
||||
&& generatedAssetIds.Contains(assetId);
|
||||
if (generated)
|
||||
bumpers.Add(measured);
|
||||
else
|
||||
programs.Add(measured);
|
||||
}
|
||||
|
||||
// Файлы, лежащие в самом assets/ мимо каталогов ассетов, — тоже занятое место.
|
||||
programs.Add(MeasureFiles(paths.AssetsDir));
|
||||
return (programs, bumpers);
|
||||
}
|
||||
|
||||
/// <summary>Всё, что лежит под корнем мимо известных каталогов, — обычно ноль, но увидеть это надо.</summary>
|
||||
private StorageAreaUsage MeasureOther(
|
||||
IReadOnlyCollection<string> known,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var root = Path.GetFullPath(_storage.RootPath);
|
||||
var measurement = new Measurement();
|
||||
if (!Directory.Exists(root))
|
||||
return new StorageAreaUsage(StorageArea.Other, 0, 0);
|
||||
|
||||
measurement.Add(MeasureFiles(root));
|
||||
foreach (var directory in SafeEnumerateDirectories(root))
|
||||
{
|
||||
if (known.Any(k => PathsEqual(k, directory)))
|
||||
continue;
|
||||
measurement.Add(Measure(directory, cancellationToken));
|
||||
}
|
||||
|
||||
return new StorageAreaUsage(StorageArea.Other, measurement.Bytes, measurement.Files);
|
||||
}
|
||||
|
||||
private StorageAreaUsage Area(
|
||||
StorageArea area,
|
||||
string directory,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var measurement = Measure(directory, cancellationToken);
|
||||
return new StorageAreaUsage(area, measurement.Bytes, measurement.Files);
|
||||
}
|
||||
|
||||
/// <summary>Размер тома хранилища; нули — файловая система не отдала метрику.</summary>
|
||||
private (long Total, long Free) Volume()
|
||||
{
|
||||
try
|
||||
{
|
||||
var drive = new DriveInfo(Path.GetFullPath(_storage.RootPath));
|
||||
return (drive.TotalSize, drive.AvailableFreeSpace);
|
||||
}
|
||||
catch (Exception ex)
|
||||
when (ex is ArgumentException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Экзотическая точка монтирования — показываем только занятое хранилищем.
|
||||
return (0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static Measurement Measure(string directory, CancellationToken cancellationToken)
|
||||
{
|
||||
var measurement = new Measurement();
|
||||
if (!Directory.Exists(directory))
|
||||
return measurement;
|
||||
|
||||
// Перечисление с игнорированием недоступного: одна запертая папка не должна ронять отчёт.
|
||||
var enumeration = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
};
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(directory, "*", enumeration))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
measurement.Add(SizeOf(file));
|
||||
}
|
||||
|
||||
return measurement;
|
||||
}
|
||||
|
||||
private static Measurement MeasureFiles(string directory)
|
||||
{
|
||||
var measurement = new Measurement();
|
||||
if (!Directory.Exists(directory))
|
||||
return measurement;
|
||||
|
||||
foreach (
|
||||
var file in Directory.EnumerateFiles(
|
||||
directory,
|
||||
"*",
|
||||
new EnumerationOptions { IgnoreInaccessible = true }
|
||||
)
|
||||
)
|
||||
measurement.Add(SizeOf(file));
|
||||
|
||||
return measurement;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SafeEnumerateDirectories(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateDirectories(
|
||||
directory,
|
||||
"*",
|
||||
new EnumerationOptions { IgnoreInaccessible = true }
|
||||
);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static long SizeOf(string file)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new FileInfo(file).Length;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Файл исчез между перечислением и замером — обычное дело при живой нарезке.
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool PathsEqual(string left, string right) =>
|
||||
string.Equals(
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
|
||||
OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal
|
||||
);
|
||||
|
||||
/// <summary>Накопитель обхода: байты и число файлов идут вместе всюду, где считается место.</summary>
|
||||
private sealed class Measurement
|
||||
{
|
||||
public long Bytes { get; private set; }
|
||||
public int Files { get; private set; }
|
||||
|
||||
public void Add(long bytes)
|
||||
{
|
||||
Bytes += bytes;
|
||||
Files++;
|
||||
}
|
||||
|
||||
public void Add(Measurement other)
|
||||
{
|
||||
Bytes += other.Bytes;
|
||||
Files += other.Files;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user