Enhance movie import process and metadata handling
ci / build-backend (push) Successful in 1m33s
ci / build-frontend (push) Successful in 55s
ci / tests (push) Successful in 3m27s
ci / sonar (push) Successful in 6m55s

Refactored the movie import functionality to ensure that the title from the source is used for the show name, while the file name is retained as the original title. Improved the handling of metadata during the import process, allowing for better integration of original titles from various sources. Updated related classes and methods to streamline the import workflow and enhance user experience. Added tests to verify the correct assignment of titles and original names during the import process. Updated documentation to reflect these changes.
This commit is contained in:
Leonid Pershin
2026-07-28 03:14:19 +03:00
parent d859a9894c
commit d0bc0465e7
21 changed files with 926 additions and 698 deletions
@@ -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");
/// <summary>Время в кадре — только часы и минуты: секунды в заставке не читаются.</summary>
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());
}
/// <summary>Схлопывает лишние пробелы и снимает осиротевшие разделители по краям.</summary>
private static string Clean(string text) =>
ExtraSpaces().Replace(text, " ").Trim().Trim(DanglingSeparators).Trim();
/// <summary>Какие плейсхолдеры встречаются в текстах — по ним планировщик решает, что грузить.</summary>
public static IReadOnlySet<string> TokensIn(IEnumerable<string> 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,
@@ -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);
@@ -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));
}
/// <summary>
/// Называет фильм так, как он называется в источнике, а не так, как назывался файл: в таблице
/// разбора поле «Название» — это строка поиска, и «Die Hard 2» там стоит ровно для того, чтобы
/// найти «Крепкий орешек 2». Название из файла при этом не пропадает — оно становится
/// оригинальным, если источник своего не дал: по нему потом ищутся релизы и сходятся имена.
/// </summary>
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);
/// <summary>Ассет фильма: уже загруженный браузером либо забранный из <c>manual/</c>.</summary>
private async Task<(Guid AssetId, bool Enqueue)?> ResolveAssetAsync(
MovieImportItem item,
@@ -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);
}
}
@@ -13,7 +13,9 @@ public sealed record MetadataCandidate(
int? Year,
string? Overview,
string? PosterUrl,
ShowKind? Kind = null
ShowKind? Kind = null,
/// <summary>Название на языке оригинала (у OMDb таких данных нет — null).</summary>
string? OriginalTitle = null
);
/// <summary>
@@ -34,7 +36,12 @@ public sealed record ShowMetadata(
IReadOnlyList<string>? Genres = null,
string? ContentRating = null,
/// <summary>Франшиза по данным источника (у OMDb таких данных нет — null).</summary>
MetadataFranchise? Franchise = null
MetadataFranchise? Franchise = null,
/// <summary>
/// Название на языке оригинала. Нужно и как подпись, и как ключ поиска: релизы называются
/// латиницей, а <see cref="Title"/> у русской локали источника русский.
/// </summary>
string? OriginalTitle = null
);
/// <summary>Метаданные серии (для этапа 2).</summary>
@@ -19,7 +19,12 @@ public sealed class ShowMetadataApplier(
GenreMatcher genreMatcher
)
{
public async Task<Result> ApplyAsync(
/// <summary>
/// Применяет метаданные и возвращает то, что отдал источник: вызывающему бывает нужно само
/// содержимое — например, чтобы назвать только что заведённый фильм так, как он называется
/// в источнике, а не так, как назывался файл.
/// </summary>
public async Task<Result<ShowMetadata>> 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<ShowMetadata>(MetadataErrors.NotForInterstitials);
var provider = resolver.Resolve(providerKey);
if (provider is null)
return Result.Failure(MetadataErrors.ProviderNotAvailable);
return Result.Failure<ShowMetadata>(MetadataErrors.ProviderNotAvailable);
// Тип берём у шоу, а не из запроса: иначе клиент смог бы заставить TMDb отдать метаданные
// одноимённого фильма для сериала — идентификаторы у них независимые.
var meta = await provider.GetShowAsync(externalId, show.Kind, cancellationToken);
if (meta is null)
return Result.Failure(MetadataErrors.NotFound);
return Result.Failure<ShowMetadata>(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);
}
/// <summary>Постер скачиваем и регистрируем в общем реестре изображений (галерея).</summary>
@@ -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) =>
/// <summary>Тип контента словами: «сериал», «полный метр» — так его читает модель.</summary>
private static string KindName(ShowKind kind) =>
kind switch
{
ShowKind.Series => "сериал",
@@ -124,14 +124,16 @@ public sealed class ExportGridQueryHandler(
.OfType<string>()
.ToList();
var collections = new List<string>();
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(
@@ -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(
);
}
/// <summary>
/// Пишет слоты одного слоя. Возвращает, сколько встало и сколько пропущено: слот, который
/// не прошёл проверки, замечанием и остаётся — валить весь файл из-за одной строки нельзя.
/// </summary>
private async Task<(int Added, int Dropped)> WriteSlotsAsync(
GridLayer layer,
GridConfigLayer configLayer,
IReadOnlyDictionary<string, Guid> groups,
IReadOnlyDictionary<string, Guid> junctions,
List<string> 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);
}
/// <summary>
/// Заводит коллекции файла, которых нет в библиотеке. Существующая по имени не трогается: имя —
/// это ключ узнаваемости, и дописывать в чужую коллекцию свои шоу импорт не вправе.
@@ -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(
@@ -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<ShowMetadata?> 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))
);
}
@@ -107,6 +107,65 @@ public class ImportMoviesTests
.CleanupManualLeftoversAsync("Gladiator.2000.BDRip.mkv", Arg.Any<CancellationToken>());
}
[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<IMediaProcessingQueue>(),
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<IMediaProcessingQueue>(),
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()
{