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:
+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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user