diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs index d66881e..1c27b21 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text; using System.Text.RegularExpressions; namespace TeleWave.Application.Broadcast.Bumpers; @@ -75,6 +76,9 @@ public static partial class BumperPlaceholders // Русская культура фиксирована: заставка рендерится один раз в видео, локали зрителя у неё нет. private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU"); + /// Время в кадре — только часы и минуты: секунды в заставке не читаются. + private const string TimeFormat = "HH:mm"; + [GeneratedRegex(@"\{([a-zA-Z][a-zA-Z.]*)\}", RegexOptions.CultureInvariant)] private static partial Regex TokenPattern(); @@ -98,27 +102,40 @@ public static partial class BumperPlaceholders if (string.IsNullOrWhiteSpace(text)) return string.Empty; - var tokens = 0; - var filled = 0; - var resolved = TokenPattern() - .Replace( - text, - match => - { - tokens++; - var value = Value(match.Groups[1].Value, context); - if (!string.IsNullOrWhiteSpace(value)) - filled++; - return value ?? string.Empty; - } - ); + var tokens = TokenPattern().Matches(text); + if (tokens.Count == 0) + return Clean(text); - if (tokens > 0 && filled == 0) + // Собираем строку сами, а не через Replace с лямбдой: подстановку надо не только сделать, + // но и сосчитать, а счётчик в замыкании читается хуже и не виден анализаторам. + var builder = new StringBuilder(); + var cursor = 0; + var filled = 0; + + foreach (Match token in tokens) + { + builder.Append(text, cursor, token.Index - cursor); + cursor = token.Index + token.Length; + + var value = Value(token.Groups[1].Value, context); + if (string.IsNullOrWhiteSpace(value)) + continue; + + builder.Append(value); + filled++; + } + + if (filled == 0) return string.Empty; - return ExtraSpaces().Replace(resolved, " ").Trim().Trim(DanglingSeparators).Trim(); + builder.Append(text, cursor, text.Length - cursor); + return Clean(builder.ToString()); } + /// Схлопывает лишние пробелы и снимает осиротевшие разделители по краям. + private static string Clean(string text) => + ExtraSpaces().Replace(text, " ").Trim().Trim(DanglingSeparators).Trim(); + /// Какие плейсхолдеры встречаются в текстах — по ним планировщик решает, что грузить. public static IReadOnlySet TokensIn(IEnumerable texts) { @@ -160,14 +177,14 @@ public static partial class BumperPlaceholders "next.episode" => context.NextEpisode, "next.year" => context.NextYear?.ToString(Culture), "next.genre" => context.NextGenre, - "next.time" => context.NextTime?.ToString("HH:mm", Culture), + "next.time" => context.NextTime?.ToString(TimeFormat, Culture), "tonight.title" => context.Tonight?.Title, - "tonight.time" => context.Tonight?.Time.ToString("HH:mm", Culture), + "tonight.time" => context.Tonight?.Time.ToString(TimeFormat, Culture), "tomorrow.title" => context.Tomorrow?.Title, - "tomorrow.time" => context.Tomorrow?.Time.ToString("HH:mm", Culture), + "tomorrow.time" => context.Tomorrow?.Time.ToString(TimeFormat, Culture), "time" => TimeOnly .FromDateTime(context.LocalMoment.DateTime) - .ToString("HH:mm", Culture), + .ToString(TimeFormat, Culture), "date" => context.LocalMoment.ToString("d MMMM", Culture), "weekday" => context.LocalMoment.ToString("dddd", Culture), "slot" => context.SlotTitle, diff --git a/backend/src/TeleWave.Application/Media/MovieImport/AutoAttachMovieCommandHandler.cs b/backend/src/TeleWave.Application/Media/MovieImport/AutoAttachMovieCommandHandler.cs index a4b53b3..99cedca 100644 --- a/backend/src/TeleWave.Application/Media/MovieImport/AutoAttachMovieCommandHandler.cs +++ b/backend/src/TeleWave.Application/Media/MovieImport/AutoAttachMovieCommandHandler.cs @@ -66,6 +66,10 @@ public sealed class AutoAttachMovieCommandHandler( cancellationToken ); + // Имя берём из карточки источника: в поиске оно могло прийти из другой локали. + if (applied.IsSuccess && applied.Value.Title is { Length: > 0 } title) + show.SetName(title); + // Метаданные не легли — шоу с файлом всё равно полезнее, чем ассет без шоу: название // у него правильное, а постер дотянет массовое обогащение. return Result.Success(applied.IsSuccess); diff --git a/backend/src/TeleWave.Application/Media/MovieImport/ImportMoviesCommandHandler.cs b/backend/src/TeleWave.Application/Media/MovieImport/ImportMoviesCommandHandler.cs index 91b0019..b1bce43 100644 --- a/backend/src/TeleWave.Application/Media/MovieImport/ImportMoviesCommandHandler.cs +++ b/backend/src/TeleWave.Application/Media/MovieImport/ImportMoviesCommandHandler.cs @@ -63,15 +63,37 @@ public sealed class ImportMoviesCommandHandler( externalId, cancellationToken ); - if (result.IsSuccess) - enriched++; - else + if (!result.IsSuccess) + { failed.Add(new MovieImportFailureDto(item.Title, result.Error.Message)); + continue; + } + + enriched++; + Rename(show, item, result.Value); } return Result.Success(new ImportMoviesResultDto(created, enriched, failed)); } + /// + /// Называет фильм так, как он называется в источнике, а не так, как назывался файл: в таблице + /// разбора поле «Название» — это строка поиска, и «Die Hard 2» там стоит ровно для того, чтобы + /// найти «Крепкий орешек 2». Название из файла при этом не пропадает — оно становится + /// оригинальным, если источник своего не дал: по нему потом ищутся релизы и сходятся имена. + /// + private static void Rename(Show show, MovieImportItem item, ShowMetadata meta) + { + if (meta.Title is { Length: > 0 } title) + show.SetName(title); + + if (string.IsNullOrWhiteSpace(show.OriginalName) && !Same(item.Title, show.Name)) + show.SetOriginalName(item.Title.Trim()); + } + + private static bool Same(string left, string right) => + string.Equals(left.Trim(), right.Trim(), StringComparison.CurrentCultureIgnoreCase); + /// Ассет фильма: уже загруженный браузером либо забранный из manual/. private async Task<(Guid AssetId, bool Enqueue)?> ResolveAssetAsync( MovieImportItem item, diff --git a/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs index 8ccfa8d..2116f20 100644 --- a/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs +++ b/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs @@ -22,11 +22,13 @@ public sealed class ApplyShowMetadataCommandHandler( if (show is null) return Result.Failure(ShowErrors.NotFound); - return await applier.ApplyAsync( + var applied = await applier.ApplyAsync( show, command.Provider, command.ExternalId, cancellationToken ); + + return applied.IsSuccess ? Result.Success() : Result.Failure(applied.Error); } } diff --git a/backend/src/TeleWave.Application/Metadata/MetadataModels.cs b/backend/src/TeleWave.Application/Metadata/MetadataModels.cs index c348450..12a2b32 100644 --- a/backend/src/TeleWave.Application/Metadata/MetadataModels.cs +++ b/backend/src/TeleWave.Application/Metadata/MetadataModels.cs @@ -13,7 +13,9 @@ public sealed record MetadataCandidate( int? Year, string? Overview, string? PosterUrl, - ShowKind? Kind = null + ShowKind? Kind = null, + /// Название на языке оригинала (у OMDb таких данных нет — null). + string? OriginalTitle = null ); /// @@ -34,7 +36,12 @@ public sealed record ShowMetadata( IReadOnlyList? Genres = null, string? ContentRating = null, /// Франшиза по данным источника (у OMDb таких данных нет — null). - MetadataFranchise? Franchise = null + MetadataFranchise? Franchise = null, + /// + /// Название на языке оригинала. Нужно и как подпись, и как ключ поиска: релизы называются + /// латиницей, а у русской локали источника русский. + /// + string? OriginalTitle = null ); /// Метаданные серии (для этапа 2). diff --git a/backend/src/TeleWave.Application/Metadata/ShowMetadataApplier.cs b/backend/src/TeleWave.Application/Metadata/ShowMetadataApplier.cs index 515c763..be6ba6d 100644 --- a/backend/src/TeleWave.Application/Metadata/ShowMetadataApplier.cs +++ b/backend/src/TeleWave.Application/Metadata/ShowMetadataApplier.cs @@ -19,7 +19,12 @@ public sealed class ShowMetadataApplier( GenreMatcher genreMatcher ) { - public async Task ApplyAsync( + /// + /// Применяет метаданные и возвращает то, что отдал источник: вызывающему бывает нужно само + /// содержимое — например, чтобы назвать только что заведённый фильм так, как он называется + /// в источнике, а не так, как назывался файл. + /// + public async Task> ApplyAsync( Show show, string providerKey, string externalId, @@ -29,17 +34,17 @@ public sealed class ShowMetadataApplier( // Ролики метаданными не обогащаются: у них нет ни года, ни постера, а сотня роликов // в поиске только мешала бы. if (show.Kind == ShowKind.Interstitial) - return Result.Failure(MetadataErrors.NotForInterstitials); + return Result.Failure(MetadataErrors.NotForInterstitials); var provider = resolver.Resolve(providerKey); if (provider is null) - return Result.Failure(MetadataErrors.ProviderNotAvailable); + return Result.Failure(MetadataErrors.ProviderNotAvailable); // Тип берём у шоу, а не из запроса: иначе клиент смог бы заставить TMDb отдать метаданные // одноимённого фильма для сериала — идентификаторы у них независимые. var meta = await provider.GetShowAsync(externalId, show.Kind, cancellationToken); if (meta is null) - return Result.Failure(MetadataErrors.NotFound); + return Result.Failure(MetadataErrors.NotFound); var posterImageId = await SavePosterAsync(show, meta.PosterUrl, cancellationToken); @@ -64,7 +69,12 @@ public sealed class ShowMetadataApplier( if (ContentRating.Parse(meta.ContentRating) is { } audience) show.SetAudience(audience); - return Result.Success(); + // Оригинальное название проставляем только пустому: у релизов оно латиницей, а карточка + // источника русская, — но если админ вписал своё, переписывать его нельзя. + if (string.IsNullOrWhiteSpace(show.OriginalName) && meta.OriginalTitle is { Length: > 0 }) + show.SetOriginalName(meta.OriginalTitle); + + return Result.Success(meta); } /// Постер скачиваем и регистрируем в общем реестре изображений (галерея). diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs index 7696a29..c59229c 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs @@ -129,7 +129,7 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa var genre = group.PrimaryGenreId is { } id ? genres.GetValueOrDefault(id) : null; text.AppendLine( Culture, - $"| {group.Name} | {Kind(group.DominantKind)} | {group.UnitCount} | " + $"| {group.Name} | {KindName(group.DominantKind)} | {group.UnitCount} | " + $"{(int)Math.Round(group.AverageUnitMinutes)} мин | " + $"{group.Strictest?.ToString() ?? "—"} | {genre ?? "—"} |" ); @@ -162,7 +162,7 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa var genre = show.PrimaryGenreId is { } id ? genres.GetValueOrDefault(id) : null; text.AppendLine( Culture, - $"| {show.Name} | {Kind(show.Kind)} | {show.Year?.ToString(Culture) ?? "—"} | " + $"| {show.Name} | {KindName(show.Kind)} | {show.Year?.ToString(Culture) ?? "—"} | " + $"{show.Units} | {show.AverageMinutes} мин | " + $"{show.Audience?.ToString() ?? "—"} | {genre ?? "—"} |" ); @@ -186,7 +186,8 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa text.AppendLine(names.Count == 0 ? empty : string.Join(", ", names)); } - private static string Kind(ShowKind kind) => + /// Тип контента словами: «сериал», «полный метр» — так его читает модель. + private static string KindName(ShowKind kind) => kind switch { ShowKind.Series => "сериал", diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ExportGridQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ExportGridQueryHandler.cs index 3f365ca..dd2dcac 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ExportGridQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ExportGridQueryHandler.cs @@ -124,14 +124,16 @@ public sealed class ExportGridQueryHandler( .OfType() .ToList(); - var collections = new List(); - foreach (var element in composition.Where(e => e.Kind == GroupElementKind.Collection)) - { - if (collectionNames.GetValueOrDefault(element.Id) is not { } name) - continue; - collections.Add(name); - collectionIds.Add(element.Id); - } + var inGroup = composition + .Where(e => + e.Kind == GroupElementKind.Collection && collectionNames.ContainsKey(e.Id) + ) + .Select(e => e.Id) + .ToList(); + + // Идентификаторы копим отдельно: по ним ниже выгружается состав самих коллекций. + collectionIds.UnionWith(inGroup); + var collections = inGroup.Select(id => collectionNames[id]).ToList(); exported.Add( new GridConfigGroup( diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs index 1abe631..b3ae96f 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs @@ -61,28 +61,17 @@ public sealed class ImportGridCommandHandler( foreach (var configLayer in command.Config.Layers) { var layer = EnsureLayer(template, configLayer); + var (added, dropped) = await WriteSlotsAsync( + layer, + configLayer, + groups, + junctions, + warnings, + cancellationToken + ); - foreach (var configSlot in configLayer.Slots) - { - var input = ToInput(configSlot, groups, junctions, warnings); - if (input is null) - { - skipped++; - continue; - } - - var applied = await writer.ApplyAsync(layer, null, input, cancellationToken); - if (applied.IsSuccess) - { - created++; - continue; - } - - skipped++; - warnings.Add( - $"Слот «{configSlot.Title}» ({configSlot.Start:HH\\:mm}): {applied.Error.Message}" - ); - } + created += added; + skipped += dropped; } Apply(template, command.Config, groups, junctions, warnings); @@ -101,6 +90,47 @@ public sealed class ImportGridCommandHandler( ); } + /// + /// Пишет слоты одного слоя. Возвращает, сколько встало и сколько пропущено: слот, который + /// не прошёл проверки, замечанием и остаётся — валить весь файл из-за одной строки нельзя. + /// + private async Task<(int Added, int Dropped)> WriteSlotsAsync( + GridLayer layer, + GridConfigLayer configLayer, + IReadOnlyDictionary groups, + IReadOnlyDictionary junctions, + List warnings, + CancellationToken cancellationToken + ) + { + var added = 0; + var dropped = 0; + + foreach (var configSlot in configLayer.Slots) + { + var input = ToInput(configSlot, groups, junctions, warnings); + if (input is null) + { + dropped++; + continue; + } + + var applied = await writer.ApplyAsync(layer, null, input, cancellationToken); + if (applied.IsSuccess) + { + added++; + continue; + } + + dropped++; + warnings.Add( + $"Слот «{configSlot.Title}» ({configSlot.Start:HH\\:mm}): {applied.Error.Message}" + ); + } + + return (added, dropped); + } + /// /// Заводит коллекции файла, которых нет в библиотеке. Существующая по имени не трогается: имя — /// это ключ узнаваемости, и дописывать в чужую коллекцию свои шоу импорт не вправе. @@ -266,17 +296,17 @@ public sealed class ImportGridCommandHandler( if (config.Rules is { } rules) template.SetRules(rules.ToJson()); - if (config.FallbackGroup is { Length: > 0 } fallback) - { - if (Resolve(fallback, groups, "Группа", warnings) is { } groupId) - template.SetFallbackGroup(groupId); - } + if ( + config.FallbackGroup is { Length: > 0 } fallback + && Resolve(fallback, groups, "Группа", warnings) is { } groupId + ) + template.SetFallbackGroup(groupId); - if (config.DefaultJunction is { Length: > 0 } junction) - { - if (Resolve(junction, junctions, "Стык", warnings) is { } junctionId) - template.SetDefaultJunction(junctionId); - } + if ( + config.DefaultJunction is { Length: > 0 } junction + && Resolve(junction, junctions, "Стык", warnings) is { } junctionId + ) + template.SetDefaultJunction(junctionId); } private static SlotInput? ToInput( diff --git a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs index 2ef932d..96556ab 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs @@ -63,7 +63,8 @@ public sealed class TmdbMetadataProvider( YearFrom(GetString(item, DateField(movie))), GetString(item, "overview"), PosterUrl(GetString(item, "poster_path")), - movie ? ShowKind.Single : ShowKind.Series + movie ? ShowKind.Single : ShowKind.Series, + GetString(item, OriginalTitleField(movie)) ) ); } @@ -83,6 +84,9 @@ public sealed class TmdbMetadataProvider( private static string DateField(bool movie) => movie ? "release_date" : "first_air_date"; + private static string OriginalTitleField(bool movie) => + movie ? "original_title" : "original_name"; + public async Task GetShowAsync( string externalId, ShowKind kind, @@ -108,7 +112,8 @@ public sealed class TmdbMetadataProvider( PosterUrl(GetString(root, "poster_path")), GenresFrom(root), movie ? MovieCertification(root) : TvCertification(root), - FranchiseFrom(root) + FranchiseFrom(root), + GetString(root, OriginalTitleField(movie)) ); } diff --git a/backend/tests/TeleWave.Application.Tests/Media/ImportMoviesTests.cs b/backend/tests/TeleWave.Application.Tests/Media/ImportMoviesTests.cs index 1bae1f9..847d842 100644 --- a/backend/tests/TeleWave.Application.Tests/Media/ImportMoviesTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Media/ImportMoviesTests.cs @@ -107,6 +107,65 @@ public class ImportMoviesTests .CleanupManualLeftoversAsync("Gladiator.2000.BDRip.mkv", Arg.Any()); } + [Fact] + public async Task NamesShowAsInTheSource_AndKeepsFileTitleAsOriginal() + { + // В таблице разбора «Название» — строка поиска: «Die Hard 2» там стоит ровно для того, + // чтобы найти «Крепкий орешек 2». В библиотеку должно попасть название источника, + // а латинское — остаться оригинальным. + var fixture = new TestDb(); + + var result = await ImportAsync( + fixture, + StorageWith("Die Hard 2 [1990 HDRip].avi"), + Resolver( + new ShowMetadata( + "42", + "Крепкий орешек 2", + 1990, + "desc", + null, + [], + null, + null, + "Die Hard 2" + ) + ), + Substitute.For(), + new MovieImportItem("Die Hard 2 [1990 HDRip].avi", null, "Die Hard 2", 1990, "42") + ); + + Assert.Equal(1, result.Enriched); + + await using var db = fixture.New(); + var show = await db.Shows.SingleAsync(CancellationToken.None); + Assert.Equal("Крепкий орешек 2", show.Name); + Assert.Equal("Die Hard 2", show.OriginalName); + } + + [Fact] + public async Task KeepsTypedTitleAsOriginal_WhenSourceHasNoOriginal() + { + // У OMDb оригинального названия нет — тогда оригинальным становится то, что вписали + // в таблице: по нему потом ищутся релизы. + var fixture = new TestDb(); + + var result = await ImportAsync( + fixture, + StorageWith("Spaun.1997.VHSRip.avi"), + Resolver(new ShowMetadata("5", "Спаун", 1997, null, null, [], null, null)), + Substitute.For(), + new MovieImportItem("Spaun.1997.VHSRip.avi", null, "Spaun", 1997, "5") + ); + + Assert.Equal(1, result.Enriched); + + await using var db = fixture.New(); + var show = await db.Shows.SingleAsync(CancellationToken.None); + Assert.Equal("Спаун", show.Name); + Assert.Equal("Spaun", show.OriginalName); + } + [Fact] public async Task CreatesShowWithoutMetadata_WhenCandidateIsNotChosen() { diff --git a/docs/media-storage-and-streaming.md b/docs/media-storage-and-streaming.md index 3c046e6..15ca551 100644 --- a/docs/media-storage-and-streaming.md +++ b/docs/media-storage-and-streaming.md @@ -237,6 +237,13 @@ Inbox-сканер — второй `BackgroundService`: замечает нов неуверенное молча остаётся ассетом без шоу и попадает в ту же таблицу разбора — автоматика обязана молчать там, где таблица показала бы жёлтым. +**Как называется заведённый фильм.** Поле «Название» в таблице — это строка поиска, а не имя шоу: +«Die Hard 2» там стоит ровно для того, чтобы найти «Крепкий орешек 2». В библиотеку попадает +название карточки источника, а то, что распозналось в имени файла, становится **оригинальным** +названием — по нему потом ищутся релизы и сходятся имена. Если источник отдал своё original title +(у TMDb это `original_title`/`original_name`), берётся оно. Уже проставленное оригинальное название +не переписывается: его мог вписать человек. + Шоу импорт создаёт, а **шоу-контент — никогда**: файл приносит загрузка, и придуманное источником название не должно превращаться в запись библиотеки без файла. Франшизы отдельным шагом не собираются: `FranchiseExternalId` проставляется вместе с метаданными, а коллекции предлагает diff --git a/frontend/src/features/admin/bumpers/placeholders.ts b/frontend/src/features/admin/bumpers/placeholders.ts index c5eb36f..e42d9d7 100644 --- a/frontend/src/features/admin/bumpers/placeholders.ts +++ b/frontend/src/features/admin/bumpers/placeholders.ts @@ -55,11 +55,24 @@ export function resolveSample(text: string) { if (tokens > 0 && filled === 0) return '' - return resolved - .replace(/[ \t]{2,}/g, ' ') - .trim() - .replace(/^[—–\-:·,;/]+|[—–\-:·,;/]+$/g, '') - .trim() + return trimSeparators(resolved.replace(/[ \t]{2,}/g, ' ').trim()).trim() +} + +/** Разделители, которые остаются висеть, когда подставлять оказалось нечего. */ +const SEPARATORS = '—–-:·,;/' + +/** + * Снимает разделители по краям. Посимвольно, а не регуляркой: у выражения с двумя якорями и `+` + * по обе стороны альтернативы катастрофический откат на длинных строках, а строк тут сотни. + */ +function trimSeparators(value: string) { + let start = 0 + let end = value.length + + while (start < end && SEPARATORS.includes(value[start])) start += 1 + while (end > start && SEPARATORS.includes(value[end - 1])) end -= 1 + + return value.slice(start, end) } /** Плейсхолдеры строки, которых нет в списке допустимых, — их сервер отвергнет при сохранении. */ diff --git a/frontend/src/features/admin/media/ManualInboxDialog.tsx b/frontend/src/features/admin/media/ManualInboxPanel.tsx similarity index 52% rename from frontend/src/features/admin/media/ManualInboxDialog.tsx rename to frontend/src/features/admin/media/ManualInboxPanel.tsx index a3bf87e..803df34 100644 --- a/frontend/src/features/admin/media/ManualInboxDialog.tsx +++ b/frontend/src/features/admin/media/ManualInboxPanel.tsx @@ -9,14 +9,7 @@ import type { ManualInboxFileDto } from '@/shared/api/types' import { useApiError } from '@/shared/lib/use-api-error' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/shared/ui/dialog' +import { DialogFooter } from '@/shared/ui/dialog' import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' import { toast } from '@/shared/ui/toast-store' @@ -51,7 +44,7 @@ function formatSize(bytes: number): string { * Номера сезона и серии распознаются здесь же и в таком виде и уходят на сервер: что показано, * то и сохранится. */ -export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }>) { +export function ManualInboxPanel({ onClose }: Readonly<{ onClose: () => void }>) { const { t } = useTranslation() const queryClient = useQueryClient() const [selected, setSelected] = useState([]) @@ -231,243 +224,230 @@ export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }> } return ( - !open && onClose()}> - - - {t('admin.media.manualTitle')} - {t('admin.media.manualHint')} - + <> +

{t('admin.media.manualHint')}

-
- {/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */} -
- - { - setShowPicked(true) - setShowId(value) - }} - /> - {autoDetected && ( -

{t('admin.media.manualDetected')}

- )} -
- -
-
- - setQuery(e.target.value)} /> -
-
- - {/* Ноль — законный сезон: в нём живут спецвыпуски и пилоты. */} - setSeasonStr(e.target.value)} - /> -
-
- - setRegexStr(e.target.value)} - className={!regexOk ? 'border-red-500' : undefined} - /> -
-
- {!regexOk && ( -

{t('admin.media.toShowRegexInvalid')}

+
+ {/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */} +
+ + { + setShowPicked(true) + setShowId(value) + }} + /> + {autoDetected && ( +

{t('admin.media.manualDetected')}

)} - - {/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */} - {sample && ( -
- - {t('admin.media.regexPickHint')} - -
- {sampleParts.map((part) => - part.number === null ? ( - - {part.text} - - ) : ( - - ), - )} -
-
- - {t('admin.media.regexPresets')} - - {REGEX_PRESETS.map((preset) => ( - - ))} - {regexStr && ( - - )} -
-
- )} - -
- - - {t('admin.media.manualSelected', { count: selected.length })} - - - {t('admin.media.manualRecognized', { - count: recognized, - total: selectable.length, - })} - -
- -
- {isLoading &&

{t('common.loading')}

} - {!isLoading && folders.length === 0 && ( -

{t('admin.media.manualEmpty')}

- )} - - {folders.map(({ folder, files }) => { - const isCollapsed = collapsed.includes(folder) - return ( -
-
- - !f.alreadyImported) - .every((f) => selected.includes(f.relativePath))} - onChange={() => toggleFolder(files)} - /> - - - {folder || t('admin.media.manualRoot')} - - {files.length} -
- - {!isCollapsed && ( -
    - {files.map((file) => { - const label = formatSeasonEpisode( - parsedByPath.get(file.relativePath) ?? { season: null, episode: null }, - ) - return ( -
  • - toggle(file.relativePath)} - /> - {label ? ( - {label} - ) : ( - - {t('admin.media.toShowUnknown')} - - )} - - {file.name} - - {file.alreadyImported && ( - {t('admin.media.manualAlready')} - )} - - {formatSize(file.sizeBytes)} - -
  • - ) - })} -
- )} -
- ) - })} -
- - {data?.truncated && ( -

{t('admin.media.manualTruncated')}

- )} - -

{t('admin.media.manualCleanupHint')}

- - +
+
+ + setQuery(e.target.value)} /> +
+
+ + {/* Ноль — законный сезон: в нём живут спецвыпуски и пилоты. */} + setSeasonStr(e.target.value)} + /> +
+
+ + setRegexStr(e.target.value)} + className={!regexOk ? 'border-red-500' : undefined} + /> +
+
+ {!regexOk &&

{t('admin.media.toShowRegexInvalid')}

} + + {/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */} + {sample && ( +
+ {t('admin.media.regexPickHint')} +
+ {sampleParts.map((part) => + part.number === null ? ( + + {part.text} + + ) : ( + + ), + )} +
+
+ {t('admin.media.regexPresets')} + {REGEX_PRESETS.map((preset) => ( + + ))} + {regexStr && ( + + )} +
+
+ )} + +
- - -
+ + {t('admin.media.manualSelected', { count: selected.length })} + + + {t('admin.media.manualRecognized', { + count: recognized, + total: selectable.length, + })} + + + +
+ {isLoading &&

{t('common.loading')}

} + {!isLoading && folders.length === 0 && ( +

{t('admin.media.manualEmpty')}

+ )} + + {folders.map(({ folder, files }) => { + const isCollapsed = collapsed.includes(folder) + return ( +
+
+ + !f.alreadyImported) + .every((f) => selected.includes(f.relativePath))} + onChange={() => toggleFolder(files)} + /> + + + {folder || t('admin.media.manualRoot')} + + {files.length} +
+ + {!isCollapsed && ( +
    + {files.map((file) => { + const label = formatSeasonEpisode( + parsedByPath.get(file.relativePath) ?? { season: null, episode: null }, + ) + return ( +
  • + toggle(file.relativePath)} + /> + {label ? ( + {label} + ) : ( + + {t('admin.media.toShowUnknown')} + + )} + + {file.name} + + {file.alreadyImported && ( + {t('admin.media.manualAlready')} + )} + + {formatSize(file.sizeBytes)} + +
  • + ) + })} +
+ )} +
+ ) + })} +
+ + {data?.truncated && ( +

{t('admin.media.manualTruncated')}

+ )} + +

{t('admin.media.manualCleanupHint')}

+ + + + + + + ) } diff --git a/frontend/src/features/admin/media/MediaImportDialog.tsx b/frontend/src/features/admin/media/MediaImportDialog.tsx new file mode 100644 index 0000000..a9156df --- /dev/null +++ b/frontend/src/features/admin/media/MediaImportDialog.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' +import { ManualInboxPanel } from './ManualInboxPanel' +import { MovieImportPanel } from './MovieImportPanel' +import { UploadToShowPanel } from './UploadToShowPanel' + +/** Способы пополнить библиотеку. «В шоу» появляется только когда файлы уже выбраны. */ +type Tab = 'movies' | 'manual' | 'toShow' + +/** + * Одно окно на все способы завести медиа: фильмы пачкой, серии из `manual/` в шоу и загрузка + * выбранных файлов в шоу. Раньше это были три кнопки и три окна — а выбор между ними делается + * один раз и по одному признаку: что за контент кладём. + */ +export function MediaImportDialog({ + files, + onClose, +}: Readonly<{ + /** Файлы, выбранные в проводнике до открытия окна: тогда сразу открывается вкладка «в шоу». */ + files: File[] | null + onClose: () => void +}>) { + const { t } = useTranslation() + const [tab, setTab] = useState(files ? 'toShow' : 'movies') + + const tabs: Tab[] = files ? ['toShow', 'movies', 'manual'] : ['movies', 'manual'] + + return ( + !open && onClose()}> + + + {t('admin.media.importTitle')} + {t('admin.media.importHint')} + + +
+ {tabs.map((item) => ( + + ))} +
+ + {tab === 'movies' && } + {tab === 'manual' && } + {tab === 'toShow' && files && } +
+
+ ) +} diff --git a/frontend/src/features/admin/media/MediaPanel.tsx b/frontend/src/features/admin/media/MediaPanel.tsx index 4b7b950..9dde719 100644 --- a/frontend/src/features/admin/media/MediaPanel.tsx +++ b/frontend/src/features/admin/media/MediaPanel.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Clapperboard, FolderInput, ListPlus, Upload } from 'lucide-react' +import { FolderInput, Upload } from 'lucide-react' import { qk } from '@/shared/api/query-keys' import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types' import { useApiError } from '@/shared/lib/use-api-error' @@ -14,9 +14,7 @@ import { useTableSort } from '@/shared/lib/table-sort' import { SortHeader } from '@/shared/ui/sortable' import { deleteMedia, getMediaStats, listMedia } from './api' import { formatDuration, splitEta } from './format' -import { ManualInboxDialog } from './ManualInboxDialog' -import { MovieImportDialog } from './MovieImportDialog' -import { UploadToShowDialog } from './UploadToShowDialog' +import { MediaImportDialog } from './MediaImportDialog' import { useUploadStore } from './upload-store' const PAGE_SIZE = 20 @@ -47,9 +45,9 @@ export function MediaPanel() { const [filter, setFilter] = useState('active') const [page, setPage] = useState(1) const { sort, toggle } = useTableSort('created', true) + // Файлы, выбранные для загрузки в шоу: с ними окно импорта открывается сразу на нужной вкладке. const [filesForShow, setFilesForShow] = useState(null) - const [manualOpen, setManualOpen] = useState(false) - const [moviesOpen, setMoviesOpen] = useState(false) + const [importOpen, setImportOpen] = useState(false) const enqueue = useUploadStore((s) => s.enqueue) const sortColumn = (key: string) => { @@ -180,21 +178,16 @@ export function MediaPanel() { className="hidden" onChange={(e) => { const files = e.target.files - if (files && files.length > 0) setFilesForShow(Array.from(files)) + if (files && files.length > 0) { + setFilesForShow(Array.from(files)) + setImportOpen(true) + } e.target.value = '' }} /> - - - + )} - - - + + {!activeProvider && ( + + + {t('admin.movies.noProvider')} + + )} + + +
+ + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + + {rows.length === 0 && ( + + + + )} + +
+ { + if (input) input.indeterminate = !allSelected && selectedCount > 0 + }} + onChange={(e) => toggleAll(e.target.checked)} + /> + {t('admin.movies.file')}{t('admin.movies.parsed')}{t('admin.movies.candidate')}
+ patch(row.name, { selected: e.target.checked })} + /> + +
{row.name}
+
+ {row.status === 'Confident' && } + {row.status === 'Uncertain' && } + {row.status === 'NotFound' && } + {t(`admin.movies.statuses.${row.status}`)} + {row.existingShowName && · {row.existingShowName}} +
+
+
+ patch(row.name, { title: e.target.value })} + /> + patch(row.name, { year: e.target.value })} + /> + +
+
+ {row.candidates.length === 0 ? ( + {t('admin.movies.empty')} + ) : ( + + )} +
+ {scan.isPending ? t('admin.movies.scanning') : t('admin.movies.nothingYet')} +
+
+ + {rows.length > 0 && ( +

{t('admin.movies.collectionsHint')}

+ )} + + + + + + ) } diff --git a/frontend/src/features/admin/media/UploadToShowDialog.tsx b/frontend/src/features/admin/media/UploadToShowDialog.tsx deleted file mode 100644 index 132cecf..0000000 --- a/frontend/src/features/admin/media/UploadToShowDialog.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { qk } from '@/shared/api/query-keys' -import { Badge } from '@/shared/ui/badge' -import { Button } from '@/shared/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/shared/ui/dialog' -import { Input } from '@/shared/ui/input' -import { Label } from '@/shared/ui/label' -import { listShows } from '@/features/admin/shows/api' -import { ShowPicker } from '@/features/admin/shows/ShowPicker' -import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse' -import { matchShowByName } from './match-show' -import { useUploadStore } from './upload-store' - -export function UploadToShowDialog({ - files, - onClose, -}: Readonly<{ files: File[]; onClose: () => void }>) { - const { t } = useTranslation() - const enqueue = useUploadStore((s) => s.enqueue) - const [seasonStr, setSeasonStr] = useState('') - const [regexStr, setRegexStr] = useState('') - // Ручные правки привязки: имя файла → id шоу ('' — явно в библиотеку). Иначе берётся автоопределение. - const [overrides, setOverrides] = useState>({}) - - const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() }) - - const regexOk = isValidRegex(regexStr) - const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null - - // Автоопределение шоу по имени файла: id распознанного шоу для каждого файла (или undefined). - const matchedByName = useMemo(() => { - const map = new Map() - if (shows) for (const file of files) map.set(file.name, matchShowByName(file.name, shows)) - return map - }, [files, shows]) - - /** Итоговая привязка файла: ручная правка (если есть) либо автоопределение, иначе '' (в библиотеку). */ - const assignment = (name: string): string => - name in overrides ? overrides[name] : (matchedByName.get(name) ?? '') - - /** Проставить одно шоу (или «в библиотеку») всем файлам разом. */ - const applyToAll = (showId: string) => - setOverrides(Object.fromEntries(files.map((f) => [f.name, showId]))) - - // Предпросмотр: что распарсим для каждого файла при текущих настройках, в порядке добавления. - const previews = useMemo(() => { - const opts = { - seasonOverride: - seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null, - episodeRegex: regexOk ? regexStr : null, - } - return files - .map((file) => ({ file, name: file.name, parsed: parseEpisodeName(file.name, opts) })) - .sort(compareParsed) - }, [files, seasonOverride, regexStr, regexOk]) - - const matchedCount = previews.filter((p) => assignment(p.name)).length - - const confirm = () => { - void enqueue( - previews.map((p) => p.file), - { resolveShowId: (file) => assignment(file.name) || undefined }, - ) - onClose() - } - - return ( - !open && onClose()}> - - - {t('admin.media.toShowTitle')} - {t('admin.media.autoDetectHint')} - - -
-
-
- - setSeasonStr(e.target.value)} - /> -
-
- - setRegexStr(e.target.value)} - className={!regexOk ? 'border-red-500' : undefined} - /> -
-
-

- {t('admin.media.toShowHint')} - {!regexOk && ( - {t('admin.media.toShowRegexInvalid')} - )} -

- -
- {t('admin.media.toShowPreview')} -
- - {t('admin.media.toShowMatched', { matched: matchedCount, total: files.length })} - - {/* Значение не храним: выбор здесь — это действие «проставить всем», а не состояние, - поэтому на кнопке всегда её собственная подпись. */} - -
-
-
    - {previews.map((p) => { - const label = formatSeasonEpisode(p.parsed) - const current = assignment(p.name) - return ( -
  • - {label ? ( - {label} - ) : ( - {t('admin.media.toShowUnknown')} - )} - - {p.name} - - setOverrides((o) => ({ ...o, [p.name]: showId }))} - /> -
  • - ) - })} -
-
- - - - - -
-
- ) -} diff --git a/frontend/src/features/admin/media/UploadToShowPanel.tsx b/frontend/src/features/admin/media/UploadToShowPanel.tsx new file mode 100644 index 0000000..de9154a --- /dev/null +++ b/frontend/src/features/admin/media/UploadToShowPanel.tsx @@ -0,0 +1,157 @@ +import { useQuery } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { qk } from '@/shared/api/query-keys' +import { Badge } from '@/shared/ui/badge' +import { Button } from '@/shared/ui/button' +import { DialogFooter } from '@/shared/ui/dialog' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { listShows } from '@/features/admin/shows/api' +import { ShowPicker } from '@/features/admin/shows/ShowPicker' +import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse' +import { matchShowByName } from './match-show' +import { useUploadStore } from './upload-store' + +export function UploadToShowPanel({ + files, + onClose, +}: Readonly<{ files: File[]; onClose: () => void }>) { + const { t } = useTranslation() + const enqueue = useUploadStore((s) => s.enqueue) + const [seasonStr, setSeasonStr] = useState('') + const [regexStr, setRegexStr] = useState('') + // Ручные правки привязки: имя файла → id шоу ('' — явно в библиотеку). Иначе берётся автоопределение. + const [overrides, setOverrides] = useState>({}) + + const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() }) + + const regexOk = isValidRegex(regexStr) + const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null + + // Автоопределение шоу по имени файла: id распознанного шоу для каждого файла (или undefined). + const matchedByName = useMemo(() => { + const map = new Map() + if (shows) for (const file of files) map.set(file.name, matchShowByName(file.name, shows)) + return map + }, [files, shows]) + + /** Итоговая привязка файла: ручная правка (если есть) либо автоопределение, иначе '' (в библиотеку). */ + const assignment = (name: string): string => + name in overrides ? overrides[name] : (matchedByName.get(name) ?? '') + + /** Проставить одно шоу (или «в библиотеку») всем файлам разом. */ + const applyToAll = (showId: string) => + setOverrides(Object.fromEntries(files.map((f) => [f.name, showId]))) + + // Предпросмотр: что распарсим для каждого файла при текущих настройках, в порядке добавления. + const previews = useMemo(() => { + const opts = { + seasonOverride: + seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null, + episodeRegex: regexOk ? regexStr : null, + } + return files + .map((file) => ({ file, name: file.name, parsed: parseEpisodeName(file.name, opts) })) + .sort(compareParsed) + }, [files, seasonOverride, regexStr, regexOk]) + + const matchedCount = previews.filter((p) => assignment(p.name)).length + + const confirm = () => { + void enqueue( + previews.map((p) => p.file), + { resolveShowId: (file) => assignment(file.name) || undefined }, + ) + onClose() + } + + return ( + <> +

{t('admin.media.autoDetectHint')}

+ +
+
+
+ + setSeasonStr(e.target.value)} + /> +
+
+ + setRegexStr(e.target.value)} + className={!regexOk ? 'border-red-500' : undefined} + /> +
+
+

+ {t('admin.media.toShowHint')} + {!regexOk && ( + {t('admin.media.toShowRegexInvalid')} + )} +

+ +
+ {t('admin.media.toShowPreview')} +
+ + {t('admin.media.toShowMatched', { matched: matchedCount, total: files.length })} + + {/* Значение не храним: выбор здесь — это действие «проставить всем», а не состояние, + поэтому на кнопке всегда её собственная подпись. */} + +
+
+
    + {previews.map((p) => { + const label = formatSeasonEpisode(p.parsed) + const current = assignment(p.name) + return ( +
  • + {label ? ( + {label} + ) : ( + {t('admin.media.toShowUnknown')} + )} + + {p.name} + + setOverrides((o) => ({ ...o, [p.name]: showId }))} + /> +
  • + ) + })} +
+
+ + + + + + + ) +} diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index c3f9ea0..7de41b7 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -257,6 +257,7 @@ export const en = { file: 'File', parsed: 'Title and year', candidate: 'Candidate', + toggleAll: 'Select all / clear all', year: 'Year', research: 'Search again', empty: 'nothing found', @@ -277,6 +278,15 @@ export const en = { title: 'Media', upload: 'Upload', manualButton: 'From manual folder', + importButton: 'Import', + importTitle: 'Media import', + importHint: + 'Three ways to fill the library: movies in bulk, episodes from the manual folder into a show, and uploading picked files into a show.', + importTabs: { + movies: 'Movies', + manual: 'Episodes from manual', + toShow: 'Picked files into a show', + }, moviesButton: 'Movies', manualTitle: 'Manual pick from the manual folder', manualHint: diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 98cd7d6..ba368bf 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -258,6 +258,7 @@ export const ru = { file: 'Файл', parsed: 'Название и год', candidate: 'Кандидат', + toggleAll: 'Отметить все / снять все', year: 'Год', research: 'Искать заново', empty: 'ничего не нашлось', @@ -278,6 +279,11 @@ export const ru = { title: 'Медиа', upload: 'Загрузить', manualButton: 'Из папки manual', + importButton: 'Импорт', + importTitle: 'Импорт медиа', + importHint: + 'Три способа пополнить библиотеку: фильмы пачкой, серии из папки manual в шоу и загрузка выбранных файлов в шоу.', + importTabs: { movies: 'Фильмы', manual: 'Серии из manual', toShow: 'Выбранные файлы в шоу' }, moviesButton: 'Фильмы', manualTitle: 'Ручной разбор папки manual', manualHint: