diff --git a/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommandHandler.cs b/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommandHandler.cs index 1a9cacf..f75e3f7 100644 --- a/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommandHandler.cs +++ b/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommandHandler.cs @@ -21,11 +21,20 @@ public sealed class CreateCollectionFromSuggestionCommandHandler( if (suggestion is null) return Result.Failure(CollectionErrors.SuggestionNotFound); - var taken = await dbContext.Collections.AnyAsync( - c => c.Name.ToLower() == suggestion.Name.ToLower(), - cancellationToken - ); - if (taken) + // Сравниваем имена в памяти, а не запросом: коллекций десятки, зато проверка идёт по тем же + // правилам, что и пометка «уже есть» в списке предложений. SQL-регистронезависимость + // (lower()) в тонких местах вроде «ё» с ней расходится, и предложение то показывалось бы + // доступным, то отбивалось конфликтом. + var names = await dbContext.Collections.Select(c => c.Name).ToListAsync(cancellationToken); + if ( + names.Any(name => + string.Equals( + name.Trim(), + suggestion.Name, + StringComparison.CurrentCultureIgnoreCase + ) + ) + ) return Result.Failure(CollectionErrors.SuggestionAlreadyCreated); var collection = Collection.Create(suggestion.Name); diff --git a/backend/src/TeleWave.Application/Programming/Groups/Suggest/CreateGroupFromSuggestionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/Suggest/CreateGroupFromSuggestionCommandHandler.cs index 45ff68f..09bce39 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/Suggest/CreateGroupFromSuggestionCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/Suggest/CreateGroupFromSuggestionCommandHandler.cs @@ -22,12 +22,19 @@ public sealed class CreateGroupFromSuggestionCommandHandler( if (suggestion is null) return Result.Failure(GroupErrors.SuggestionNotFound); - // Имя — ключ узнаваемости группы, и две «Мультфильм» в списке слота неразличимы. - var taken = await dbContext.Groups.AnyAsync( - g => g.Name.ToLower() == suggestion.Name.ToLower(), - cancellationToken - ); - if (taken) + // Имя — ключ узнаваемости группы, и две «Мультфильм» в списке слота неразличимы. Сравниваем + // в памяти, а не запросом: групп десятки, зато проверка идёт по тем же правилам, что и + // пометка «уже есть» в списке предложений, — SQL-регистронезависимость с ней расходится. + var names = await dbContext.Groups.Select(g => g.Name).ToListAsync(cancellationToken); + if ( + names.Any(name => + string.Equals( + name.Trim(), + suggestion.Name, + StringComparison.CurrentCultureIgnoreCase + ) + ) + ) return Result.Failure(GroupErrors.SuggestionAlreadyCreated); var group = Group.Create(suggestion.Name); diff --git a/backend/tests/TeleWave.Application.Tests/Library/ListShowsTests.cs b/backend/tests/TeleWave.Application.Tests/Library/ListShowsTests.cs index d795a67..d81feeb 100644 --- a/backend/tests/TeleWave.Application.Tests/Library/ListShowsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Library/ListShowsTests.cs @@ -58,7 +58,7 @@ public class ListShowsTests ); // Ролики-врезки живут на своей странице и в общей библиотеке только мешали бы. - Assert.Equal(new[] { "Автофильм", "Сериал" }, shows.Select(s => s.Name).ToArray()); + Assert.Equal(["Автофильм", "Сериал"], shows.Select(s => s.Name).ToArray()); } [Fact] @@ -77,7 +77,7 @@ public class ListShowsTests Assert.Equal(2, series.SeasonCount); Assert.True(series.HasPoster); Assert.Equal("Боевик", series.PrimaryGenre); - Assert.Equal(new[] { "Драма" }, series.OtherGenres.ToArray()); + Assert.Equal(["Драма"], series.OtherGenres.ToArray()); } [Fact] diff --git a/backend/tests/TeleWave.Application.Tests/Media/UploadsCleanupTests.cs b/backend/tests/TeleWave.Application.Tests/Media/UploadsCleanupTests.cs index 87bb948..0391c14 100644 --- a/backend/tests/TeleWave.Application.Tests/Media/UploadsCleanupTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Media/UploadsCleanupTests.cs @@ -54,8 +54,13 @@ public class UploadsCleanupTests : IDisposable [Fact] public void Sweep_SurvivesMissingDirectory() { - // Каталога может не быть на самом первом старте — это не повод падать. - UploadsCleaner.Sweep(Paths(), TimeSpan.FromHours(24), NullLogger.Instance); + // Каталога может не быть на самом первом старте — это не повод падать. И заводить его + // уборка тоже не должна: каталоги создаёт хранилище, когда в них появляется нужда. + var paths = Paths(); + + UploadsCleaner.Sweep(paths, TimeSpan.FromHours(24), NullLogger.Instance); + + Assert.False(Directory.Exists(paths.UploadsDir)); } /// diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/OmdbMetadataProviderTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/OmdbMetadataProviderTests.cs index 0b4de30..511201a 100644 --- a/backend/tests/TeleWave.Application.Tests/Metadata/OmdbMetadataProviderTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Metadata/OmdbMetadataProviderTests.cs @@ -79,7 +79,7 @@ public class OmdbMetadataProviderTests Assert.Equal("Сериал", show.Title); Assert.Equal(2001, show.Year); Assert.Equal("описание", show.Overview); - Assert.Equal(new[] { "Action", "Sci-Fi" }, show.Genres); + Assert.Equal(["Action", "Sci-Fi"], show.Genres); Assert.Equal("TV-14", show.ContentRating); } @@ -185,7 +185,7 @@ public class OmdbMetadataProviderTests var seasons = await Provider(http).GetSeasonNumbersAsync("tt1", CancellationToken.None); - Assert.Equal(new[] { 1, 2, 3 }, seasons); + Assert.Equal([1, 2, 3], seasons); } [Fact] diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/TmdbMetadataProviderTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/TmdbMetadataProviderTests.cs index 0eac00c..bfe1852 100644 --- a/backend/tests/TeleWave.Application.Tests/Metadata/TmdbMetadataProviderTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Metadata/TmdbMetadataProviderTests.cs @@ -97,7 +97,7 @@ public class TmdbMetadataProviderTests Assert.Equal("Сериал", show.Title); Assert.Equal(2001, show.Year); // Идентификатор впереди названия, безымянный жанр — только идентификатором. - Assert.Equal(new[] { "tmdb:28", "Боевик", "Драма", "tmdb:99" }, show.Genres); + Assert.Equal(["tmdb:28", "Боевик", "Драма", "tmdb:99"], show.Genres); // US приоритетнее RU, хотя в ответе идёт вторым. Assert.Equal("TV-14", show.ContentRating); } @@ -236,7 +236,7 @@ public class TmdbMetadataProviderTests var seasons = await Provider(http).GetSeasonNumbersAsync("42", CancellationToken.None); - Assert.Equal(new[] { 1, 2 }, seasons); + Assert.Equal([1, 2], seasons); } [Fact] diff --git a/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs index 3ec3f70..0611b0d 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs @@ -360,7 +360,7 @@ public class TemplateEditingTests Assert.Equal(30, layer.Priority); Assert.False(layer.IsEnabled); var stored = LayerApplicability.FromJson(layer.ApplicabilityJson); - Assert.Equal(new[] { 5, 6 }, stored?.Weekdays); + Assert.Equal([5, 6], stored?.Weekdays); } [Fact] diff --git a/backend/tests/TeleWave.Application.Tests/Streaming/ListPublicChannelsTests.cs b/backend/tests/TeleWave.Application.Tests/Streaming/ListPublicChannelsTests.cs index 365e764..7f11132 100644 --- a/backend/tests/TeleWave.Application.Tests/Streaming/ListPublicChannelsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Streaming/ListPublicChannelsTests.cs @@ -47,10 +47,7 @@ public class ListPublicChannelsTests ); // Ненумерованный уходит в конец: на телевизоре порядок задаёт номер. - Assert.Equal( - new[] { "Первый", "Второй", "Аврора" }, - channels.Select(c => c.Name).ToArray() - ); + Assert.Equal(["Первый", "Второй", "Аврора"], channels.Select(c => c.Name).ToArray()); Assert.Null(channels[^1].Number); Assert.All(channels, c => Assert.Null(c.CurrentShowId)); } diff --git a/frontend/src/features/admin/media/episode-parse.ts b/frontend/src/features/admin/media/episode-parse.ts index 84170fe..3c4ff75 100644 --- a/frontend/src/features/admin/media/episode-parse.ts +++ b/frontend/src/features/admin/media/episode-parse.ts @@ -73,9 +73,11 @@ export function parseEpisodeName(name: string, opts?: ParseOptions): ParsedEpiso * Диапазон сезонов в имени: «Season.3-4», «S01-S03». Такая папка накрывает несколько сезонов, и * подставлять из неё один номер нельзя — честнее не угадывать вовсе. */ +// Разделитель вокруг тире — без самого дефиса: иначе «-» подходит и под разделитель, и под тире, +// и движок перебирает варианты впустую (у Sonar это «super-linear backtracking»). const SEASON_RANGES = [ - /(?:s|season|сезон[ы]?)[\s._-]*\d{1,2}[\s._-]*[-–][\s._-]*(?:s|season)?\d{1,2}/i, - /\d{1,2}[\s._-]*[-–][\s._-]*\d{1,2}[\s._-]*сезон/i, + /(?:s|season|сезоны?)[\s._-]*\d{1,2}[\s._]*[-–][\s._]*(?:s|season)?\d{1,2}/i, + /\d{1,2}[\s._]*[-–][\s._]*\d{1,2}[\s._-]*сезон/i, ] /** Сезон в имени одного сегмента пути: «S02», «Season 2», «Сезон 2», «2 сезон». */ diff --git a/frontend/src/features/admin/shows/ShowsPanel.tsx b/frontend/src/features/admin/shows/ShowsPanel.tsx index 0325384..77c507d 100644 --- a/frontend/src/features/admin/shows/ShowsPanel.tsx +++ b/frontend/src/features/admin/shows/ShowsPanel.tsx @@ -74,6 +74,24 @@ export function ShowsPanel() { const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.all }) const onError = useApiError() + // Отметки страницей и по одной. Вынесены из разметки: три вложенных стрелки прямо в onChange + // читаются хуже, чем имя действия. + const pageAllSelected = + pageItems.length > 0 && pageItems.every((show) => selected.includes(show.id)) + + const togglePage = (checked: boolean) => + setSelected((current) => { + const ids = pageItems.map((show) => show.id) + return checked + ? [...new Set([...current, ...ids])] + : current.filter((id) => !ids.includes(id)) + }) + + const toggleShow = (showId: string) => + setSelected((current) => + current.includes(showId) ? current.filter((id) => id !== showId) : [...current, showId], + ) + const createMutation = useMutation({ mutationFn: () => createShow({ @@ -184,14 +202,8 @@ export function ShowsPanel() { 0 && pageItems.every((s) => selected.includes(s.id))} - onChange={(e) => - setSelected((current) => - e.target.checked - ? [...new Set([...current, ...pageItems.map((s) => s.id)])] - : current.filter((id) => !pageItems.some((s) => s.id === id)), - ) - } + checked={pageAllSelected} + onChange={(e) => togglePage(e.target.checked)} /> - setSelected((current) => - current.includes(show.id) - ? current.filter((id) => id !== show.id) - : [...current, show.id], - ) - } + onChange={() => toggleShow(show.id)} />