Add missing episodes feature: implement FindMissingEpisodes endpoint, update metadata providers to retrieve season episode counts, and enhance frontend components for displaying missing episodes report. Update translations for new UI elements.
build / backend (push) Successful in 3m5s
build / frontend (push) Successful in 44s
tests / backend-tests (push) Successful in 2m38s

This commit is contained in:
Leonid Pershin
2026-07-26 02:15:47 +03:00
parent 8962840654
commit 44e954920c
11 changed files with 344 additions and 2 deletions
@@ -6,6 +6,7 @@ using TeleWave.Application.Images.UploadImage;
using TeleWave.Application.Metadata;
using TeleWave.Application.Metadata.ApplyShowMetadata;
using TeleWave.Application.Metadata.ClearShowMetadata;
using TeleWave.Application.Metadata.FindMissingEpisodes;
using TeleWave.Application.Metadata.GetProviders;
using TeleWave.Application.Metadata.RefreshEpisodes;
using TeleWave.Application.Metadata.SearchShows;
@@ -48,6 +49,9 @@ public static class MetadataEndpoints
.MapPut("/shows/{showId:guid}/poster-image", SetPosterImage)
.Produces(StatusCodes.Status204NoContent);
admin.MapPost("/shows/{showId:guid}/refresh-episodes", RefreshEpisodes).Produces<int>();
admin
.MapGet("/shows/{showId:guid}/missing-episodes", FindMissing)
.Produces<MissingEpisodesReport>();
// Постеры шоу и кадры серий теперь в общем реестре и отдаются по /api/images/{id}.
return app;
@@ -183,6 +187,16 @@ public static class MetadataEndpoints
);
return result.ToHttpResult();
}
private static async Task<IResult> FindMissing(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new FindMissingEpisodesQuery(showId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record ApplyMetadataBody(string Provider, string ExternalId);
@@ -21,6 +21,13 @@ public interface IMetadataProvider
int episode,
CancellationToken cancellationToken
);
/// <summary>Сколько серий в указанном сезоне по данным источника (null — сезон не найден/нет данных).</summary>
Task<int?> GetSeasonEpisodeCountAsync(
string externalId,
int season,
CancellationToken cancellationToken
);
}
/// <summary>Резолвит провайдер по ключу и перечисляет реально настроенные (с API-ключом) источники.</summary>
@@ -0,0 +1,20 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Metadata.FindMissingEpisodes;
/// <summary>Отчёт: каких серий не хватает в загруженных сезонах шоу (по данным привязанного источника).</summary>
public sealed record FindMissingEpisodesQuery(Guid ShowId) : IQuery<Result<MissingEpisodesReport>>;
public sealed record MissingEpisodesReport(IReadOnlyList<SeasonGapDto> Seasons);
/// <param name="Season">Номер сезона (есть хотя бы одна загруженная серия).</param>
/// <param name="Expected">Сколько серий в сезоне по источнику (null — источник не отдал данные).</param>
/// <param name="Loaded">Сколько серий этого сезона загружено.</param>
/// <param name="Missing">Отсутствующие номера серий (пусто — все на месте либо Expected неизвестен).</param>
public sealed record SeasonGapDto(
int Season,
int? Expected,
int Loaded,
IReadOnlyList<int> Missing
);
@@ -0,0 +1,78 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Library;
namespace TeleWave.Application.Metadata.FindMissingEpisodes;
public sealed class FindMissingEpisodesQueryHandler(
IAppDbContext dbContext,
IMetadataProviderResolver resolver
) : IQueryHandler<FindMissingEpisodesQuery, Result<MissingEpisodesReport>>
{
public async Task<Result<MissingEpisodesReport>> Handle(
FindMissingEpisodesQuery query,
CancellationToken cancellationToken
)
{
var show = await dbContext
.Shows.AsNoTracking()
.Include(s => s.Episodes)
.FirstOrDefaultAsync(s => s.Id == query.ShowId, cancellationToken);
if (show is null)
return Result.Failure<MissingEpisodesReport>(ShowErrors.NotFound);
if (string.IsNullOrEmpty(show.MetadataExternalId))
return Result.Failure<MissingEpisodesReport>(MetadataErrors.NoLinkedSource);
var provider = show.MetadataProvider is { } key ? resolver.Resolve(key) : null;
if (provider is null)
return Result.Failure<MissingEpisodesReport>(MetadataErrors.ProviderNotAvailable);
// Имена файлов — чтобы распознать номера у серий, где они ещё не проставлены.
var assetIds = show.Episodes.Select(e => e.MediaAssetId).Distinct().ToList();
var names = await dbContext
.MediaAssets.AsNoTracking()
.Where(a => assetIds.Contains(a.Id))
.Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
// Загруженные номера серий по сезонам (только сезоны с хотя бы одной серией).
var loadedBySeason = new Dictionary<int, HashSet<int>>();
foreach (var episode in show.Episodes)
{
var season = episode.Season;
var number = episode.Episode;
if ((season is null || number is null) && names.TryGetValue(episode.MediaAssetId, out var name))
{
if (EpisodeName.Parse(name) is { } parsed)
(season, number) = (parsed.Season, parsed.Episode);
}
if (season is null || number is null)
continue;
if (!loadedBySeason.TryGetValue(season.Value, out var set))
loadedBySeason[season.Value] = set = [];
set.Add(number.Value);
}
var seasons = new List<SeasonGapDto>();
foreach (var season in loadedBySeason.Keys.OrderBy(s => s))
{
var loaded = loadedBySeason[season];
var expected = await provider.GetSeasonEpisodeCountAsync(
show.MetadataExternalId,
season,
cancellationToken
);
var missing =
expected is { } exp
? Enumerable.Range(1, exp).Where(n => !loaded.Contains(n)).ToList()
: [];
seasons.Add(new SeasonGapDto(season, expected, loaded.Count, missing));
}
return Result.Success(new MissingEpisodesReport(seasons));
}
}
@@ -87,6 +87,25 @@ public sealed class OmdbMetadataProvider(
);
}
public async Task<int?> GetSeasonEpisodeCountAsync(
string externalId,
int season,
CancellationToken cancellationToken
)
{
var url =
$"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}&Season={season}";
using var doc = await TryGetJsonAsync(url, cancellationToken);
if (
doc is null
|| !IsResponseTrue(doc.RootElement)
|| !doc.RootElement.TryGetProperty("Episodes", out var episodes)
|| episodes.ValueKind != JsonValueKind.Array
)
return null;
return episodes.GetArrayLength();
}
/// <summary>GET+parse; бросает при не-2xx/сетевой ошибке (для поиска — чтобы показать сбой).</summary>
private async Task<JsonDocument> GetJsonAsync(string url, CancellationToken cancellationToken)
{
@@ -91,6 +91,25 @@ public sealed class TmdbMetadataProvider(
);
}
public async Task<int?> GetSeasonEpisodeCountAsync(
string externalId,
int season,
CancellationToken cancellationToken
)
{
var url =
$"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}"
+ $"?api_key={Tmdb.ApiKey}&language={_options.Language}";
using var doc = await TryGetJsonAsync(url, cancellationToken);
if (
doc is null
|| !doc.RootElement.TryGetProperty("episodes", out var episodes)
|| episodes.ValueKind != JsonValueKind.Array
)
return null;
return episodes.GetArrayLength();
}
private string? PosterUrl(string? path) =>
string.IsNullOrEmpty(path) ? null : $"{Tmdb.ImageBaseUrl}/{Tmdb.PosterSize}{path}";