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}";
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user