Implement media retry functionality and enhance error handling
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Failing after 15s
ci / tests (push) Skipped
ci / sonar (push) Skipped

Added endpoints for retrying failed media processing, allowing users to requeue media assets that encountered errors. Introduced error messages for scenarios where a media asset cannot be retried due to its status. Updated the MaintenanceBackgroundService to remove orphaned episodes and recompute group statistics, ensuring data integrity. Enhanced the frontend to support retry actions, including bulk retry options for failed media. Updated localization strings to reflect new features in both English and Russian.
This commit is contained in:
Leonid Pershin
2026-07-31 03:58:01 +03:00
parent 789357481d
commit 7163a0b937
16 changed files with 594 additions and 52 deletions
@@ -10,6 +10,7 @@ using TeleWave.Application.Media.ListMedia;
using TeleWave.Application.Media.ManualInbox; using TeleWave.Application.Media.ManualInbox;
using TeleWave.Application.Media.MovieImport; using TeleWave.Application.Media.MovieImport;
using TeleWave.Application.Media.Register; using TeleWave.Application.Media.Register;
using TeleWave.Application.Media.Retry;
using TeleWave.Application.Media.Stats; using TeleWave.Application.Media.Stats;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
@@ -30,6 +31,10 @@ public static class MediaEndpoints
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>(); admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
// Перезапуск упавшей обработки: файл на месте, повторить нарезку — обычное дело.
admin.MapPost("/{id:guid}/retry", Retry).Produces<RetryMediaResultDto>();
admin.MapPost("/retry-failed", RetryFailed).Produces<RetryMediaResultDto>();
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу. // Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>(); admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>(); admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
@@ -48,6 +53,25 @@ public static class MediaEndpoints
return app; return app;
} }
private static async Task<IResult> Retry(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RetryMediaCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RetryFailed(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RetryMediaCommand(), cancellationToken);
return result.ToHttpResult();
}
/// <summary> /// <summary>
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName». /// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку. /// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
@@ -38,6 +38,22 @@ public sealed class DeleteMediaAssetCommandHandler(IAppDbContext dbContext, IMed
MediaErrors.InUse(await UsedByAsync(asset.Id, cancellationToken)) 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-файл на диске // Сначала коммитим удаление в БД, потом чистим файлы (best-effort): orphan-файл на диске
// безопаснее, чем строка БД, ссылающаяся на уже удалённые артефакты. // безопаснее, чем строка БД, ссылающаяся на уже удалённые артефакты.
var extension = asset.OriginalExtension; var extension = asset.OriginalExtension;
@@ -9,6 +9,11 @@ public static class MediaErrors
"Медиа-ассет не найден." "Медиа-ассет не найден."
); );
public static readonly Error NotFailed = Error.Validation(
"Media.NotFailed",
"Перезапускать нечего: ассет не в состоянии ошибки."
);
public static readonly Error UnsupportedFormat = Error.Validation( public static readonly Error UnsupportedFormat = Error.Validation(
"Media.UnsupportedFormat", "Media.UnsupportedFormat",
"Неподдерживаемое расширение файла." "Неподдерживаемое расширение файла."
@@ -0,0 +1,11 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Media.Retry;
/// <summary>
/// Вернуть упавшую обработку в очередь. <paramref name="Id"/> = null — все упавшие разом: падают
/// они обычно пачкой и по одной причине (кончилось место, ffmpeg упёрся в таймаут), и чинят их
/// тоже пачкой.
/// </summary>
public sealed record RetryMediaCommand(Guid? Id = null) : ICommand<Result<RetryMediaResultDto>>;
@@ -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;
/// <summary>
/// Возвращает упавшие ассеты в очередь обработки.
///
/// Перезапуск — не редкость, а норма эксплуатации: ffmpeg упирается в таймаут на длинном фильме,
/// кончается место, обрывается диск. Файл при этом на месте, и единственное, чего не хватало, —
/// кнопки «попробовать ещё раз»; без неё оставалось удалить и залить заново.
/// </summary>
public sealed class RetryMediaCommandHandler(IAppDbContext dbContext, IMediaProcessingQueue queue)
: ICommandHandler<RetryMediaCommand, Result<RetryMediaResultDto>>
{
public async Task<Result<RetryMediaResultDto>> 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<RetryMediaResultDto>(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));
}
}
@@ -0,0 +1,4 @@
namespace TeleWave.Application.Media.Retry;
/// <summary>Сколько ассетов вернулось в очередь.</summary>
public sealed record RetryMediaResultDto(int Requeued);
@@ -266,9 +266,14 @@ public sealed class ChannelDebugCollector(
- `library-collections.json` — коллекции и порядок частей: по нему считается «часть N из M». - `library-collections.json` — коллекции и порядок частей: по нему считается «часть N из M».
- `library-interstitials.json` — ролики: длительности и состояние. - `library-interstitials.json` — ролики: длительности и состояние.
- `library-media.json` — свод по ассетам и поимённо всё, что не доехало до Ready. - `library-media.json` — свод по ассетам и поимённо всё, что не доехало до Ready.
- `issues.json` — что в библиотеке сломано прямо сейчас: упавшие серии, серии без ассета,
шоу без единой играбельной серии, пустые группы и разошедшийся кэш статистики.
- `bumpers.json` — блоки заставок: подблоки, триггеры, строки, длина звука. - `bumpers.json` — блоки заставок: подблоки, триггеры, строки, длина звука.
- `groups-composition.json` — группы как настроены и что из этого вычислилось - `groups-composition.json` — группы как настроены и что из этого вычислилось
(у динамических состав живёт в правиле), плюс кэш статистики для сверки. (у динамических состав живёт в правиле), плюс кэш статистики для сверки.
`unitCount` — сколько единиц заведено, `playableUnits` — сколько из них с готовым
ассетом: планировщик видит только вторые, и расхождение объясняет «почему в эфир
пошло меньше». `groups.json` рядом — только те группы, что участвуют в этом канале.
Времена везде UTC. Время канала = UTC{( Времена везде UTC. Время канала = UTC{(
channel.UtcOffsetMinutes < 0 ? "-" : "+" channel.UtcOffsetMinutes < 0 ? "-" : "+"
@@ -64,6 +64,13 @@ public sealed class LibraryDebugCollector(
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var showNames = shows.ToDictionary(s => s.Id, s => s.Name); var showNames = shows.ToDictionary(s => s.Id, s => s.Name);
var groupSnapshots = await GroupSnapshotsAsync(
groups,
showNames,
collections,
Playable(shows, assets),
cancellationToken
);
return return
[ [
@@ -72,13 +79,101 @@ public sealed class LibraryDebugCollector(
new DebugFile("library-interstitials.json", InterstitialFacts(shows, assets)), new DebugFile("library-interstitials.json", InterstitialFacts(shows, assets)),
new DebugFile("library-media.json", MediaFacts(assets, shows)), new DebugFile("library-media.json", MediaFacts(assets, shows)),
new DebugFile("bumpers.json", BumperFacts(bumpers)), new DebugFile("bumpers.json", BumperFacts(bumpers)),
new DebugFile( new DebugFile("issues.json", Issues(shows, assets, groups, groupSnapshots)),
"groups-composition.json", new DebugFile("groups-composition.json", groupSnapshots.Select(s => s.Facts)),
await GroupFactsAsync(groups, showNames, collections, cancellationToken)
),
]; ];
} }
/// <summary>
/// Что в библиотеке сломано прямо сейчас. Дамп собирают, когда эфир выглядит не так, как ждали,
/// и первые подозреваемые всегда одни и те же: файл не дорезался, серия висит без ассета,
/// счётчик группы устарел. Искать их по трём файлам глазами каждый раз — потерянное время,
/// поэтому они собраны здесь списком.
///
/// Это не пост-проверки эфира (те живут в планировщике и предупреждают при генерации), а состояние
/// библиотеки: оно ломает эфир ещё до того, как планировщик что-то решит.
/// </summary>
private static object Issues(
IReadOnlyList<Show> shows,
IReadOnlyDictionary<Guid, AssetFacts> assets,
IReadOnlyList<Group> groups,
IReadOnlyList<GroupSnapshot> 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,
}),
};
}
/// <summary>Ассет в дампе: этого хватает, чтобы понять, почему серия не пошла в эфир.</summary> /// <summary>Ассет в дампе: этого хватает, чтобы понять, почему серия не пошла в эфир.</summary>
private sealed record AssetFacts( private sealed record AssetFacts(
Guid Id, Guid Id,
@@ -315,15 +410,41 @@ public sealed class LibraryDebugCollector(
/// Рядом лежит кэш статистики: если он разошёлся с вычисленным составом, значит библиотека /// Рядом лежит кэш статистики: если он разошёлся с вычисленным составом, значит библиотека
/// поменялась после последнего пересчёта — и цифры в карточке группы врут. /// поменялась после последнего пересчёта — и цифры в карточке группы врут.
/// </summary> /// </summary>
private async Task<object> GroupFactsAsync( /// <summary>
/// Сколько единиц шоу реально может выйти в эфир: только серии с готовым ассетом. Именно это
/// число видит планировщик, тогда как счётчик группы считает все заведённые серии — расхождение
/// между ними и есть ответ на «почему в эфир пошло меньше, чем заведено».
/// </summary>
private static Dictionary<Guid, int> Playable(
IReadOnlyList<Show> shows,
IReadOnlyDictionary<Guid, AssetFacts> assets
) =>
shows.ToDictionary(
show => show.Id,
show =>
show.Episodes.Count(e =>
assets.GetValueOrDefault(e.MediaAssetId)
is { Status: MediaAssetStatus.Ready, Duration: not null }
)
);
/// <summary>Группа в дампе: готовые факты и числа, по которым сверяется её кэш статистики.</summary>
private sealed record GroupSnapshot(Group Group, object Facts, int Units, int PlayableUnits);
private async Task<IReadOnlyList<GroupSnapshot>> GroupSnapshotsAsync(
IReadOnlyList<Group> groups, IReadOnlyList<Group> groups,
IReadOnlyDictionary<Guid, string> showNames, IReadOnlyDictionary<Guid, string> showNames,
IReadOnlyList<Collection> collections, IReadOnlyList<Collection> collections,
IReadOnlyDictionary<Guid, int> playableByShow,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var collectionNames = collections.ToDictionary(c => c.Id, c => c.Name); var collectionNames = collections.ToDictionary(c => c.Id, c => c.Name);
var facts = new List<object>(groups.Count); var collectionParts = collections.ToDictionary(
c => c.Id,
c => c.Items.Select(i => i.ShowId).ToList()
);
var snapshots = new List<GroupSnapshot>(groups.Count);
foreach (var group in groups.OrderBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase)) foreach (var group in groups.OrderBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase))
{ {
@@ -333,56 +454,79 @@ public sealed class LibraryDebugCollector(
cancellationToken cancellationToken
); );
facts.Add( var composition = resolved
new .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.ItemCount,
group.Name, group.UnitCount,
group.Mode, group.TotalDuration,
Filter = GroupFilter.FromJson(group.FilterJson), group.StatsComputedAt,
StatsCache = new },
Items = group
.Items.OrderBy(i => i.Position)
.Select(item => new
{ {
group.ItemCount, item.Position,
group.UnitCount, item.ElementKind,
group.TotalDuration, item.ElementId,
group.StatsComputedAt, Name = Name(item.ElementKind, item.ElementId),
}, item.Weight,
Items = group item.Role,
.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,
}), }),
} 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) => string? Name(GroupElementKind kind, Guid id) =>
kind == GroupElementKind.Collection kind == GroupElementKind.Collection
? collectionNames.GetValueOrDefault(id) ? collectionNames.GetValueOrDefault(id)
: showNames.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);
} }
} }
@@ -3,16 +3,21 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Groups;
namespace TeleWave.Infrastructure.Broadcast; namespace TeleWave.Infrastructure.Broadcast;
/// <summary> /// <summary>
/// Обслуживание, которое раньше не делалось вовсе: чистка отрендеренных заставок, на которые больше /// Обслуживание, которое раньше не делалось вовсе: чистка отрендеренных заставок, на которые больше
/// никто не ссылается. Они копятся при каждой смене пары шоу и при каждой правке блока заставки, /// никто не ссылается, снятие серий без ассета и пересчёт статистики групп.
/// а после перехода на длинное хранение расписания их станет заметно больше. ///
/// Общее у этих трёх — их некому делать по событию: заставки копятся при каждой смене пары шоу,
/// серия остаётся висеть после удаления ассета в обход админки, а статистика динамической группы
/// устаревает молча — состав считает правило, и меняется он вместе с библиотекой, а не по правке
/// группы.
/// ///
/// Идёт по расписанию отдельно от генерации: та держит advisory-лок канала, и подмешивать в неё /// Идёт по расписанию отдельно от генерации: та держит advisory-лок канала, и подмешивать в неё
/// удаление по всей таблице не нужно. /// проходы по всей таблице не нужно.
/// </summary> /// </summary>
public sealed class MaintenanceBackgroundService( public sealed class MaintenanceBackgroundService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
@@ -65,5 +70,53 @@ public sealed class MaintenanceBackgroundService(
if (removed > 0) if (removed > 0)
logger.LogInformation("Обслуживание: удалено осиротевших заставок — {Count}", removed); logger.LogInformation("Обслуживание: удалено осиротевших заставок — {Count}", removed);
await RemoveOrphanEpisodesAsync(db, cancellationToken);
await RecomputeGroupStatsAsync(scope.ServiceProvider, db, cancellationToken);
}
/// <summary>
/// Снимает серии, чей ассет исчез из базы. В норме их не бывает — удаление ассета снимает
/// серию само, — но правка базы руками и удаления прежних версий такие строки оставили.
/// Висячая серия в эфир не идёт и ничем себя не выдаёт: шоу просто числится длиннее, чем есть.
/// </summary>
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);
}
/// <summary>
/// Пересчитывает статистику групп. Кэш обновляется при правке состава, но состав динамической
/// группы задан правилом: доехавший ассет или новое шоу меняют её молча, и карточка группы
/// начинает врать — «55 единиц, 0 часов» там, где на деле 66 фильмов.
/// </summary>
private async Task RecomputeGroupStatsAsync(
IServiceProvider services,
IAppDbContext db,
CancellationToken cancellationToken
)
{
var stats = services.GetRequiredService<GroupStatsService>();
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
);
} }
} }
@@ -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;
/// <summary>
/// Перезапуск упавшей обработки. Файл при этом на месте — упала только нарезка, и повторить её
/// обычно и есть всё лечение; до этого оставалось удалить ассет и залить заново.
/// </summary>
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<IMediaProcessingQueue>();
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<IMediaProcessingQueue>();
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<IMediaStorage>()
).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<IMediaProcessingQueue>()
).Handle(new RetryMediaCommand(ready.Id), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Equal(MediaErrors.NotFailed, result.Error);
}
}
@@ -172,6 +172,7 @@ public class ChannelDebugExportTests
"effective-grid.json", "effective-grid.json",
"groups-composition.json", "groups-composition.json",
"groups.json", "groups.json",
"issues.json",
"junctions.json", "junctions.json",
"library-collections.json", "library-collections.json",
"library-interstitials.json", "library-interstitials.json",
@@ -235,10 +236,21 @@ public class ChannelDebugExportTests
var composition = group.GetProperty("composition").EnumerateArray().Single(); var composition = group.GetProperty("composition").EnumerateArray().Single();
Assert.Equal("Симпсоны", composition.GetProperty("name").GetString()); Assert.Equal("Симпсоны", composition.GetProperty("name").GetString());
Assert.Equal(2, composition.GetProperty("unitCount").GetInt32()); Assert.Equal(2, composition.GetProperty("unitCount").GetInt32());
// «Заведено» и «может выйти» — разные числа, и в дампе они стоят рядом: их расхождение
// и есть ответ на «почему в эфир пошло меньше, чем в библиотеке».
Assert.Equal(2, composition.GetProperty("playableUnits").GetInt32());
using var media = JsonDocument.Parse(files["library-media.json"]); using var media = JsonDocument.Parse(files["library-media.json"]);
Assert.Equal(2, media.RootElement.GetProperty("total").GetInt32()); Assert.Equal(2, media.RootElement.GetProperty("total").GetInt32());
Assert.Empty(media.RootElement.GetProperty("problems").EnumerateArray()); 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] [Fact]
+12
View File
@@ -124,6 +124,18 @@ Override, начинающийся в будущем, подхватится и
и потолок повторов, и окно обязано покрывать самое долгое правило канала. Сегменты ассетов при этом и потолок повторов, и окно обязано покрывать самое долгое правило канала. Сегменты ассетов при этом
не трогаются — ассеты переиспользуются, чистится только расписание. не трогаются — ассеты переиспользуются, чистится только расписание.
**Упавшую обработку перезапускают, а не переливают.** Файл при падении остаётся на месте — упала
нарезка: ffmpeg упёрся в таймаут на длинном фильме, кончилось место, отвалился диск. Кнопка в списке
медиа возвращает ассет в `Pending` и будит обработчик; на фильтре «Ошибка» есть и массовый
перезапуск — падают такие вещи пачкой и по одной причине. Идущую обработку и готовые ассеты
перезапуск не трогает: первое оставило бы ffmpeg работать в пустоту, второе — переработало бы то,
что уже стоит в эфире.
**Удаление ассета снимает и серию.** Серия ссылается на ассет без внешнего ключа, поэтому БД сама
её не тронет, и в шоу остаётся строка, за которой нет файла: в эфир она не идёт (играют только
готовые), а в карточке числится. Уже накопившиеся такие строки подчищает обслуживание — там же,
где чистятся осиротевшие заставки и пересчитывается статистика групп.
**Дыры.** В норме их нет. Если у канала нет ни одного включённого шоу с готовыми сериями (или **Дыры.** В норме их нет. Если у канала нет ни одного включённого шоу с готовыми сериями (или
планировщик отстал) — эфир играет `FillerAssetId` по кругу, пока не появится расписание. планировщик отстал) — эфир играет `FillerAssetId` по кругу, пока не появится расписание.
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' 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 { qk } from '@/shared/api/query-keys'
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types' import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error' 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 { toast } from '@/shared/ui/toast-store'
import { useTableSort } from '@/shared/lib/table-sort' import { useTableSort } from '@/shared/lib/table-sort'
import { SortHeader } from '@/shared/ui/sortable' 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 { formatDuration, splitEta } from './format'
import { MediaImportDialog } from './MediaImportDialog' import { MediaImportDialog } from './MediaImportDialog'
import { useUploadStore } from './upload-store' import { useUploadStore } from './upload-store'
@@ -104,6 +104,15 @@ export function MediaPanel() {
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError }) 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 ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
@@ -170,6 +179,19 @@ export function MediaPanel() {
e.target.value = '' e.target.value = ''
}} }}
/> />
{/* Массовый перезапуск виден только на фильтре ошибок: падают пачкой по одной причине,
и чинить их поштучно — девять кликов вместо одного. */}
{filter === 'Failed' && (data?.items.length ?? 0) > 0 && (
<Button
size="sm"
variant="outline"
disabled={retryMutation.isPending}
onClick={() => retryMutation.mutate(undefined)}
>
<RotateCcw className="h-4 w-4" />
{t('admin.media.retryAll')}
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}> <Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
<FolderInput className="h-4 w-4" /> <FolderInput className="h-4 w-4" />
{t('admin.media.importButton')} {t('admin.media.importButton')}
@@ -234,6 +256,7 @@ export function MediaPanel() {
key={asset.id} key={asset.id}
asset={asset} asset={asset}
onPreview={() => setPreview(asset)} onPreview={() => setPreview(asset)}
onRetry={() => retryMutation.mutate(asset.id)}
onDelete={() => deleteMutation.mutate(asset.id)} onDelete={() => deleteMutation.mutate(asset.id)}
/> />
))} ))}
@@ -286,8 +309,14 @@ function EtaValue({ seconds }: Readonly<{ seconds: number }>) {
function MediaRow({ function MediaRow({
asset, asset,
onPreview, onPreview,
onRetry,
onDelete, onDelete,
}: Readonly<{ asset: MediaAssetDto; onPreview: () => void; onDelete: () => void }>) { }: Readonly<{
asset: MediaAssetDto
onPreview: () => void
onRetry: () => void
onDelete: () => void
}>) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<tr className="border-b border-border last:border-0"> <tr className="border-b border-border last:border-0">
@@ -317,6 +346,12 @@ function MediaRow({
<Play className="h-4 w-4" /> <Play className="h-4 w-4" />
</Button> </Button>
)} )}
{/* Файл на месте — упала только нарезка, и повторить её обычно и есть всё лечение. */}
{asset.status === 'Failed' && (
<Button size="sm" variant="outline" title={t('admin.media.retry')} onClick={onRetry}>
<RotateCcw className="h-4 w-4" />
</Button>
)}
<Button size="sm" variant="destructive" onClick={onDelete}> <Button size="sm" variant="destructive" onClick={onDelete}>
{t('common.delete')} {t('common.delete')}
</Button> </Button>
+11
View File
@@ -88,6 +88,17 @@ export function deleteMedia(id: string) {
/** /**
* Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса. * Потоковая загрузка файла (сырое тело + 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( export function uploadMedia(
file: File, file: File,
onProgress?: (percent: number) => void, onProgress?: (percent: number) => void,
+3
View File
@@ -298,6 +298,9 @@ export const en = {
title: 'Media', title: 'Media',
upload: 'Upload', upload: 'Upload',
preview: 'Preview', preview: 'Preview',
retry: 'Retry',
retryAll: 'Retry failed',
retryQueued: 'Requeued: {{count}}',
manualButton: 'From manual folder', manualButton: 'From manual folder',
importButton: 'Import', importButton: 'Import',
importTitle: 'Media import', importTitle: 'Media import',
+3
View File
@@ -299,6 +299,9 @@ export const ru = {
title: 'Медиа', title: 'Медиа',
upload: 'Загрузить', upload: 'Загрузить',
preview: 'Просмотр', preview: 'Просмотр',
retry: 'Перезапустить',
retryAll: 'Перезапустить упавшие',
retryQueued: 'Возвращено в очередь: {{count}}',
manualButton: 'Из папки manual', manualButton: 'Из папки manual',
importButton: 'Импорт', importButton: 'Импорт',
importTitle: 'Импорт медиа', importTitle: 'Импорт медиа',