diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs index e678c3a..bf0b3f5 100644 --- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs @@ -10,6 +10,7 @@ using TeleWave.Application.Media.ListMedia; using TeleWave.Application.Media.ManualInbox; using TeleWave.Application.Media.MovieImport; using TeleWave.Application.Media.Register; +using TeleWave.Application.Media.Retry; using TeleWave.Application.Media.Stats; using TeleWave.Domain.Media; using TeleWave.Infrastructure.Identity; @@ -30,6 +31,10 @@ public static class MediaEndpoints admin.MapGet("/stats", Stats).Produces(); admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent); + // Перезапуск упавшей обработки: файл на месте, повторить нарезку — обычное дело. + admin.MapPost("/{id:guid}/retry", Retry).Produces(); + admin.MapPost("/retry-failed", RetryFailed).Produces(); + // Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу. admin.MapGet("/manual", ListManual).Produces(); admin.MapPost("/manual/import", ImportManual).Produces(); @@ -48,6 +53,25 @@ public static class MediaEndpoints return app; } + private static async Task Retry( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new RetryMediaCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task RetryFailed( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new RetryMediaCommand(), cancellationToken); + return result.ToHttpResult(); + } + /// /// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName». /// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку. diff --git a/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs b/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs index e3c5c4c..076028f 100644 --- a/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs +++ b/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs @@ -38,6 +38,22 @@ public sealed class DeleteMediaAssetCommandHandler(IAppDbContext dbContext, IMed MediaErrors.InUse(await UsedByAsync(asset.Id, cancellationToken)) ); + // Серия ссылается на ассет без внешнего ключа, поэтому БД её не тронет — и в шоу остаётся + // строка, за которой нет файла. Планировщик такую серию молча пропускает (в эфир идут + // только готовые ассеты), а в карточке шоу она числится: «серий 61, в эфире 60» без + // единого объяснения. Снимаем сами, через шоу: порядок серий — его забота. + var owners = await dbContext + .Shows.Include(s => s.Episodes) + .Where(s => s.Episodes.Any(e => e.MediaAssetId == asset.Id)) + .ToListAsync(cancellationToken); + + foreach (var show in owners) + { + var episodes = show.Episodes.Where(e => e.MediaAssetId == asset.Id).ToList(); + foreach (var episode in episodes) + show.RemoveEpisode(episode.Id); + } + // Сначала коммитим удаление в БД, потом чистим файлы (best-effort): orphan-файл на диске // безопаснее, чем строка БД, ссылающаяся на уже удалённые артефакты. var extension = asset.OriginalExtension; diff --git a/backend/src/TeleWave.Application/Media/MediaErrors.cs b/backend/src/TeleWave.Application/Media/MediaErrors.cs index bbda962..6b34f7b 100644 --- a/backend/src/TeleWave.Application/Media/MediaErrors.cs +++ b/backend/src/TeleWave.Application/Media/MediaErrors.cs @@ -9,6 +9,11 @@ public static class MediaErrors "Медиа-ассет не найден." ); + public static readonly Error NotFailed = Error.Validation( + "Media.NotFailed", + "Перезапускать нечего: ассет не в состоянии ошибки." + ); + public static readonly Error UnsupportedFormat = Error.Validation( "Media.UnsupportedFormat", "Неподдерживаемое расширение файла." diff --git a/backend/src/TeleWave.Application/Media/Retry/RetryMediaCommand.cs b/backend/src/TeleWave.Application/Media/Retry/RetryMediaCommand.cs new file mode 100644 index 0000000..1ecb4e4 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/Retry/RetryMediaCommand.cs @@ -0,0 +1,11 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Media.Retry; + +/// +/// Вернуть упавшую обработку в очередь. = null — все упавшие разом: падают +/// они обычно пачкой и по одной причине (кончилось место, ffmpeg упёрся в таймаут), и чинят их +/// тоже пачкой. +/// +public sealed record RetryMediaCommand(Guid? Id = null) : ICommand>; diff --git a/backend/src/TeleWave.Application/Media/Retry/RetryMediaCommandHandler.cs b/backend/src/TeleWave.Application/Media/Retry/RetryMediaCommandHandler.cs new file mode 100644 index 0000000..54ea348 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/Retry/RetryMediaCommandHandler.cs @@ -0,0 +1,49 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Media; + +namespace TeleWave.Application.Media.Retry; + +/// +/// Возвращает упавшие ассеты в очередь обработки. +/// +/// Перезапуск — не редкость, а норма эксплуатации: ffmpeg упирается в таймаут на длинном фильме, +/// кончается место, обрывается диск. Файл при этом на месте, и единственное, чего не хватало, — +/// кнопки «попробовать ещё раз»; без неё оставалось удалить и залить заново. +/// +public sealed class RetryMediaCommandHandler(IAppDbContext dbContext, IMediaProcessingQueue queue) + : ICommandHandler> +{ + public async Task> Handle( + RetryMediaCommand command, + CancellationToken cancellationToken + ) + { + // Берём только упавшие: сбросить идущую обработку значит оставить ffmpeg работать в пустоту, + // а готовый ассет — переработать то, что уже в эфире. + var query = dbContext.MediaAssets.Where(a => a.Status == MediaAssetStatus.Failed); + if (command.Id is { } id) + query = query.Where(a => a.Id == id); + + var assets = await query.ToListAsync(cancellationToken); + + // Просили конкретный, а его среди упавших нет: либо его нет вовсе, либо он не падал — + // в обоих случаях перезапускать нечего, и молчать об этом нельзя. + if (command.Id is not null && assets.Count == 0) + return Result.Failure(MediaErrors.NotFailed); + + foreach (var asset in assets) + asset.ResetToPending(); + + await dbContext.SaveChangesAsync(cancellationToken); + + // Будим обработчик после сохранения: он читает статус из базы и до коммита увидел бы ещё + // Failed. + foreach (var asset in assets) + queue.Enqueue(asset.Id); + + return Result.Success(new RetryMediaResultDto(assets.Count)); + } +} diff --git a/backend/src/TeleWave.Application/Media/Retry/RetryMediaResultDto.cs b/backend/src/TeleWave.Application/Media/Retry/RetryMediaResultDto.cs new file mode 100644 index 0000000..e896a1b --- /dev/null +++ b/backend/src/TeleWave.Application/Media/Retry/RetryMediaResultDto.cs @@ -0,0 +1,4 @@ +namespace TeleWave.Application.Media.Retry; + +/// Сколько ассетов вернулось в очередь. +public sealed record RetryMediaResultDto(int Requeued); diff --git a/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ChannelDebugCollector.cs b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ChannelDebugCollector.cs index 54038a0..45713c9 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ChannelDebugCollector.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ChannelDebugCollector.cs @@ -266,9 +266,14 @@ public sealed class ChannelDebugCollector( - `library-collections.json` — коллекции и порядок частей: по нему считается «часть N из M». - `library-interstitials.json` — ролики: длительности и состояние. - `library-media.json` — свод по ассетам и поимённо всё, что не доехало до Ready. + - `issues.json` — что в библиотеке сломано прямо сейчас: упавшие серии, серии без ассета, + шоу без единой играбельной серии, пустые группы и разошедшийся кэш статистики. - `bumpers.json` — блоки заставок: подблоки, триггеры, строки, длина звука. - `groups-composition.json` — группы как настроены и что из этого вычислилось (у динамических состав живёт в правиле), плюс кэш статистики для сверки. + `unitCount` — сколько единиц заведено, `playableUnits` — сколько из них с готовым + ассетом: планировщик видит только вторые, и расхождение объясняет «почему в эфир + пошло меньше». `groups.json` рядом — только те группы, что участвуют в этом канале. Времена везде UTC. Время канала = UTC{( channel.UtcOffsetMinutes < 0 ? "-" : "+" diff --git a/backend/src/TeleWave.Application/Programming/Planning/DebugExport/LibraryDebugCollector.cs b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/LibraryDebugCollector.cs index 8a2b2e0..0335dd7 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/DebugExport/LibraryDebugCollector.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/LibraryDebugCollector.cs @@ -64,6 +64,13 @@ public sealed class LibraryDebugCollector( .ToListAsync(cancellationToken); var showNames = shows.ToDictionary(s => s.Id, s => s.Name); + var groupSnapshots = await GroupSnapshotsAsync( + groups, + showNames, + collections, + Playable(shows, assets), + cancellationToken + ); return [ @@ -72,13 +79,101 @@ public sealed class LibraryDebugCollector( new DebugFile("library-interstitials.json", InterstitialFacts(shows, assets)), new DebugFile("library-media.json", MediaFacts(assets, shows)), new DebugFile("bumpers.json", BumperFacts(bumpers)), - new DebugFile( - "groups-composition.json", - await GroupFactsAsync(groups, showNames, collections, cancellationToken) - ), + new DebugFile("issues.json", Issues(shows, assets, groups, groupSnapshots)), + new DebugFile("groups-composition.json", groupSnapshots.Select(s => s.Facts)), ]; } + /// + /// Что в библиотеке сломано прямо сейчас. Дамп собирают, когда эфир выглядит не так, как ждали, + /// и первые подозреваемые всегда одни и те же: файл не дорезался, серия висит без ассета, + /// счётчик группы устарел. Искать их по трём файлам глазами каждый раз — потерянное время, + /// поэтому они собраны здесь списком. + /// + /// Это не пост-проверки эфира (те живут в планировщике и предупреждают при генерации), а состояние + /// библиотеки: оно ломает эфир ещё до того, как планировщик что-то решит. + /// + private static object Issues( + IReadOnlyList shows, + IReadOnlyDictionary assets, + IReadOnlyList groups, + IReadOnlyList snapshots + ) + { + var failed = shows + .SelectMany(s => s.Episodes.Select(e => (Show: s, Episode: e))) + .Select(pair => + ( + pair.Show, + pair.Episode, + Asset: assets.GetValueOrDefault(pair.Episode.MediaAssetId) + ) + ) + .Where(x => x.Asset is not null && x.Asset.Status != MediaAssetStatus.Ready) + .ToList(); + + var orphanEpisodes = shows + .SelectMany(s => s.Episodes.Select(e => (Show: s, Episode: e))) + .Where(pair => !assets.ContainsKey(pair.Episode.MediaAssetId)) + .ToList(); + + var emptyShows = shows + .Where(s => + s.Episodes.Count == 0 + || s.Episodes.All(e => + assets.GetValueOrDefault(e.MediaAssetId) + is not { Status: MediaAssetStatus.Ready, Duration: not null } + ) + ) + .ToList(); + + return new + { + // Файл на месте, нарезка упала: лечится перезапуском обработки, а до тех пор шоу + // числится в библиотеке, но в эфир не идёт. + FailedEpisodes = failed.Select(x => new + { + Show = x.Show.Name, + x.Episode.Position, + File = x.Asset!.OriginalFileName, + x.Asset.Status, + x.Asset.ErrorMessage, + }), + // Серия есть, ассета нет вовсе: удаление в обход админки. В эфир не идёт и молчит. + OrphanEpisodes = orphanEpisodes.Select(x => new + { + Show = x.Show.Name, + x.Episode.Position, + x.Episode.MediaAssetId, + }), + // Шоу без единой играбельной серии: в группе оно числится, в эфире его нет никогда. + ShowsWithoutPlayableEpisodes = emptyShows.Select(s => new + { + s.Id, + s.Name, + s.Kind, + Episodes = s.Episodes.Count, + }), + // Пустая группа — дыра в сетке: слот по ней встанет, место закроет фон. + EmptyGroups = groups + .Where(g => g.Items.Count == 0 && g.Mode == GroupMode.Static) + .Select(g => new { g.Id, g.Name }), + // Кэш статистики считается при правке состава, а динамическую группу меняет библиотека: + // старая отметка означает, что цифры в карточке группы могут врать. + StaleGroupStats = snapshots + .Where(s => s.Group.UnitCount != s.Units) + .Select(s => new + { + s.Group.Id, + s.Group.Name, + CachedUnits = s.Group.UnitCount, + ActualUnits = s.Units, + PlayableUnits = s.PlayableUnits, + s.Group.StatsComputedAt, + }), + }; + } + /// Ассет в дампе: этого хватает, чтобы понять, почему серия не пошла в эфир. private sealed record AssetFacts( Guid Id, @@ -315,15 +410,41 @@ public sealed class LibraryDebugCollector( /// Рядом лежит кэш статистики: если он разошёлся с вычисленным составом, значит библиотека /// поменялась после последнего пересчёта — и цифры в карточке группы врут. /// - private async Task GroupFactsAsync( + /// + /// Сколько единиц шоу реально может выйти в эфир: только серии с готовым ассетом. Именно это + /// число видит планировщик, тогда как счётчик группы считает все заведённые серии — расхождение + /// между ними и есть ответ на «почему в эфир пошло меньше, чем заведено». + /// + private static Dictionary Playable( + IReadOnlyList shows, + IReadOnlyDictionary assets + ) => + shows.ToDictionary( + show => show.Id, + show => + show.Episodes.Count(e => + assets.GetValueOrDefault(e.MediaAssetId) + is { Status: MediaAssetStatus.Ready, Duration: not null } + ) + ); + + /// Группа в дампе: готовые факты и числа, по которым сверяется её кэш статистики. + private sealed record GroupSnapshot(Group Group, object Facts, int Units, int PlayableUnits); + + private async Task> GroupSnapshotsAsync( IReadOnlyList groups, IReadOnlyDictionary showNames, IReadOnlyList collections, + IReadOnlyDictionary playableByShow, CancellationToken cancellationToken ) { var collectionNames = collections.ToDictionary(c => c.Id, c => c.Name); - var facts = new List(groups.Count); + var collectionParts = collections.ToDictionary( + c => c.Id, + c => c.Items.Select(i => i.ShowId).ToList() + ); + var snapshots = new List(groups.Count); foreach (var group in groups.OrderBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase)) { @@ -333,56 +454,79 @@ public sealed class LibraryDebugCollector( cancellationToken ); - facts.Add( - new + var composition = resolved + .Select(element => + ( + Units: info.GetValueOrDefault((element.Kind, element.Id))?.UnitCount ?? 0, + Playable: PlayableOf(element.Kind, element.Id) + ) + ) + .ToList(); + + var facts = new + { + group.Id, + group.Name, + group.Mode, + Filter = GroupFilter.FromJson(group.FilterJson), + StatsCache = new { - group.Id, - group.Name, - group.Mode, - Filter = GroupFilter.FromJson(group.FilterJson), - StatsCache = new + group.ItemCount, + group.UnitCount, + group.TotalDuration, + group.StatsComputedAt, + }, + Items = group + .Items.OrderBy(i => i.Position) + .Select(item => new { - group.ItemCount, - group.UnitCount, - group.TotalDuration, - group.StatsComputedAt, - }, - Items = group - .Items.OrderBy(i => i.Position) - .Select(item => new - { - item.Position, - item.ElementKind, - item.ElementId, - Name = Name(item.ElementKind, item.ElementId), - item.Weight, - item.Role, - }), - Composition = resolved.Select(element => new - { - element.Kind, - element.Id, - Name = Name(element.Kind, element.Id), - element.Position, - element.Weight, - element.Pinned, - UnitCount = info.GetValueOrDefault((element.Kind, element.Id))?.UnitCount, - Minutes = info.GetValueOrDefault((element.Kind, element.Id)) - is { } resolvedInfo - ? Math.Round(resolvedInfo.TotalDuration.TotalMinutes, 1) - : (double?)null, - ShowKind = info.GetValueOrDefault((element.Kind, element.Id))?.ShowKind, - Audience = info.GetValueOrDefault((element.Kind, element.Id))?.Audience, + item.Position, + item.ElementKind, + item.ElementId, + Name = Name(item.ElementKind, item.ElementId), + item.Weight, + item.Role, }), - } + Composition = resolved.Select(element => new + { + element.Kind, + element.Id, + Name = Name(element.Kind, element.Id), + element.Position, + element.Weight, + element.Pinned, + UnitCount = info.GetValueOrDefault((element.Kind, element.Id))?.UnitCount, + PlayableUnits = PlayableOf(element.Kind, element.Id), + Minutes = info.GetValueOrDefault((element.Kind, element.Id)) is { } resolvedInfo + ? Math.Round(resolvedInfo.TotalDuration.TotalMinutes, 1) + : (double?)null, + ShowKind = info.GetValueOrDefault((element.Kind, element.Id))?.ShowKind, + Audience = info.GetValueOrDefault((element.Kind, element.Id))?.Audience, + }), + }; + + snapshots.Add( + new GroupSnapshot( + group, + facts, + composition.Sum(c => c.Units), + composition.Sum(c => c.Playable) + ) ); } - return facts; + return snapshots; string? Name(GroupElementKind kind, Guid id) => kind == GroupElementKind.Collection ? collectionNames.GetValueOrDefault(id) : showNames.GetValueOrDefault(id); + + int PlayableOf(GroupElementKind kind, Guid id) => + kind == GroupElementKind.Collection + ? collectionParts + .GetValueOrDefault(id, []) + .Sum(showId => playableByShow.GetValueOrDefault(showId)) + : playableByShow.GetValueOrDefault(id); } } diff --git a/backend/src/TeleWave.Infrastructure/Broadcast/MaintenanceBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Broadcast/MaintenanceBackgroundService.cs index 97806aa..0a8fa20 100644 --- a/backend/src/TeleWave.Infrastructure/Broadcast/MaintenanceBackgroundService.cs +++ b/backend/src/TeleWave.Infrastructure/Broadcast/MaintenanceBackgroundService.cs @@ -3,16 +3,21 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Programming.Groups; namespace TeleWave.Infrastructure.Broadcast; /// /// Обслуживание, которое раньше не делалось вовсе: чистка отрендеренных заставок, на которые больше -/// никто не ссылается. Они копятся при каждой смене пары шоу и при каждой правке блока заставки, -/// а после перехода на длинное хранение расписания их станет заметно больше. +/// никто не ссылается, снятие серий без ассета и пересчёт статистики групп. +/// +/// Общее у этих трёх — их некому делать по событию: заставки копятся при каждой смене пары шоу, +/// серия остаётся висеть после удаления ассета в обход админки, а статистика динамической группы +/// устаревает молча — состав считает правило, и меняется он вместе с библиотекой, а не по правке +/// группы. /// /// Идёт по расписанию отдельно от генерации: та держит advisory-лок канала, и подмешивать в неё -/// удаление по всей таблице не нужно. +/// проходы по всей таблице не нужно. /// public sealed class MaintenanceBackgroundService( IServiceScopeFactory scopeFactory, @@ -65,5 +70,53 @@ public sealed class MaintenanceBackgroundService( if (removed > 0) logger.LogInformation("Обслуживание: удалено осиротевших заставок — {Count}", removed); + + await RemoveOrphanEpisodesAsync(db, cancellationToken); + await RecomputeGroupStatsAsync(scope.ServiceProvider, db, cancellationToken); + } + + /// + /// Снимает серии, чей ассет исчез из базы. В норме их не бывает — удаление ассета снимает + /// серию само, — но правка базы руками и удаления прежних версий такие строки оставили. + /// Висячая серия в эфир не идёт и ничем себя не выдаёт: шоу просто числится длиннее, чем есть. + /// + private async Task RemoveOrphanEpisodesAsync( + IAppDbContext db, + CancellationToken cancellationToken + ) + { + var removed = await db + .Shows.SelectMany(s => s.Episodes) + .Where(e => !db.MediaAssets.Any(a => a.Id == e.MediaAssetId)) + .ExecuteDeleteAsync(cancellationToken); + + if (removed > 0) + logger.LogInformation("Обслуживание: снято серий без ассета — {Count}", removed); + } + + /// + /// Пересчитывает статистику групп. Кэш обновляется при правке состава, но состав динамической + /// группы задан правилом: доехавший ассет или новое шоу меняют её молча, и карточка группы + /// начинает врать — «55 единиц, 0 часов» там, где на деле 66 фильмов. + /// + private async Task RecomputeGroupStatsAsync( + IServiceProvider services, + IAppDbContext db, + CancellationToken cancellationToken + ) + { + var stats = services.GetRequiredService(); + var groups = await db.Groups.Include(g => g.Items).ToListAsync(cancellationToken); + + foreach (var group in groups) + await stats.RecomputeAsync(group, cancellationToken); + + await db.SaveChangesAsync(cancellationToken); + + if (groups.Count > 0) + logger.LogInformation( + "Обслуживание: пересчитана статистика групп — {Count}", + groups.Count + ); } } diff --git a/backend/tests/TeleWave.Application.Tests/Media/RetryMediaTests.cs b/backend/tests/TeleWave.Application.Tests/Media/RetryMediaTests.cs new file mode 100644 index 0000000..d77842e --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Media/RetryMediaTests.cs @@ -0,0 +1,155 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Media; +using TeleWave.Application.Media.Delete; +using TeleWave.Application.Media.Retry; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using Xunit; + +namespace TeleWave.Application.Tests.Media; + +/// +/// Перезапуск упавшей обработки. Файл при этом на месте — упала только нарезка, и повторить её +/// обычно и есть всё лечение; до этого оставалось удалить ассет и залить заново. +/// +public class RetryMediaTests +{ + private static MediaAsset Failed(string name) + { + var asset = MediaAsset.Register(name, ".mkv", MediaSource.Upload); + asset.MarkProcessing(); + asset.MarkFailed("Процесс /usr/bin/ffmpeg превысил таймаут"); + return asset; + } + + private static MediaAsset Ready(string name) + { + var asset = MediaAsset.Register(name, ".mkv", MediaSource.Upload); + asset.MarkProcessing(); + asset.MarkReady( + new MediaReadyInfo(TimeSpan.FromMinutes(30), 2, 900, 1920, 1080, "h264", "aac", "x") + ); + return asset; + } + + [Fact] + public async Task Retry_ReturnsFailedAssetToTheQueue() + { + var fixture = new TestDb(); + var failed = Failed("Гладиатор.mkv"); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.Add(failed); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var queue = Substitute.For(); + await using var db = fixture.New(); + var result = await new RetryMediaCommandHandler(db, queue).Handle( + new RetryMediaCommand(failed.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(1, result.Value.Requeued); + // Обработчик будят после сохранения: до коммита он прочитал бы из базы ещё Failed. + queue.Received(1).Enqueue(failed.Id); + + await using var verify = fixture.New(); + var stored = verify.MediaAssets.Single(a => a.Id == failed.Id); + Assert.Equal(MediaAssetStatus.Pending, stored.Status); + Assert.Null(stored.ErrorMessage); + } + + [Fact] + public async Task Retry_WithoutId_TakesEveryFailedButLeavesTheRest() + { + // Падают пачкой и по одной причине, чинят тоже пачкой — но готовое трогать нельзя: + // оно уже стоит в эфире. + var fixture = new TestDb(); + var first = Failed("Терминатор.mkv"); + var second = Failed("Криминальное чтиво.mkv"); + var ready = Ready("Симпсоны s01e01.mkv"); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.AddRange(first, second, ready); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var queue = Substitute.For(); + await using var db = fixture.New(); + var result = await new RetryMediaCommandHandler(db, queue).Handle( + new RetryMediaCommand(), + CancellationToken.None + ); + + Assert.Equal(2, result.Value.Requeued); + + await using var verify = fixture.New(); + Assert.Equal( + MediaAssetStatus.Ready, + verify.MediaAssets.Single(a => a.Id == ready.Id).Status + ); + Assert.All( + verify.MediaAssets.Where(a => a.Id != ready.Id).ToList(), + a => Assert.Equal(MediaAssetStatus.Pending, a.Status) + ); + } + + [Fact] + public async Task Delete_AlsoRemovesTheEpisodeFromTheShow() + { + // Серия ссылается на ассет без внешнего ключа: без этого шага в шоу остаётся строка, + // за которой нет файла, — в эфир она не идёт и ничем себя не выдаёт. + var fixture = new TestDb(); + var asset = Ready("Металлопокалипсис s01e38.mkv"); + var show = Show.Create("Металлопокалипсис", ShowKind.Series); + show.AddEpisode(asset.Id); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.Add(asset); + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new DeleteMediaAssetCommandHandler( + db, + Substitute.For() + ).Handle(new DeleteMediaAssetCommand(asset.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + + await using var verify = fixture.New(); + var stored = verify.Shows.Include(s => s.Episodes).Single(s => s.Id == show.Id); + Assert.Empty(stored.Episodes); + } + + [Fact] + public async Task Retry_OfHealthyAsset_IsRefused() + { + var fixture = new TestDb(); + var ready = Ready("Симпсоны s01e02.mkv"); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.Add(ready); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new RetryMediaCommandHandler( + db, + Substitute.For() + ).Handle(new RetryMediaCommand(ready.Id), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(MediaErrors.NotFailed, result.Error); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/ChannelDebugExportTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/ChannelDebugExportTests.cs index e2633f3..8adea6b 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/ChannelDebugExportTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/ChannelDebugExportTests.cs @@ -172,6 +172,7 @@ public class ChannelDebugExportTests "effective-grid.json", "groups-composition.json", "groups.json", + "issues.json", "junctions.json", "library-collections.json", "library-interstitials.json", @@ -235,10 +236,21 @@ public class ChannelDebugExportTests var composition = group.GetProperty("composition").EnumerateArray().Single(); Assert.Equal("Симпсоны", composition.GetProperty("name").GetString()); Assert.Equal(2, composition.GetProperty("unitCount").GetInt32()); + // «Заведено» и «может выйти» — разные числа, и в дампе они стоят рядом: их расхождение + // и есть ответ на «почему в эфир пошло меньше, чем в библиотеке». + Assert.Equal(2, composition.GetProperty("playableUnits").GetInt32()); using var media = JsonDocument.Parse(files["library-media.json"]); Assert.Equal(2, media.RootElement.GetProperty("total").GetInt32()); Assert.Empty(media.RootElement.GetProperty("problems").EnumerateArray()); + + // На здоровой библиотеке список проблем пуст — иначе он бы кричал волком при каждом дампе. + using var issues = JsonDocument.Parse(files["issues.json"]); + Assert.Empty(issues.RootElement.GetProperty("failedEpisodes").EnumerateArray()); + Assert.Empty(issues.RootElement.GetProperty("orphanEpisodes").EnumerateArray()); + Assert.Empty( + issues.RootElement.GetProperty("showsWithoutPlayableEpisodes").EnumerateArray() + ); } [Fact] diff --git a/docs/media-storage-and-streaming.md b/docs/media-storage-and-streaming.md index 81c00a5..9555209 100644 --- a/docs/media-storage-and-streaming.md +++ b/docs/media-storage-and-streaming.md @@ -124,6 +124,18 @@ Override, начинающийся в будущем, подхватится и и потолок повторов, и окно обязано покрывать самое долгое правило канала. Сегменты ассетов при этом не трогаются — ассеты переиспользуются, чистится только расписание. +**Упавшую обработку перезапускают, а не переливают.** Файл при падении остаётся на месте — упала +нарезка: ffmpeg упёрся в таймаут на длинном фильме, кончилось место, отвалился диск. Кнопка в списке +медиа возвращает ассет в `Pending` и будит обработчик; на фильтре «Ошибка» есть и массовый +перезапуск — падают такие вещи пачкой и по одной причине. Идущую обработку и готовые ассеты +перезапуск не трогает: первое оставило бы ffmpeg работать в пустоту, второе — переработало бы то, +что уже стоит в эфире. + +**Удаление ассета снимает и серию.** Серия ссылается на ассет без внешнего ключа, поэтому БД сама +её не тронет, и в шоу остаётся строка, за которой нет файла: в эфир она не идёт (играют только +готовые), а в карточке числится. Уже накопившиеся такие строки подчищает обслуживание — там же, +где чистятся осиротевшие заставки и пересчитывается статистика групп. + **Дыры.** В норме их нет. Если у канала нет ни одного включённого шоу с готовыми сериями (или планировщик отстал) — эфир играет `FillerAssetId` по кругу, пока не появится расписание. diff --git a/frontend/src/features/admin/media/MediaPanel.tsx b/frontend/src/features/admin/media/MediaPanel.tsx index 01ca71b..9b14e9b 100644 --- a/frontend/src/features/admin/media/MediaPanel.tsx +++ b/frontend/src/features/admin/media/MediaPanel.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { FolderInput, Play, Upload } from 'lucide-react' +import { FolderInput, Play, RotateCcw, Upload } from 'lucide-react' import { qk } from '@/shared/api/query-keys' import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types' import { useApiError } from '@/shared/lib/use-api-error' @@ -14,7 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { toast } from '@/shared/ui/toast-store' import { useTableSort } from '@/shared/lib/table-sort' import { SortHeader } from '@/shared/ui/sortable' -import { deleteMedia, getMediaStats, listMedia, mediaPreviewUrl } from './api' +import { deleteMedia, getMediaStats, listMedia, mediaPreviewUrl, retryMedia } from './api' import { formatDuration, splitEta } from './format' import { MediaImportDialog } from './MediaImportDialog' import { useUploadStore } from './upload-store' @@ -104,6 +104,15 @@ export function MediaPanel() { const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError }) + const retryMutation = useMutation({ + mutationFn: (id?: string) => retryMedia(id), + onSuccess: (result) => { + invalidate() + toast.success(t('admin.media.retryQueued', { count: result.requeued })) + }, + onError, + }) + return (
@@ -170,6 +179,19 @@ export function MediaPanel() { e.target.value = '' }} /> + {/* Массовый перезапуск виден только на фильтре ошибок: падают пачкой по одной причине, + и чинить их поштучно — девять кликов вместо одного. */} + {filter === 'Failed' && (data?.items.length ?? 0) > 0 && ( + + )} )} + {/* Файл на месте — упала только нарезка, и повторить её обычно и есть всё лечение. */} + {asset.status === 'Failed' && ( + + )} diff --git a/frontend/src/features/admin/media/api.ts b/frontend/src/features/admin/media/api.ts index 25b13e5..74d3b39 100644 --- a/frontend/src/features/admin/media/api.ts +++ b/frontend/src/features/admin/media/api.ts @@ -88,6 +88,17 @@ export function deleteMedia(id: string) { /** * Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса. */ +/** + * Возвращает упавшую обработку в очередь. Без `id` — все упавшие разом: падают они обычно пачкой + * и по одной причине, и чинят их тоже пачкой. + */ +export function retryMedia(id?: string) { + return apiRequest<{ requeued: number }>( + id ? `/admin/media/${id}/retry` : '/admin/media/retry-failed', + { method: 'POST' }, + ) +} + export function uploadMedia( file: File, onProgress?: (percent: number) => void, diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index 899c64d..ad7ea8a 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -298,6 +298,9 @@ export const en = { title: 'Media', upload: 'Upload', preview: 'Preview', + retry: 'Retry', + retryAll: 'Retry failed', + retryQueued: 'Requeued: {{count}}', manualButton: 'From manual folder', importButton: 'Import', importTitle: 'Media import', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index ffbd62c..395173b 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -299,6 +299,9 @@ export const ru = { title: 'Медиа', upload: 'Загрузить', preview: 'Просмотр', + retry: 'Перезапустить', + retryAll: 'Перезапустить упавшие', + retryQueued: 'Возвращено в очередь: {{count}}', manualButton: 'Из папки manual', importButton: 'Импорт', importTitle: 'Импорт медиа',