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}}.', }, }, }