From 986f7ff52dbb58eb1660c578af286344e4907dfe Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 27 Jul 2026 02:40:09 +0300 Subject: [PATCH] Add missing seasons endpoint and update metadata handling Implemented a new endpoint to retrieve missing seasons for shows, enhancing the MetadataEndpoints class. Updated the IMetadataProvider interface to include a method for fetching season numbers. Adjusted the ShowSummaryDto to include other genres and modified the ListShowsQueryHandler to handle multiple genres. Enhanced the Omdb and Tmdb metadata providers to support season number retrieval. Updated frontend components to integrate missing seasons functionality, including API calls and UI elements for displaying missing seasons reports. --- .../Endpoints/MetadataEndpoints.cs | 14 ++ .../Common/Interfaces/IMetadataProvider.cs | 9 + .../ListShows/ListShowsQueryHandler.cs | 20 ++- .../TeleWave.Application/Library/ShowDtos.cs | 6 +- .../FindMissingEpisodesQueryHandler.cs | 39 +---- .../FindMissingSeasonsQuery.cs | 21 +++ .../FindMissingSeasonsQueryHandler.cs | 63 +++++++ .../Metadata/LoadedEpisodeNumbers.cs | 45 +++++ .../Metadata/OmdbMetadataProvider.cs | 20 +++ .../Metadata/TmdbMetadataProvider.cs | 26 +++ .../Metadata/FindMissingSeasonsTests.cs | 154 ++++++++++++++++++ .../features/admin/shows/ShowMetadataCard.tsx | 68 +++++++- .../src/features/admin/shows/ShowsPanel.tsx | 73 ++++++++- frontend/src/features/admin/shows/api.ts | 6 + frontend/src/shared/api/types.ts | 15 +- frontend/src/shared/lib/locales/en.ts | 7 + frontend/src/shared/lib/locales/ru.ts | 7 + 17 files changed, 540 insertions(+), 53 deletions(-) create mode 100644 backend/src/TeleWave.Application/Metadata/FindMissingSeasons/FindMissingSeasonsQuery.cs create mode 100644 backend/src/TeleWave.Application/Metadata/FindMissingSeasons/FindMissingSeasonsQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Metadata/LoadedEpisodeNumbers.cs create mode 100644 backend/tests/TeleWave.Application.Tests/Metadata/FindMissingSeasonsTests.cs diff --git a/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs index 5b52e3c..7535734 100644 --- a/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs @@ -4,6 +4,7 @@ using TeleWave.Application.Metadata; using TeleWave.Application.Metadata.ApplyShowMetadata; using TeleWave.Application.Metadata.ClearShowMetadata; using TeleWave.Application.Metadata.FindMissingEpisodes; +using TeleWave.Application.Metadata.FindMissingSeasons; using TeleWave.Application.Metadata.GetProviders; using TeleWave.Application.Metadata.RefreshEpisodes; using TeleWave.Application.Metadata.SearchShows; @@ -34,6 +35,9 @@ public static class MetadataEndpoints admin .MapGet("/shows/{showId:guid}/missing-episodes", FindMissing) .Produces(); + admin + .MapGet("/shows/{showId:guid}/missing-seasons", FindMissingSeasons) + .Produces(); // Постеры шоу и кадры серий теперь в общем реестре и отдаются по /api/images/{id}. return app; @@ -137,6 +141,16 @@ public static class MetadataEndpoints var result = await sender.Send(new FindMissingEpisodesQuery(showId), cancellationToken); return result.ToHttpResult(); } + + private static async Task FindMissingSeasons( + Guid showId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new FindMissingSeasonsQuery(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 a7cf4e0..e6047a9 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IMetadataProvider.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMetadataProvider.cs @@ -48,6 +48,15 @@ public interface IMetadataProvider int season, CancellationToken cancellationToken ); + + /// + /// Номера сезонов сериала по данным источника (пусто — источник не отдал данные). Спецвыпуски + /// (нулевой сезон) не отдаются: в библиотеку они не грузятся и считались бы вечно отсутствующими. + /// + Task> GetSeasonNumbersAsync( + string externalId, + CancellationToken cancellationToken + ); } /// Резолвит провайдер по ключу и перечисляет реально настроенные (с API-ключом) источники. diff --git a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs index 07381d6..50f14e7 100644 --- a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs @@ -32,16 +32,11 @@ public sealed class ListShowsQueryHandler(IAppDbContext dbContext) var shows = await filtered.OrderBy(s => s.Name).ToListAsync(cancellationToken); - // Названия только для основных жанров — в списке показывается один. - var primaryIds = shows - .Select(s => s.PrimaryGenreId) - .Where(id => id is not null) - .Select(id => id!.Value) - .Distinct() - .ToList(); + // Названия всех проставленных жанров: основной показывается в колонке, прочие — под «…». + var genreIds = shows.SelectMany(s => s.Genres.Select(g => g.GenreId)).Distinct().ToList(); var genreNames = await dbContext .Genres.AsNoTracking() - .Where(g => primaryIds.Contains(g.Id)) + .Where(g => genreIds.Contains(g.Id)) .ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken); // Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся). @@ -81,7 +76,14 @@ public sealed class ListShowsQueryHandler(IAppDbContext dbContext) s.PrimaryGenreId is { } primaryId && genreNames.TryGetValue(primaryId, out var g) ? g - : null + : null, + [ + .. s + .Genres.Where(x => !x.IsPrimary) + .Select(x => genreNames.GetValueOrDefault(x.GenreId)) + .OfType() + .Order(StringComparer.OrdinalIgnoreCase), + ] ); }) .ToList(); diff --git a/backend/src/TeleWave.Application/Library/ShowDtos.cs b/backend/src/TeleWave.Application/Library/ShowDtos.cs index 12628db..967178b 100644 --- a/backend/src/TeleWave.Application/Library/ShowDtos.cs +++ b/backend/src/TeleWave.Application/Library/ShowDtos.cs @@ -15,8 +15,10 @@ public sealed record ShowSummaryDto( int? Year, bool HasPoster, DateTimeOffset CreatedAt, - /// Название основного жанра — в списке показываем только его, остальные видны в карточке шоу. - string? PrimaryGenre = null + /// Название основного жанра — в списке колонка показывает его. + string? PrimaryGenre, + /// Названия прочих жанров — в списке прячутся под «…», полный набор правится в карточке шоу. + IReadOnlyList OtherGenres ); /// Жанр, проставленный шоу. diff --git a/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQueryHandler.cs b/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQueryHandler.cs index 9b6ba31..79786d5 100644 --- a/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQueryHandler.cs +++ b/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQueryHandler.cs @@ -44,7 +44,7 @@ public sealed class FindMissingEpisodesQueryHandler( .Select(a => new { a.Id, a.OriginalFileName }) .ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken); - var loadedBySeason = GroupLoadedBySeason(show, names); + var loadedBySeason = LoadedEpisodeNumbers.GroupBySeason(show, names); var seasons = new List(); foreach (var season in loadedBySeason.Keys.OrderBy(s => s)) @@ -63,41 +63,4 @@ public sealed class FindMissingEpisodesQueryHandler( return Result.Success(new MissingEpisodesReport(seasons)); } - - /// - /// Загруженные номера серий по сезонам — только те сезоны, где есть хотя бы одна серия. Номера - /// берутся проставленные, а при их отсутствии распознаются из имени файла (здесь, в отличие от - /// обновления метаданных, распознанное не сохраняется: запрос ничего не меняет). - /// - private static Dictionary> GroupLoadedBySeason( - Show show, - IReadOnlyDictionary assetNames - ) - { - var loadedBySeason = new Dictionary>(); - foreach (var episode in show.Episodes) - { - if (ResolveNumbers(episode, assetNames) is not { } numbers) - continue; - - if (!loadedBySeason.TryGetValue(numbers.Season, out var set)) - loadedBySeason[numbers.Season] = set = []; - set.Add(numbers.Episode); - } - return loadedBySeason; - } - - /// Сезон и номер серии: проставленные либо распознанные из имени файла; null — не вышло. - private static (int Season, int Episode)? ResolveNumbers( - ShowEpisode episode, - IReadOnlyDictionary assetNames - ) - { - if (episode.Season is { } season && episode.Episode is { } number) - return (season, number); - - return assetNames.TryGetValue(episode.MediaAssetId, out var name) - ? EpisodeName.Parse(name) - : null; - } } diff --git a/backend/src/TeleWave.Application/Metadata/FindMissingSeasons/FindMissingSeasonsQuery.cs b/backend/src/TeleWave.Application/Metadata/FindMissingSeasons/FindMissingSeasonsQuery.cs new file mode 100644 index 0000000..1ba08c9 --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/FindMissingSeasons/FindMissingSeasonsQuery.cs @@ -0,0 +1,21 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Metadata.FindMissingSeasons; + +/// Отчёт: каких сезонов шоу нет в библиотеке вовсе (по данным привязанного источника). +public sealed record FindMissingSeasonsQuery(Guid ShowId) : IQuery>; + +/// Сколько сезонов у сериала по источнику (null — источник не отдал данные). +/// Номера сезонов, где загружена хотя бы одна серия. +/// Номера сезонов из источника, где не загружено ни одной серии. +/// +/// Загруженные сезоны, которых источник не знает (опечатка в номере либо чужая привязка) — пусто, +/// если неизвестен. +/// +public sealed record MissingSeasonsReport( + int? Expected, + IReadOnlyList Loaded, + IReadOnlyList Missing, + IReadOnlyList Unknown +); diff --git a/backend/src/TeleWave.Application/Metadata/FindMissingSeasons/FindMissingSeasonsQueryHandler.cs b/backend/src/TeleWave.Application/Metadata/FindMissingSeasons/FindMissingSeasonsQueryHandler.cs new file mode 100644 index 0000000..1225faa --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/FindMissingSeasons/FindMissingSeasonsQueryHandler.cs @@ -0,0 +1,63 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Library; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Metadata.FindMissingSeasons; + +public sealed class FindMissingSeasonsQueryHandler( + IAppDbContext dbContext, + IMetadataProviderResolver resolver +) : IQueryHandler> +{ + public async Task> Handle( + FindMissingSeasonsQuery 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); + + // Сезоны есть только у сериала — см. FindMissingEpisodesQueryHandler. + if (show.Kind != ShowKind.Series) + return Result.Failure(MetadataErrors.EpisodesForSeriesOnly); + + 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 loaded = LoadedEpisodeNumbers.GroupBySeason(show, names).Keys.ToHashSet(); + var known = await provider.GetSeasonNumbersAsync( + show.MetadataExternalId, + cancellationToken + ); + + // Источник молчит — отдаём только загруженное: считать все сезоны отсутствующими или, + // наоборот, лишними было бы одинаково неверно. + return Result.Success( + new MissingSeasonsReport( + known.Count > 0 ? known.Count : null, + [.. loaded.Order()], + [.. known.Where(s => !loaded.Contains(s))], + known.Count > 0 ? [.. loaded.Where(s => !known.Contains(s)).Order()] : [] + ) + ); + } +} diff --git a/backend/src/TeleWave.Application/Metadata/LoadedEpisodeNumbers.cs b/backend/src/TeleWave.Application/Metadata/LoadedEpisodeNumbers.cs new file mode 100644 index 0000000..b3d9346 --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/LoadedEpisodeNumbers.cs @@ -0,0 +1,45 @@ +using TeleWave.Application.Library; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Metadata; + +/// +/// Что из сезонов/серий шоу реально загружено — общая основа отчётов об отсутствующем. Номера +/// берутся проставленные, а при их отсутствии распознаются из имени файла (в отличие от обновления +/// метаданных, распознанное не сохраняется: отчёты ничего не меняют). +/// +internal static class LoadedEpisodeNumbers +{ + /// Загруженные номера серий по сезонам — только сезоны, где есть хотя бы одна серия. + public static Dictionary> GroupBySeason( + Show show, + IReadOnlyDictionary assetNames + ) + { + var loadedBySeason = new Dictionary>(); + foreach (var episode in show.Episodes) + { + if (Resolve(episode, assetNames) is not { } numbers) + continue; + + if (!loadedBySeason.TryGetValue(numbers.Season, out var set)) + loadedBySeason[numbers.Season] = set = []; + set.Add(numbers.Episode); + } + return loadedBySeason; + } + + /// Сезон и номер серии: проставленные либо распознанные из имени файла; null — не вышло. + private static (int Season, int Episode)? Resolve( + ShowEpisode episode, + IReadOnlyDictionary assetNames + ) + { + if (episode.Season is { } season && episode.Episode is { } number) + return (season, number); + + return assetNames.TryGetValue(episode.MediaAssetId, out var name) + ? EpisodeName.Parse(name) + : null; + } +} diff --git a/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs index 6aa07da..7c7c13c 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs @@ -144,6 +144,26 @@ public sealed class OmdbMetadataProvider( return episodes.GetArrayLength(); } + /// + /// OMDb перечня сезонов не отдаёт — только их количество (totalSeasons, строкой) в ответе + /// по id. Считаем сезоны сплошной нумерацией с единицы: разрывов в ней у OMDb не бывает. + /// + public async Task> GetSeasonNumbersAsync( + string externalId, + CancellationToken cancellationToken + ) + { + var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}"; + using var doc = await TryGetAsync(httpFactory, url, cancellationToken); + if (doc is null || !IsResponseTrue(doc.RootElement)) + return []; + + var total = Clean(GetString(doc.RootElement, "totalSeasons")); + return int.TryParse(total, CultureInfo.InvariantCulture, out var count) && count > 0 + ? Enumerable.Range(1, count).ToList() + : []; + } + private static bool IsResponseTrue(JsonElement root) => GetString(root, "Response") is { } r && r.Equals("True", StringComparison.OrdinalIgnoreCase); diff --git a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs index 8762293..346c241 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs @@ -244,6 +244,32 @@ public sealed class TmdbMetadataProvider( return episodes.GetArrayLength(); } + public async Task> GetSeasonNumbersAsync( + string externalId, + CancellationToken cancellationToken + ) + { + var url = + $"{Tmdb.BaseUrl}/tv/{externalId}?api_key={Tmdb.ApiKey}&language={_options.Language}"; + using var doc = await TryGetAsync(httpFactory, url, cancellationToken); + if ( + doc is null + || !doc.RootElement.TryGetProperty("seasons", out var seasons) + || seasons.ValueKind != JsonValueKind.Array + ) + return []; + + var numbers = new List(); + foreach (var season in seasons.EnumerateArray()) + { + // Нулевой сезон у TMDb — спецвыпуски; обычные сезоны нумеруются с единицы. + if (GetInt(season, "season_number") is { } number && number > 0) + numbers.Add(number); + } + numbers.Sort(); + return numbers; + } + private string? PosterUrl(string? path) => string.IsNullOrEmpty(path) ? null : $"{Tmdb.ImageBaseUrl}/{Tmdb.PosterSize}{path}"; diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/FindMissingSeasonsTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/FindMissingSeasonsTests.cs new file mode 100644 index 0000000..142790d --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Metadata/FindMissingSeasonsTests.cs @@ -0,0 +1,154 @@ +using NSubstitute; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Metadata.FindMissingSeasons; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Library; +using Xunit; + +namespace TeleWave.Application.Tests.Metadata; + +public class FindMissingSeasonsTests +{ + [Fact] + public async Task ReportsSeasonsWithoutAnyLoadedEpisode() + { + var fixture = new TestDb(); + var show = Show.Create("A", ShowKind.Series); + show.ApplyMetadata("tmdb", "123", "desc", 2000, null); + // Загружены сезоны 1 и 3; у источника их четыре — не хватает 2 и 4. + var e11 = show.AddEpisode(Guid.NewGuid()); + e11.SetNumbers(1, 1); + var e31 = show.AddEpisode(Guid.NewGuid()); + e31.SetNumbers(3, 1); + + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var resolver = ResolverFor("123", [1, 2, 3, 4]); + await using var db = fixture.New(); + var result = await new FindMissingSeasonsQueryHandler(db, resolver).Handle( + new FindMissingSeasonsQuery(show.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(4, result.Value.Expected); + Assert.Equal([1, 3], result.Value.Loaded); + Assert.Equal([2, 4], result.Value.Missing); + Assert.Empty(result.Value.Unknown); + } + + [Fact] + public async Task ReportsLoadedSeasonsUnknownToSource() + { + var fixture = new TestDb(); + var show = Show.Create("A", ShowKind.Series); + show.ApplyMetadata("tmdb", "123", null, 2000, null); + var e91 = show.AddEpisode(Guid.NewGuid()); + e91.SetNumbers(9, 1); + + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var resolver = ResolverFor("123", [1, 2]); + await using var db = fixture.New(); + var result = await new FindMissingSeasonsQueryHandler(db, resolver).Handle( + new FindMissingSeasonsQuery(show.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal([1, 2], result.Value.Missing); + Assert.Equal([9], result.Value.Unknown); + } + + [Fact] + public async Task ReportsExpectedNull_WhenSourceReturnsNothing() + { + // Источник промолчал: ни отсутствующих, ни лишних сезонов утверждать нельзя. + var fixture = new TestDb(); + var show = Show.Create("A", ShowKind.Series); + show.ApplyMetadata("tmdb", "123", null, 2000, null); + var e11 = show.AddEpisode(Guid.NewGuid()); + e11.SetNumbers(1, 1); + + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var resolver = ResolverFor("123", []); + await using var db = fixture.New(); + var result = await new FindMissingSeasonsQueryHandler(db, resolver).Handle( + new FindMissingSeasonsQuery(show.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Null(result.Value.Expected); + Assert.Equal([1], result.Value.Loaded); + Assert.Empty(result.Value.Missing); + Assert.Empty(result.Value.Unknown); + } + + [Fact] + public async Task Fails_ForNonSeries() + { + var fixture = new TestDb(); + var show = Show.Create("A", ShowKind.Single); + show.ApplyMetadata("tmdb", "123", null, 2000, null); + 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 FindMissingSeasonsQueryHandler(db, resolver).Handle( + new FindMissingSeasonsQuery(show.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + } + + [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 FindMissingSeasonsQueryHandler(db, resolver).Handle( + new FindMissingSeasonsQuery(show.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + } + + private static IMetadataProviderResolver ResolverFor(string externalId, int[] seasons) + { + var provider = Substitute.For(); + provider + .GetSeasonNumbersAsync(externalId, Arg.Any()) + .Returns(seasons.ToList()); + var resolver = Substitute.For(); + resolver.Resolve("tmdb").Returns(provider); + return resolver; + } +} diff --git a/frontend/src/features/admin/shows/ShowMetadataCard.tsx b/frontend/src/features/admin/shows/ShowMetadataCard.tsx index ea46284..0003c83 100644 --- a/frontend/src/features/admin/shows/ShowMetadataCard.tsx +++ b/frontend/src/features/admin/shows/ShowMetadataCard.tsx @@ -3,7 +3,12 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Loader2 } from 'lucide-react' import { qk } from '@/shared/api/query-keys' -import type { MetadataCandidate, MissingEpisodesReport, ShowDto } from '@/shared/api/types' +import type { + MetadataCandidate, + MissingEpisodesReport, + MissingSeasonsReport, + ShowDto, +} from '@/shared/api/types' import { useApiError } from '@/shared/lib/use-api-error' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' @@ -19,6 +24,7 @@ import { applyMetadata, clearMetadata, findMissingEpisodes, + findMissingSeasons, getMetadataProviders, refreshEpisodesMetadata, renameShow, @@ -132,6 +138,12 @@ export function ShowMetadataCard({ onSuccess: (report) => setMissing(report), onError, }) + const [missingSeasons, setMissingSeasons] = useState(null) + const findSeasons = useMutation({ + mutationFn: () => findMissingSeasons(show.id), + onSuccess: (report) => setMissingSeasons(report), + onError, + }) const linked = !!show.metadataExternalId && !!show.metadataProvider && show.metadataProvider !== 'manual' @@ -319,6 +331,17 @@ export function ShowMetadataCard({ {t('admin.metadata.findMissing')} )} + {linked && show.kind === 'Series' && ( + + )} {(show.metadataProvider || show.posterImageId) && ( + {at && ( +
+

+ {t('admin.shows.otherGenres')} +

+
    + {others.map((genre) => ( +
  • {genre}
  • + ))} +
+
+ )} + + ) +} diff --git a/frontend/src/features/admin/shows/api.ts b/frontend/src/features/admin/shows/api.ts index 542949b..c1d3182 100644 --- a/frontend/src/features/admin/shows/api.ts +++ b/frontend/src/features/admin/shows/api.ts @@ -3,6 +3,7 @@ import type { CreatedIdResponse, MetadataCandidate, MissingEpisodesReport, + MissingSeasonsReport, ShowAudience, ShowDto, ShowKind, @@ -114,3 +115,8 @@ export function refreshEpisodesMetadata(showId: string) { export function findMissingEpisodes(showId: string) { return apiRequest(`/admin/metadata/shows/${showId}/missing-episodes`) } + +/** Отчёт: каких сезонов нет в библиотеке вовсе (по данным источника). */ +export function findMissingSeasons(showId: string) { + return apiRequest(`/admin/metadata/shows/${showId}/missing-seasons`) +} diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 93e554b..02a4e27 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -154,8 +154,10 @@ export type ShowSummaryDto = { year: number | null hasPoster: boolean createdAt: string - /** Основной жанр — в списке показывается только он. */ + /** Основной жанр — его показывает колонка списка. */ primaryGenre: string | null + /** Прочие жанры — в списке прячутся под «…». */ + otherGenres: string[] } type ShowGenreDto = { @@ -310,6 +312,17 @@ export type MissingEpisodesReport = { seasons: SeasonGapDto[] } +export type MissingSeasonsReport = { + /** Сколько сезонов у сериала по источнику; null — источник не отдал данные. */ + expected: number | null + /** Номера сезонов, где загружена хотя бы одна серия. */ + loaded: number[] + /** Номера сезонов из источника, где не загружено ни одной серии. */ + missing: number[] + /** Загруженные сезоны, которых источник не знает. */ + unknown: number[] +} + type EpisodeDto = { id: string mediaAssetId: string diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index a07f968..6ad84fd 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -308,6 +308,7 @@ export const en = { loadedSeasons: 'Loaded seasons', genre: 'Genre', allGenres: 'All genres', + otherGenres: 'Other genres', genresEmpty: 'No genres set', genresEdit: 'Genres', genresHint: @@ -676,6 +677,12 @@ export const en = { missingUnknown: 'The source did not return the episode count for this season.', missingNone: 'All episodes present.', missingList: 'Missing', + findMissingSeasons: 'Find missing seasons', + missingSeasonsTitle: 'Missing seasons', + missingSeasonsUnknownTotal: 'The source did not return the season list for this series.', + missingSeasonsNone: 'All seasons present.', + missingSeasonsList: 'Seasons with no loaded episodes', + missingSeasonsUnknown: 'The source does not know these loaded seasons: {{list}}.', }, }, } diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 50e3430..217dee4 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -309,6 +309,7 @@ export const ru = { loadedSeasons: 'Загружены сезоны', genre: 'Жанр', allGenres: 'Все жанры', + otherGenres: 'Остальные жанры', genresEmpty: 'Жанры не проставлены', genresEdit: 'Жанры', genresHint: @@ -682,6 +683,12 @@ export const ru = { missingUnknown: 'Источник не отдал число серий этого сезона.', missingNone: 'Все серии на месте.', missingList: 'Не хватает', + findMissingSeasons: 'Найти отсутствующие сезоны', + missingSeasonsTitle: 'Отсутствующие сезоны', + missingSeasonsUnknownTotal: 'Источник не отдал список сезонов этого сериала.', + missingSeasonsNone: 'Все сезоны на месте.', + missingSeasonsList: 'Не загружено ни одной серии из сезонов', + missingSeasonsUnknown: 'Источник не знает загруженные сезоны: {{list}}.', }, }, }