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 (
-