Implement media retry functionality and enhance error handling
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:
@@ -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<MediaStatsDto>();
|
||||
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
// Перезапуск упавшей обработки: файл на месте, повторить нарезку — обычное дело.
|
||||
admin.MapPost("/{id:guid}/retry", Retry).Produces<RetryMediaResultDto>();
|
||||
admin.MapPost("/retry-failed", RetryFailed).Produces<RetryMediaResultDto>();
|
||||
|
||||
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
|
||||
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
|
||||
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
|
||||
@@ -48,6 +53,25 @@ public static class MediaEndpoints
|
||||
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>
|
||||
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
|
||||
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
"Неподдерживаемое расширение файла."
|
||||
|
||||
@@ -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);
|
||||
+5
@@ -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 ? "-" : "+"
|
||||
|
||||
+190
-46
@@ -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)),
|
||||
];
|
||||
}
|
||||
|
||||
/// <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>
|
||||
private sealed record AssetFacts(
|
||||
Guid Id,
|
||||
@@ -315,15 +410,41 @@ public sealed class LibraryDebugCollector(
|
||||
/// Рядом лежит кэш статистики: если он разошёлся с вычисленным составом, значит библиотека
|
||||
/// поменялась после последнего пересчёта — и цифры в карточке группы врут.
|
||||
/// </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,
|
||||
IReadOnlyDictionary<Guid, string> showNames,
|
||||
IReadOnlyList<Collection> collections,
|
||||
IReadOnlyDictionary<Guid, int> playableByShow,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
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))
|
||||
{
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Обслуживание, которое раньше не делалось вовсе: чистка отрендеренных заставок, на которые больше
|
||||
/// никто не ссылается. Они копятся при каждой смене пары шоу и при каждой правке блока заставки,
|
||||
/// а после перехода на длинное хранение расписания их станет заметно больше.
|
||||
/// никто не ссылается, снятие серий без ассета и пересчёт статистики групп.
|
||||
///
|
||||
/// Общее у этих трёх — их некому делать по событию: заставки копятся при каждой смене пары шоу,
|
||||
/// серия остаётся висеть после удаления ассета в обход админки, а статистика динамической группы
|
||||
/// устаревает молча — состав считает правило, и меняется он вместе с библиотекой, а не по правке
|
||||
/// группы.
|
||||
///
|
||||
/// Идёт по расписанию отдельно от генерации: та держит advisory-лок канала, и подмешивать в неё
|
||||
/// удаление по всей таблице не нужно.
|
||||
/// проходы по всей таблице не нужно.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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",
|
||||
"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]
|
||||
|
||||
@@ -124,6 +124,18 @@ Override, начинающийся в будущем, подхватится и
|
||||
и потолок повторов, и окно обязано покрывать самое долгое правило канала. Сегменты ассетов при этом
|
||||
не трогаются — ассеты переиспользуются, чистится только расписание.
|
||||
|
||||
**Упавшую обработку перезапускают, а не переливают.** Файл при падении остаётся на месте — упала
|
||||
нарезка: ffmpeg упёрся в таймаут на длинном фильме, кончилось место, отвалился диск. Кнопка в списке
|
||||
медиа возвращает ассет в `Pending` и будит обработчик; на фильтре «Ошибка» есть и массовый
|
||||
перезапуск — падают такие вещи пачкой и по одной причине. Идущую обработку и готовые ассеты
|
||||
перезапуск не трогает: первое оставило бы ffmpeg работать в пустоту, второе — переработало бы то,
|
||||
что уже стоит в эфире.
|
||||
|
||||
**Удаление ассета снимает и серию.** Серия ссылается на ассет без внешнего ключа, поэтому БД сама
|
||||
её не тронет, и в шоу остаётся строка, за которой нет файла: в эфир она не идёт (играют только
|
||||
готовые), а в карточке числится. Уже накопившиеся такие строки подчищает обслуживание — там же,
|
||||
где чистятся осиротевшие заставки и пересчитывается статистика групп.
|
||||
|
||||
**Дыры.** В норме их нет. Если у канала нет ни одного включённого шоу с готовыми сериями (или
|
||||
планировщик отстал) — эфир играет `FillerAssetId` по кругу, пока не появится расписание.
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
@@ -170,6 +179,19 @@ export function MediaPanel() {
|
||||
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)}>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
{t('admin.media.importButton')}
|
||||
@@ -234,6 +256,7 @@ export function MediaPanel() {
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
onPreview={() => setPreview(asset)}
|
||||
onRetry={() => retryMutation.mutate(asset.id)}
|
||||
onDelete={() => deleteMutation.mutate(asset.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -286,8 +309,14 @@ function EtaValue({ seconds }: Readonly<{ seconds: number }>) {
|
||||
function MediaRow({
|
||||
asset,
|
||||
onPreview,
|
||||
onRetry,
|
||||
onDelete,
|
||||
}: Readonly<{ asset: MediaAssetDto; onPreview: () => void; onDelete: () => void }>) {
|
||||
}: Readonly<{
|
||||
asset: MediaAssetDto
|
||||
onPreview: () => void
|
||||
onRetry: () => void
|
||||
onDelete: () => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<tr className="border-b border-border last:border-0">
|
||||
@@ -317,6 +346,12 @@ function MediaRow({
|
||||
<Play className="h-4 w-4" />
|
||||
</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}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -299,6 +299,9 @@ export const ru = {
|
||||
title: 'Медиа',
|
||||
upload: 'Загрузить',
|
||||
preview: 'Просмотр',
|
||||
retry: 'Перезапустить',
|
||||
retryAll: 'Перезапустить упавшие',
|
||||
retryQueued: 'Возвращено в очередь: {{count}}',
|
||||
manualButton: 'Из папки manual',
|
||||
importButton: 'Импорт',
|
||||
importTitle: 'Импорт медиа',
|
||||
|
||||
Reference in New Issue
Block a user