diff --git a/.env.example b/.env.example index 0448261..f93428c 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,9 @@ Storage__LiveWindowSegments=10 Storage__KeepOriginals=false # Порог свободного места, ниже которого загрузка отклоняется (10 ГБ). Storage__MinFreeSpaceBytes=10737418240 +# Через сколько часов простоя убирать брошенное в uploads/ (след оборванной заливки или рестарта +# посреди неё). Идущую загрузку не задевает — отсчёт от времени последней записи. 0 — не убирать. +# Storage__StaleUploadHours=24 # TTL stream-токена (cookie tw_stream), минуты. Короткий срок ограничивает окно доступа после # блокировки/логаута; фронт перевыпускает cookie, пока идёт просмотр. По умолчанию 30. # Storage__StreamTokenMinutes=30 diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs index 5d71fc4..55a5d59 100644 --- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs @@ -88,13 +88,27 @@ public static class MediaEndpoints var extension = Path.GetExtension(fileName); var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken); - var result = await sender.Send( - new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName), - cancellationToken - ); + // Файл уже на диске в uploads/, но в БД его ещё нет и ссылок на токен нигде не остаётся: + // любой исход, кроме успешной регистрации, обязан за собой убрать. Удаляем и при исключении + // (оборванный запрос, сбой БД) — иначе гигабайты остаются в uploads/ до уборщика. + // Токен отмены для уборки не пробрасываем: при обрыве запроса он уже сработал. + Result result; + try + { + result = await sender.Send( + new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName), + cancellationToken + ); + } + catch + { + await storage.DeleteUploadAsync(token, CancellationToken.None); + throw; + } + if (!result.IsSuccess) { - await storage.DeleteUploadAsync(token, cancellationToken); + await storage.DeleteUploadAsync(token, CancellationToken.None); return result.ToHttpResult(); } diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index 467fd1d..f0d0dca 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -163,6 +163,7 @@ public static class DependencyInjection services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); services.AddHostedService(); } } diff --git a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs index 1266b2f..e3be26f 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs @@ -32,18 +32,43 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant(); var path = paths.UploadPath(token); - await using var file = new FileStream( - path, - FileMode.CreateNew, - FileAccess.Write, - FileShare.None, - CopyBufferSize, - useAsync: true - ); - await content.CopyToAsync(file, CopyBufferSize, cancellationToken); + try + { + await using var file = new FileStream( + path, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + CopyBufferSize, + useAsync: true + ); + await content.CopyToAsync(file, CopyBufferSize, cancellationToken); + } + catch + { + // Заливка оборвалась (клиент отвалился, отмена, диск) — недописанный файл никому не + // нужен: токен наружу не ушёл, и подобрать его некому. Сносим сразу, а не оставляем + // уборщику: это гигабайты. Поток к этому моменту уже закрыт — using вышел из области. + TryDelete(path); + throw; + } + return token; } + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Не наш день: файл заперт. Подберёт UploadsCleanupBackgroundService. + } + } + public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken) { var path = paths.UploadPath(uploadToken); diff --git a/backend/src/TeleWave.Infrastructure/Media/StorageOptions.cs b/backend/src/TeleWave.Infrastructure/Media/StorageOptions.cs index 80d3996..be8b08b 100644 --- a/backend/src/TeleWave.Infrastructure/Media/StorageOptions.cs +++ b/backend/src/TeleWave.Infrastructure/Media/StorageOptions.cs @@ -15,4 +15,12 @@ public sealed class StorageOptions /// Порог свободного места, ниже которого загрузка отклоняется, байт. public long MinFreeSpaceBytes { get; init; } = 10L * 1024 * 1024 * 1024; + + /// + /// Через сколько часов простоя удалять брошенный файл в uploads/. Каталог перевалочный: + /// после регистрации файл уходит в originals/, и всё, что там залежалось, — след от + /// оборванной заливки или аварийного рестарта. Идущую заливку порог не задевает: она правит + /// файл, и отсчёт идёт от времени последней записи. 0 — уборку не делать. + /// + public int StaleUploadHours { get; init; } = 24; } diff --git a/backend/src/TeleWave.Infrastructure/Media/UploadsCleaner.cs b/backend/src/TeleWave.Infrastructure/Media/UploadsCleaner.cs new file mode 100644 index 0000000..a1dddc2 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/UploadsCleaner.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Logging; + +namespace TeleWave.Infrastructure.Media; + +/// +/// Уборка перевалочного каталога uploads/: удаляет файлы, которых не касались дольше порога. +/// После регистрации ассета файл уходит в originals/, а токен загрузки нигде не хранится — +/// значит, всё залежавшееся в uploads/ это след оборванной заливки или аварийного рестарта, +/// и подобрать его некому. +/// +public static class UploadsCleaner +{ + /// + /// Один проход. Порог считается от времени последней записи, а не создания: идущая многочасовая + /// заливка правит файл и под него не попадает. + /// + public static void Sweep(MediaPathResolver paths, TimeSpan retention, ILogger logger) + { + try + { + if (!Directory.Exists(paths.UploadsDir)) + return; + + var deadline = DateTime.UtcNow - retention; + foreach (var path in Directory.EnumerateFiles(paths.UploadsDir)) + DeleteIfStale(path, deadline, logger); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка уборки uploads/"); + } + } + + private static void DeleteIfStale(string path, DateTime deadline, ILogger logger) + { + try + { + var file = new FileInfo(path); + if (!file.Exists || file.LastWriteTimeUtc > deadline) + return; + + // Метрики снимаем до удаления: после него FileInfo их уже не отдаст. + var size = file.Length; + var lastWrite = file.LastWriteTimeUtc; + file.Delete(); + logger.LogInformation( + "Удалён брошенный файл загрузки {Name} ({Size} байт, последняя запись {LastWrite:u})", + file.Name, + size, + lastWrite + ); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Файл занят или права не те — попробуем на следующем тике. + logger.LogWarning(ex, "Не удалось удалить файл загрузки {Path}", path); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Media/UploadsCleanupBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Media/UploadsCleanupBackgroundService.cs new file mode 100644 index 0000000..af2691d --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/UploadsCleanupBackgroundService.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace TeleWave.Infrastructure.Media; + +/// +/// Расписание уборки uploads/ (сама уборка — ): проход на старте +/// и раз в час. Удаление недописанного файла на месте +/// () закрывает штатные обрывы, этот сервис — +/// страховка от аварийного рестарта посреди заливки. +/// +public sealed class UploadsCleanupBackgroundService( + MediaPathResolver paths, + IOptions options, + ILogger logger +) : BackgroundService +{ + /// Как часто заглядывать в каталог. Порог измеряется часами — чаще незачем. + private static readonly TimeSpan Period = TimeSpan.FromHours(1); + + private readonly StorageOptions _storage = options.Value; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (_storage.StaleUploadHours <= 0) + { + logger.LogInformation("Уборка uploads/ выключена (Storage:StaleUploadHours = 0)."); + return; + } + + var retention = TimeSpan.FromHours(_storage.StaleUploadHours); + paths.EnsureDirectories(); + + // Первый проход сразу: после аварийного рестарта недописанное лежит с прошлого запуска. + UploadsCleaner.Sweep(paths, retention, logger); + + using var timer = new PeriodicTimer(Period); + while (await timer.WaitForNextTickAsync(stoppingToken)) + UploadsCleaner.Sweep(paths, retention, logger); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Media/UploadsCleanupTests.cs b/backend/tests/TeleWave.Application.Tests/Media/UploadsCleanupTests.cs new file mode 100644 index 0000000..87bb948 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Media/UploadsCleanupTests.cs @@ -0,0 +1,121 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using TeleWave.Infrastructure.Media; +using Xunit; + +namespace TeleWave.Application.Tests.Media; + +/// +/// Уборка перевалочного каталога: в uploads/ не должно оставаться файлов, на которые никто +/// не сослался — это гигабайты на диске, невидимые в админке. +/// +public class UploadsCleanupTests : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), + "telewave-tests", + Guid.NewGuid().ToString("N") + ); + + private MediaPathResolver Paths() => + new(Options.Create(new StorageOptions { RootPath = _root })); + + [Fact] + public async Task DeletesPartialUpload_WhenStreamFails() + { + var paths = Paths(); + paths.EnsureDirectories(); + var storage = new FileSystemMediaStorage(paths); + + await Assert.ThrowsAsync(() => + storage.SaveUploadAsync(new FailingStream(), ".mkv", CancellationToken.None) + ); + + Assert.Empty(Directory.GetFiles(paths.UploadsDir)); + } + + [Fact] + public void Sweep_DeletesStaleFilesOnly() + { + var paths = Paths(); + paths.EnsureDirectories(); + var stale = Path.Combine(paths.UploadsDir, "stale.mkv"); + var fresh = Path.Combine(paths.UploadsDir, "fresh.mkv"); + // Идущую заливку от брошенного файла отличает именно время последней записи. + WriteAged(stale, TimeSpan.FromHours(25)); + WriteAged(fresh, TimeSpan.Zero); + + UploadsCleaner.Sweep(paths, TimeSpan.FromHours(24), NullLogger.Instance); + + Assert.False(File.Exists(stale)); + Assert.True(File.Exists(fresh)); + } + + [Fact] + public void Sweep_SurvivesMissingDirectory() + { + // Каталога может не быть на самом первом старте — это не повод падать. + UploadsCleaner.Sweep(Paths(), TimeSpan.FromHours(24), NullLogger.Instance); + } + + /// + /// Кладёт файл и «состаривает» его. Отметку перепроверяем: Windows дописывает время последней + /// записи при закрытии handle и может затереть выставленное сразу после записи. + /// + private static void WriteAged(string path, TimeSpan age) + { + File.WriteAllText(path, "x"); + if (age == TimeSpan.Zero) + return; + + var stamp = DateTime.UtcNow - age; + for (var attempt = 0; attempt < 20; attempt++) + { + File.SetLastWriteTimeUtc(path, stamp); + if ((File.GetLastWriteTimeUtc(path) - stamp).Duration() < TimeSpan.FromSeconds(1)) + return; + Thread.Sleep(20); + } + + Assert.Fail("Не удалось выставить файлу время последней записи."); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + if (Directory.Exists(_root)) + Directory.Delete(_root, recursive: true); + } + + /// Поток, обрывающийся на середине, — имитация отвалившегося клиента. + private sealed class FailingStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) => + throw new IOException("Соединение разорвано."); + + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) => throw new IOException("Соединение разорвано."); + + public override void Flush() { } + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + } +} diff --git a/docs/media-storage-and-streaming.md b/docs/media-storage-and-streaming.md index 3698331..8f26caf 100644 --- a/docs/media-storage-and-streaming.md +++ b/docs/media-storage-and-streaming.md @@ -162,7 +162,7 @@ UTC; локальный пояс показывает фронтенд. Разд ``` /media ├── inbox/ # ручная укладка файлов (SFTP/rsync), подбирается сканером -├── uploads/ # незавершённые chunked-загрузки из админки +├── uploads/ # незавершённые загрузки из админки; регистрация уносит файл в originals/ ├── originals/{assetId}/ # исходник; удаляется после успешной нарезки └── assets/{assetId}/ ├── seg00000.ts … # сегменты строго по 2с @@ -227,6 +227,7 @@ Storage__SegmentSeconds=2 Storage__LiveWindowSegments=10 Storage__KeepOriginals=false Storage__MinFreeSpaceBytes=10737418240 +Storage__StaleUploadHours=24 Media__FfmpegPath=/usr/bin/ffmpeg Media__FfprobePath=/usr/bin/ffprobe Media__MaxUploadBytes=21474836480 @@ -264,6 +265,13 @@ Scheduler__TickMinutes=30 буферизации тела в память: chunked-куски пишутся в `uploads/`, `complete` делает атомарный `move` внутри той же ФС. +`uploads/` — перевалочный каталог: токен загрузки нигде не хранится, поэтому всё, что там осталось +после запроса, — мусор, на который никто не сослался. Убирается в два эшелона: недописанный файл +сносится на месте (обрыв заливки, отказ регистрации, сбой БД), а `UploadsCleanupBackgroundService` +раз в час подметает залежавшееся дольше `Storage__StaleUploadHours` (0 — уборку не делать) — это +страховка от аварийного рестарта посреди заливки. Порог считается от времени последней записи, +поэтому идущая многочасовая загрузка под него не попадает. + ## Открытые эксплуатационные вопросы - Удаление ассета/шоу запрещать, пока на них ссылается будущее расписание или пул канала. diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx index 1c3a286..6a8420b 100644 --- a/frontend/src/features/admin/shows/ShowDetail.tsx +++ b/frontend/src/features/admin/shows/ShowDetail.tsx @@ -170,7 +170,9 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) { audienceMutation.mutate(v === AUDIENCE_UNSET ? null : (v as ShowAudience)) } > - + {/* Ширина под самую длинную расшифровку рейтинга («NC-17 — только взрослым»): + у́же — и она переносится на вторую строку, ломая высоту строки заголовка. */} + diff --git a/frontend/src/features/admin/shows/ShowsPanel.tsx b/frontend/src/features/admin/shows/ShowsPanel.tsx index 6180460..4767cc5 100644 --- a/frontend/src/features/admin/shows/ShowsPanel.tsx +++ b/frontend/src/features/admin/shows/ShowsPanel.tsx @@ -118,7 +118,8 @@ export function ShowsPanel() { value={audience ?? AUDIENCE_UNSET} onValueChange={(v) => setAudience(v === AUDIENCE_UNSET ? null : (v as ShowAudience))} > - + {/* Та же ширина, что у селекта на карточке шоу: расшифровки рейтингов длинные. */} +