From 44e954920c6254285c7a118eda9fd40bffdd1d51 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 26 Jul 2026 02:15:47 +0300 Subject: [PATCH] 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. --- .../Endpoints/MetadataEndpoints.cs | 14 ++++ .../Common/Interfaces/IMetadataProvider.cs | 7 ++ .../FindMissingEpisodesQuery.cs | 20 +++++ .../FindMissingEpisodesQueryHandler.cs | 78 ++++++++++++++++++ .../Metadata/OmdbMetadataProvider.cs | 19 +++++ .../Metadata/TmdbMetadataProvider.cs | 19 +++++ .../Metadata/FindMissingEpisodesTests.cs | 77 ++++++++++++++++++ .../features/admin/shows/ShowMetadataCard.tsx | 79 ++++++++++++++++++- frontend/src/features/admin/shows/api.ts | 6 ++ frontend/src/shared/api/types.ts | 11 +++ frontend/src/shared/lib/i18n.ts | 16 ++++ 11 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQuery.cs create mode 100644 backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQueryHandler.cs create mode 100644 backend/tests/TeleWave.Application.Tests/Metadata/FindMissingEpisodesTests.cs diff --git a/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs index cd6dfc2..8e84cdc 100644 --- a/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs @@ -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(); + admin + .MapGet("/shows/{showId:guid}/missing-episodes", FindMissing) + .Produces(); // Постеры шоу и кадры серий теперь в общем реестре и отдаются по /api/images/{id}. return app; @@ -183,6 +187,16 @@ public static class MetadataEndpoints ); return result.ToHttpResult(); } + + private static async Task 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); diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMetadataProvider.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMetadataProvider.cs index fe880b4..67c2eee 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IMetadataProvider.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMetadataProvider.cs @@ -21,6 +21,13 @@ public interface IMetadataProvider int episode, CancellationToken cancellationToken ); + + /// Сколько серий в указанном сезоне по данным источника (null — сезон не найден/нет данных). + Task GetSeasonEpisodeCountAsync( + string externalId, + int season, + CancellationToken cancellationToken + ); } /// Резолвит провайдер по ключу и перечисляет реально настроенные (с API-ключом) источники. diff --git a/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQuery.cs b/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQuery.cs new file mode 100644 index 0000000..d2de24e --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQuery.cs @@ -0,0 +1,20 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Metadata.FindMissingEpisodes; + +/// Отчёт: каких серий не хватает в загруженных сезонах шоу (по данным привязанного источника). +public sealed record FindMissingEpisodesQuery(Guid ShowId) : IQuery>; + +public sealed record MissingEpisodesReport(IReadOnlyList Seasons); + +/// Номер сезона (есть хотя бы одна загруженная серия). +/// Сколько серий в сезоне по источнику (null — источник не отдал данные). +/// Сколько серий этого сезона загружено. +/// Отсутствующие номера серий (пусто — все на месте либо Expected неизвестен). +public sealed record SeasonGapDto( + int Season, + int? Expected, + int Loaded, + IReadOnlyList Missing +); diff --git a/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQueryHandler.cs b/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQueryHandler.cs new file mode 100644 index 0000000..13202cd --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQueryHandler.cs @@ -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> +{ + public async Task> 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(ShowErrors.NotFound); + + if (string.IsNullOrEmpty(show.MetadataExternalId)) + return Result.Failure(MetadataErrors.NoLinkedSource); + + var provider = show.MetadataProvider is { } key ? resolver.Resolve(key) : null; + if (provider is null) + return Result.Failure(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>(); + 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(); + 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)); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs index 9335a6a..b7f22bc 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs @@ -87,6 +87,25 @@ public sealed class OmdbMetadataProvider( ); } + public async Task 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(); + } + /// GET+parse; бросает при не-2xx/сетевой ошибке (для поиска — чтобы показать сбой). private async Task GetJsonAsync(string url, CancellationToken cancellationToken) { diff --git a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs index 442d04a..1a6c3ce 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs @@ -91,6 +91,25 @@ public sealed class TmdbMetadataProvider( ); } + public async Task 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}"; diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/FindMissingEpisodesTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/FindMissingEpisodesTests.cs new file mode 100644 index 0000000..f4fd58b --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Metadata/FindMissingEpisodesTests.cs @@ -0,0 +1,77 @@ +using NSubstitute; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Metadata.FindMissingEpisodes; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Library; +using Xunit; + +namespace TeleWave.Application.Tests.Metadata; + +public class FindMissingEpisodesTests +{ + [Fact] + public async Task ReportsMissingNumbers_PerLoadedSeason() + { + var fixture = new TestDb(); + var show = Show.Create("A", ShowKind.Series); + show.ApplyMetadata("tmdb", "123", "desc", 2000, null); + // Сезон 1: загружены 1 и 3 (из 5). Сезон 2: загружена только 1 (из 3). + var e11 = show.AddEpisode(Guid.NewGuid()); + e11.SetNumbers(1, 1); + var e13 = show.AddEpisode(Guid.NewGuid()); + e13.SetNumbers(1, 3); + var e21 = show.AddEpisode(Guid.NewGuid()); + e21.SetNumbers(2, 1); + + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var provider = Substitute.For(); + provider + .GetSeasonEpisodeCountAsync("123", 1, Arg.Any()) + .Returns(5); + provider + .GetSeasonEpisodeCountAsync("123", 2, Arg.Any()) + .Returns(3); + var resolver = Substitute.For(); + resolver.Resolve("tmdb").Returns(provider); + + await using var db = fixture.New(); + var result = await new FindMissingEpisodesQueryHandler(db, resolver).Handle( + new FindMissingEpisodesQuery(show.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var s1 = result.Value.Seasons.Single(x => x.Season == 1); + Assert.Equal(5, s1.Expected); + Assert.Equal(2, s1.Loaded); + Assert.Equal([2, 4, 5], s1.Missing); + var s2 = result.Value.Seasons.Single(x => x.Season == 2); + Assert.Equal([2, 3], s2.Missing); + } + + [Fact] + public async Task Fails_WhenNoLinkedSource() + { + var fixture = new TestDb(); + var show = Show.Create("A", ShowKind.Series); + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var resolver = Substitute.For(); + await using var db = fixture.New(); + var result = await new FindMissingEpisodesQueryHandler(db, resolver).Handle( + new FindMissingEpisodesQuery(show.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + } +} diff --git a/frontend/src/features/admin/shows/ShowMetadataCard.tsx b/frontend/src/features/admin/shows/ShowMetadataCard.tsx index 627db66..729b82d 100644 --- a/frontend/src/features/admin/shows/ShowMetadataCard.tsx +++ b/frontend/src/features/admin/shows/ShowMetadataCard.tsx @@ -1,10 +1,12 @@ import { useMutation, useQuery } from '@tanstack/react-query' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { Loader2 } from 'lucide-react' import { HttpError } from '@/shared/api/client' -import type { MetadataCandidate, ShowDto } from '@/shared/api/types' +import type { MetadataCandidate, MissingEpisodesReport, ShowDto } from '@/shared/api/types' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog' import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' @@ -14,6 +16,7 @@ import { ImageGallery } from '@/features/admin/images/ImageGallery' import { applyMetadata, clearMetadata, + findMissingEpisodes, getMetadataProviders, refreshEpisodesMetadata, renameShow, @@ -34,6 +37,14 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged const [description, setDescription] = useState(show.description ?? '') const [year, setYear] = useState(show.year != null ? String(show.year) : '') + // После «Применить»/обновления с сервера показанные значения меняются — подхватываем их в поля формы + // (иначе описание/год оставались бы пустыми, хотя в БД уже записаны). Реагируем только на смену + // серверных значений, так что ручной ввод между сохранениями не затирается. + useEffect(() => { + setDescription(show.description ?? '') + setYear(show.year != null ? String(show.year) : '') + }, [show.description, show.year]) + const { data: providers } = useQuery({ queryKey: ['admin', 'metadata', 'providers'], queryFn: getMetadataProviders, @@ -110,11 +121,18 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged }, onError, }) + const [missing, setMissing] = useState(null) + const findMissing = useMutation({ + mutationFn: () => findMissingEpisodes(show.id), + onSuccess: (report) => setMissing(report), + onError, + }) const linked = !!show.metadataExternalId && !!show.metadataProvider && show.metadataProvider !== 'manual' return ( + <> {t('admin.metadata.title')} @@ -260,11 +278,23 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged disabled={refreshEpisodes.isPending} onClick={() => refreshEpisodes.mutate()} > + {refreshEpisodes.isPending && } {refreshEpisodes.isPending ? t('admin.metadata.refreshing') : t('admin.metadata.refreshEpisodes')} )} + {linked && ( + + )} {(show.metadataProvider || show.posterImageId) && (