Enhance movie import process and metadata handling
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:
@@ -1,4 +1,5 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
@@ -75,6 +76,9 @@ public static partial class BumperPlaceholders
|
|||||||
// Русская культура фиксирована: заставка рендерится один раз в видео, локали зрителя у неё нет.
|
// Русская культура фиксирована: заставка рендерится один раз в видео, локали зрителя у неё нет.
|
||||||
private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU");
|
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)]
|
[GeneratedRegex(@"\{([a-zA-Z][a-zA-Z.]*)\}", RegexOptions.CultureInvariant)]
|
||||||
private static partial Regex TokenPattern();
|
private static partial Regex TokenPattern();
|
||||||
|
|
||||||
@@ -98,27 +102,40 @@ public static partial class BumperPlaceholders
|
|||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
|
|
||||||
var tokens = 0;
|
var tokens = TokenPattern().Matches(text);
|
||||||
var filled = 0;
|
if (tokens.Count == 0)
|
||||||
var resolved = TokenPattern()
|
return Clean(text);
|
||||||
.Replace(
|
|
||||||
text,
|
|
||||||
match =>
|
|
||||||
{
|
|
||||||
tokens++;
|
|
||||||
var value = Value(match.Groups[1].Value, context);
|
|
||||||
if (!string.IsNullOrWhiteSpace(value))
|
|
||||||
filled++;
|
|
||||||
return value ?? string.Empty;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
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 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>
|
/// <summary>Какие плейсхолдеры встречаются в текстах — по ним планировщик решает, что грузить.</summary>
|
||||||
public static IReadOnlySet<string> TokensIn(IEnumerable<string> texts)
|
public static IReadOnlySet<string> TokensIn(IEnumerable<string> texts)
|
||||||
{
|
{
|
||||||
@@ -160,14 +177,14 @@ public static partial class BumperPlaceholders
|
|||||||
"next.episode" => context.NextEpisode,
|
"next.episode" => context.NextEpisode,
|
||||||
"next.year" => context.NextYear?.ToString(Culture),
|
"next.year" => context.NextYear?.ToString(Culture),
|
||||||
"next.genre" => context.NextGenre,
|
"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.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.title" => context.Tomorrow?.Title,
|
||||||
"tomorrow.time" => context.Tomorrow?.Time.ToString("HH:mm", Culture),
|
"tomorrow.time" => context.Tomorrow?.Time.ToString(TimeFormat, Culture),
|
||||||
"time" => TimeOnly
|
"time" => TimeOnly
|
||||||
.FromDateTime(context.LocalMoment.DateTime)
|
.FromDateTime(context.LocalMoment.DateTime)
|
||||||
.ToString("HH:mm", Culture),
|
.ToString(TimeFormat, Culture),
|
||||||
"date" => context.LocalMoment.ToString("d MMMM", Culture),
|
"date" => context.LocalMoment.ToString("d MMMM", Culture),
|
||||||
"weekday" => context.LocalMoment.ToString("dddd", Culture),
|
"weekday" => context.LocalMoment.ToString("dddd", Culture),
|
||||||
"slot" => context.SlotTitle,
|
"slot" => context.SlotTitle,
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ public sealed class AutoAttachMovieCommandHandler(
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Имя берём из карточки источника: в поиске оно могло прийти из другой локали.
|
||||||
|
if (applied.IsSuccess && applied.Value.Title is { Length: > 0 } title)
|
||||||
|
show.SetName(title);
|
||||||
|
|
||||||
// Метаданные не легли — шоу с файлом всё равно полезнее, чем ассет без шоу: название
|
// Метаданные не легли — шоу с файлом всё равно полезнее, чем ассет без шоу: название
|
||||||
// у него правильное, а постер дотянет массовое обогащение.
|
// у него правильное, а постер дотянет массовое обогащение.
|
||||||
return Result.Success(applied.IsSuccess);
|
return Result.Success(applied.IsSuccess);
|
||||||
|
|||||||
@@ -63,15 +63,37 @@ public sealed class ImportMoviesCommandHandler(
|
|||||||
externalId,
|
externalId,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
enriched++;
|
{
|
||||||
else
|
|
||||||
failed.Add(new MovieImportFailureDto(item.Title, result.Error.Message));
|
failed.Add(new MovieImportFailureDto(item.Title, result.Error.Message));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched++;
|
||||||
|
Rename(show, item, result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.Success(new ImportMoviesResultDto(created, enriched, failed));
|
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>
|
/// <summary>Ассет фильма: уже загруженный браузером либо забранный из <c>manual/</c>.</summary>
|
||||||
private async Task<(Guid AssetId, bool Enqueue)?> ResolveAssetAsync(
|
private async Task<(Guid AssetId, bool Enqueue)?> ResolveAssetAsync(
|
||||||
MovieImportItem item,
|
MovieImportItem item,
|
||||||
|
|||||||
+3
-1
@@ -22,11 +22,13 @@ public sealed class ApplyShowMetadataCommandHandler(
|
|||||||
if (show is null)
|
if (show is null)
|
||||||
return Result.Failure(ShowErrors.NotFound);
|
return Result.Failure(ShowErrors.NotFound);
|
||||||
|
|
||||||
return await applier.ApplyAsync(
|
var applied = await applier.ApplyAsync(
|
||||||
show,
|
show,
|
||||||
command.Provider,
|
command.Provider,
|
||||||
command.ExternalId,
|
command.ExternalId,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return applied.IsSuccess ? Result.Success() : Result.Failure(applied.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ public sealed record MetadataCandidate(
|
|||||||
int? Year,
|
int? Year,
|
||||||
string? Overview,
|
string? Overview,
|
||||||
string? PosterUrl,
|
string? PosterUrl,
|
||||||
ShowKind? Kind = null
|
ShowKind? Kind = null,
|
||||||
|
/// <summary>Название на языке оригинала (у OMDb таких данных нет — null).</summary>
|
||||||
|
string? OriginalTitle = null
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -34,7 +36,12 @@ public sealed record ShowMetadata(
|
|||||||
IReadOnlyList<string>? Genres = null,
|
IReadOnlyList<string>? Genres = null,
|
||||||
string? ContentRating = null,
|
string? ContentRating = null,
|
||||||
/// <summary>Франшиза по данным источника (у OMDb таких данных нет — null).</summary>
|
/// <summary>Франшиза по данным источника (у OMDb таких данных нет — null).</summary>
|
||||||
MetadataFranchise? Franchise = null
|
MetadataFranchise? Franchise = null,
|
||||||
|
/// <summary>
|
||||||
|
/// Название на языке оригинала. Нужно и как подпись, и как ключ поиска: релизы называются
|
||||||
|
/// латиницей, а <see cref="Title"/> у русской локали источника русский.
|
||||||
|
/// </summary>
|
||||||
|
string? OriginalTitle = null
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Метаданные серии (для этапа 2).</summary>
|
/// <summary>Метаданные серии (для этапа 2).</summary>
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ public sealed class ShowMetadataApplier(
|
|||||||
GenreMatcher genreMatcher
|
GenreMatcher genreMatcher
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public async Task<Result> ApplyAsync(
|
/// <summary>
|
||||||
|
/// Применяет метаданные и возвращает то, что отдал источник: вызывающему бывает нужно само
|
||||||
|
/// содержимое — например, чтобы назвать только что заведённый фильм так, как он называется
|
||||||
|
/// в источнике, а не так, как назывался файл.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Result<ShowMetadata>> ApplyAsync(
|
||||||
Show show,
|
Show show,
|
||||||
string providerKey,
|
string providerKey,
|
||||||
string externalId,
|
string externalId,
|
||||||
@@ -29,17 +34,17 @@ public sealed class ShowMetadataApplier(
|
|||||||
// Ролики метаданными не обогащаются: у них нет ни года, ни постера, а сотня роликов
|
// Ролики метаданными не обогащаются: у них нет ни года, ни постера, а сотня роликов
|
||||||
// в поиске только мешала бы.
|
// в поиске только мешала бы.
|
||||||
if (show.Kind == ShowKind.Interstitial)
|
if (show.Kind == ShowKind.Interstitial)
|
||||||
return Result.Failure(MetadataErrors.NotForInterstitials);
|
return Result.Failure<ShowMetadata>(MetadataErrors.NotForInterstitials);
|
||||||
|
|
||||||
var provider = resolver.Resolve(providerKey);
|
var provider = resolver.Resolve(providerKey);
|
||||||
if (provider is null)
|
if (provider is null)
|
||||||
return Result.Failure(MetadataErrors.ProviderNotAvailable);
|
return Result.Failure<ShowMetadata>(MetadataErrors.ProviderNotAvailable);
|
||||||
|
|
||||||
// Тип берём у шоу, а не из запроса: иначе клиент смог бы заставить TMDb отдать метаданные
|
// Тип берём у шоу, а не из запроса: иначе клиент смог бы заставить TMDb отдать метаданные
|
||||||
// одноимённого фильма для сериала — идентификаторы у них независимые.
|
// одноимённого фильма для сериала — идентификаторы у них независимые.
|
||||||
var meta = await provider.GetShowAsync(externalId, show.Kind, cancellationToken);
|
var meta = await provider.GetShowAsync(externalId, show.Kind, cancellationToken);
|
||||||
if (meta is null)
|
if (meta is null)
|
||||||
return Result.Failure(MetadataErrors.NotFound);
|
return Result.Failure<ShowMetadata>(MetadataErrors.NotFound);
|
||||||
|
|
||||||
var posterImageId = await SavePosterAsync(show, meta.PosterUrl, cancellationToken);
|
var posterImageId = await SavePosterAsync(show, meta.PosterUrl, cancellationToken);
|
||||||
|
|
||||||
@@ -64,7 +69,12 @@ public sealed class ShowMetadataApplier(
|
|||||||
if (ContentRating.Parse(meta.ContentRating) is { } audience)
|
if (ContentRating.Parse(meta.ContentRating) is { } audience)
|
||||||
show.SetAudience(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>
|
/// <summary>Постер скачиваем и регистрируем в общем реестре изображений (галерея).</summary>
|
||||||
|
|||||||
+4
-3
@@ -129,7 +129,7 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa
|
|||||||
var genre = group.PrimaryGenreId is { } id ? genres.GetValueOrDefault(id) : null;
|
var genre = group.PrimaryGenreId is { } id ? genres.GetValueOrDefault(id) : null;
|
||||||
text.AppendLine(
|
text.AppendLine(
|
||||||
Culture,
|
Culture,
|
||||||
$"| {group.Name} | {Kind(group.DominantKind)} | {group.UnitCount} | "
|
$"| {group.Name} | {KindName(group.DominantKind)} | {group.UnitCount} | "
|
||||||
+ $"{(int)Math.Round(group.AverageUnitMinutes)} мин | "
|
+ $"{(int)Math.Round(group.AverageUnitMinutes)} мин | "
|
||||||
+ $"{group.Strictest?.ToString() ?? "—"} | {genre ?? "—"} |"
|
+ $"{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;
|
var genre = show.PrimaryGenreId is { } id ? genres.GetValueOrDefault(id) : null;
|
||||||
text.AppendLine(
|
text.AppendLine(
|
||||||
Culture,
|
Culture,
|
||||||
$"| {show.Name} | {Kind(show.Kind)} | {show.Year?.ToString(Culture) ?? "—"} | "
|
$"| {show.Name} | {KindName(show.Kind)} | {show.Year?.ToString(Culture) ?? "—"} | "
|
||||||
+ $"{show.Units} | {show.AverageMinutes} мин | "
|
+ $"{show.Units} | {show.AverageMinutes} мин | "
|
||||||
+ $"{show.Audience?.ToString() ?? "—"} | {genre ?? "—"} |"
|
+ $"{show.Audience?.ToString() ?? "—"} | {genre ?? "—"} |"
|
||||||
);
|
);
|
||||||
@@ -186,7 +186,8 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa
|
|||||||
text.AppendLine(names.Count == 0 ? empty : string.Join(", ", names));
|
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
|
kind switch
|
||||||
{
|
{
|
||||||
ShowKind.Series => "сериал",
|
ShowKind.Series => "сериал",
|
||||||
|
|||||||
+10
-8
@@ -124,14 +124,16 @@ public sealed class ExportGridQueryHandler(
|
|||||||
.OfType<string>()
|
.OfType<string>()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var collections = new List<string>();
|
var inGroup = composition
|
||||||
foreach (var element in composition.Where(e => e.Kind == GroupElementKind.Collection))
|
.Where(e =>
|
||||||
{
|
e.Kind == GroupElementKind.Collection && collectionNames.ContainsKey(e.Id)
|
||||||
if (collectionNames.GetValueOrDefault(element.Id) is not { } name)
|
)
|
||||||
continue;
|
.Select(e => e.Id)
|
||||||
collections.Add(name);
|
.ToList();
|
||||||
collectionIds.Add(element.Id);
|
|
||||||
}
|
// Идентификаторы копим отдельно: по ним ниже выгружается состав самих коллекций.
|
||||||
|
collectionIds.UnionWith(inGroup);
|
||||||
|
var collections = inGroup.Select(id => collectionNames[id]).ToList();
|
||||||
|
|
||||||
exported.Add(
|
exported.Add(
|
||||||
new GridConfigGroup(
|
new GridConfigGroup(
|
||||||
|
|||||||
+61
-31
@@ -61,28 +61,17 @@ public sealed class ImportGridCommandHandler(
|
|||||||
foreach (var configLayer in command.Config.Layers)
|
foreach (var configLayer in command.Config.Layers)
|
||||||
{
|
{
|
||||||
var layer = EnsureLayer(template, configLayer);
|
var layer = EnsureLayer(template, configLayer);
|
||||||
|
var (added, dropped) = await WriteSlotsAsync(
|
||||||
|
layer,
|
||||||
|
configLayer,
|
||||||
|
groups,
|
||||||
|
junctions,
|
||||||
|
warnings,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
foreach (var configSlot in configLayer.Slots)
|
created += added;
|
||||||
{
|
skipped += dropped;
|
||||||
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}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Apply(template, command.Config, groups, junctions, warnings);
|
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>
|
/// <summary>
|
||||||
/// Заводит коллекции файла, которых нет в библиотеке. Существующая по имени не трогается: имя —
|
/// Заводит коллекции файла, которых нет в библиотеке. Существующая по имени не трогается: имя —
|
||||||
/// это ключ узнаваемости, и дописывать в чужую коллекцию свои шоу импорт не вправе.
|
/// это ключ узнаваемости, и дописывать в чужую коллекцию свои шоу импорт не вправе.
|
||||||
@@ -266,17 +296,17 @@ public sealed class ImportGridCommandHandler(
|
|||||||
if (config.Rules is { } rules)
|
if (config.Rules is { } rules)
|
||||||
template.SetRules(rules.ToJson());
|
template.SetRules(rules.ToJson());
|
||||||
|
|
||||||
if (config.FallbackGroup is { Length: > 0 } fallback)
|
if (
|
||||||
{
|
config.FallbackGroup is { Length: > 0 } fallback
|
||||||
if (Resolve(fallback, groups, "Группа", warnings) is { } groupId)
|
&& Resolve(fallback, groups, "Группа", warnings) is { } groupId
|
||||||
template.SetFallbackGroup(groupId);
|
)
|
||||||
}
|
template.SetFallbackGroup(groupId);
|
||||||
|
|
||||||
if (config.DefaultJunction is { Length: > 0 } junction)
|
if (
|
||||||
{
|
config.DefaultJunction is { Length: > 0 } junction
|
||||||
if (Resolve(junction, junctions, "Стык", warnings) is { } junctionId)
|
&& Resolve(junction, junctions, "Стык", warnings) is { } junctionId
|
||||||
template.SetDefaultJunction(junctionId);
|
)
|
||||||
}
|
template.SetDefaultJunction(junctionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SlotInput? ToInput(
|
private static SlotInput? ToInput(
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ public sealed class TmdbMetadataProvider(
|
|||||||
YearFrom(GetString(item, DateField(movie))),
|
YearFrom(GetString(item, DateField(movie))),
|
||||||
GetString(item, "overview"),
|
GetString(item, "overview"),
|
||||||
PosterUrl(GetString(item, "poster_path")),
|
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 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(
|
public async Task<ShowMetadata?> GetShowAsync(
|
||||||
string externalId,
|
string externalId,
|
||||||
ShowKind kind,
|
ShowKind kind,
|
||||||
@@ -108,7 +112,8 @@ public sealed class TmdbMetadataProvider(
|
|||||||
PosterUrl(GetString(root, "poster_path")),
|
PosterUrl(GetString(root, "poster_path")),
|
||||||
GenresFrom(root),
|
GenresFrom(root),
|
||||||
movie ? MovieCertification(root) : TvCertification(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>());
|
.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]
|
[Fact]
|
||||||
public async Task CreatesShowWithoutMetadata_WhenCandidateIsNotChosen()
|
public async Task CreatesShowWithoutMetadata_WhenCandidateIsNotChosen()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -237,6 +237,13 @@ Inbox-сканер — второй `BackgroundService`: замечает нов
|
|||||||
неуверенное молча остаётся ассетом без шоу и попадает в ту же таблицу разбора — автоматика
|
неуверенное молча остаётся ассетом без шоу и попадает в ту же таблицу разбора — автоматика
|
||||||
обязана молчать там, где таблица показала бы жёлтым.
|
обязана молчать там, где таблица показала бы жёлтым.
|
||||||
|
|
||||||
|
**Как называется заведённый фильм.** Поле «Название» в таблице — это строка поиска, а не имя шоу:
|
||||||
|
«Die Hard 2» там стоит ровно для того, чтобы найти «Крепкий орешек 2». В библиотеку попадает
|
||||||
|
название карточки источника, а то, что распозналось в имени файла, становится **оригинальным**
|
||||||
|
названием — по нему потом ищутся релизы и сходятся имена. Если источник отдал своё original title
|
||||||
|
(у TMDb это `original_title`/`original_name`), берётся оно. Уже проставленное оригинальное название
|
||||||
|
не переписывается: его мог вписать человек.
|
||||||
|
|
||||||
Шоу импорт создаёт, а **шоу-контент — никогда**: файл приносит загрузка, и придуманное источником
|
Шоу импорт создаёт, а **шоу-контент — никогда**: файл приносит загрузка, и придуманное источником
|
||||||
название не должно превращаться в запись библиотеки без файла. Франшизы отдельным шагом не
|
название не должно превращаться в запись библиотеки без файла. Франшизы отдельным шагом не
|
||||||
собираются: `FranchiseExternalId` проставляется вместе с метаданными, а коллекции предлагает
|
собираются: `FranchiseExternalId` проставляется вместе с метаданными, а коллекции предлагает
|
||||||
|
|||||||
@@ -55,11 +55,24 @@ export function resolveSample(text: string) {
|
|||||||
|
|
||||||
if (tokens > 0 && filled === 0) return ''
|
if (tokens > 0 && filled === 0) return ''
|
||||||
|
|
||||||
return resolved
|
return trimSeparators(resolved.replace(/[ \t]{2,}/g, ' ').trim()).trim()
|
||||||
.replace(/[ \t]{2,}/g, ' ')
|
}
|
||||||
.trim()
|
|
||||||
.replace(/^[—–\-:·,;/]+|[—–\-:·,;/]+$/g, '')
|
/** Разделители, которые остаются висеть, когда подставлять оказалось нечего. */
|
||||||
.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Плейсхолдеры строки, которых нет в списке допустимых, — их сервер отвергнет при сохранении. */
|
/** Плейсхолдеры строки, которых нет в списке допустимых, — их сервер отвергнет при сохранении. */
|
||||||
|
|||||||
+219
-239
@@ -9,14 +9,7 @@ import type { ManualInboxFileDto } from '@/shared/api/types'
|
|||||||
import { useApiError } from '@/shared/lib/use-api-error'
|
import { useApiError } from '@/shared/lib/use-api-error'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import {
|
import { DialogFooter } from '@/shared/ui/dialog'
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/shared/ui/dialog'
|
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
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 { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [selected, setSelected] = useState<string[]>([])
|
const [selected, setSelected] = useState<string[]>([])
|
||||||
@@ -231,243 +224,230 @@ export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }>
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
<>
|
||||||
<DialogContent className="max-w-4xl">
|
<p className="text-sm text-muted-foreground">{t('admin.media.manualHint')}</p>
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t('admin.media.manualTitle')}</DialogTitle>
|
|
||||||
<DialogDescription>{t('admin.media.manualHint')}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
|
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.media.manualShow')}</Label>
|
<Label>{t('admin.media.manualShow')}</Label>
|
||||||
<ShowPicker
|
<ShowPicker
|
||||||
value={showId}
|
value={showId}
|
||||||
shows={shows}
|
shows={shows}
|
||||||
placeholder={t('admin.media.manualPickShow')}
|
placeholder={t('admin.media.manualPickShow')}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setShowPicked(true)
|
setShowPicked(true)
|
||||||
setShowId(value)
|
setShowId(value)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{autoDetected && (
|
{autoDetected && (
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.media.manualDetected')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.media.manualDetected')}</p>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t('common.search')}</Label>
|
|
||||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.media.toShowSeason')}</Label>
|
|
||||||
{/* Ноль — законный сезон: в нём живут спецвыпуски и пилоты. */}
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
placeholder={t('admin.media.toShowAuto')}
|
|
||||||
value={seasonStr}
|
|
||||||
onChange={(e) => setSeasonStr(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.media.toShowRegex')}</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="^(\d+)"
|
|
||||||
value={regexStr}
|
|
||||||
onChange={(e) => setRegexStr(e.target.value)}
|
|
||||||
className={!regexOk ? 'border-red-500' : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!regexOk && (
|
|
||||||
<p className="text-xs text-red-500">{t('admin.media.toShowRegexInvalid')}</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */}
|
|
||||||
{sample && (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{t('admin.media.regexPickHint')}
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-wrap items-center gap-0.5 font-mono text-xs">
|
|
||||||
{sampleParts.map((part) =>
|
|
||||||
part.number === null ? (
|
|
||||||
<span key={part.start} className="text-muted-foreground">
|
|
||||||
{part.text}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
key={part.start}
|
|
||||||
type="button"
|
|
||||||
title={t('admin.media.regexPickTitle')}
|
|
||||||
className="rounded border border-primary/60 bg-primary/10 px-1 text-primary hover:bg-primary/25"
|
|
||||||
onClick={() => setRegexStr(buildEpisodeRegex(sample.name, part.number!))}
|
|
||||||
>
|
|
||||||
{part.text}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{t('admin.media.regexPresets')}
|
|
||||||
</span>
|
|
||||||
{REGEX_PRESETS.map((preset) => (
|
|
||||||
<button
|
|
||||||
key={preset.key}
|
|
||||||
type="button"
|
|
||||||
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
|
||||||
onClick={() => setRegexStr(preset.pattern)}
|
|
||||||
>
|
|
||||||
{t(`admin.media.regexPresetNames.${preset.key}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{regexStr && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
|
||||||
onClick={() => setRegexStr('')}
|
|
||||||
>
|
|
||||||
{t('admin.media.regexClear')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={selectable.length === 0}
|
|
||||||
onClick={() =>
|
|
||||||
setSelected(
|
|
||||||
selected.length === selectable.length
|
|
||||||
? []
|
|
||||||
: selectable.map((f) => f.relativePath),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t('admin.media.manualSelectAll')}
|
|
||||||
</Button>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t('admin.media.manualSelected', { count: selected.length })}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t('admin.media.manualRecognized', {
|
|
||||||
count: recognized,
|
|
||||||
total: selectable.length,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="crt-panel max-h-80 overflow-y-auto rounded-md text-sm">
|
|
||||||
{isLoading && <p className="px-3 py-2 text-muted-foreground">{t('common.loading')}</p>}
|
|
||||||
{!isLoading && folders.length === 0 && (
|
|
||||||
<p className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{folders.map(({ folder, files }) => {
|
|
||||||
const isCollapsed = collapsed.includes(folder)
|
|
||||||
return (
|
|
||||||
<div key={folder || '/'} className="border-b border-border last:border-0">
|
|
||||||
<div className="flex items-center gap-2 bg-muted/30 px-3 py-1.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => toggleCollapsed(folder)}
|
|
||||||
>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="shrink-0"
|
|
||||||
checked={files
|
|
||||||
.filter((f) => !f.alreadyImported)
|
|
||||||
.every((f) => selected.includes(f.relativePath))}
|
|
||||||
onChange={() => toggleFolder(files)}
|
|
||||||
/>
|
|
||||||
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="min-w-0 flex-1 truncate font-medium" title={folder}>
|
|
||||||
{folder || t('admin.media.manualRoot')}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">{files.length}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isCollapsed && (
|
|
||||||
<ul className="divide-y divide-border">
|
|
||||||
{files.map((file) => {
|
|
||||||
const label = formatSeasonEpisode(
|
|
||||||
parsedByPath.get(file.relativePath) ?? { season: null, episode: null },
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={file.relativePath}
|
|
||||||
className="flex items-center gap-2 px-3 py-1.5 pl-9"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="shrink-0"
|
|
||||||
disabled={file.alreadyImported}
|
|
||||||
checked={selected.includes(file.relativePath)}
|
|
||||||
onChange={() => toggle(file.relativePath)}
|
|
||||||
/>
|
|
||||||
{label ? (
|
|
||||||
<Badge className="shrink-0">{label}</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="muted" className="shrink-0">
|
|
||||||
{t('admin.media.toShowUnknown')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={`min-w-0 flex-1 truncate ${file.alreadyImported ? 'text-muted-foreground' : ''}`}
|
|
||||||
title={file.name}
|
|
||||||
>
|
|
||||||
{file.name}
|
|
||||||
</span>
|
|
||||||
{file.alreadyImported && (
|
|
||||||
<Badge variant="muted">{t('admin.media.manualAlready')}</Badge>
|
|
||||||
)}
|
|
||||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
|
||||||
{formatSize(file.sizeBytes)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data?.truncated && (
|
|
||||||
<p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.media.manualCleanupHint')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<Button size="sm" variant="outline" onClick={onClose}>
|
<div className="flex flex-col gap-1.5">
|
||||||
{t('common.cancel')}
|
<Label>{t('common.search')}</Label>
|
||||||
</Button>
|
<Input value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.media.toShowSeason')}</Label>
|
||||||
|
{/* Ноль — законный сезон: в нём живут спецвыпуски и пилоты. */}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
placeholder={t('admin.media.toShowAuto')}
|
||||||
|
value={seasonStr}
|
||||||
|
onChange={(e) => setSeasonStr(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.media.toShowRegex')}</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="^(\d+)"
|
||||||
|
value={regexStr}
|
||||||
|
onChange={(e) => setRegexStr(e.target.value)}
|
||||||
|
className={!regexOk ? 'border-red-500' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!regexOk && <p className="text-xs text-red-500">{t('admin.media.toShowRegexInvalid')}</p>}
|
||||||
|
|
||||||
|
{/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */}
|
||||||
|
{sample && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs text-muted-foreground">{t('admin.media.regexPickHint')}</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5 font-mono text-xs">
|
||||||
|
{sampleParts.map((part) =>
|
||||||
|
part.number === null ? (
|
||||||
|
<span key={part.start} className="text-muted-foreground">
|
||||||
|
{part.text}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={part.start}
|
||||||
|
type="button"
|
||||||
|
title={t('admin.media.regexPickTitle')}
|
||||||
|
className="rounded border border-primary/60 bg-primary/10 px-1 text-primary hover:bg-primary/25"
|
||||||
|
onClick={() => setRegexStr(buildEpisodeRegex(sample.name, part.number!))}
|
||||||
|
>
|
||||||
|
{part.text}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">{t('admin.media.regexPresets')}</span>
|
||||||
|
{REGEX_PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.key}
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||||
|
onClick={() => setRegexStr(preset.pattern)}
|
||||||
|
>
|
||||||
|
{t(`admin.media.regexPresetNames.${preset.key}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{regexStr && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||||
|
onClick={() => setRegexStr('')}
|
||||||
|
>
|
||||||
|
{t('admin.media.regexClear')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={selected.length === 0 || !showId || importMutation.isPending}
|
variant="outline"
|
||||||
onClick={() => importMutation.mutate()}
|
disabled={selectable.length === 0}
|
||||||
|
onClick={() =>
|
||||||
|
setSelected(
|
||||||
|
selected.length === selectable.length ? [] : selectable.map((f) => f.relativePath),
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t('admin.media.manualImport')}
|
{t('admin.media.manualSelectAll')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
<span className="text-muted-foreground">
|
||||||
</DialogContent>
|
{t('admin.media.manualSelected', { count: selected.length })}
|
||||||
</Dialog>
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t('admin.media.manualRecognized', {
|
||||||
|
count: recognized,
|
||||||
|
total: selectable.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="crt-panel max-h-80 overflow-y-auto rounded-md text-sm">
|
||||||
|
{isLoading && <p className="px-3 py-2 text-muted-foreground">{t('common.loading')}</p>}
|
||||||
|
{!isLoading && folders.length === 0 && (
|
||||||
|
<p className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{folders.map(({ folder, files }) => {
|
||||||
|
const isCollapsed = collapsed.includes(folder)
|
||||||
|
return (
|
||||||
|
<div key={folder || '/'} className="border-b border-border last:border-0">
|
||||||
|
<div className="flex items-center gap-2 bg-muted/30 px-3 py-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => toggleCollapsed(folder)}
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="shrink-0"
|
||||||
|
checked={files
|
||||||
|
.filter((f) => !f.alreadyImported)
|
||||||
|
.every((f) => selected.includes(f.relativePath))}
|
||||||
|
onChange={() => toggleFolder(files)}
|
||||||
|
/>
|
||||||
|
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium" title={folder}>
|
||||||
|
{folder || t('admin.media.manualRoot')}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">{files.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{files.map((file) => {
|
||||||
|
const label = formatSeasonEpisode(
|
||||||
|
parsedByPath.get(file.relativePath) ?? { season: null, episode: null },
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={file.relativePath}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 pl-9"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="shrink-0"
|
||||||
|
disabled={file.alreadyImported}
|
||||||
|
checked={selected.includes(file.relativePath)}
|
||||||
|
onChange={() => toggle(file.relativePath)}
|
||||||
|
/>
|
||||||
|
{label ? (
|
||||||
|
<Badge className="shrink-0">{label}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="muted" className="shrink-0">
|
||||||
|
{t('admin.media.toShowUnknown')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`min-w-0 flex-1 truncate ${file.alreadyImported ? 'text-muted-foreground' : ''}`}
|
||||||
|
title={file.name}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
{file.alreadyImported && (
|
||||||
|
<Badge variant="muted">{t('admin.media.manualAlready')}</Badge>
|
||||||
|
)}
|
||||||
|
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||||
|
{formatSize(file.sizeBytes)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data?.truncated && (
|
||||||
|
<p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.media.manualCleanupHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button size="sm" variant="outline" onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={selected.length === 0 || !showId || importMutation.isPending}
|
||||||
|
onClick={() => importMutation.mutate()}
|
||||||
|
>
|
||||||
|
{t('admin.media.manualImport')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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<Tab>(files ? 'toShow' : 'movies')
|
||||||
|
|
||||||
|
const tabs: Tab[] = files ? ['toShow', 'movies', 'manual'] : ['movies', 'manual']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-w-[min(96vw,80rem)]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('admin.media.importTitle')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('admin.media.importHint')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex gap-1 border-b border-border">
|
||||||
|
{tabs.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(item)}
|
||||||
|
className={`-mb-px border-b-2 px-3 py-1.5 text-sm ${
|
||||||
|
tab === item
|
||||||
|
? 'border-primary text-foreground'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`admin.media.importTabs.${item}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'movies' && <MovieImportPanel onClose={onClose} />}
|
||||||
|
{tab === 'manual' && <ManualInboxPanel onClose={onClose} />}
|
||||||
|
{tab === 'toShow' && files && <UploadToShowPanel files={files} onClose={onClose} />}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 { qk } from '@/shared/api/query-keys'
|
||||||
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
|
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
|
||||||
import { useApiError } from '@/shared/lib/use-api-error'
|
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 { SortHeader } from '@/shared/ui/sortable'
|
||||||
import { deleteMedia, getMediaStats, listMedia } from './api'
|
import { deleteMedia, getMediaStats, listMedia } from './api'
|
||||||
import { formatDuration, splitEta } from './format'
|
import { formatDuration, splitEta } from './format'
|
||||||
import { ManualInboxDialog } from './ManualInboxDialog'
|
import { MediaImportDialog } from './MediaImportDialog'
|
||||||
import { MovieImportDialog } from './MovieImportDialog'
|
|
||||||
import { UploadToShowDialog } from './UploadToShowDialog'
|
|
||||||
import { useUploadStore } from './upload-store'
|
import { useUploadStore } from './upload-store'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
@@ -47,9 +45,9 @@ export function MediaPanel() {
|
|||||||
const [filter, setFilter] = useState<MediaFilter>('active')
|
const [filter, setFilter] = useState<MediaFilter>('active')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const { sort, toggle } = useTableSort('created', true)
|
const { sort, toggle } = useTableSort('created', true)
|
||||||
|
// Файлы, выбранные для загрузки в шоу: с ними окно импорта открывается сразу на нужной вкладке.
|
||||||
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
||||||
const [manualOpen, setManualOpen] = useState(false)
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
const [moviesOpen, setMoviesOpen] = useState(false)
|
|
||||||
const enqueue = useUploadStore((s) => s.enqueue)
|
const enqueue = useUploadStore((s) => s.enqueue)
|
||||||
|
|
||||||
const sortColumn = (key: string) => {
|
const sortColumn = (key: string) => {
|
||||||
@@ -180,21 +178,16 @@ export function MediaPanel() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const files = e.target.files
|
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 = ''
|
e.target.value = ''
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button size="sm" variant="outline" onClick={() => setMoviesOpen(true)}>
|
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
||||||
<Clapperboard className="h-4 w-4" />
|
|
||||||
{t('admin.media.moviesButton')}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => setManualOpen(true)}>
|
|
||||||
<FolderInput className="h-4 w-4" />
|
<FolderInput className="h-4 w-4" />
|
||||||
{t('admin.media.manualButton')}
|
{t('admin.media.importButton')}
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => fileInputShow.current?.click()}>
|
|
||||||
<ListPlus className="h-4 w-4" />
|
|
||||||
{t('admin.media.uploadToShow')}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => fileInput.current?.click()}>
|
<Button size="sm" onClick={() => fileInput.current?.click()}>
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
@@ -203,13 +196,16 @@ export function MediaPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filesForShow && (
|
{importOpen && (
|
||||||
<UploadToShowDialog files={filesForShow} onClose={() => setFilesForShow(null)} />
|
<MediaImportDialog
|
||||||
|
files={filesForShow}
|
||||||
|
onClose={() => {
|
||||||
|
setImportOpen(false)
|
||||||
|
setFilesForShow(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{manualOpen && <ManualInboxDialog onClose={() => setManualOpen(false)} />}
|
|
||||||
{moviesOpen && <MovieImportDialog onClose={() => setMoviesOpen(false)} />}
|
|
||||||
|
|
||||||
<div className="crt-panel overflow-x-auto rounded-md">
|
<div className="crt-panel overflow-x-auto rounded-md">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-border text-left text-muted-foreground">
|
<thead className="border-b border-border text-left text-muted-foreground">
|
||||||
|
|||||||
+191
-188
@@ -7,14 +7,7 @@ import { qk } from '@/shared/api/query-keys'
|
|||||||
import type { MovieImportItem, MovieMatchDto, MovieMatchStatus } from '@/shared/api/types'
|
import type { MovieImportItem, MovieMatchDto, MovieMatchStatus } from '@/shared/api/types'
|
||||||
import { useApiError } from '@/shared/lib/use-api-error'
|
import { useApiError } from '@/shared/lib/use-api-error'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import {
|
import { DialogFooter } from '@/shared/ui/dialog'
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/shared/ui/dialog'
|
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { importMovies, listManualInbox, matchMovie, matchMovies } from './api'
|
import { importMovies, listManualInbox, matchMovie, matchMovies } from './api'
|
||||||
@@ -78,7 +71,7 @@ function manualRows(
|
|||||||
* Во втором случае имена разбираются **до** загрузки — иначе, чтобы узнать, что половина строк
|
* Во втором случае имена разбираются **до** загрузки — иначе, чтобы узнать, что половина строк
|
||||||
* не распозналась, пришлось бы сначала залить десятки гигабайт.
|
* не распозналась, пришлось бы сначала залить десятки гигабайт.
|
||||||
*/
|
*/
|
||||||
export function MovieImportDialog({ onClose }: Readonly<{ onClose: () => void }>) {
|
export function MovieImportPanel({ onClose }: Readonly<{ onClose: () => void }>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const onError = useApiError()
|
const onError = useApiError()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -186,194 +179,204 @@ export function MovieImportDialog({ onClose }: Readonly<{ onClose: () => void }>
|
|||||||
setRows((current) => current.map((row) => (row.name === name ? { ...row, ...changes } : row)))
|
setRows((current) => current.map((row) => (row.name === name ? { ...row, ...changes } : row)))
|
||||||
|
|
||||||
const selectedCount = rows.filter((row) => row.selected).length
|
const selectedCount = rows.filter((row) => row.selected).length
|
||||||
|
const allSelected = rows.length > 0 && selectedCount === rows.length
|
||||||
const canScan = sources.size > 0 && Boolean(activeProvider) && !scan.isPending
|
const canScan = sources.size > 0 && Boolean(activeProvider) && !scan.isPending
|
||||||
|
|
||||||
|
const toggleAll = (selected: boolean) =>
|
||||||
|
setRows((current) => current.map((row) => ({ ...row, selected })))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={onClose}>
|
<>
|
||||||
<DialogContent className="max-w-5xl">
|
<p className="text-sm text-muted-foreground">{t('admin.movies.hint')}</p>
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t('admin.movies.title')}</DialogTitle>
|
|
||||||
<DialogDescription>{t('admin.movies.hint')}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{(['manual', 'disk'] as Source[]).map((item) => (
|
{(['manual', 'disk'] as Source[]).map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item}
|
key={item}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSource(item)
|
setSource(item)
|
||||||
setRows([])
|
setRows([])
|
||||||
}}
|
}}
|
||||||
className={`rounded border px-2 py-1 text-xs ${
|
className={`rounded border px-2 py-1 text-xs ${
|
||||||
source === item
|
source === item
|
||||||
? 'border-primary text-primary'
|
? 'border-primary text-primary'
|
||||||
: 'border-border text-muted-foreground'
|
: 'border-border text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
|
||||||
{t(`admin.movies.sources.${item}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{source === 'disk' && (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
ref={fileInput}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept="video/*,.mkv,.avi,.mp4,.m4v,.mov,.ts,.mpg,.mpeg,.wmv,.flv"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
setFiles([...(e.target.files ?? [])])
|
|
||||||
setRows([])
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => fileInput.current?.click()}>
|
|
||||||
<Upload className="h-4 w-4" />
|
|
||||||
{t('admin.movies.pickFiles')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(providers?.length ?? 0) > 1 && (
|
|
||||||
<select
|
|
||||||
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
|
||||||
value={activeProvider}
|
|
||||||
onChange={(e) => setProvider(e.target.value)}
|
|
||||||
>
|
>
|
||||||
{providers?.map((key) => (
|
{t(`admin.movies.sources.${item}`)}
|
||||||
<option key={key} value={key}>
|
</button>
|
||||||
{key}
|
))}
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button size="sm" disabled={!canScan} onClick={() => scan.mutate()}>
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
{t('admin.movies.scan', { count: sources.size })}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{!activeProvider && (
|
|
||||||
<span className="flex items-center gap-1.5 text-xs text-amber-500">
|
|
||||||
<AlertTriangle className="h-3.5 w-3.5" />
|
|
||||||
{t('admin.movies.noProvider')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[55vh] overflow-auto rounded border border-border">
|
{source === 'disk' && (
|
||||||
<table className="w-full text-sm">
|
<>
|
||||||
<thead className="sticky top-0 bg-background text-xs uppercase text-muted-foreground">
|
<input
|
||||||
<tr>
|
ref={fileInput}
|
||||||
<th className="w-8 p-2" />
|
type="file"
|
||||||
<th className="p-2 text-left">{t('admin.movies.file')}</th>
|
multiple
|
||||||
<th className="p-2 text-left">{t('admin.movies.parsed')}</th>
|
accept="video/*,.mkv,.avi,.mp4,.m4v,.mov,.ts,.mpg,.mpeg,.wmv,.flv"
|
||||||
<th className="p-2 text-left">{t('admin.movies.candidate')}</th>
|
className="hidden"
|
||||||
</tr>
|
onChange={(e) => {
|
||||||
</thead>
|
setFiles([...(e.target.files ?? [])])
|
||||||
<tbody>
|
setRows([])
|
||||||
{rows.map((row) => (
|
}}
|
||||||
<tr key={row.name} className="border-t border-border align-top">
|
/>
|
||||||
<td className="p-2">
|
<Button size="sm" variant="outline" onClick={() => fileInput.current?.click()}>
|
||||||
<input
|
<Upload className="h-4 w-4" />
|
||||||
type="checkbox"
|
{t('admin.movies.pickFiles')}
|
||||||
checked={row.selected}
|
</Button>
|
||||||
onChange={(e) => patch(row.name, { selected: e.target.checked })}
|
</>
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="max-w-[18rem] truncate p-2 font-mono text-xs" title={row.name}>
|
|
||||||
{row.name}
|
|
||||||
<div
|
|
||||||
className={`mt-1 flex items-center gap-1 text-[11px] ${STATUS_TONE[row.status]}`}
|
|
||||||
>
|
|
||||||
{row.status === 'Confident' && <Check className="h-3 w-3" />}
|
|
||||||
{row.status === 'Uncertain' && <HelpCircle className="h-3 w-3" />}
|
|
||||||
{row.status === 'NotFound' && <AlertTriangle className="h-3 w-3" />}
|
|
||||||
{t(`admin.movies.statuses.${row.status}`)}
|
|
||||||
{row.existingShowName && <span>· {row.existingShowName}</span>}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-2">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Input
|
|
||||||
className="h-8"
|
|
||||||
value={row.title}
|
|
||||||
onChange={(e) => patch(row.name, { title: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
className="h-8 w-20"
|
|
||||||
placeholder={t('admin.movies.year')}
|
|
||||||
value={row.year}
|
|
||||||
onChange={(e) => patch(row.name, { year: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={research.isPending || !row.title.trim()}
|
|
||||||
onClick={() => research.mutate(row)}
|
|
||||||
title={t('admin.movies.research')}
|
|
||||||
>
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-2">
|
|
||||||
{row.candidates.length === 0 ? (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{t('admin.movies.empty')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<select
|
|
||||||
className="h-8 w-full rounded-md border border-border bg-transparent px-2 text-sm"
|
|
||||||
value={row.chosenExternalId ?? ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
patch(row.name, { chosenExternalId: e.target.value || null })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">{t('admin.movies.noCandidate')}</option>
|
|
||||||
{row.candidates.map((candidate) => (
|
|
||||||
<option key={candidate.externalId} value={candidate.externalId}>
|
|
||||||
{candidate.title}
|
|
||||||
{candidate.year ? ` (${candidate.year})` : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{rows.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="p-6 text-center text-sm text-muted-foreground">
|
|
||||||
{scan.isPending ? t('admin.movies.scanning') : t('admin.movies.nothingYet')}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{rows.length > 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.movies.collectionsHint')}</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
{(providers?.length ?? 0) > 1 && (
|
||||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
<select
|
||||||
{t('common.close')}
|
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
||||||
</Button>
|
value={activeProvider}
|
||||||
<Button
|
onChange={(e) => setProvider(e.target.value)}
|
||||||
size="sm"
|
|
||||||
disabled={selectedCount === 0 || importing.isPending}
|
|
||||||
onClick={() => importing.mutate()}
|
|
||||||
>
|
>
|
||||||
{t('admin.movies.import', { count: selectedCount })}
|
{providers?.map((key) => (
|
||||||
</Button>
|
<option key={key} value={key}>
|
||||||
</DialogFooter>
|
{key}
|
||||||
</DialogContent>
|
</option>
|
||||||
</Dialog>
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button size="sm" disabled={!canScan} onClick={() => scan.mutate()}>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
{t('admin.movies.scan', { count: sources.size })}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!activeProvider && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-amber-500">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
{t('admin.movies.noProvider')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[60vh] overflow-auto rounded border border-border">
|
||||||
|
<table className="w-full table-fixed text-sm">
|
||||||
|
<thead className="sticky top-0 bg-background text-xs uppercase text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="w-8 p-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
title={t('admin.movies.toggleAll')}
|
||||||
|
disabled={rows.length === 0}
|
||||||
|
checked={allSelected}
|
||||||
|
// Часть строк отмечена — галочка «в промежутке»: иначе по ней не понять,
|
||||||
|
// что клик сейчас сделает.
|
||||||
|
ref={(input) => {
|
||||||
|
if (input) input.indeterminate = !allSelected && selectedCount > 0
|
||||||
|
}}
|
||||||
|
onChange={(e) => toggleAll(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="w-[26%] p-2 text-left">{t('admin.movies.file')}</th>
|
||||||
|
<th className="w-[38%] p-2 text-left">{t('admin.movies.parsed')}</th>
|
||||||
|
<th className="p-2 text-left">{t('admin.movies.candidate')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.name} className="border-t border-border align-top">
|
||||||
|
<td className="p-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={row.selected}
|
||||||
|
onChange={(e) => patch(row.name, { selected: e.target.checked })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="max-w-0 p-2 font-mono text-xs" title={row.name}>
|
||||||
|
<div className="truncate">{row.name}</div>
|
||||||
|
<div
|
||||||
|
className={`mt-1 flex items-center gap-1 text-[11px] ${STATUS_TONE[row.status]}`}
|
||||||
|
>
|
||||||
|
{row.status === 'Confident' && <Check className="h-3 w-3" />}
|
||||||
|
{row.status === 'Uncertain' && <HelpCircle className="h-3 w-3" />}
|
||||||
|
{row.status === 'NotFound' && <AlertTriangle className="h-3 w-3" />}
|
||||||
|
{t(`admin.movies.statuses.${row.status}`)}
|
||||||
|
{row.existingShowName && <span>· {row.existingShowName}</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Input
|
||||||
|
className="h-8 min-w-0 flex-1"
|
||||||
|
value={row.title}
|
||||||
|
onChange={(e) => patch(row.name, { title: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="h-8 w-20 shrink-0"
|
||||||
|
placeholder={t('admin.movies.year')}
|
||||||
|
value={row.year}
|
||||||
|
onChange={(e) => patch(row.name, { year: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={research.isPending || !row.title.trim()}
|
||||||
|
onClick={() => research.mutate(row)}
|
||||||
|
title={t('admin.movies.research')}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-2">
|
||||||
|
{row.candidates.length === 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground">{t('admin.movies.empty')}</span>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
className="h-8 w-full rounded-md border border-border bg-transparent px-2 text-sm"
|
||||||
|
value={row.chosenExternalId ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch(row.name, { chosenExternalId: e.target.value || null })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">{t('admin.movies.noCandidate')}</option>
|
||||||
|
{row.candidates.map((candidate) => (
|
||||||
|
<option key={candidate.externalId} value={candidate.externalId}>
|
||||||
|
{candidate.title}
|
||||||
|
{candidate.year ? ` (${candidate.year})` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{scan.isPending ? t('admin.movies.scanning') : t('admin.movies.nothingYet')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.movies.collectionsHint')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={selectedCount === 0 || importing.isPending}
|
||||||
|
onClick={() => importing.mutate()}
|
||||||
|
>
|
||||||
|
{t('admin.movies.import', { count: selectedCount })}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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<Record<string, string>>({})
|
|
||||||
|
|
||||||
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<string, string | undefined>()
|
|
||||||
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 (
|
|
||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
||||||
<DialogContent className="max-w-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t('admin.media.toShowTitle')}</DialogTitle>
|
|
||||||
<DialogDescription>{t('admin.media.autoDetectHint')}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.media.toShowSeason')}</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
placeholder={t('admin.media.toShowAuto')}
|
|
||||||
value={seasonStr}
|
|
||||||
onChange={(e) => setSeasonStr(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.media.toShowRegex')}</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="^(\d+)"
|
|
||||||
value={regexStr}
|
|
||||||
onChange={(e) => setRegexStr(e.target.value)}
|
|
||||||
className={!regexOk ? 'border-red-500' : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('admin.media.toShowHint')}
|
|
||||||
{!regexOk && (
|
|
||||||
<span className="ml-2 text-red-500">{t('admin.media.toShowRegexInvalid')}</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
|
||||||
<span className="font-medium">{t('admin.media.toShowPreview')}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t('admin.media.toShowMatched', { matched: matchedCount, total: files.length })}
|
|
||||||
</span>
|
|
||||||
{/* Значение не храним: выбор здесь — это действие «проставить всем», а не состояние,
|
|
||||||
поэтому на кнопке всегда её собственная подпись. */}
|
|
||||||
<ShowPicker
|
|
||||||
value=""
|
|
||||||
shows={shows}
|
|
||||||
noneLabel={t('admin.media.toShowLibrary')}
|
|
||||||
placeholder={t('admin.media.applyToAll')}
|
|
||||||
className="h-8 w-52"
|
|
||||||
onChange={applyToAll}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md text-sm">
|
|
||||||
{previews.map((p) => {
|
|
||||||
const label = formatSeasonEpisode(p.parsed)
|
|
||||||
const current = assignment(p.name)
|
|
||||||
return (
|
|
||||||
<li key={p.name} className="flex items-center gap-2 px-3 py-1.5">
|
|
||||||
{label ? (
|
|
||||||
<Badge>{label}</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="muted">{t('admin.media.toShowUnknown')}</Badge>
|
|
||||||
)}
|
|
||||||
<span className="min-w-0 flex-1 truncate" title={p.name}>
|
|
||||||
{p.name}
|
|
||||||
</span>
|
|
||||||
<ShowPicker
|
|
||||||
value={current}
|
|
||||||
shows={shows}
|
|
||||||
noneLabel={t('admin.media.toShowLibrary')}
|
|
||||||
className="h-8 w-56 shrink-0"
|
|
||||||
onChange={(showId) => setOverrides((o) => ({ ...o, [p.name]: showId }))}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" size="sm" onClick={onClose}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={confirm}>
|
|
||||||
{t('admin.media.toShowConfirm')}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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<Record<string, string>>({})
|
||||||
|
|
||||||
|
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<string, string | undefined>()
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.media.autoDetectHint')}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.media.toShowSeason')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder={t('admin.media.toShowAuto')}
|
||||||
|
value={seasonStr}
|
||||||
|
onChange={(e) => setSeasonStr(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.media.toShowRegex')}</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="^(\d+)"
|
||||||
|
value={regexStr}
|
||||||
|
onChange={(e) => setRegexStr(e.target.value)}
|
||||||
|
className={!regexOk ? 'border-red-500' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.media.toShowHint')}
|
||||||
|
{!regexOk && (
|
||||||
|
<span className="ml-2 text-red-500">{t('admin.media.toShowRegexInvalid')}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||||
|
<span className="font-medium">{t('admin.media.toShowPreview')}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t('admin.media.toShowMatched', { matched: matchedCount, total: files.length })}
|
||||||
|
</span>
|
||||||
|
{/* Значение не храним: выбор здесь — это действие «проставить всем», а не состояние,
|
||||||
|
поэтому на кнопке всегда её собственная подпись. */}
|
||||||
|
<ShowPicker
|
||||||
|
value=""
|
||||||
|
shows={shows}
|
||||||
|
noneLabel={t('admin.media.toShowLibrary')}
|
||||||
|
placeholder={t('admin.media.applyToAll')}
|
||||||
|
className="h-8 w-52"
|
||||||
|
onChange={applyToAll}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md text-sm">
|
||||||
|
{previews.map((p) => {
|
||||||
|
const label = formatSeasonEpisode(p.parsed)
|
||||||
|
const current = assignment(p.name)
|
||||||
|
return (
|
||||||
|
<li key={p.name} className="flex items-center gap-2 px-3 py-1.5">
|
||||||
|
{label ? (
|
||||||
|
<Badge>{label}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="muted">{t('admin.media.toShowUnknown')}</Badge>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate" title={p.name}>
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
<ShowPicker
|
||||||
|
value={current}
|
||||||
|
shows={shows}
|
||||||
|
noneLabel={t('admin.media.toShowLibrary')}
|
||||||
|
className="h-8 w-56 shrink-0"
|
||||||
|
onChange={(showId) => setOverrides((o) => ({ ...o, [p.name]: showId }))}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={confirm}>
|
||||||
|
{t('admin.media.toShowConfirm')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -257,6 +257,7 @@ export const en = {
|
|||||||
file: 'File',
|
file: 'File',
|
||||||
parsed: 'Title and year',
|
parsed: 'Title and year',
|
||||||
candidate: 'Candidate',
|
candidate: 'Candidate',
|
||||||
|
toggleAll: 'Select all / clear all',
|
||||||
year: 'Year',
|
year: 'Year',
|
||||||
research: 'Search again',
|
research: 'Search again',
|
||||||
empty: 'nothing found',
|
empty: 'nothing found',
|
||||||
@@ -277,6 +278,15 @@ export const en = {
|
|||||||
title: 'Media',
|
title: 'Media',
|
||||||
upload: 'Upload',
|
upload: 'Upload',
|
||||||
manualButton: 'From manual folder',
|
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',
|
moviesButton: 'Movies',
|
||||||
manualTitle: 'Manual pick from the manual folder',
|
manualTitle: 'Manual pick from the manual folder',
|
||||||
manualHint:
|
manualHint:
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export const ru = {
|
|||||||
file: 'Файл',
|
file: 'Файл',
|
||||||
parsed: 'Название и год',
|
parsed: 'Название и год',
|
||||||
candidate: 'Кандидат',
|
candidate: 'Кандидат',
|
||||||
|
toggleAll: 'Отметить все / снять все',
|
||||||
year: 'Год',
|
year: 'Год',
|
||||||
research: 'Искать заново',
|
research: 'Искать заново',
|
||||||
empty: 'ничего не нашлось',
|
empty: 'ничего не нашлось',
|
||||||
@@ -278,6 +279,11 @@ export const ru = {
|
|||||||
title: 'Медиа',
|
title: 'Медиа',
|
||||||
upload: 'Загрузить',
|
upload: 'Загрузить',
|
||||||
manualButton: 'Из папки manual',
|
manualButton: 'Из папки manual',
|
||||||
|
importButton: 'Импорт',
|
||||||
|
importTitle: 'Импорт медиа',
|
||||||
|
importHint:
|
||||||
|
'Три способа пополнить библиотеку: фильмы пачкой, серии из папки manual в шоу и загрузка выбранных файлов в шоу.',
|
||||||
|
importTabs: { movies: 'Фильмы', manual: 'Серии из manual', toShow: 'Выбранные файлы в шоу' },
|
||||||
moviesButton: 'Фильмы',
|
moviesButton: 'Фильмы',
|
||||||
manualTitle: 'Ручной разбор папки manual',
|
manualTitle: 'Ручной разбор папки manual',
|
||||||
manualHint:
|
manualHint:
|
||||||
|
|||||||
Reference in New Issue
Block a user