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.
This commit is contained in:
@@ -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>
|
||||
|
||||
+20
@@ -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
|
||||
);
|
||||
+78
@@ -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}";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user