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:
@@ -0,0 +1,33 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Storage;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
/// <summary>Чем занят диск: объём по областям хранилища и заполненность тома.</summary>
|
||||
public static class StorageEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapStorageEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/admin/storage", Stats)
|
||||
.WithTags("Admin.Storage")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))
|
||||
.Produces<StorageStatsDto>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <param name="refresh">Пересчитать, не дожидаясь истечения кэша: обход дерева долгий.</param>
|
||||
private static async Task<IResult> Stats(
|
||||
bool? refresh,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new GetStorageStatsQuery(refresh ?? false),
|
||||
cancellationToken
|
||||
);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,7 @@ app.MapJunctionEndpoints();
|
||||
app.MapChannelEndpoints();
|
||||
app.MapStreamingEndpoints();
|
||||
app.MapMaintenanceEndpoints();
|
||||
app.MapStorageEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapMetadataEndpoints();
|
||||
app.MapImageEndpoints();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Части хранилища, которые считаются отдельно. Это не «папки», а ответ на вопрос «чем занят диск»:
|
||||
/// сегменты программ и сегменты заставок лежат в одном каталоге, но растут по разным причинам,
|
||||
/// и администратору важно видеть их порознь.
|
||||
/// </summary>
|
||||
public enum StorageArea
|
||||
{
|
||||
/// <summary>HLS-сегменты программ — обычно это почти весь объём.</summary>
|
||||
Assets = 0,
|
||||
|
||||
/// <summary>HLS-сегменты отрендеренных заставок.</summary>
|
||||
BumperAssets = 1,
|
||||
|
||||
/// <summary>Исходники, оставленные после нарезки (<c>Storage:KeepOriginals</c>).</summary>
|
||||
Originals = 2,
|
||||
|
||||
/// <summary>Автоматический inbox — то, что ещё не разобрано сканером.</summary>
|
||||
Inbox = 3,
|
||||
|
||||
/// <summary>Ручной inbox — то, что админ забирает в шоу сам.</summary>
|
||||
ManualInbox = 4,
|
||||
|
||||
/// <summary>Перевалочный каталог заливок: всё, что здесь залежалось, — след оборванной загрузки.</summary>
|
||||
Uploads = 5,
|
||||
|
||||
/// <summary>Звук и фоны блоков заставок (сырые файлы, не нарезка).</summary>
|
||||
BumperSources = 6,
|
||||
|
||||
/// <summary>Общий реестр изображений: постеры, кадры, логотипы.</summary>
|
||||
Images = 7,
|
||||
|
||||
/// <summary>Всё прочее под корнем хранилища — обычно ноль, а если нет, то это и надо увидеть.</summary>
|
||||
Other = 8,
|
||||
}
|
||||
|
||||
/// <param name="Bytes">Объём области, байт.</param>
|
||||
/// <param name="Files">Число файлов — по нему видно, что «пусто» и «много мелочи» это разные беды.</param>
|
||||
public sealed record StorageAreaUsage(StorageArea Area, long Bytes, int Files);
|
||||
|
||||
/// <param name="RootPath">Корень хранилища — на сервере их может быть несколько, и надо видеть, о каком речь.</param>
|
||||
/// <param name="VolumeTotalBytes">Размер тома целиком; 0 — файловая система не отдала метрику.</param>
|
||||
/// <param name="VolumeFreeBytes">Свободно на томе. Свободное место делится со всем, что есть на диске.</param>
|
||||
/// <param name="MinFreeSpaceBytes">Порог, ниже которого загрузка отклоняется; едет вместе с цифрами тома, чтобы их было с чем сравнить.</param>
|
||||
/// <param name="ComputedAt">Когда посчитано: обход дерева кэшируется, и возраст цифры надо показывать.</param>
|
||||
public sealed record StorageUsage(
|
||||
string RootPath,
|
||||
long VolumeTotalBytes,
|
||||
long VolumeFreeBytes,
|
||||
long StorageBytes,
|
||||
int StorageFiles,
|
||||
long MinFreeSpaceBytes,
|
||||
IReadOnlyList<StorageAreaUsage> Areas,
|
||||
DateTimeOffset ComputedAt
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Порт подсчёта занятого места. Размеров в БД нет и не будет: правда о диске лежит на диске,
|
||||
/// а любое дублирование в таблицах разошлось бы с ней при первой же ручной правке файлов.
|
||||
///
|
||||
/// Обход дерева стоит дорого (сегментов — сотни тысяч), поэтому результат кэшируется реализацией,
|
||||
/// а <paramref name="refresh"/> — это явная кнопка «пересчитать» в админке.
|
||||
/// </summary>
|
||||
public interface IStorageInspector
|
||||
{
|
||||
Task<StorageUsage> InspectAsync(
|
||||
IReadOnlySet<Guid> generatedAssetIds,
|
||||
bool refresh,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Чем занят диск. <paramref name="Refresh"/> — явный пересчёт: обход дерева долгий, поэтому
|
||||
/// обычный запрос отдаёт кэш и его возраст.
|
||||
/// </summary>
|
||||
public sealed record GetStorageStatsQuery(bool Refresh = false) : IQuery<StorageStatsDto>;
|
||||
|
||||
public sealed record StorageAreaDto(StorageArea Area, long Bytes, int Files);
|
||||
|
||||
/// <param name="RootPath">Корень хранилища — на сервере их может быть несколько, и надо видеть, о каком речь.</param>
|
||||
/// <param name="MinFreeSpaceBytes">Порог, ниже которого загрузка отклоняется (<c>Storage:MinFreeSpaceBytes</c>).</param>
|
||||
public sealed record StorageStatsDto(
|
||||
string RootPath,
|
||||
long VolumeTotalBytes,
|
||||
long VolumeFreeBytes,
|
||||
long StorageBytes,
|
||||
int StorageFiles,
|
||||
long MinFreeSpaceBytes,
|
||||
IReadOnlyList<StorageAreaDto> Areas,
|
||||
DateTimeOffset ComputedAt
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Storage;
|
||||
|
||||
public sealed class GetStorageStatsQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
IStorageInspector inspector
|
||||
) : IQueryHandler<GetStorageStatsQuery, StorageStatsDto>
|
||||
{
|
||||
public async Task<StorageStatsDto> Handle(
|
||||
GetStorageStatsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
// Сегменты программ и заставок лежат в одном каталоге; отличить их можно только по базе.
|
||||
var generated = await dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a => a.Source == MediaSource.Generated)
|
||||
.Select(a => a.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var usage = await inspector.InspectAsync(
|
||||
generated.ToHashSet(),
|
||||
query.Refresh,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return new StorageStatsDto(
|
||||
usage.RootPath,
|
||||
usage.VolumeTotalBytes,
|
||||
usage.VolumeFreeBytes,
|
||||
usage.StorageBytes,
|
||||
usage.StorageFiles,
|
||||
usage.MinFreeSpaceBytes,
|
||||
usage.Areas.Select(a => new StorageAreaDto(a.Area, a.Bytes, a.Files)).ToList(),
|
||||
usage.ComputedAt
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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