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}";
@@ -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<IMetadataProvider>();
provider
.GetSeasonEpisodeCountAsync("123", 1, Arg.Any<CancellationToken>())
.Returns(5);
provider
.GetSeasonEpisodeCountAsync("123", 2, Arg.Any<CancellationToken>())
.Returns(3);
var resolver = Substitute.For<IMetadataProviderResolver>();
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<IMetadataProviderResolver>();
await using var db = fixture.New();
var result = await new FindMissingEpisodesQueryHandler(db, resolver).Handle(
new FindMissingEpisodesQuery(show.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
}
}
@@ -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<MissingEpisodesReport | null>(null)
const findMissing = useMutation({
mutationFn: () => findMissingEpisodes(show.id),
onSuccess: (report) => setMissing(report),
onError,
})
const linked =
!!show.metadataExternalId && !!show.metadataProvider && show.metadataProvider !== 'manual'
return (
<>
<Card>
<CardHeader>
<CardTitle>{t('admin.metadata.title')}</CardTitle>
@@ -260,11 +278,23 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
disabled={refreshEpisodes.isPending}
onClick={() => refreshEpisodes.mutate()}
>
{refreshEpisodes.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{refreshEpisodes.isPending
? t('admin.metadata.refreshing')
: t('admin.metadata.refreshEpisodes')}
</Button>
)}
{linked && (
<Button
size="sm"
variant="outline"
disabled={findMissing.isPending}
onClick={() => findMissing.mutate()}
>
{findMissing.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{t('admin.metadata.findMissing')}
</Button>
)}
{(show.metadataProvider || show.posterImageId) && (
<Button
size="sm"
@@ -285,5 +315,50 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
</div>
</CardContent>
</Card>
<Dialog open={missing != null} onOpenChange={(open) => !open && setMissing(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('admin.metadata.missingTitle')}</DialogTitle>
</DialogHeader>
{missing && missing.seasons.length === 0 && (
<p className="text-sm text-muted-foreground">{t('admin.metadata.missingNoSeasons')}</p>
)}
{missing && missing.seasons.length > 0 && (
<div className="flex max-h-[60vh] flex-col gap-3 overflow-y-auto">
{missing.seasons.map((s) => (
<div key={s.season} className="rounded-md border border-border p-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">
{t('admin.metadata.seasonN', { n: s.season })}
</span>
<span className="text-xs text-muted-foreground">
{t('admin.metadata.loadedOf', {
loaded: s.loaded,
total: s.expected ?? '?',
})}
</span>
</div>
{s.expected == null ? (
<p className="mt-1 text-xs text-amber-500">
{t('admin.metadata.missingUnknown')}
</p>
) : s.missing.length === 0 ? (
<p className="mt-1 text-xs text-emerald-500">
{t('admin.metadata.missingNone')}
</p>
) : (
<p className="mt-1 text-muted-foreground">
{t('admin.metadata.missingList')}:{' '}
<span className="text-foreground">{s.missing.join(', ')}</span>
</p>
)}
</div>
))}
</div>
)}
</DialogContent>
</Dialog>
</>
)
}
+6
View File
@@ -2,6 +2,7 @@ import { apiRequest } from '@/shared/api/client'
import type {
CreatedIdResponse,
MetadataCandidate,
MissingEpisodesReport,
ShowAudience,
ShowDto,
ShowKind,
@@ -94,3 +95,8 @@ export function setShowPoster(showId: string, imageId: string | null) {
export function refreshEpisodesMetadata(showId: string) {
return apiRequest<number>(`/admin/metadata/shows/${showId}/refresh-episodes`, { method: 'POST' })
}
/** Отчёт: каких серий не хватает в загруженных сезонах (по данным источника). */
export function findMissingEpisodes(showId: string) {
return apiRequest<MissingEpisodesReport>(`/admin/metadata/shows/${showId}/missing-episodes`)
}
+11
View File
@@ -96,6 +96,17 @@ export type MetadataCandidate = {
posterUrl: string | null
}
export type SeasonGapDto = {
season: number
expected: number | null
loaded: number
missing: number[]
}
export type MissingEpisodesReport = {
seasons: SeasonGapDto[]
}
export type EpisodeDto = {
id: string
mediaAssetId: string
+16
View File
@@ -380,6 +380,14 @@ const resources = {
refreshEpisodes: 'Обновить серии',
refreshing: 'Обновляем…',
refreshedCount: 'Обновлено серий: {{count}}',
findMissing: 'Найти отсутствующие серии',
missingTitle: 'Отсутствующие серии',
missingNoSeasons: 'В шоу нет загруженных серий с распознанными номерами.',
seasonN: 'Сезон {{n}}',
loadedOf: 'загружено {{loaded}} из {{total}}',
missingUnknown: 'Источник не отдал число серий этого сезона.',
missingNone: 'Все серии на месте.',
missingList: 'Не хватает',
},
},
},
@@ -762,6 +770,14 @@ const resources = {
refreshEpisodes: 'Refresh episodes',
refreshing: 'Refreshing…',
refreshedCount: 'Episodes updated: {{count}}',
findMissing: 'Find missing episodes',
missingTitle: 'Missing episodes',
missingNoSeasons: 'No loaded episodes with recognized numbers.',
seasonN: 'Season {{n}}',
loadedOf: 'loaded {{loaded}} of {{total}}',
missingUnknown: 'The source did not return the episode count for this season.',
missingNone: 'All episodes present.',
missingList: 'Missing',
},
},
},