Refactor AdminUserEndpoints and MediaEndpoints to include default parameter values for pagination and filtering, improving API usability. Update ManualInboxDialog to enhance show selection logic with auto-detection based on file names, and improve user feedback with new translations. Refactor PostgresFixture for better container management in integration tests.
This commit is contained in:
@@ -32,16 +32,20 @@ public static class AdminUserEndpoints
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Все параметры со значениями по умолчанию: обязательный <c>bool</c> в query превращает
|
||||||
|
/// запрос без него в 400 ещё до хендлера — ошибку в таком виде отладить тяжело.
|
||||||
|
/// </summary>
|
||||||
private static async Task<IResult> ListUsers(
|
private static async Task<IResult> ListUsers(
|
||||||
int page,
|
|
||||||
int pageSize,
|
|
||||||
string? search,
|
|
||||||
Guid? roleId,
|
|
||||||
bool? isBlocked,
|
|
||||||
string? sort,
|
|
||||||
bool desc,
|
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
string? search = null,
|
||||||
|
Guid? roleId = null,
|
||||||
|
bool? isBlocked = null,
|
||||||
|
string? sort = null,
|
||||||
|
bool desc = false
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
|
|||||||
@@ -109,15 +109,20 @@ public static class MediaEndpoints
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Все параметры со значениями по умолчанию: обязательный <c>bool</c> в query заставлял
|
||||||
|
/// минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые ассеты»,
|
||||||
|
/// которым сортировка не нужна.
|
||||||
|
/// </summary>
|
||||||
private static async Task<IResult> List(
|
private static async Task<IResult> List(
|
||||||
int page,
|
|
||||||
int pageSize,
|
|
||||||
MediaAssetStatus[]? status,
|
|
||||||
string? search,
|
|
||||||
string? sort,
|
|
||||||
bool desc,
|
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
MediaAssetStatus[]? status = null,
|
||||||
|
string? search = null,
|
||||||
|
string? sort = null,
|
||||||
|
bool desc = false
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
|
|||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
// Сортировка идёт до сборки DTO: по свойству record'а EF порядок не переводит и падает
|
||||||
|
// на компиляции запроса («The LINQ expression could not be translated»).
|
||||||
var collections = await dbContext
|
var collections = await dbContext
|
||||||
.CollectionItems.AsNoTracking()
|
.CollectionItems.AsNoTracking()
|
||||||
.Where(i => i.ShowId == show.Id)
|
.Where(i => i.ShowId == show.Id)
|
||||||
@@ -82,9 +84,15 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
|
|||||||
item => item.CollectionId,
|
item => item.CollectionId,
|
||||||
collection => collection.Id,
|
collection => collection.Id,
|
||||||
(item, collection) =>
|
(item, collection) =>
|
||||||
new ShowCollectionRefDto(collection.Id, collection.Name, item.Position)
|
new
|
||||||
|
{
|
||||||
|
collection.Id,
|
||||||
|
collection.Name,
|
||||||
|
item.Position,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.OrderBy(c => c.Name)
|
.OrderBy(c => c.Name)
|
||||||
|
.Select(c => new ShowCollectionRefDto(c.Id, c.Name, c.Position))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return Result.Success(
|
return Result.Success(
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using TeleWave.Application.Library.GetShow;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
using TeleWave.Domain.Media;
|
||||||
|
using TeleWave.Infrastructure.Persistence;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Integration.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Карточка шоу против настоящей БД: запрос собирает серии, жанры и коллекции несколькими
|
||||||
|
/// проекциями, и часть из них EF может не суметь перевести — на InMemory такое не всплывает.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("postgres")]
|
||||||
|
public sealed class GetShowIntegrationTests(PostgresFixture fixture)
|
||||||
|
{
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task GetShow_ReturnsEpisodesGenresAndCollections()
|
||||||
|
{
|
||||||
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
|
||||||
|
await using var seed = fixture.CreateContext();
|
||||||
|
var showId = await SeedAsync(seed);
|
||||||
|
|
||||||
|
await using var db = fixture.CreateContext();
|
||||||
|
var result = await new GetShowQueryHandler(db).Handle(new GetShowQuery(showId), default);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(2, result.Value.Episodes.Count);
|
||||||
|
Assert.Equal(2, result.Value.Genres.Count);
|
||||||
|
Assert.Single(result.Value.Collections);
|
||||||
|
Assert.True(result.Value.Genres[0].IsPrimary);
|
||||||
|
Assert.All(result.Value.Episodes, e => Assert.NotNull(e.AssetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task GetShow_WithoutGenresAndCollections_Works()
|
||||||
|
{
|
||||||
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
|
||||||
|
await using var seed = fixture.CreateContext();
|
||||||
|
var show = Show.Create($"Голое шоу {Guid.NewGuid():N}"[..20], ShowKind.Series);
|
||||||
|
seed.Shows.Add(show);
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
|
||||||
|
await using var db = fixture.CreateContext();
|
||||||
|
var result = await new GetShowQueryHandler(db).Handle(new GetShowQuery(show.Id), default);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Empty(result.Value.Episodes);
|
||||||
|
Assert.Empty(result.Value.Genres);
|
||||||
|
Assert.Empty(result.Value.Collections);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Guid> SeedAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
var tag = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
|
||||||
|
var action = Genre.Create($"Боевик {tag}", $"action-{tag}");
|
||||||
|
var comedy = Genre.Create($"Комедия {tag}", $"comedy-{tag}");
|
||||||
|
db.Genres.AddRange(action, comedy);
|
||||||
|
|
||||||
|
var show = Show.Create($"Шоу {tag}", ShowKind.Series);
|
||||||
|
for (var i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
var asset = MediaAsset.Register($"{tag}-s01e0{i}.mkv", ".mkv", MediaSource.Upload);
|
||||||
|
db.MediaAssets.Add(asset);
|
||||||
|
show.AddEpisode(asset.Id);
|
||||||
|
}
|
||||||
|
show.SetGenres([action.Id, comedy.Id], action.Id);
|
||||||
|
db.Shows.Add(show);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var collection = Collection.Create($"Франшиза {tag}");
|
||||||
|
collection.AddShow(show.Id);
|
||||||
|
db.Collections.Add(collection);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return show.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,63 +1,70 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Infrastructure.Persistence;
|
using TeleWave.Infrastructure.Persistence;
|
||||||
using Testcontainers.PostgreSql;
|
using Testcontainers.PostgreSql;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace TeleWave.Integration.Tests;
|
namespace TeleWave.Integration.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие
|
/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие
|
||||||
/// экземпляры <see cref="AppDbContext"/> (каждый — своё соединение), чтобы тестировать то, что InMemory
|
/// экземпляры <see cref="AppDbContext"/> (каждый — своё соединение), чтобы тестировать то, что InMemory
|
||||||
/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker.
|
/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PostgresFixture : IAsyncLifetime
|
public sealed class PostgresFixture : IAsyncLifetime
|
||||||
{
|
{
|
||||||
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
|
// Контейнер создаётся внутри InitializeAsync: сам билдер бросает, когда Docker недоступен,
|
||||||
.WithImage("postgres:16-alpine")
|
// и в инициализаторе поля это ронял бы всю коллекцию тестов вместо честного пропуска.
|
||||||
.Build();
|
private PostgreSqlContainer? _container;
|
||||||
|
|
||||||
/// <summary>false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются.</summary>
|
/// <summary>false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются.</summary>
|
||||||
public bool Available { get; private set; }
|
public bool Available { get; private set; }
|
||||||
|
|
||||||
public string ConnectionString => _container.GetConnectionString();
|
public string ConnectionString =>
|
||||||
|
_container?.GetConnectionString()
|
||||||
public async Task InitializeAsync()
|
?? throw new InvalidOperationException("Контейнер не запущен — проверяйте Available.");
|
||||||
{
|
|
||||||
try
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
await _container.StartAsync();
|
try
|
||||||
await using var db = CreateContext();
|
{
|
||||||
await db.Database.MigrateAsync();
|
_container = new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build();
|
||||||
Available = true;
|
await _container.StartAsync();
|
||||||
}
|
await using var db = CreateContext();
|
||||||
catch (Exception)
|
await db.Database.MigrateAsync();
|
||||||
{
|
Available = true;
|
||||||
// Docker не запущен/недоступен — интеграционные тесты будут пропущены (Skip), а не упадут.
|
}
|
||||||
Available = false;
|
catch (Exception)
|
||||||
}
|
{
|
||||||
}
|
// Docker не запущен/недоступен — интеграционные тесты будут пропущены (Skip), а не упадут.
|
||||||
|
Available = false;
|
||||||
public AppDbContext CreateContext() =>
|
}
|
||||||
new(new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(ConnectionString).Options);
|
}
|
||||||
|
|
||||||
public async Task DisposeAsync() => await _container.DisposeAsync();
|
public AppDbContext CreateContext() =>
|
||||||
}
|
new(new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(ConnectionString).Options);
|
||||||
|
|
||||||
[CollectionDefinition("postgres")]
|
public async Task DisposeAsync()
|
||||||
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
|
{
|
||||||
|
if (_container is not null)
|
||||||
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary>
|
await _container.DisposeAsync();
|
||||||
internal sealed class SequenceRandom(params int[] sequence)
|
}
|
||||||
: Domain.Broadcast.Scheduling.IRandomSource
|
}
|
||||||
{
|
|
||||||
private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence;
|
[CollectionDefinition("postgres")]
|
||||||
private int _i;
|
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
|
||||||
|
|
||||||
public int Next(int maxExclusive)
|
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary>
|
||||||
{
|
internal sealed class SequenceRandom(params int[] sequence)
|
||||||
if (maxExclusive <= 0)
|
: Domain.Broadcast.Scheduling.IRandomSource
|
||||||
return 0;
|
{
|
||||||
var value = _sequence[_i++ % _sequence.Length];
|
private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence;
|
||||||
return ((value % maxExclusive) + maxExclusive) % maxExclusive;
|
private int _i;
|
||||||
}
|
|
||||||
}
|
public int Next(int maxExclusive)
|
||||||
|
{
|
||||||
|
if (maxExclusive <= 0)
|
||||||
|
return 0;
|
||||||
|
var value = _sequence[_i++ % _sequence.Length];
|
||||||
|
return ((value % maxExclusive) + maxExclusive) % maxExclusive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Library.Collections;
|
||||||
|
using TeleWave.Infrastructure.Persistence;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Integration.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Проверка переводимости запросов в SQL. Соединение не открывается: <c>ToQueryString</c> компилирует
|
||||||
|
/// запрос и падает ровно там, где падал бы прод («The LINQ expression could not be translated»).
|
||||||
|
/// Поэтому Docker не нужен, и такие ошибки ловятся быстро.
|
||||||
|
///
|
||||||
|
/// Повод: карточка шоу отвечала 500, потому что сортировка стояла после сборки DTO — EF не переводит
|
||||||
|
/// порядок по свойству record'а.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class QueryTranslationTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db = new(
|
||||||
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
// Сервера по адресу нет и не требуется — запрос только компилируется.
|
||||||
|
.UseNpgsql("Host=127.0.0.1;Port=1;Database=telewave;Username=u;Password=p")
|
||||||
|
.Options
|
||||||
|
);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShowCollections_Translates()
|
||||||
|
{
|
||||||
|
var showId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var sql = _db
|
||||||
|
.CollectionItems.AsNoTracking()
|
||||||
|
.Where(i => i.ShowId == showId)
|
||||||
|
.Join(
|
||||||
|
_db.Collections.AsNoTracking(),
|
||||||
|
item => item.CollectionId,
|
||||||
|
collection => collection.Id,
|
||||||
|
(item, collection) =>
|
||||||
|
new
|
||||||
|
{
|
||||||
|
collection.Id,
|
||||||
|
collection.Name,
|
||||||
|
item.Position,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.Select(c => new ShowCollectionRefDto(c.Id, c.Name, c.Position))
|
||||||
|
.ToQueryString();
|
||||||
|
|
||||||
|
Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShowCollections_OrderingAfterProjection_DoesNotTranslate()
|
||||||
|
{
|
||||||
|
// Зафиксировано намеренно: именно эта форма роняла карточку шоу в 500. Если EF когда-нибудь
|
||||||
|
// научится её переводить, тест упадёт — и это будет поводом упростить запрос обратно.
|
||||||
|
var showId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var query = _db
|
||||||
|
.CollectionItems.AsNoTracking()
|
||||||
|
.Where(i => i.ShowId == showId)
|
||||||
|
.Join(
|
||||||
|
_db.Collections.AsNoTracking(),
|
||||||
|
item => item.CollectionId,
|
||||||
|
collection => collection.Id,
|
||||||
|
(item, collection) =>
|
||||||
|
new ShowCollectionRefDto(collection.Id, collection.Name, item.Position)
|
||||||
|
)
|
||||||
|
.OrderBy(c => c.Name);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => query.ToQueryString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _db.Dispose();
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
|
import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { listShows } from '@/features/admin/shows/api'
|
import { listShows } from '@/features/admin/shows/api'
|
||||||
import { HttpError } from '@/shared/api/client'
|
import { HttpError } from '@/shared/api/client'
|
||||||
@@ -22,6 +22,7 @@ import { toast } from '@/shared/ui/toast-store'
|
|||||||
import { importManualInbox, listManualInbox } from './api'
|
import { importManualInbox, listManualInbox } from './api'
|
||||||
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
|
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
|
||||||
import { buildEpisodeRegex, findNumbers, REGEX_PRESETS } from './episode-regex'
|
import { buildEpisodeRegex, findNumbers, REGEX_PRESETS } from './episode-regex'
|
||||||
|
import { matchShowByName } from './match-show'
|
||||||
|
|
||||||
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
|
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
@@ -48,6 +49,8 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [selected, setSelected] = useState<string[]>([])
|
const [selected, setSelected] = useState<string[]>([])
|
||||||
const [showId, setShowId] = useState('')
|
const [showId, setShowId] = useState('')
|
||||||
|
// Ручной выбор шоу отключает автоопределение: перебивать решение человека нельзя.
|
||||||
|
const [showPicked, setShowPicked] = useState(false)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [seasonStr, setSeasonStr] = useState('')
|
const [seasonStr, setSeasonStr] = useState('')
|
||||||
const [regexStr, setRegexStr] = useState('')
|
const [regexStr, setRegexStr] = useState('')
|
||||||
@@ -120,6 +123,25 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
|||||||
parts.push({ text: sample.name.slice(cursor), number: null })
|
parts.push({ text: sample.name.slice(cursor), number: null })
|
||||||
return parts
|
return parts
|
||||||
}, [sample])
|
}, [sample])
|
||||||
|
/**
|
||||||
|
* Автоопределение шоу по имени релиза — то же, что в загрузке в шоу. Сначала пробуем имя файла,
|
||||||
|
* затем имя папки: в раздачах название сериала обычно есть и там, и там («Mr.Pickles.S01.1080p»).
|
||||||
|
*/
|
||||||
|
const detectedShowId = useMemo(
|
||||||
|
() =>
|
||||||
|
shows && sample
|
||||||
|
? (matchShowByName(sample.name, shows) ?? matchShowByName(sample.folder, shows))
|
||||||
|
: undefined,
|
||||||
|
[shows, sample],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showPicked || showId || !detectedShowId) return
|
||||||
|
setShowId(detectedShowId)
|
||||||
|
}, [detectedShowId, showPicked, showId])
|
||||||
|
|
||||||
|
const autoDetected = !showPicked && !!detectedShowId && showId === detectedShowId
|
||||||
|
|
||||||
const recognized = selectable.filter(
|
const recognized = selectable.filter(
|
||||||
(f) => parsedByPath.get(f.relativePath)?.episode != null,
|
(f) => parsedByPath.get(f.relativePath)?.episode != null,
|
||||||
).length
|
).length
|
||||||
@@ -180,7 +202,13 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
|||||||
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
|
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
|
||||||
<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>
|
||||||
<Select value={showId} onValueChange={setShowId}>
|
<Select
|
||||||
|
value={showId}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setShowPicked(true)
|
||||||
|
setShowId(value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t('admin.media.manualPickShow')} />
|
<SelectValue placeholder={t('admin.media.manualPickShow')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -192,6 +220,11 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{autoDetected && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.media.manualDetected')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ export function MediaPanel() {
|
|||||||
sortKey="status"
|
sortKey="status"
|
||||||
sort={sort}
|
sort={sort}
|
||||||
onToggle={sortColumn}
|
onToggle={sortColumn}
|
||||||
|
className="whitespace-nowrap"
|
||||||
/>
|
/>
|
||||||
<SortHeader
|
<SortHeader
|
||||||
label={t('admin.media.duration')}
|
label={t('admin.media.duration')}
|
||||||
@@ -274,7 +275,7 @@ function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; onDelete: () => v
|
|||||||
return (
|
return (
|
||||||
<tr className="border-b border-border last:border-0">
|
<tr className="border-b border-border last:border-0">
|
||||||
<td className="px-4 py-2">{asset.originalFileName}</td>
|
<td className="px-4 py-2">{asset.originalFileName}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="whitespace-nowrap px-4 py-2">
|
||||||
<Badge variant={statusVariant[asset.status]} title={asset.errorMessage ?? undefined}>
|
<Badge variant={statusVariant[asset.status]} title={asset.errorMessage ?? undefined}>
|
||||||
{t(`admin.media.statuses.${asset.status}`)}
|
{t(`admin.media.statuses.${asset.status}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ const resources = {
|
|||||||
'Файлы уйдут из папки, спутники (субтитры, nfo) и опустевший каталог будут удалены.',
|
'Файлы уйдут из папки, спутники (субтитры, nfo) и опустевший каталог будут удалены.',
|
||||||
manualTruncated: 'Показаны первые 500 файлов — в папке есть ещё.',
|
manualTruncated: 'Показаны первые 500 файлов — в папке есть ещё.',
|
||||||
manualShow: 'Шоу',
|
manualShow: 'Шоу',
|
||||||
|
manualDetected: 'Определено по имени релиза — проверьте и поправьте, если не то.',
|
||||||
manualPickShow: 'Выберите шоу',
|
manualPickShow: 'Выберите шоу',
|
||||||
manualImport: 'Забрать в шоу',
|
manualImport: 'Забрать в шоу',
|
||||||
manualImported: 'Импортировано файлов: {{count}}',
|
manualImported: 'Импортировано файлов: {{count}}',
|
||||||
@@ -917,6 +918,7 @@ const resources = {
|
|||||||
'Files leave the folder; siblings (subtitles, nfo) and the emptied folder are removed.',
|
'Files leave the folder; siblings (subtitles, nfo) and the emptied folder are removed.',
|
||||||
manualTruncated: 'Showing the first 500 files — there are more in the folder.',
|
manualTruncated: 'Showing the first 500 files — there are more in the folder.',
|
||||||
manualShow: 'Show',
|
manualShow: 'Show',
|
||||||
|
manualDetected: 'Detected from the release name — check it and change if wrong.',
|
||||||
manualPickShow: 'Pick a show',
|
manualPickShow: 'Pick a show',
|
||||||
manualImport: 'Import into show',
|
manualImport: 'Import into show',
|
||||||
manualImported: 'Files imported: {{count}}',
|
manualImported: 'Files imported: {{count}}',
|
||||||
|
|||||||
Reference in New Issue
Block a user