From 66040a8841f6a842e76038abccb09ce09acb1f6f Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 26 Jul 2026 13:32:13 +0300 Subject: [PATCH] Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations. --- .../Endpoints/ChannelEndpoints.Overrides.cs | 62 - .../Endpoints/ChannelEndpoints.Shows.cs | 122 -- .../Endpoints/ChannelEndpoints.cs | 422 ++-- .../Endpoints/CollectionEndpoints.cs | 163 ++ .../TeleWave.Api/Endpoints/GenreEndpoints.cs | 77 + .../TeleWave.Api/Endpoints/GroupEndpoints.cs | 185 ++ .../Endpoints/JunctionEndpoints.cs | 168 ++ .../TeleWave.Api/Endpoints/ShowEndpoints.cs | 344 ++-- .../Endpoints/TemplateEndpoints.cs | 191 ++ backend/src/TeleWave.Api/Program.cs | 289 +-- backend/src/TeleWave.Api/appsettings.json | 4 +- .../AddChannelAd/AddChannelAdCommand.cs | 7 - .../AddChannelAdCommandHandler.cs | 35 - .../AddChannelShow/AddChannelShowCommand.cs | 13 - .../AddChannelShowCommandHandler.cs | 40 - .../AddChannelShowCommandValidator.cs | 12 - .../RenderBumperPreviewCommandHandler.cs | 251 +-- .../Broadcast/ChannelDtos.cs | 151 +- .../Broadcast/ChannelErrors.cs | 137 +- .../CreateChannelCommandHandler.cs | 7 + .../CreateProgrammingOverrideCommand.cs | 17 - ...CreateProgrammingOverrideCommandHandler.cs | 74 - ...eateProgrammingOverrideCommandValidator.cs | 14 - .../CreateOverride/OverrideShowInput.cs | 3 - .../DeleteProgrammingOverrideCommand.cs | 7 - ...DeleteProgrammingOverrideCommandHandler.cs | 26 - .../GetChannel/GetChannelQueryHandler.cs | 84 +- .../RegenerateChannelScheduleCommand.cs | 6 - ...RegenerateChannelScheduleCommandHandler.cs | 35 - .../RemoveChannelAd/RemoveChannelAdCommand.cs | 6 - .../RemoveChannelAdCommandHandler.cs | 26 - .../RemoveChannelShowCommand.cs | 7 - .../RemoveChannelShowCommandHandler.cs | 26 - .../Scheduling/ScheduleBumperResolver.cs | 206 -- .../Broadcast/Scheduling/ScheduleGenerator.cs | 304 --- .../Broadcast/Scheduling/SchedulerOptions.cs | 36 +- .../UpdateChannelSettingsCommand.cs | 48 +- .../UpdateChannelSettingsCommandHandler.cs | 98 +- .../UpdateChannelSettingsCommandValidator.cs | 33 +- .../UpdateChannelShowCommand.cs | 19 - .../UpdateChannelShowCommandHandler.cs | 39 - .../UpdateChannelShowCommandValidator.cs | 22 - .../UpdateChannelTimeCommand.cs | 15 + .../UpdateChannelTimeCommandHandler.cs | 38 + .../Common/Interfaces/IAppDbContext.cs | 82 +- .../AddCollectionShowCommand.cs | 6 + .../AddCollectionShowCommandHandler.cs | 29 + .../Library/Collections/CollectionDtos.cs | 39 + .../Library/Collections/CollectionErrors.cs | 21 + .../CreateCollectionCommand.cs | 7 + .../CreateCollectionCommandHandler.cs | 20 + .../CreateCollectionCommandValidator.cs | 12 + .../DeleteCollectionCommand.cs | 6 + .../DeleteCollectionCommandHandler.cs | 37 + .../GetCollection/GetCollectionQuery.cs | 6 + .../GetCollectionQueryHandler.cs | 68 + .../ListCollections/ListCollectionsQuery.cs | 5 + .../ListCollectionsQueryHandler.cs | 42 + .../RemoveCollectionShowCommand.cs | 6 + .../RemoveCollectionShowCommandHandler.cs | 26 + .../ReorderCollectionCommand.cs | 9 + .../ReorderCollectionCommandHandler.cs | 25 + .../SetCollectionPosterCommand.cs | 7 + .../SetCollectionPosterCommandHandler.cs | 33 + .../UpdateCollectionCommand.cs | 7 + .../UpdateCollectionCommandHandler.cs | 26 + .../UpdateCollectionCommandValidator.cs | 12 + .../DeleteShow/DeleteShowCommandHandler.cs | 15 +- .../Genres/CreateGenre/CreateGenreCommand.cs | 12 + .../CreateGenre/CreateGenreCommandHandler.cs | 36 + .../CreateGenreCommandValidator.cs | 13 + .../Genres/DeleteGenre/DeleteGenreCommand.cs | 6 + .../DeleteGenre/DeleteGenreCommandHandler.cs | 34 + .../Library/Genres/GenreAliasInput.cs | 21 + .../Library/Genres/GenreDto.cs | 13 + .../Library/Genres/GenreErrors.cs | 28 + .../Library/Genres/GenreMatcher.cs | 47 + .../Genres/ListGenres/ListGenresQuery.cs | 5 + .../ListGenres/ListGenresQueryHandler.cs | 40 + .../Genres/UpdateGenre/UpdateGenreCommand.cs | 11 + .../UpdateGenre/UpdateGenreCommandHandler.cs | 36 + .../UpdateGenreCommandValidator.cs | 13 + .../Library/GetShow/GetShowQueryHandler.cs | 36 +- .../Library/ListShows/ListShowsQuery.cs | 8 +- .../ListShows/ListShowsQueryHandler.cs | 144 +- .../SetShowGenres/SetShowGenresCommand.cs | 12 + .../SetShowGenresCommandHandler.cs | 37 + .../TeleWave.Application/Library/ShowDtos.cs | 13 +- .../ApplyShowMetadataCommandHandler.cs | 18 +- .../Metadata/MetadataModels.cs | 10 +- .../AddGroupElementsCommand.cs | 15 + .../AddGroupElementsCommandHandler.cs | 57 + .../Groups/CreateGroup/CreateGroupCommand.cs | 7 + .../CreateGroup/CreateGroupCommandHandler.cs | 17 + .../CreateGroupCommandValidator.cs | 12 + .../Groups/DeleteGroup/DeleteGroupCommand.cs | 6 + .../DeleteGroup/DeleteGroupCommandHandler.cs | 24 + .../FindGroupCandidatesQuery.cs | 11 + .../FindGroupCandidatesQueryHandler.cs | 139 ++ .../Groups/GetGroup/GetGroupQuery.cs | 6 + .../Groups/GetGroup/GetGroupQueryHandler.cs | 65 + .../Programming/Groups/GroupDtos.cs | 60 + .../Groups/GroupElementResolver.cs | 137 ++ .../Programming/Groups/GroupErrors.cs | 28 + .../Programming/Groups/GroupFilter.cs | 48 + .../Groups/GroupMembershipCleaner.cs | 43 + .../Programming/Groups/GroupStatsService.cs | 45 + .../Groups/ListGroups/ListGroupsQuery.cs | 5 + .../ListGroups/ListGroupsQueryHandler.cs | 32 + .../RemoveGroupItem/RemoveGroupItemCommand.cs | 6 + .../RemoveGroupItemCommandHandler.cs | 28 + .../ReorderGroup/ReorderGroupCommand.cs | 9 + .../ReorderGroupCommandHandler.cs | 25 + .../SetGroupItemWeightCommand.cs | 8 + .../SetGroupItemWeightCommandHandler.cs | 27 + .../Groups/UpdateGroup/UpdateGroupCommand.cs | 13 + .../UpdateGroup/UpdateGroupCommandHandler.cs | 24 + .../UpdateGroupCommandValidator.cs | 23 + .../ApplyChannelTemplateCommand.cs | 20 + .../ApplyChannelTemplateCommandHandler.cs | 36 + .../Programming/Planning/BumperResolver.cs | 192 ++ .../Planning/EffectiveGridBuilder.cs | 122 ++ .../Planning/GridScheduleGenerator.cs | 508 +++++ .../Programming/Planning/GroupExpander.cs | 171 ++ .../Templates/CreateSlot/CreateSlotCommand.cs | 13 + .../CreateSlot/CreateSlotCommandHandler.cs | 31 + .../Templates/DeleteSlot/DeleteSlotCommand.cs | 6 + .../DeleteSlot/DeleteSlotCommandHandler.cs | 19 + .../GetTemplate/GetChannelTemplateQuery.cs | 6 + .../GetChannelTemplateQueryHandler.cs | 100 + .../Templates/JunctionConditions.cs | 40 + .../Templates/Junctions/JunctionCommands.cs | 64 + .../Templates/Junctions/JunctionDtos.cs | 23 + .../Templates/Junctions/JunctionHandlers.cs | 297 +++ .../Templates/LayerApplicability.cs | 87 + .../Templates/Layers/LayerCommandHandlers.cs | 122 ++ .../Templates/Layers/LayerCommands.cs | 49 + .../Programming/Templates/RepeatSource.cs | 35 + .../Programming/Templates/SlotInput.cs | 59 + .../Programming/Templates/SlotStrategy.cs | 72 + .../Programming/Templates/SlotWriter.cs | 106 + .../Programming/Templates/TemplateDtos.cs | 52 + .../Programming/Templates/TemplateErrors.cs | 66 + .../Templates/UpdateSlot/UpdateSlotCommand.cs | 13 + .../UpdateSlot/UpdateSlotCommandHandler.cs | 24 + .../TeleWave.Domain/Broadcast/AdInsertion.cs | 11 - .../TeleWave.Domain/Broadcast/BlockMode.cs | 11 - .../src/TeleWave.Domain/Broadcast/Channel.cs | 140 +- .../TeleWave.Domain/Broadcast/ChannelAd.cs | 21 - .../TeleWave.Domain/Broadcast/ChannelShow.cs | 87 - .../Broadcast/ChannelShowHour.cs | 32 - .../TeleWave.Domain/Broadcast/OverrideMode.cs | 11 - .../Broadcast/OverrideRecurrence.cs | 11 - .../TeleWave.Domain/Broadcast/OverrideShow.cs | 21 - .../Broadcast/ProgrammingOverride.cs | 108 - .../Broadcast/ScheduleEntry.cs | 36 + .../Broadcast/ScheduleEntryKind.cs | 8 +- .../Broadcast/Scheduling/SchedulePlanner.cs | 352 ---- .../Scheduling/SchedulePlannerModels.cs | 143 -- .../src/TeleWave.Domain/Library/Collection.cs | 89 + .../TeleWave.Domain/Library/CollectionItem.cs | 25 + backend/src/TeleWave.Domain/Library/Genre.cs | 70 + .../src/TeleWave.Domain/Library/GenreAlias.cs | 46 + backend/src/TeleWave.Domain/Library/Show.cs | 26 +- .../TeleWave.Domain/Library/ShowAudience.cs | 25 +- .../src/TeleWave.Domain/Library/ShowGenre.cs | 23 + .../src/TeleWave.Domain/Library/ShowKind.cs | 14 +- .../TeleWave.Domain/Programming/Daypart.cs | 15 + .../TeleWave.Domain/Programming/GridLayer.cs | 112 + .../src/TeleWave.Domain/Programming/Group.cs | 127 ++ .../Programming/GroupElementKind.cs | 8 + .../TeleWave.Domain/Programming/GroupItem.cs | 49 + .../Programming/JunctionElement.cs | 102 + .../Programming/JunctionTemplate.cs | 70 + .../Programming/OverflowPolicy.cs | 18 + .../Programming/Planning/ElementSelector.cs | 151 ++ .../Programming/Planning/JunctionFiller.cs | 172 ++ .../Programming/Planning/PlanningModels.cs | 192 ++ .../Programming/Planning/SchedulePlanner.cs | 487 +++++ .../Programming/ScheduleTemplate.cs | 96 + .../src/TeleWave.Domain/Programming/Slot.cs | 142 ++ .../Programming/SlotBlockMode.cs | 18 + .../TeleWave.Domain/Programming/SlotKind.cs | 20 + .../TeleWave.Domain/Programming/SlotState.cs | 42 + .../Broadcast/MaintenanceBackgroundService.cs | 71 + .../Broadcast/SchedulingBackgroundService.cs | 180 +- .../DependencyInjection.cs | 347 ++-- .../Identity/DbInitializerExtensions.cs | 5 + .../Library/GenreSeeder.cs | 111 + .../Metadata/OmdbMetadataProvider.cs | 11 +- .../Metadata/TmdbMetadataProvider.cs | 24 +- .../20260726012618_AddGenres.Designer.cs | 1130 +++++++++++ .../Migrations/20260726012618_AddGenres.cs | 110 + ...260726013755_ShowAudienceScale.Designer.cs | 1130 +++++++++++ .../20260726013755_ShowAudienceScale.cs | 54 + .../20260726014301_AddCollections.Designer.cs | 1201 +++++++++++ .../20260726014301_AddCollections.cs | 82 + .../20260726014922_AddGroups.Designer.cs | 1284 ++++++++++++ .../Migrations/20260726014922_AddGroups.cs | 82 + ...0726020225_AddScheduleTemplate.Designer.cs | 1489 ++++++++++++++ .../20260726020225_AddScheduleTemplate.cs | 203 ++ ...60726072637_ScheduleEntryTrace.Designer.cs | 1495 ++++++++++++++ .../20260726072637_ScheduleEntryTrace.cs | 57 + ...60726100547_DropLegacyRotation.Designer.cs | 1288 ++++++++++++ .../20260726100547_DropLegacyRotation.cs | 202 ++ ...726102012_AddJunctionTemplates.Designer.cs | 1380 +++++++++++++ .../20260726102012_AddJunctionTemplates.cs | 116 ++ .../Migrations/AppDbContextModelSnapshot.cs | 738 +++++-- .../Persistence/AppDbContext.cs | 152 +- .../Configurations/ChannelConfiguration.cs | 194 +- .../Configurations/CollectionConfiguration.cs | 41 + .../Configurations/GenreConfiguration.cs | 50 + .../Configurations/GroupConfiguration.cs | 44 + .../JunctionTemplateConfiguration.cs | 38 + .../ScheduleEntryConfiguration.cs | 10 +- .../ScheduleTemplateConfiguration.cs | 74 + .../Configurations/ShowConfiguration.cs | 7 + .../Broadcast/ChannelHandlersTests.cs | 406 +--- .../Broadcast/QueryHandlersTests.cs | 463 ++--- .../Validators/ValidatorTests.cs | 230 +-- .../Broadcast/ChannelShowTests.cs | 89 - .../Broadcast/ChannelTests.cs | 298 ++- .../Broadcast/DomainRecordFactoryTests.cs | 97 +- .../Broadcast/ProgrammingOverrideTests.cs | 85 - .../Broadcast/SchedulePlannerTests.cs | 877 -------- .../Library/CollectionTests.cs | 106 + .../Library/ShowGenreTests.cs | 114 ++ .../Programming/GoldenScheduleTests.cs | 198 ++ .../Programming/GroupTests.cs | 104 + .../Programming/JunctionFillerTests.cs | 252 +++ .../Programming/SchedulePlannerTests.cs | 377 ++++ .../Programming/ScheduleTemplateTests.cs | 141 ++ .../ScheduleGeneratorIntegrationTests.cs | 207 -- docs/tv-scheduler-tasks.md | 846 ++++---- .../features/admin/channels/ChannelDetail.tsx | 383 ++-- frontend/src/features/admin/channels/api.ts | 489 +++-- .../admin/channels/components/AddAdForm.tsx | 50 - .../admin/channels/components/AddShowForm.tsx | 71 - .../admin/channels/components/BumperCard.tsx | 360 ++-- .../channels/components/ChannelShowRow.tsx | 183 -- .../channels/components/OverrideForm.tsx | 158 -- .../channels/components/ScheduleGrid.tsx | 203 ++ .../channels/components/SettingsCard.tsx | 253 +-- .../channels/components/SlotInspector.tsx | 408 ++++ .../admin/collections/CollectionDetail.tsx | 233 +++ .../admin/collections/CollectionsPanel.tsx | 145 ++ .../src/features/admin/collections/api.ts | 46 + .../src/features/admin/genres/GenresPanel.tsx | 287 +++ frontend/src/features/admin/genres/api.ts | 21 + .../features/admin/groups/DurationLabel.tsx | 13 + .../src/features/admin/groups/GroupDetail.tsx | 273 +++ .../admin/groups/GroupFilterPanel.tsx | 145 ++ .../src/features/admin/groups/GroupsPanel.tsx | 142 ++ frontend/src/features/admin/groups/api.ts | 69 + frontend/src/features/admin/groups/format.ts | 9 + .../admin/maintenance/MaintenancePanel.tsx | 2 +- .../admin/media/UploadToShowDialog.tsx | 2 +- .../src/features/admin/shows/ShowDetail.tsx | 29 +- .../features/admin/shows/ShowGenresField.tsx | 115 ++ .../src/features/admin/shows/ShowsPanel.tsx | 68 +- frontend/src/features/admin/shows/api.ts | 13 +- frontend/src/routeTree.gen.ts | 163 ++ frontend/src/routes/admin.tsx | 21 + .../admin/collections.$collectionId.tsx | 11 + .../src/routes/admin/collections.index.tsx | 4 + frontend/src/routes/admin/collections.tsx | 4 + frontend/src/routes/admin/genres.tsx | 4 + frontend/src/routes/admin/groups.$groupId.tsx | 9 + frontend/src/routes/admin/groups.index.tsx | 4 + frontend/src/routes/admin/groups.tsx | 4 + frontend/src/shared/api/types.ts | 771 ++++--- frontend/src/shared/lib/i18n.ts | 1796 ++++++++++------- 272 files changed, 27944 insertions(+), 8699 deletions(-) delete mode 100644 backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Overrides.cs delete mode 100644 backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Shows.cs create mode 100644 backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs create mode 100644 backend/src/TeleWave.Api/Endpoints/GenreEndpoints.cs create mode 100644 backend/src/TeleWave.Api/Endpoints/GroupEndpoints.cs create mode 100644 backend/src/TeleWave.Api/Endpoints/JunctionEndpoints.cs create mode 100644 backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommand.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommandHandler.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommand.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandHandler.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandValidator.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandValidator.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/CreateOverride/OverrideShowInput.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommand.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommandHandler.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommand.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommandHandler.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommand.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommandHandler.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommand.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommandHandler.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleBumperResolver.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommand.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandHandler.cs delete mode 100644 backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/UpdateChannelTime/UpdateChannelTimeCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/UpdateChannelTime/UpdateChannelTimeCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/AddCollectionShow/AddCollectionShowCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/AddCollectionShow/AddCollectionShowCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/CollectionDtos.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/CollectionErrors.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/CreateCollection/CreateCollectionCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/CreateCollection/CreateCollectionCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/CreateCollection/CreateCollectionCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/DeleteCollection/DeleteCollectionCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/DeleteCollection/DeleteCollectionCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/GetCollection/GetCollectionQuery.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/GetCollection/GetCollectionQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/ListCollections/ListCollectionsQuery.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/ListCollections/ListCollectionsQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/RemoveCollectionShow/RemoveCollectionShowCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/RemoveCollectionShow/RemoveCollectionShowCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/ReorderCollection/ReorderCollectionCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/ReorderCollection/ReorderCollectionCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/SetCollectionPoster/SetCollectionPosterCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/SetCollectionPoster/SetCollectionPosterCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/UpdateCollection/UpdateCollectionCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/UpdateCollection/UpdateCollectionCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/UpdateCollection/UpdateCollectionCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Library/Genres/CreateGenre/CreateGenreCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/Genres/CreateGenre/CreateGenreCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Genres/CreateGenre/CreateGenreCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Library/Genres/DeleteGenre/DeleteGenreCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/Genres/DeleteGenre/DeleteGenreCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Genres/GenreAliasInput.cs create mode 100644 backend/src/TeleWave.Application/Library/Genres/GenreDto.cs create mode 100644 backend/src/TeleWave.Application/Library/Genres/GenreErrors.cs create mode 100644 backend/src/TeleWave.Application/Library/Genres/GenreMatcher.cs create mode 100644 backend/src/TeleWave.Application/Library/Genres/ListGenres/ListGenresQuery.cs create mode 100644 backend/src/TeleWave.Application/Library/Genres/ListGenres/ListGenresQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Genres/UpdateGenre/UpdateGenreCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/Genres/UpdateGenre/UpdateGenreCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Genres/UpdateGenre/UpdateGenreCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Library/SetShowGenres/SetShowGenresCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/SetShowGenres/SetShowGenresCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/AddGroupElements/AddGroupElementsCommand.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/AddGroupElements/AddGroupElementsCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/CreateGroup/CreateGroupCommand.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/CreateGroup/CreateGroupCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/CreateGroup/CreateGroupCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommand.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/FindGroupCandidates/FindGroupCandidatesQuery.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/FindGroupCandidates/FindGroupCandidatesQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/GetGroup/GetGroupQuery.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/GetGroup/GetGroupQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/GroupDtos.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/GroupElementResolver.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/GroupFilter.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/GroupMembershipCleaner.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/GroupStatsService.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/ListGroups/ListGroupsQuery.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/ListGroups/ListGroupsQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/RemoveGroupItem/RemoveGroupItemCommand.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/RemoveGroupItem/RemoveGroupItemCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/ReorderGroup/ReorderGroupCommand.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/ReorderGroup/ReorderGroupCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/SetGroupItemWeight/SetGroupItemWeightCommand.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/SetGroupItemWeight/SetGroupItemWeightCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommand.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Programming/Planning/ApplyTemplate/ApplyChannelTemplateCommand.cs create mode 100644 backend/src/TeleWave.Application/Programming/Planning/ApplyTemplate/ApplyChannelTemplateCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs create mode 100644 backend/src/TeleWave.Application/Programming/Planning/EffectiveGridBuilder.cs create mode 100644 backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs create mode 100644 backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/CreateSlot/CreateSlotCommand.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/CreateSlot/CreateSlotCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/DeleteSlot/DeleteSlotCommand.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/DeleteSlot/DeleteSlotCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQuery.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/JunctionConditions.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionCommands.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionDtos.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionHandlers.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/LayerApplicability.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommands.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/RepeatSource.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/SlotInput.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/SlotStrategy.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/TemplateDtos.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/UpdateSlot/UpdateSlotCommand.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/UpdateSlot/UpdateSlotCommandHandler.cs delete mode 100644 backend/src/TeleWave.Domain/Broadcast/AdInsertion.cs delete mode 100644 backend/src/TeleWave.Domain/Broadcast/BlockMode.cs delete mode 100644 backend/src/TeleWave.Domain/Broadcast/ChannelAd.cs delete mode 100644 backend/src/TeleWave.Domain/Broadcast/ChannelShow.cs delete mode 100644 backend/src/TeleWave.Domain/Broadcast/ChannelShowHour.cs delete mode 100644 backend/src/TeleWave.Domain/Broadcast/OverrideMode.cs delete mode 100644 backend/src/TeleWave.Domain/Broadcast/OverrideRecurrence.cs delete mode 100644 backend/src/TeleWave.Domain/Broadcast/OverrideShow.cs delete mode 100644 backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs delete mode 100644 backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs delete mode 100644 backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs create mode 100644 backend/src/TeleWave.Domain/Library/Collection.cs create mode 100644 backend/src/TeleWave.Domain/Library/CollectionItem.cs create mode 100644 backend/src/TeleWave.Domain/Library/Genre.cs create mode 100644 backend/src/TeleWave.Domain/Library/GenreAlias.cs create mode 100644 backend/src/TeleWave.Domain/Library/ShowGenre.cs create mode 100644 backend/src/TeleWave.Domain/Programming/Daypart.cs create mode 100644 backend/src/TeleWave.Domain/Programming/GridLayer.cs create mode 100644 backend/src/TeleWave.Domain/Programming/Group.cs create mode 100644 backend/src/TeleWave.Domain/Programming/GroupElementKind.cs create mode 100644 backend/src/TeleWave.Domain/Programming/GroupItem.cs create mode 100644 backend/src/TeleWave.Domain/Programming/JunctionElement.cs create mode 100644 backend/src/TeleWave.Domain/Programming/JunctionTemplate.cs create mode 100644 backend/src/TeleWave.Domain/Programming/OverflowPolicy.cs create mode 100644 backend/src/TeleWave.Domain/Programming/Planning/ElementSelector.cs create mode 100644 backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs create mode 100644 backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs create mode 100644 backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs create mode 100644 backend/src/TeleWave.Domain/Programming/ScheduleTemplate.cs create mode 100644 backend/src/TeleWave.Domain/Programming/Slot.cs create mode 100644 backend/src/TeleWave.Domain/Programming/SlotBlockMode.cs create mode 100644 backend/src/TeleWave.Domain/Programming/SlotKind.cs create mode 100644 backend/src/TeleWave.Domain/Programming/SlotState.cs create mode 100644 backend/src/TeleWave.Infrastructure/Broadcast/MaintenanceBackgroundService.cs create mode 100644 backend/src/TeleWave.Infrastructure/Library/GenreSeeder.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726012618_AddGenres.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726012618_AddGenres.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726013755_ShowAudienceScale.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726013755_ShowAudienceScale.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726014301_AddCollections.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726014301_AddCollections.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726014922_AddGroups.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726014922_AddGroups.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726020225_AddScheduleTemplate.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726020225_AddScheduleTemplate.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726072637_ScheduleEntryTrace.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726072637_ScheduleEntryTrace.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726100547_DropLegacyRotation.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726100547_DropLegacyRotation.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726102012_AddJunctionTemplates.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726102012_AddJunctionTemplates.cs create mode 100644 backend/src/TeleWave.Infrastructure/Persistence/Configurations/CollectionConfiguration.cs create mode 100644 backend/src/TeleWave.Infrastructure/Persistence/Configurations/GenreConfiguration.cs create mode 100644 backend/src/TeleWave.Infrastructure/Persistence/Configurations/GroupConfiguration.cs create mode 100644 backend/src/TeleWave.Infrastructure/Persistence/Configurations/JunctionTemplateConfiguration.cs create mode 100644 backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleTemplateConfiguration.cs delete mode 100644 backend/tests/TeleWave.Domain.Tests/Broadcast/ChannelShowTests.cs delete mode 100644 backend/tests/TeleWave.Domain.Tests/Broadcast/ProgrammingOverrideTests.cs delete mode 100644 backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs create mode 100644 backend/tests/TeleWave.Domain.Tests/Library/CollectionTests.cs create mode 100644 backend/tests/TeleWave.Domain.Tests/Library/ShowGenreTests.cs create mode 100644 backend/tests/TeleWave.Domain.Tests/Programming/GoldenScheduleTests.cs create mode 100644 backend/tests/TeleWave.Domain.Tests/Programming/GroupTests.cs create mode 100644 backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs create mode 100644 backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs create mode 100644 backend/tests/TeleWave.Domain.Tests/Programming/ScheduleTemplateTests.cs delete mode 100644 backend/tests/TeleWave.Integration.Tests/ScheduleGeneratorIntegrationTests.cs delete mode 100644 frontend/src/features/admin/channels/components/AddAdForm.tsx delete mode 100644 frontend/src/features/admin/channels/components/AddShowForm.tsx delete mode 100644 frontend/src/features/admin/channels/components/ChannelShowRow.tsx delete mode 100644 frontend/src/features/admin/channels/components/OverrideForm.tsx create mode 100644 frontend/src/features/admin/channels/components/ScheduleGrid.tsx create mode 100644 frontend/src/features/admin/channels/components/SlotInspector.tsx create mode 100644 frontend/src/features/admin/collections/CollectionDetail.tsx create mode 100644 frontend/src/features/admin/collections/CollectionsPanel.tsx create mode 100644 frontend/src/features/admin/collections/api.ts create mode 100644 frontend/src/features/admin/genres/GenresPanel.tsx create mode 100644 frontend/src/features/admin/genres/api.ts create mode 100644 frontend/src/features/admin/groups/DurationLabel.tsx create mode 100644 frontend/src/features/admin/groups/GroupDetail.tsx create mode 100644 frontend/src/features/admin/groups/GroupFilterPanel.tsx create mode 100644 frontend/src/features/admin/groups/GroupsPanel.tsx create mode 100644 frontend/src/features/admin/groups/api.ts create mode 100644 frontend/src/features/admin/groups/format.ts create mode 100644 frontend/src/features/admin/shows/ShowGenresField.tsx create mode 100644 frontend/src/routes/admin/collections.$collectionId.tsx create mode 100644 frontend/src/routes/admin/collections.index.tsx create mode 100644 frontend/src/routes/admin/collections.tsx create mode 100644 frontend/src/routes/admin/genres.tsx create mode 100644 frontend/src/routes/admin/groups.$groupId.tsx create mode 100644 frontend/src/routes/admin/groups.index.tsx create mode 100644 frontend/src/routes/admin/groups.tsx diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Overrides.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Overrides.cs deleted file mode 100644 index 538323d..0000000 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Overrides.cs +++ /dev/null @@ -1,62 +0,0 @@ -using LiteCqrs; -using TeleWave.Api.Common; -using TeleWave.Application.Broadcast.CreateOverride; -using TeleWave.Application.Broadcast.DeleteOverride; -using TeleWave.Domain.Broadcast; - -namespace TeleWave.Api.Endpoints; - -/// Эндпоинты временных override'ов / марафонов канала (разовые и еженедельные). -public static partial class ChannelEndpoints -{ - private static async Task CreateOverride( - Guid id, - CreateOverrideBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new CreateProgrammingOverrideCommand( - id, - body.Mode, - body.Recurrence, - body.StartsAtUtc, - body.EndsAtUtc, - body.DayOfWeek, - body.StartMinute, - body.EndMinute, - body.Shows - ), - cancellationToken - ); - return result.IsSuccess - ? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value)) - : result.ToHttpResult(); - } - - private static async Task DeleteOverride( - Guid id, - Guid overrideId, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new DeleteProgrammingOverrideCommand(id, overrideId), - cancellationToken - ); - return result.ToHttpResult(); - } -} - -public sealed record CreateOverrideBody( - OverrideMode Mode, - OverrideRecurrence Recurrence, - DateTimeOffset? StartsAtUtc, - DateTimeOffset? EndsAtUtc, - int? DayOfWeek, - int? StartMinute, - int? EndMinute, - IReadOnlyList Shows -); diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Shows.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Shows.cs deleted file mode 100644 index 205c88d..0000000 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Shows.cs +++ /dev/null @@ -1,122 +0,0 @@ -using LiteCqrs; -using TeleWave.Api.Common; -using TeleWave.Application.Broadcast.AddChannelAd; -using TeleWave.Application.Broadcast.AddChannelShow; -using TeleWave.Application.Broadcast.RemoveChannelAd; -using TeleWave.Application.Broadcast.RemoveChannelShow; -using TeleWave.Application.Broadcast.UpdateChannelShow; -using TeleWave.Domain.Broadcast; - -namespace TeleWave.Api.Endpoints; - -/// Эндпоинты канала: шоу в ротации и рекламный пул. -public static partial class ChannelEndpoints -{ - private static async Task AddShow( - Guid id, - AddChannelShowBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new AddChannelShowCommand( - id, - body.ShowId, - body.Weight, - body.BlockMode, - body.BlockValue - ), - cancellationToken - ); - return result.IsSuccess - ? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value)) - : result.ToHttpResult(); - } - - private static async Task UpdateShow( - Guid id, - Guid channelShowId, - UpdateChannelShowBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new UpdateChannelShowCommand( - id, - channelShowId, - body.Weight, - body.BlockMode, - body.BlockValue, - body.IsEnabled, - body.PreferredWeightMultiplier, - body.PreferredHours ?? [] - ), - cancellationToken - ); - return result.ToHttpResult(); - } - - private static async Task RemoveShow( - Guid id, - Guid channelShowId, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new RemoveChannelShowCommand(id, channelShowId), - cancellationToken - ); - return result.ToHttpResult(); - } - - private static async Task AddAd( - Guid id, - AddChannelAdBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new AddChannelAdCommand(id, body.MediaAssetId), - cancellationToken - ); - return result.IsSuccess - ? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value)) - : result.ToHttpResult(); - } - - private static async Task RemoveAd( - Guid id, - Guid channelAdId, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new RemoveChannelAdCommand(id, channelAdId), - cancellationToken - ); - return result.ToHttpResult(); - } -} - -public sealed record AddChannelShowBody( - Guid ShowId, - int Weight, - BlockMode BlockMode, - int BlockValue -); - -public sealed record UpdateChannelShowBody( - int Weight, - BlockMode BlockMode, - int BlockValue, - bool IsEnabled, - int PreferredWeightMultiplier, - IReadOnlyList PreferredHours -); - -public sealed record AddChannelAdBody(Guid MediaAssetId); diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs index a14c77b..a38d96a 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs @@ -1,217 +1,205 @@ -using LiteCqrs; -using TeleWave.Api.Common; -using TeleWave.Application.Broadcast; -using TeleWave.Application.Broadcast.CreateChannel; -using TeleWave.Application.Broadcast.GetChannel; -using TeleWave.Application.Broadcast.GetSchedule; -using TeleWave.Application.Broadcast.ListChannels; -using TeleWave.Application.Broadcast.RegenerateSchedule; -using TeleWave.Application.Broadcast.UpdateChannelSettings; -using TeleWave.Domain.Broadcast; -using TeleWave.Infrastructure.Identity; - -namespace TeleWave.Api.Endpoints; - -/// -/// Админ-эндпоинты канала. Реализация разнесена по partial-файлам под-ресурсов: -/// ChannelEndpoints.Shows.cs (шоу+реклама), ChannelEndpoints.Bumpers.cs -/// (блоки/подблоки/файлы/preview), ChannelEndpoints.Overrides.cs (override'ы). Здесь — -/// регистрация всех маршрутов и хендлеры уровня канала (создание/список/настройки/расписание). -/// -public static partial class ChannelEndpoints -{ - public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app) - { - var admin = app.MapGroup("/api/admin/channels") - .WithTags("Admin.Channels") - .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); - - admin.MapPost("", CreateChannel).Produces(StatusCodes.Status201Created); - admin.MapGet("", ListChannels).Produces>(); - admin.MapGet("/{id:guid}", GetChannel).Produces(); - admin - .MapPut("/{id:guid}/settings", UpdateSettings) - .Produces(StatusCodes.Status204NoContent); - - admin - .MapPost("/{id:guid}/shows", AddShow) - .Produces(StatusCodes.Status201Created); - admin - .MapPut("/{id:guid}/shows/{channelShowId:guid}", UpdateShow) - .Produces(StatusCodes.Status204NoContent); - admin - .MapDelete("/{id:guid}/shows/{channelShowId:guid}", RemoveShow) - .Produces(StatusCodes.Status204NoContent); - - admin - .MapPost("/{id:guid}/ads", AddAd) - .Produces(StatusCodes.Status201Created); - admin - .MapDelete("/{id:guid}/ads/{channelAdId:guid}", RemoveAd) - .Produces(StatusCodes.Status204NoContent); - - admin - .MapPost("/{id:guid}/bumper/templates", AddBumperTemplate) - .Produces(StatusCodes.Status201Created); - admin - .MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate) - .Produces(StatusCodes.Status204NoContent); - admin - .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate) - .Produces(StatusCodes.Status204NoContent); - admin - .MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio) - .Produces(StatusCodes.Status204NoContent); - admin - .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio) - .Produces(StatusCodes.Status204NoContent); - admin - .MapPut( - "/{id:guid}/bumper/templates/{templateId:guid}/background", - SetTemplateBackground - ) - .Produces(StatusCodes.Status204NoContent); - admin - .MapDelete( - "/{id:guid}/bumper/templates/{templateId:guid}/background", - ClearTemplateBackground - ) - .Produces(StatusCodes.Status204NoContent); - - admin - .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview) - .Produces(StatusCodes.Status204NoContent); - admin.MapGet( - "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8", - PreviewPlaylist - ); - admin.MapGet( - "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}", - PreviewSegment - ); - - admin - .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant) - .Produces(StatusCodes.Status201Created); - admin - .MapPut( - "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}", - UpdateBumperVariant - ) - .Produces(StatusCodes.Status204NoContent); - admin - .MapDelete( - "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}", - RemoveBumperVariant - ) - .Produces(StatusCodes.Status204NoContent); - - admin - .MapPost("/{id:guid}/overrides", CreateOverride) - .Produces(StatusCodes.Status201Created); - admin - .MapDelete("/{id:guid}/overrides/{overrideId:guid}", DeleteOverride) - .Produces(StatusCodes.Status204NoContent); - - admin.MapPost("/{id:guid}/regenerate", Regenerate).Produces(StatusCodes.Status204NoContent); - admin - .MapGet("/{id:guid}/schedule", GetSchedule) - .Produces>(); - - return app; - } - - private static async Task CreateChannel( - CreateChannelCommand command, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(command, cancellationToken); - return result.IsSuccess - ? Results.Created( - $"/api/admin/channels/{result.Value}", - new CreatedIdResponse(result.Value) - ) - : result.ToHttpResult(); - } - - private static async Task ListChannels( - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new ListChannelsQuery(), cancellationToken); - return Results.Ok(result); - } - - private static async Task GetChannel( - Guid id, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new GetChannelQuery(id), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task UpdateSettings( - Guid id, - UpdateChannelSettingsBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new UpdateChannelSettingsCommand( - id, - body.Name, - body.IsEnabled, - body.AdInsertion, - body.AdsPerBreak, - body.BumpersEnabled, - body.Bumper, - body.FillerAssetId - ), - cancellationToken - ); - return result.ToHttpResult(); - } - - private static async Task Regenerate( - Guid id, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new RegenerateChannelScheduleCommand(id), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task GetSchedule( - Guid id, - DateTimeOffset? from, - DateTimeOffset? to, - ISender sender, - CancellationToken cancellationToken - ) - { - var fromUtc = from ?? DateTimeOffset.UtcNow; - var toUtc = to ?? fromUtc.AddDays(1); - var result = await sender.Send( - new GetChannelScheduleQuery(id, fromUtc, toUtc), - cancellationToken - ); - return result.ToHttpResult(); - } -} - -public sealed record UpdateChannelSettingsBody( - string Name, - bool IsEnabled, - AdInsertion AdInsertion, - int AdsPerBreak, - bool BumpersEnabled, - BumperSettingsInput Bumper, - Guid? FillerAssetId -); +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Broadcast.CreateChannel; +using TeleWave.Application.Broadcast.GetChannel; +using TeleWave.Application.Broadcast.GetSchedule; +using TeleWave.Application.Broadcast.ListChannels; +using TeleWave.Application.Broadcast.UpdateChannelSettings; +using TeleWave.Application.Broadcast.UpdateChannelTime; +using TeleWave.Domain.Broadcast; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +/// +/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки — +/// в ChannelEndpoints.Bumpers.cs. Что и когда идёт в эфире, задаёт шаблон сетки +/// (TemplateEndpoints). +/// +public static partial class ChannelEndpoints +{ + public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/channels") + .WithTags("Admin.Channels") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapPost("", CreateChannel).Produces(StatusCodes.Status201Created); + admin.MapGet("", ListChannels).Produces>(); + admin.MapGet("/{id:guid}", GetChannel).Produces(); + admin + .MapPut("/{id:guid}/settings", UpdateSettings) + .Produces(StatusCodes.Status204NoContent); + admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent); + + admin + .MapPost("/{id:guid}/bumper/templates", AddBumperTemplate) + .Produces(StatusCodes.Status201Created); + admin + .MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate) + .Produces(StatusCodes.Status204NoContent); + admin + .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate) + .Produces(StatusCodes.Status204NoContent); + admin + .MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio) + .Produces(StatusCodes.Status204NoContent); + admin + .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio) + .Produces(StatusCodes.Status204NoContent); + admin + .MapPut( + "/{id:guid}/bumper/templates/{templateId:guid}/background", + SetTemplateBackground + ) + .Produces(StatusCodes.Status204NoContent); + admin + .MapDelete( + "/{id:guid}/bumper/templates/{templateId:guid}/background", + ClearTemplateBackground + ) + .Produces(StatusCodes.Status204NoContent); + + admin + .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview) + .Produces(StatusCodes.Status204NoContent); + admin.MapGet( + "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8", + PreviewPlaylist + ); + admin.MapGet( + "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}", + PreviewSegment + ); + + admin + .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant) + .Produces(StatusCodes.Status201Created); + admin + .MapPut( + "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}", + UpdateBumperVariant + ) + .Produces(StatusCodes.Status204NoContent); + admin + .MapDelete( + "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}", + RemoveBumperVariant + ) + .Produces(StatusCodes.Status204NoContent); + + admin + .MapGet("/{id:guid}/schedule", GetSchedule) + .Produces>(); + + return app; + } + + private static async Task CreateChannel( + CreateChannelCommand command, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + return result.IsSuccess + ? Results.Created( + $"/api/admin/channels/{result.Value}", + new CreatedIdResponse(result.Value) + ) + : result.ToHttpResult(); + } + + private static async Task ListChannels( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new ListChannelsQuery(), cancellationToken); + return Results.Ok(result); + } + + private static async Task GetChannel( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new GetChannelQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UpdateTime( + Guid id, + UpdateChannelTimeBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new UpdateChannelTimeCommand( + id, + body.Number, + body.UtcOffsetMinutes, + body.DayStartTime + ), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task UpdateSettings( + Guid id, + UpdateChannelSettingsBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new UpdateChannelSettingsCommand( + id, + body.Name, + body.IsEnabled, + body.BumpersEnabled, + body.Bumper, + body.FillerAssetId + ), + cancellationToken + ); + return result.ToHttpResult(); + } + + + private static async Task GetSchedule( + Guid id, + DateTimeOffset? from, + DateTimeOffset? to, + ISender sender, + CancellationToken cancellationToken + ) + { + var fromUtc = from ?? DateTimeOffset.UtcNow; + var toUtc = to ?? fromUtc.AddDays(1); + var result = await sender.Send( + new GetChannelScheduleQuery(id, fromUtc, toUtc), + cancellationToken + ); + return result.ToHttpResult(); + } +} + +/// Номер канала и его время: смещение от UTC и начало вещательных суток. +public sealed record UpdateChannelTimeBody( + int? Number, + int UtcOffsetMinutes, + TimeOnly DayStartTime +); + +public sealed record UpdateChannelSettingsBody( + string Name, + bool IsEnabled, + bool BumpersEnabled, + BumperSettingsInput Bumper, + Guid? FillerAssetId +); diff --git a/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs new file mode 100644 index 0000000..8dcc52f --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs @@ -0,0 +1,163 @@ +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Library.Collections; +using TeleWave.Application.Library.Collections.AddCollectionShow; +using TeleWave.Application.Library.Collections.CreateCollection; +using TeleWave.Application.Library.Collections.DeleteCollection; +using TeleWave.Application.Library.Collections.GetCollection; +using TeleWave.Application.Library.Collections.ListCollections; +using TeleWave.Application.Library.Collections.RemoveCollectionShow; +using TeleWave.Application.Library.Collections.ReorderCollection; +using TeleWave.Application.Library.Collections.SetCollectionPoster; +using TeleWave.Application.Library.Collections.UpdateCollection; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +public static class CollectionEndpoints +{ + public static IEndpointRouteBuilder MapCollectionEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/collections") + .WithTags("Admin.Collections") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", ListCollections).Produces>(); + admin.MapGet("/{id:guid}", GetCollection).Produces(); + admin + .MapPost("", CreateCollection) + .Produces(StatusCodes.Status201Created); + admin.MapPut("/{id:guid}", UpdateCollection).Produces(StatusCodes.Status204NoContent); + admin.MapDelete("/{id:guid}", DeleteCollection).Produces(StatusCodes.Status204NoContent); + admin.MapPost("/{id:guid}/shows", AddShow).Produces(StatusCodes.Status204NoContent); + admin + .MapDelete("/{id:guid}/shows/{showId:guid}", RemoveShow) + .Produces(StatusCodes.Status204NoContent); + admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent); + admin.MapPut("/{id:guid}/poster-image", SetPoster).Produces(StatusCodes.Status204NoContent); + + return app; + } + + private static async Task ListCollections( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new ListCollectionsQuery(), cancellationToken); + return Results.Ok(result); + } + + private static async Task GetCollection( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new GetCollectionQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task CreateCollection( + CreateCollectionCommand command, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + return result.IsSuccess + ? Results.Created( + $"/api/admin/collections/{result.Value}", + new CreatedIdResponse(result.Value) + ) + : result.ToHttpResult(); + } + + private static async Task UpdateCollection( + Guid id, + UpdateCollectionBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new UpdateCollectionCommand(id, body.Name, body.Description), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task DeleteCollection( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteCollectionCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task AddShow( + Guid id, + CollectionShowBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new AddCollectionShowCommand(id, body.ShowId), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task RemoveShow( + Guid id, + Guid showId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new RemoveCollectionShowCommand(id, showId), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task Reorder( + Guid id, + ReorderCollectionBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new ReorderCollectionCommand(id, body.ShowIdsInOrder), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task SetPoster( + Guid id, + CollectionPosterBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new SetCollectionPosterCommand(id, body.ImageId), + cancellationToken + ); + return result.ToHttpResult(); + } +} + +public sealed record UpdateCollectionBody(string Name, string? Description); + +public sealed record CollectionShowBody(Guid ShowId); + +public sealed record ReorderCollectionBody(IReadOnlyList ShowIdsInOrder); + +public sealed record CollectionPosterBody(Guid? ImageId); diff --git a/backend/src/TeleWave.Api/Endpoints/GenreEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/GenreEndpoints.cs new file mode 100644 index 0000000..87bea0e --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/GenreEndpoints.cs @@ -0,0 +1,77 @@ +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Library.Genres; +using TeleWave.Application.Library.Genres.CreateGenre; +using TeleWave.Application.Library.Genres.DeleteGenre; +using TeleWave.Application.Library.Genres.ListGenres; +using TeleWave.Application.Library.Genres.UpdateGenre; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +public static class GenreEndpoints +{ + public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/genres") + .WithTags("Admin.Genres") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", ListGenres).Produces>(); + admin.MapPost("", CreateGenre).Produces(StatusCodes.Status201Created); + admin.MapPut("/{id:guid}", UpdateGenre).Produces(StatusCodes.Status204NoContent); + admin.MapDelete("/{id:guid}", DeleteGenre).Produces(StatusCodes.Status204NoContent); + + return app; + } + + private static async Task ListGenres( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new ListGenresQuery(), cancellationToken); + return Results.Ok(result); + } + + private static async Task CreateGenre( + CreateGenreCommand command, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + return result.IsSuccess + ? Results.Created( + $"/api/admin/genres/{result.Value}", + new CreatedIdResponse(result.Value) + ) + : result.ToHttpResult(); + } + + private static async Task UpdateGenre( + Guid id, + UpdateGenreBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new UpdateGenreCommand(id, body.Name, body.SortOrder, body.Aliases), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task DeleteGenre( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteGenreCommand(id), cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record UpdateGenreBody(string Name, int SortOrder, IReadOnlyList? Aliases); diff --git a/backend/src/TeleWave.Api/Endpoints/GroupEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/GroupEndpoints.cs new file mode 100644 index 0000000..3ade5bb --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/GroupEndpoints.cs @@ -0,0 +1,185 @@ +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Groups.AddGroupElements; +using TeleWave.Application.Programming.Groups.CreateGroup; +using TeleWave.Application.Programming.Groups.DeleteGroup; +using TeleWave.Application.Programming.Groups.FindGroupCandidates; +using TeleWave.Application.Programming.Groups.GetGroup; +using TeleWave.Application.Programming.Groups.ListGroups; +using TeleWave.Application.Programming.Groups.RemoveGroupItem; +using TeleWave.Application.Programming.Groups.ReorderGroup; +using TeleWave.Application.Programming.Groups.SetGroupItemWeight; +using TeleWave.Application.Programming.Groups.UpdateGroup; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +public static class GroupEndpoints +{ + public static IEndpointRouteBuilder MapGroupEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/groups") + .WithTags("Admin.Groups") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", ListGroups).Produces>(); + admin.MapGet("/{id:guid}", GetGroup).Produces(); + admin.MapPost("", CreateGroup).Produces(StatusCodes.Status201Created); + admin.MapPut("/{id:guid}", UpdateGroup).Produces(StatusCodes.Status204NoContent); + admin.MapDelete("/{id:guid}", DeleteGroup).Produces(StatusCodes.Status204NoContent); + + // Подбор по правилу набора: правило можно передать в теле, чтобы крутить его до сохранения. + admin.MapPost("/{id:guid}/candidates", FindCandidates) + .Produces>(); + + admin.MapPost("/{id:guid}/items", AddElements).Produces(); + admin + .MapDelete("/{id:guid}/items/{itemId:guid}", RemoveItem) + .Produces(StatusCodes.Status204NoContent); + admin + .MapPut("/{id:guid}/items/{itemId:guid}/weight", SetWeight) + .Produces(StatusCodes.Status204NoContent); + admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent); + + return app; + } + + private static async Task ListGroups(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListGroupsQuery(), cancellationToken); + return Results.Ok(result); + } + + private static async Task GetGroup( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new GetGroupQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task CreateGroup( + CreateGroupCommand command, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + return result.IsSuccess + ? Results.Created( + $"/api/admin/groups/{result.Value}", + new CreatedIdResponse(result.Value) + ) + : result.ToHttpResult(); + } + + private static async Task UpdateGroup( + Guid id, + UpdateGroupBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new UpdateGroupCommand(id, body.Name, body.Description, body.Filter), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task DeleteGroup( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteGroupCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task FindCandidates( + Guid id, + FindCandidatesBody? body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new FindGroupCandidatesQuery(id, body?.Filter), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task AddElements( + Guid id, + AddGroupElementsBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new AddGroupElementsCommand(id, body.Elements), + cancellationToken + ); + return result.IsSuccess + ? Results.Ok(new AddedCountResponse(result.Value)) + : result.ToHttpResult(); + } + + private static async Task RemoveItem( + Guid id, + Guid itemId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new RemoveGroupItemCommand(id, itemId), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task SetWeight( + Guid id, + Guid itemId, + GroupItemWeightBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new SetGroupItemWeightCommand(id, itemId, body.Weight), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task Reorder( + Guid id, + ReorderGroupBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new ReorderGroupCommand(id, body.ItemIdsInOrder), + cancellationToken + ); + return result.ToHttpResult(); + } +} + +public sealed record UpdateGroupBody(string Name, string? Description, GroupFilter? Filter); + +public sealed record FindCandidatesBody(GroupFilter? Filter); + +public sealed record AddGroupElementsBody(IReadOnlyList Elements); + +public sealed record GroupItemWeightBody(int Weight); + +public sealed record ReorderGroupBody(IReadOnlyList ItemIdsInOrder); + +/// Сколько позиций реально добавлено (уже входящие в группу пропускаются). +public sealed record AddedCountResponse(int Added); diff --git a/backend/src/TeleWave.Api/Endpoints/JunctionEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/JunctionEndpoints.cs new file mode 100644 index 0000000..d5ed8dd --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/JunctionEndpoints.cs @@ -0,0 +1,168 @@ +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Programming.Templates.Junctions; +using TeleWave.Domain.Programming; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +/// +/// Шаблоны стыков канала: что играет между программами. Как и правка сетки, эфира не двигают — +/// помечают шаблон канала изменённым, а хвост пересобирается применением. +/// +public static class JunctionEndpoints +{ + public static IEndpointRouteBuilder MapJunctionEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin") + .WithTags("Admin.Junctions") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin + .MapGet("/channels/{channelId:guid}/junctions", List) + .Produces>(); + admin + .MapPost("/channels/{channelId:guid}/junctions", Create) + .Produces(StatusCodes.Status201Created); + admin.MapPut("/junctions/{junctionId:guid}", Rename).Produces(StatusCodes.Status204NoContent); + admin + .MapDelete("/junctions/{junctionId:guid}", Delete) + .Produces(StatusCodes.Status204NoContent); + + admin + .MapPost("/junctions/{junctionId:guid}/elements", AddElement) + .Produces(StatusCodes.Status201Created); + admin + .MapPut("/junctions/{junctionId:guid}/elements/{elementId:guid}", UpdateElement) + .Produces(StatusCodes.Status204NoContent); + admin + .MapDelete("/junctions/{junctionId:guid}/elements/{elementId:guid}", RemoveElement) + .Produces(StatusCodes.Status204NoContent); + admin + .MapPut("/junctions/{junctionId:guid}/order", Reorder) + .Produces(StatusCodes.Status204NoContent); + + return app; + } + + private static async Task List( + Guid channelId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new ListJunctionsQuery(channelId), cancellationToken); + return Results.Ok(result); + } + + private static async Task Create( + Guid channelId, + JunctionNameBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new CreateJunctionCommand(channelId, body.Name), + cancellationToken + ); + return result.IsSuccess + ? Results.Created( + $"/api/admin/junctions/{result.Value}", + new CreatedIdResponse(result.Value) + ) + : result.ToHttpResult(); + } + + private static async Task Rename( + Guid junctionId, + JunctionNameBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new RenameJunctionCommand(junctionId, body.Name), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task Delete( + Guid junctionId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteJunctionCommand(junctionId), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task AddElement( + Guid junctionId, + JunctionElementKindBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new AddJunctionElementCommand(junctionId, body.Kind), + cancellationToken + ); + return result.IsSuccess + ? Results.Created( + $"/api/admin/junctions/{junctionId}", + new CreatedIdResponse(result.Value) + ) + : result.ToHttpResult(); + } + + private static async Task UpdateElement( + Guid junctionId, + Guid elementId, + JunctionElementInput input, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new UpdateJunctionElementCommand(junctionId, elementId, input), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task RemoveElement( + Guid junctionId, + Guid elementId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new RemoveJunctionElementCommand(junctionId, elementId), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task Reorder( + Guid junctionId, + ReorderJunctionBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new ReorderJunctionCommand(junctionId, body.ElementIdsInOrder), + cancellationToken + ); + return result.ToHttpResult(); + } +} + +public sealed record JunctionNameBody(string Name); + +public sealed record JunctionElementKindBody(JunctionElementKind Kind); + +public sealed record ReorderJunctionBody(IReadOnlyList ElementIdsInOrder); diff --git a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs index 1a0680b..7a7fc61 100644 --- a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs @@ -1,162 +1,182 @@ -using LiteCqrs; -using TeleWave.Api.Common; -using TeleWave.Application.Library; -using TeleWave.Application.Library.AddEpisode; -using TeleWave.Application.Library.CreateShow; -using TeleWave.Application.Library.DeleteShow; -using TeleWave.Application.Library.GetShow; -using TeleWave.Application.Library.ListShows; -using TeleWave.Application.Library.RemoveEpisode; -using TeleWave.Application.Library.RenameShow; -using TeleWave.Application.Library.SetShowAudience; -using TeleWave.Application.Library.SetShowOriginalName; -using TeleWave.Domain.Library; -using TeleWave.Infrastructure.Identity; - -namespace TeleWave.Api.Endpoints; - -public static class ShowEndpoints -{ - public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app) - { - var admin = app.MapGroup("/api/admin/shows") - .WithTags("Admin.Shows") - .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); - - admin.MapPost("", CreateShow).Produces(StatusCodes.Status201Created); - admin.MapGet("", ListShows).Produces>(); - admin.MapGet("/{id:guid}", GetShow).Produces(); - admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent); - admin - .MapPut("/{id:guid}/original-name", SetOriginalName) - .Produces(StatusCodes.Status204NoContent); - admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent); - admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent); - admin - .MapPost("/{id:guid}/episodes", AddEpisode) - .Produces(StatusCodes.Status201Created); - admin - .MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode) - .Produces(StatusCodes.Status204NoContent); - - return app; - } - - private static async Task CreateShow( - CreateShowCommand command, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(command, cancellationToken); - return result.IsSuccess - ? Results.Created( - $"/api/admin/shows/{result.Value}", - new CreatedIdResponse(result.Value) - ) - : result.ToHttpResult(); - } - - private static async Task ListShows( - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new ListShowsQuery(), cancellationToken); - return Results.Ok(result); - } - - private static async Task GetShow( - Guid id, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new GetShowQuery(id), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task Rename( - Guid id, - RenameShowBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task SetOriginalName( - Guid id, - SetShowOriginalNameBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new SetShowOriginalNameCommand(id, body.OriginalName), - cancellationToken - ); - return result.ToHttpResult(); - } - - private static async Task SetAudience( - Guid id, - SetShowAudienceBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new SetShowAudienceCommand(id, body.Audience), - cancellationToken - ); - return result.ToHttpResult(); - } - - private static async Task DeleteShow( - Guid id, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new DeleteShowCommand(id), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task AddEpisode( - Guid id, - AddEpisodeBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new AddEpisodeCommand(id, body.MediaAssetId), - cancellationToken - ); - return result.IsSuccess - ? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value)) - : result.ToHttpResult(); - } - - private static async Task RemoveEpisode( - Guid id, - Guid episodeId, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken); - return result.ToHttpResult(); - } -} - -public sealed record AddEpisodeBody(Guid MediaAssetId); - -public sealed record RenameShowBody(string Name); - -public sealed record SetShowOriginalNameBody(string? OriginalName); - -public sealed record SetShowAudienceBody(ShowAudience Audience); +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Library; +using TeleWave.Application.Library.AddEpisode; +using TeleWave.Application.Library.CreateShow; +using TeleWave.Application.Library.DeleteShow; +using TeleWave.Application.Library.GetShow; +using TeleWave.Application.Library.ListShows; +using TeleWave.Application.Library.RemoveEpisode; +using TeleWave.Application.Library.RenameShow; +using TeleWave.Application.Library.SetShowAudience; +using TeleWave.Application.Library.SetShowGenres; +using TeleWave.Application.Library.SetShowOriginalName; +using TeleWave.Domain.Library; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +public static class ShowEndpoints +{ + public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/shows") + .WithTags("Admin.Shows") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapPost("", CreateShow).Produces(StatusCodes.Status201Created); + admin.MapGet("", ListShows).Produces>(); + admin.MapGet("/{id:guid}", GetShow).Produces(); + admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent); + admin + .MapPut("/{id:guid}/original-name", SetOriginalName) + .Produces(StatusCodes.Status204NoContent); + admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent); + admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent); + admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent); + admin + .MapPost("/{id:guid}/episodes", AddEpisode) + .Produces(StatusCodes.Status201Created); + admin + .MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode) + .Produces(StatusCodes.Status204NoContent); + + return app; + } + + private static async Task CreateShow( + CreateShowCommand command, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + return result.IsSuccess + ? Results.Created( + $"/api/admin/shows/{result.Value}", + new CreatedIdResponse(result.Value) + ) + : result.ToHttpResult(); + } + + private static async Task ListShows( + ISender sender, + CancellationToken cancellationToken, + Guid? genreId = null, + bool interstitials = false + ) + { + var result = await sender.Send(new ListShowsQuery(genreId, interstitials), cancellationToken); + return Results.Ok(result); + } + + private static async Task GetShow( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new GetShowQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task Rename( + Guid id, + RenameShowBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task SetOriginalName( + Guid id, + SetShowOriginalNameBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new SetShowOriginalNameCommand(id, body.OriginalName), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task SetAudience( + Guid id, + SetShowAudienceBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new SetShowAudienceCommand(id, body.Audience), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task SetGenres( + Guid id, + SetShowGenresBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task DeleteShow( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteShowCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task AddEpisode( + Guid id, + AddEpisodeBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new AddEpisodeCommand(id, body.MediaAssetId), + cancellationToken + ); + return result.IsSuccess + ? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value)) + : result.ToHttpResult(); + } + + private static async Task RemoveEpisode( + Guid id, + Guid episodeId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record AddEpisodeBody(Guid MediaAssetId); + +public sealed record RenameShowBody(string Name); + +public sealed record SetShowOriginalNameBody(string? OriginalName); + +public sealed record SetShowAudienceBody(ShowAudience Audience); + +public sealed record SetShowGenresBody(IReadOnlyList GenreIds, Guid? PrimaryGenreId); diff --git a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs new file mode 100644 index 0000000..8acc6cc --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs @@ -0,0 +1,191 @@ +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Programming.Planning.ApplyTemplate; +using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Programming.Templates.CreateSlot; +using TeleWave.Application.Programming.Templates.DeleteSlot; +using TeleWave.Application.Programming.Templates.GetTemplate; +using TeleWave.Application.Programming.Templates.Layers; +using TeleWave.Application.Programming.Templates.UpdateSlot; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +/// +/// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон +/// изменённым, а хвост пересобирается отдельной командой применения. +/// +public static class TemplateEndpoints +{ + public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin") + .WithTags("Admin.Templates") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin + .MapGet("/channels/{channelId:guid}/template", GetTemplate) + .Produces(); + admin + .MapPut("/templates/{templateId:guid}", UpdateTemplate) + .Produces(StatusCodes.Status204NoContent); + + // Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает. + admin + .MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate) + .Produces(); + + admin + .MapPost("/templates/{templateId:guid}/layers", CreateLayer) + .Produces(StatusCodes.Status201Created); + admin.MapPut("/layers/{layerId:guid}", UpdateLayer).Produces(StatusCodes.Status204NoContent); + admin + .MapDelete("/layers/{layerId:guid}", DeleteLayer) + .Produces(StatusCodes.Status204NoContent); + + admin + .MapPost("/layers/{layerId:guid}/slots", CreateSlot) + .Produces(StatusCodes.Status201Created); + admin.MapPut("/slots/{slotId:guid}", UpdateSlot).Produces(StatusCodes.Status204NoContent); + admin.MapDelete("/slots/{slotId:guid}", DeleteSlot).Produces(StatusCodes.Status204NoContent); + + return app; + } + + private static async Task GetTemplate( + Guid channelId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ApplyTemplate( + Guid channelId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new ApplyChannelTemplateCommand(channelId), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task UpdateTemplate( + Guid templateId, + UpdateTemplateBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new UpdateTemplateCommand( + templateId, + body.Name, + body.FallbackGroupId, + body.DefaultJunctionId + ), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task CreateLayer( + Guid templateId, + CreateLayerBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new CreateLayerCommand(templateId, body.Name, body.Priority), + cancellationToken + ); + return result.IsSuccess + ? Results.Created($"/api/admin/layers/{result.Value}", new CreatedIdResponse(result.Value)) + : result.ToHttpResult(); + } + + private static async Task UpdateLayer( + Guid layerId, + UpdateLayerBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new UpdateLayerCommand( + layerId, + body.Name, + body.Priority, + body.Applicability, + body.IsEnabled + ), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task DeleteLayer( + Guid layerId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task CreateSlot( + Guid layerId, + SlotInput input, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new CreateSlotCommand(layerId, input), cancellationToken); + return result.IsSuccess + ? Results.Created($"/api/admin/slots/{result.Value}", new CreatedIdResponse(result.Value)) + : result.ToHttpResult(); + } + + private static async Task UpdateSlot( + Guid slotId, + SlotInput input, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new UpdateSlotCommand(slotId, input), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task DeleteSlot( + Guid slotId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteSlotCommand(slotId), cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record UpdateTemplateBody( + string Name, + Guid? FallbackGroupId, + Guid? DefaultJunctionId +); + +public sealed record CreateLayerBody(string Name, int Priority); + +public sealed record UpdateLayerBody( + string Name, + int Priority, + LayerApplicability? Applicability, + bool IsEnabled +); diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index f04628e..1d5f07a 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -1,142 +1,147 @@ -using System.Net; -using System.Text.Json.Serialization; -using System.Threading.RateLimiting; -using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.RateLimiting; -using Scalar.AspNetCore; -using Serilog; -using TeleWave.Api.Common; -using TeleWave.Api.Endpoints; -using TeleWave.Application; -using TeleWave.Infrastructure; -using TeleWave.Infrastructure.Identity; -using TeleWave.Infrastructure.Persistence; - -var builder = WebApplication.CreateBuilder(args); - -// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки -// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints. -builder.WebHost.ConfigureKestrel(options => - options.Limits.MaxRequestBodySize = - builder.Configuration.GetValue("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024 -); - -// Структурное логирование (Serilog), конфигурация из appsettings/env. -builder.Services.AddSerilog( - (services, configuration) => - configuration - .ReadFrom.Configuration(builder.Configuration) - .ReadFrom.Services(services) - .Enrich.FromLogContext() -); - -// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно -// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя -// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback; -// для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example). -builder.Services.Configure(options => -{ - options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - - foreach ( - var proxy in builder - .Configuration.GetSection("ForwardedHeaders:KnownProxies") - .Get() - ?? [] - ) - options.KnownProxies.Add(IPAddress.Parse(proxy)); - - foreach ( - var network in builder - .Configuration.GetSection("ForwardedHeaders:KnownNetworks") - .Get() - ?? [] - ) - { - var parts = network.Split('/'); - options.KnownIPNetworks.Add( - new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1])) - ); - } -}); - -builder.Services.AddHttpContextAccessor(); -builder.Services.AddApplication(); -builder.Services.AddInfrastructure(builder.Configuration); - -var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20); -builder.Services.AddRateLimiter(options => -{ - // Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый - // непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех. - options.AddPolicy( - RateLimiting.AuthPolicy, - httpContext => - RateLimitPartition.GetFixedWindowLimiter( - httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", - _ => new FixedWindowRateLimiterOptions - { - PermitLimit = authPermitLimit, - Window = TimeSpan.FromMinutes(1), - QueueLimit = 0, - } - ) - ); - options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; -}); - -// Энумы сериализуются строками, не числами — самодокументируемый JSON. -builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()) -); - -builder.Services.AddProblemDetails(); -builder.Services.AddOpenApi(); -builder.Services.AddHealthChecks().AddDbContextCheck(); - -var app = builder.Build(); - -// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте. -await app.Services.ApplyMigrationsAsync(); -await app.Services.SeedDataAsync(); - -app.UseForwardedHeaders(); -app.UseSerilogRequestLogging(); -app.UseExceptionHandler(); - -app.UseRateLimiter(); - -app.UseAuthentication(); -app.UseAuthorization(); - -// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене -// не раскрывать полную карту эндпоинтов без необходимости. -if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false)) -{ - app.MapOpenApi(); - app.MapScalarApiReference(); -} - -app.MapHealthChecks("/health"); - -app.MapAuthEndpoints(); -app.MapRoleEndpoints(); -app.MapAdminUserEndpoints(); -app.MapMediaEndpoints(); -app.MapShowEndpoints(); -app.MapChannelEndpoints(); -app.MapStreamingEndpoints(); -app.MapMaintenanceEndpoints(); -app.MapSettingsEndpoints(); -app.MapMetadataEndpoints(); -app.MapImageEndpoints(); - -// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. -app.UseDefaultFiles(); -app.UseStaticFiles(); -app.MapFallbackToFile("index.html"); - -app.Run(); - -/// Делает неявный класс Program доступным для WebApplicationFactory<Program> в интеграционных тестах. -public partial class Program; +using System.Net; +using System.Text.Json.Serialization; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; +using Scalar.AspNetCore; +using Serilog; +using TeleWave.Api.Common; +using TeleWave.Api.Endpoints; +using TeleWave.Application; +using TeleWave.Infrastructure; +using TeleWave.Infrastructure.Identity; +using TeleWave.Infrastructure.Persistence; + +var builder = WebApplication.CreateBuilder(args); + +// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки +// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints. +builder.WebHost.ConfigureKestrel(options => + options.Limits.MaxRequestBodySize = + builder.Configuration.GetValue("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024 +); + +// Структурное логирование (Serilog), конфигурация из appsettings/env. +builder.Services.AddSerilog( + (services, configuration) => + configuration + .ReadFrom.Configuration(builder.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() +); + +// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно +// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя +// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback; +// для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example). +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + + foreach ( + var proxy in builder + .Configuration.GetSection("ForwardedHeaders:KnownProxies") + .Get() + ?? [] + ) + options.KnownProxies.Add(IPAddress.Parse(proxy)); + + foreach ( + var network in builder + .Configuration.GetSection("ForwardedHeaders:KnownNetworks") + .Get() + ?? [] + ) + { + var parts = network.Split('/'); + options.KnownIPNetworks.Add( + new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1])) + ); + } +}); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(builder.Configuration); + +var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20); +builder.Services.AddRateLimiter(options => +{ + // Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый + // непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех. + options.AddPolicy( + RateLimiting.AuthPolicy, + httpContext => + RateLimitPartition.GetFixedWindowLimiter( + httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = authPermitLimit, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + } + ) + ); + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; +}); + +// Энумы сериализуются строками, не числами — самодокументируемый JSON. +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()) +); + +builder.Services.AddProblemDetails(); +builder.Services.AddOpenApi(); +builder.Services.AddHealthChecks().AddDbContextCheck(); + +var app = builder.Build(); + +// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте. +await app.Services.ApplyMigrationsAsync(); +await app.Services.SeedDataAsync(); + +app.UseForwardedHeaders(); +app.UseSerilogRequestLogging(); +app.UseExceptionHandler(); + +app.UseRateLimiter(); + +app.UseAuthentication(); +app.UseAuthorization(); + +// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене +// не раскрывать полную карту эндпоинтов без необходимости. +if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false)) +{ + app.MapOpenApi(); + app.MapScalarApiReference(); +} + +app.MapHealthChecks("/health"); + +app.MapAuthEndpoints(); +app.MapRoleEndpoints(); +app.MapAdminUserEndpoints(); +app.MapMediaEndpoints(); +app.MapShowEndpoints(); +app.MapGenreEndpoints(); +app.MapCollectionEndpoints(); +app.MapGroupEndpoints(); +app.MapTemplateEndpoints(); +app.MapJunctionEndpoints(); +app.MapChannelEndpoints(); +app.MapStreamingEndpoints(); +app.MapMaintenanceEndpoints(); +app.MapSettingsEndpoints(); +app.MapMetadataEndpoints(); +app.MapImageEndpoints(); + +// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. +app.UseDefaultFiles(); +app.UseStaticFiles(); +app.MapFallbackToFile("index.html"); + +app.Run(); + +/// Делает неявный класс Program доступным для WebApplicationFactory<Program> в интеграционных тестах. +public partial class Program; diff --git a/backend/src/TeleWave.Api/appsettings.json b/backend/src/TeleWave.Api/appsettings.json index 38b1feb..8ac69ee 100644 --- a/backend/src/TeleWave.Api/appsettings.json +++ b/backend/src/TeleWave.Api/appsettings.json @@ -21,8 +21,8 @@ "MinFreeSpaceBytes": 10737418240 }, "Scheduler": { - "HorizonDays": 3, - "RetentionHours": 24, + "HorizonDays": 7, + "RetentionDays": 90, "TickMinutes": 30 }, "Media": { diff --git a/backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommand.cs b/backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommand.cs deleted file mode 100644 index 8cab3ee..0000000 --- a/backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.AddChannelAd; - -public sealed record AddChannelAdCommand(Guid ChannelId, Guid MediaAssetId) - : ICommand>; diff --git a/backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommandHandler.cs deleted file mode 100644 index a806da5..0000000 --- a/backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommandHandler.cs +++ /dev/null @@ -1,35 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.AddChannelAd; - -public sealed class AddChannelAdCommandHandler(IAppDbContext dbContext) - : ICommandHandler> -{ - public async Task> Handle( - AddChannelAdCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext - .Channels.Include(c => c.Ads) - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var assetExists = await dbContext.MediaAssets.AnyAsync( - a => a.Id == command.MediaAssetId, - cancellationToken - ); - if (!assetExists) - return Result.Failure(ChannelErrors.AssetNotFound); - - if (channel.HasAd(command.MediaAssetId)) - return Result.Failure(ChannelErrors.AdAlreadyAdded); - - var ad = channel.AddAd(command.MediaAssetId); - return Result.Success(ad.Id); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommand.cs b/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommand.cs deleted file mode 100644 index f58ba74..0000000 --- a/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommand.cs +++ /dev/null @@ -1,13 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; -using TeleWave.Domain.Broadcast; - -namespace TeleWave.Application.Broadcast.AddChannelShow; - -public sealed record AddChannelShowCommand( - Guid ChannelId, - Guid ShowId, - int Weight, - BlockMode BlockMode, - int BlockValue -) : ICommand>; diff --git a/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandHandler.cs deleted file mode 100644 index 546d0ca..0000000 --- a/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandHandler.cs +++ /dev/null @@ -1,40 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.AddChannelShow; - -public sealed class AddChannelShowCommandHandler(IAppDbContext dbContext) - : ICommandHandler> -{ - public async Task> Handle( - AddChannelShowCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext - .Channels.Include(c => c.Shows) - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var showExists = await dbContext.Shows.AnyAsync( - s => s.Id == command.ShowId, - cancellationToken - ); - if (!showExists) - return Result.Failure(ChannelErrors.ShowNotFound); - - if (channel.HasShow(command.ShowId)) - return Result.Failure(ChannelErrors.ShowAlreadyAdded); - - var channelShow = channel.AddShow( - command.ShowId, - command.Weight, - command.BlockMode, - command.BlockValue - ); - return Result.Success(channelShow.Id); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandValidator.cs deleted file mode 100644 index a9158bb..0000000 --- a/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandValidator.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FluentValidation; - -namespace TeleWave.Application.Broadcast.AddChannelShow; - -public sealed class AddChannelShowCommandValidator : AbstractValidator -{ - public AddChannelShowCommandValidator() - { - RuleFor(x => x.Weight).InclusiveBetween(1, 1000); - RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs index 3cfb486..9e96a91 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs @@ -1,123 +1,128 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; -using TeleWave.Application.Streaming; -using TeleWave.Domain.Broadcast; - -namespace TeleWave.Application.Broadcast.Bumpers; - -public sealed class RenderBumperPreviewCommandHandler( - IAppDbContext dbContext, - IBumperRenderer renderer, - IBumperTemplateStorage storage, - IImageStore imageStore, - IOptions bumperOptions, - IOptions streamingOptions -) : ICommandHandler -{ - private readonly BumperOptions _bumper = bumperOptions.Value; - private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); - - /// Длительность заставки без загруженного звука (сек) — как в генераторе. - private const int DefaultBumperDurationSeconds = 8; - - public async Task Handle( - RenderBumperPreviewCommand query, - CancellationToken cancellationToken - ) - { - var channel = await dbContext - .Channels.AsNoTracking() - .Include(c => c.BumperTemplates) - .ThenInclude(t => t.Variants) - .Include(c => c.Shows) - .AsSplitQuery() - .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId); - if (template is null) - return Result.Failure(ChannelErrors.BumperTemplateNotFound); - - var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken); - var fontFile = - channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans; - - // Фон блока — из общего реестра по id (общий для всех подблоков). - string? backgroundPath = null; - if (template.BackgroundImageId is { } bgId) - { - var bgExt = await dbContext - .Images.AsNoTracking() - .Where(i => i.Id == bgId) - .Select(i => i.FileExtension) - .FirstOrDefaultAsync(cancellationToken); - if (bgExt is not null) - backgroundPath = imageStore.ResolvePath(bgId, bgExt); - } - - var seconds = template.AudioDurationSeconds is { } d and > 0 - ? d - : DefaultBumperDurationSeconds; - var aligned = (int)( - Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds - ); - var audioPath = storage.AudioPath(template.Id, template.AudioExtension); - - // Рендерим каждый подблок в свой ассет-превью (id по подблоку). - foreach (var variant in template.Variants.OrderBy(v => v.Position)) - { - var free = variant.Kind == BumperTextKind.Free; - var spec = new BumperRenderSpec( - aligned, - _bumper.Width, - _bumper.Height, - template.BackgroundColor, - template.BackgroundColor2, - template.AccentColor, - template.TextColor, - fontFile, - free ? "" : variant.NowLabel, - free ? "" : fromName, - free ? "" : variant.NextLabel, - free ? "" : toName, - backgroundPath, - audioPath, - // Постер зависит от конкретного «следующего» шоу — в превью не подставляем. - null, - free, - variant.Line1, - variant.Line2 - ); - await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken); - } - - return Result.Success(); - } - - /// Примерные названия «из/в» — берём первые два шоу канала, иначе заглушки. - private async Task<(string From, string To)> SampleNamesAsync( - Channel channel, - CancellationToken cancellationToken - ) - { - var showIds = channel.Shows.Select(s => s.ShowId).Distinct().Take(2).ToList(); - var names = - showIds.Count == 0 - ? [] - : await dbContext - .Shows.AsNoTracking() - .Where(s => showIds.Contains(s.Id)) - .Select(s => s.Name) - .Take(2) - .ToListAsync(cancellationToken); - - return ( - names.ElementAtOrDefault(0) ?? "Первое шоу", - names.ElementAtOrDefault(1) ?? "Второе шоу" - ); - } -} +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Streaming; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed class RenderBumperPreviewCommandHandler( + IAppDbContext dbContext, + IBumperRenderer renderer, + IBumperTemplateStorage storage, + IImageStore imageStore, + IOptions bumperOptions, + IOptions streamingOptions +) : ICommandHandler +{ + private readonly BumperOptions _bumper = bumperOptions.Value; + private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); + + /// Длительность заставки без загруженного звука (сек) — как в генераторе. + private const int DefaultBumperDurationSeconds = 8; + + public async Task Handle( + RenderBumperPreviewCommand query, + CancellationToken cancellationToken + ) + { + var channel = await dbContext + .Channels.AsNoTracking() + .Include(c => c.BumperTemplates) + .ThenInclude(t => t.Variants) + .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId); + if (template is null) + return Result.Failure(ChannelErrors.BumperTemplateNotFound); + + var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken); + var fontFile = + channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans; + + // Фон блока — из общего реестра по id (общий для всех подблоков). + string? backgroundPath = null; + if (template.BackgroundImageId is { } bgId) + { + var bgExt = await dbContext + .Images.AsNoTracking() + .Where(i => i.Id == bgId) + .Select(i => i.FileExtension) + .FirstOrDefaultAsync(cancellationToken); + if (bgExt is not null) + backgroundPath = imageStore.ResolvePath(bgId, bgExt); + } + + var seconds = template.AudioDurationSeconds is { } d and > 0 + ? d + : DefaultBumperDurationSeconds; + var aligned = (int)( + Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds + ); + var audioPath = storage.AudioPath(template.Id, template.AudioExtension); + + // Рендерим каждый подблок в свой ассет-превью (id по подблоку). + foreach (var variant in template.Variants.OrderBy(v => v.Position)) + { + var free = variant.Kind == BumperTextKind.Free; + var spec = new BumperRenderSpec( + aligned, + _bumper.Width, + _bumper.Height, + template.BackgroundColor, + template.BackgroundColor2, + template.AccentColor, + template.TextColor, + fontFile, + free ? "" : variant.NowLabel, + free ? "" : fromName, + free ? "" : variant.NextLabel, + free ? "" : toName, + backgroundPath, + audioPath, + // Постер зависит от конкретного «следующего» шоу — в превью не подставляем. + null, + free, + variant.Line1, + variant.Line2 + ); + await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken); + } + + return Result.Success(); + } + + /// + /// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала: + /// так превью показывает реальные названия этого канала, а не случайные из библиотеки. + /// + private async Task<(string From, string To)> SampleNamesAsync( + Channel channel, + CancellationToken cancellationToken + ) + { + var names = await ( + from slot in dbContext.Slots.AsNoTracking() + join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id + join item in dbContext.GroupItems.AsNoTracking() + on slot.GroupId equals item.GroupId + join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id + where layer.TemplateId == channel.TemplateId + && item.ElementKind == GroupElementKind.Show + select show.Name + ) + .Distinct() + .Take(2) + .ToListAsync(cancellationToken); + + return ( + names.ElementAtOrDefault(0) ?? "Первое шоу", + names.ElementAtOrDefault(1) ?? "Второе шоу" + ); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs index 2a7c627..4583658 100644 --- a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs +++ b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs @@ -1,92 +1,59 @@ -using TeleWave.Domain.Broadcast; - -namespace TeleWave.Application.Broadcast; - -public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled); - -public sealed record ChannelShowDto( - Guid Id, - Guid ShowId, - string ShowName, - int Weight, - BlockMode BlockMode, - int BlockValue, - bool IsEnabled, - int NextEpisodeIndex, - int PreferredWeightMultiplier, - IReadOnlyList PreferredHours -); - -/// Окно предпочтительных часов [StartHour, EndHour) суток (UTC). -public sealed record HourWindowDto(int StartHour, int EndHour); - -public sealed record ChannelAdDto(Guid Id, Guid MediaAssetId, string? AssetName, int Position); - -public sealed record OverrideShowDto(Guid ShowId, string ShowName, int Weight); - -public sealed record ProgrammingOverrideDto( - Guid Id, - OverrideMode Mode, - OverrideRecurrence Recurrence, - DateTimeOffset? StartsAtUtc, - DateTimeOffset? EndsAtUtc, - int? DayOfWeek, - int? StartMinute, - int? EndMinute, - IReadOnlyList Shows -); - -/// Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках). -public sealed record BumperSettingsDto( - BumperFont Font, - int MinIntervalMinutes, - BumperSelection Selection, - double ShowChangeChance, - double EpisodeChangeChance -); - -/// Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока. -public sealed record BumperTextVariantDto( - Guid Id, - int Position, - string Name, - BumperTextKind Kind, - string NowLabel, - string NextLabel, - string Line1, - string Line2, - BumperTrigger Trigger, - int Weight -); - -/// Блок заставки: своё оформление + звук + подблоки. — длина звука (сек). -public sealed record BumperTemplateDto( - Guid Id, - int Position, - bool IsDefault, - string Name, - string BackgroundColor, - string BackgroundColor2, - string AccentColor, - string TextColor, - Guid? BackgroundImageId, - bool HasAudio, - double? AudioDurationSeconds, - IReadOnlyList Variants -); - -public sealed record ChannelDto( - Guid Id, - string Name, - string Slug, - bool IsEnabled, - AdInsertion AdInsertion, - int AdsPerBreak, - bool BumpersEnabled, - BumperSettingsDto Bumper, - IReadOnlyList BumperTemplates, - Guid? FillerAssetId, - IReadOnlyList Shows, - IReadOnlyList Ads, - IReadOnlyList Overrides -); +using TeleWave.Domain.Broadcast; + +namespace TeleWave.Application.Broadcast; + +public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled); + +/// Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках). +public sealed record BumperSettingsDto( + BumperFont Font, + int MinIntervalMinutes, + BumperSelection Selection, + double ShowChangeChance, + double EpisodeChangeChance +); + +/// Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока. +public sealed record BumperTextVariantDto( + Guid Id, + int Position, + string Name, + BumperTextKind Kind, + string NowLabel, + string NextLabel, + string Line1, + string Line2, + BumperTrigger Trigger, + int Weight +); + +/// Блок заставки: своё оформление + звук + подблоки. — длина звука (сек). +public sealed record BumperTemplateDto( + Guid Id, + int Position, + bool IsDefault, + string Name, + string BackgroundColor, + string BackgroundColor2, + string AccentColor, + string TextColor, + Guid? BackgroundImageId, + bool HasAudio, + double? AudioDurationSeconds, + IReadOnlyList Variants +); + +public sealed record ChannelDto( + Guid Id, + string Name, + string Slug, + bool IsEnabled, + int? Number, + int UtcOffsetMinutes, + TimeOnly DayStartTime, + Guid? TemplateId, + bool BumpersEnabled, + BumperSettingsDto Bumper, + IReadOnlyList BumperTemplates, + Guid? FillerAssetId +); diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs b/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs index 121e930..7ac083c 100644 --- a/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs +++ b/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs @@ -1,83 +1,54 @@ -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast; - -public static class ChannelErrors -{ - public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден."); - - public static readonly Error DuplicateSlug = Error.Conflict( - "Channels.DuplicateSlug", - "Канал с таким slug уже существует." - ); - - public static readonly Error ShowAlreadyAdded = Error.Conflict( - "Channels.ShowAlreadyAdded", - "Это шоу уже добавлено в канал." - ); - - public static readonly Error ShowNotFound = Error.NotFound( - "Channels.ShowNotFound", - "Шоу не найдено в библиотеке." - ); - - public static readonly Error ChannelShowNotFound = Error.NotFound( - "Channels.ChannelShowNotFound", - "Шоу не найдено в канале." - ); - - public static readonly Error AdAlreadyAdded = Error.Conflict( - "Channels.AdAlreadyAdded", - "Этот ролик уже в пуле рекламы канала." - ); - - public static readonly Error AdNotFound = Error.NotFound( - "Channels.AdNotFound", - "Реклама не найдена в пуле канала." - ); - - public static readonly Error BumperTemplateNotFound = Error.NotFound( - "Channels.BumperTemplateNotFound", - "Блок заставки не найден." - ); - - public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation( - "Channels.CannotRemoveDefaultBumperTemplate", - "Дефолтный блок заставки удалить нельзя." - ); - - public static readonly Error BumperTextVariantNotFound = Error.NotFound( - "Channels.BumperTextVariantNotFound", - "Подблок заставки не найден." - ); - - public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation( - "Channels.CannotRemoveLastBumperTextVariant", - "Нельзя удалить последний подблок — нужен хотя бы один." - ); - - public static readonly Error AssetNotFound = Error.NotFound( - "Channels.AssetNotFound", - "Медиа-ассет не найден." - ); - - public static readonly Error InvalidBumperFile = Error.Validation( - "Channels.InvalidBumperFile", - "Недопустимый файл заставки (формат или размер)." - ); - - public static readonly Error OverrideNotFound = Error.NotFound( - "Channels.OverrideNotFound", - "Override не найден." - ); - - public static readonly Error InvalidOverrideWindow = Error.Validation( - "Channels.InvalidOverrideWindow", - "Окончание override должно быть позже начала." - ); - - public static readonly Error OverrideNeedsShow = Error.Validation( - "Channels.OverrideNeedsShow", - "Override должен ссылаться хотя бы на одно шоу." - ); -} +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast; + +public static class ChannelErrors +{ + public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден."); + + public static readonly Error NumberTaken = Error.Conflict( + "Channels.NumberTaken", + "Канал с таким номером уже есть." + ); + + public static readonly Error TemplateNotFound = Error.NotFound( + "Channels.TemplateNotFound", + "У канала нет шаблона сетки." + ); + + public static readonly Error DuplicateSlug = Error.Conflict( + "Channels.DuplicateSlug", + "Канал с таким slug уже существует." + ); + + public static readonly Error BumperTemplateNotFound = Error.NotFound( + "Channels.BumperTemplateNotFound", + "Блок заставки не найден." + ); + + public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation( + "Channels.CannotRemoveDefaultBumperTemplate", + "Дефолтный блок заставки удалить нельзя." + ); + + public static readonly Error BumperTextVariantNotFound = Error.NotFound( + "Channels.BumperTextVariantNotFound", + "Подблок заставки не найден." + ); + + public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation( + "Channels.CannotRemoveLastBumperTextVariant", + "Нельзя удалить последний подблок — нужен хотя бы один." + ); + + public static readonly Error AssetNotFound = Error.NotFound( + "Channels.AssetNotFound", + "Медиа-ассет не найден." + ); + + public static readonly Error InvalidBumperFile = Error.Validation( + "Channels.InvalidBumperFile", + "Недопустимый файл заставки (формат или размер)." + ); + +} diff --git a/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommandHandler.cs index eece1b0..ce13a69 100644 --- a/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommandHandler.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Programming; namespace TeleWave.Application.Broadcast.CreateChannel; @@ -22,7 +23,13 @@ public sealed class CreateChannelCommandHandler(IAppDbContext dbContext) return Result.Failure(ChannelErrors.DuplicateSlug); var channel = Channel.Create(command.Name, command.Slug, DateTimeOffset.UtcNow); + + // Канал без шаблона вещать не может, поэтому шаблон с фоновым слоем заводится сразу. + var template = ScheduleTemplate.Create(channel.Id, command.Name); + channel.SetTemplate(template.Id); + dbContext.Channels.Add(channel); + dbContext.ScheduleTemplates.Add(template); return Result.Success(channel.Id); } } diff --git a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs deleted file mode 100644 index fb0b975..0000000 --- a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs +++ /dev/null @@ -1,17 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; -using TeleWave.Domain.Broadcast; - -namespace TeleWave.Application.Broadcast.CreateOverride; - -public sealed record CreateProgrammingOverrideCommand( - Guid ChannelId, - OverrideMode Mode, - OverrideRecurrence Recurrence, - DateTimeOffset? StartsAtUtc, - DateTimeOffset? EndsAtUtc, - int? DayOfWeek, - int? StartMinute, - int? EndMinute, - IReadOnlyList Shows -) : ICommand>; diff --git a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs deleted file mode 100644 index 1658fd1..0000000 --- a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs +++ /dev/null @@ -1,74 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; -using TeleWave.Domain.Broadcast; - -namespace TeleWave.Application.Broadcast.CreateOverride; - -public sealed class CreateProgrammingOverrideCommandHandler(IAppDbContext dbContext) - : ICommandHandler> -{ - public async Task> Handle( - CreateProgrammingOverrideCommand command, - CancellationToken cancellationToken - ) - { - var weekly = command.Recurrence == OverrideRecurrence.Weekly; - if (weekly) - { - if ( - command.DayOfWeek is not (>= 0 and <= 6) - || command.StartMinute is not { } sm - || command.EndMinute is not { } em - || em <= sm - || sm < 0 - || em > 1440 - ) - return Result.Failure(ChannelErrors.InvalidOverrideWindow); - } - else if ( - command.StartsAtUtc is not { } start - || command.EndsAtUtc is not { } end - || end <= start - ) - { - return Result.Failure(ChannelErrors.InvalidOverrideWindow); - } - - if (command.Shows.Count == 0) - return Result.Failure(ChannelErrors.OverrideNeedsShow); - - var channel = await dbContext - .Channels.Include(c => c.Overrides) - .ThenInclude(o => o.Shows) - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var showIds = command.Shows.Select(s => s.ShowId).Distinct().ToList(); - var existingCount = await dbContext.Shows.CountAsync( - s => showIds.Contains(s.Id), - cancellationToken - ); - if (existingCount != showIds.Count) - return Result.Failure(ChannelErrors.ShowNotFound); - - var ovr = weekly - ? channel.AddWeeklyOverride( - command.Mode, - command.DayOfWeek!.Value, - command.StartMinute!.Value, - command.EndMinute!.Value - ) - : channel.AddOverride( - command.Mode, - command.StartsAtUtc!.Value, - command.EndsAtUtc!.Value - ); - foreach (var show in command.Shows) - ovr.AddShow(show.ShowId, show.Weight); - - return Result.Success(ovr.Id); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandValidator.cs deleted file mode 100644 index 3e49836..0000000 --- a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandValidator.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentValidation; - -namespace TeleWave.Application.Broadcast.CreateOverride; - -public sealed class CreateProgrammingOverrideCommandValidator - : AbstractValidator -{ - public CreateProgrammingOverrideCommandValidator() - { - RuleFor(x => x.Shows).NotEmpty(); - RuleForEach(x => x.Shows) - .ChildRules(s => s.RuleFor(i => i.Weight).InclusiveBetween(1, 1000)); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/CreateOverride/OverrideShowInput.cs b/backend/src/TeleWave.Application/Broadcast/CreateOverride/OverrideShowInput.cs deleted file mode 100644 index a1c475e..0000000 --- a/backend/src/TeleWave.Application/Broadcast/CreateOverride/OverrideShowInput.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace TeleWave.Application.Broadcast.CreateOverride; - -public sealed record OverrideShowInput(Guid ShowId, int Weight); diff --git a/backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommand.cs b/backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommand.cs deleted file mode 100644 index 7a4f60c..0000000 --- a/backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.DeleteOverride; - -public sealed record DeleteProgrammingOverrideCommand(Guid ChannelId, Guid OverrideId) - : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommandHandler.cs deleted file mode 100644 index 3c068bd..0000000 --- a/backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommandHandler.cs +++ /dev/null @@ -1,26 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.DeleteOverride; - -public sealed class DeleteProgrammingOverrideCommandHandler(IAppDbContext dbContext) - : ICommandHandler -{ - public async Task Handle( - DeleteProgrammingOverrideCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext - .Channels.Include(c => c.Overrides) - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - return channel.RemoveOverride(command.OverrideId) - ? Result.Success() - : Result.Failure(ChannelErrors.OverrideNotFound); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs index 3936a8e..27aa129 100644 --- a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs @@ -13,67 +13,16 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) CancellationToken cancellationToken ) { + // Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно + // (GetChannelTemplateQuery) — здесь только собственные свойства канала. var channel = await dbContext .Channels.AsNoTracking() - .Include(c => c.Shows) - .ThenInclude(s => s.PreferredHours) - .Include(c => c.Ads) .Include(c => c.BumperTemplates) .ThenInclude(t => t.Variants) - .Include(c => c.Overrides) - .ThenInclude(o => o.Shows) - .AsSplitQuery() .FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken); if (channel is null) return Result.Failure(ChannelErrors.NotFound); - var showIds = channel - .Shows.Select(s => s.ShowId) - .Concat(channel.Overrides.SelectMany(o => o.Shows.Select(s => s.ShowId))) - .Distinct() - .ToList(); - var showNames = await dbContext - .Shows.AsNoTracking() - .Where(s => showIds.Contains(s.Id)) - .Select(s => new { s.Id, s.Name }) - .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); - - var poolAssetIds = channel.Ads.Select(a => a.MediaAssetId).Distinct().ToList(); - var assetNames = await dbContext - .MediaAssets.AsNoTracking() - .Where(a => poolAssetIds.Contains(a.Id)) - .Select(a => new { a.Id, a.OriginalFileName }) - .ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken); - - string ShowName(Guid id) => showNames.GetValueOrDefault(id, "(удалено)"); - - var shows = channel - .Shows.Select(s => new ChannelShowDto( - s.Id, - s.ShowId, - ShowName(s.ShowId), - s.Weight, - s.BlockMode, - s.BlockValue, - s.IsEnabled, - s.NextEpisodeIndex, - s.PreferredWeightMultiplier, - s.PreferredHours.OrderBy(h => h.StartHour) - .Select(h => new HourWindowDto(h.StartHour, h.EndHour)) - .ToList() - )) - .ToList(); - - var ads = channel - .Ads.OrderBy(a => a.Position) - .Select(a => new ChannelAdDto( - a.Id, - a.MediaAssetId, - assetNames.GetValueOrDefault(a.MediaAssetId), - a.Position - )) - .ToList(); - var bumperTemplates = channel .BumperTemplates.OrderBy(t => t.Position) .Select(t => new BumperTemplateDto( @@ -105,32 +54,16 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) )) .ToList(); - var overrides = channel - .Overrides.OrderBy(o => o.Recurrence) - .ThenBy(o => o.StartsAtUtc) - .ThenBy(o => o.DayOfWeek) - .Select(o => new ProgrammingOverrideDto( - o.Id, - o.Mode, - o.Recurrence, - o.StartsAtUtc, - o.EndsAtUtc, - o.DayOfWeek, - o.StartMinute, - o.EndMinute, - o.Shows.Select(s => new OverrideShowDto(s.ShowId, ShowName(s.ShowId), s.Weight)) - .ToList() - )) - .ToList(); - return Result.Success( new ChannelDto( channel.Id, channel.Name, channel.Slug, channel.IsEnabled, - channel.AdInsertion, - channel.AdsPerBreak, + channel.Number, + channel.UtcOffsetMinutes, + channel.DayStartTime, + channel.TemplateId, channel.BumpersEnabled, new BumperSettingsDto( channel.BumperFont, @@ -140,10 +73,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) channel.BumperEpisodeChangeChance ), bumperTemplates, - channel.FillerAssetId, - shows, - ads, - overrides + channel.FillerAssetId ) ); } diff --git a/backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommand.cs b/backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommand.cs deleted file mode 100644 index 38f533a..0000000 --- a/backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.RegenerateSchedule; - -public sealed record RegenerateChannelScheduleCommand(Guid ChannelId) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommandHandler.cs deleted file mode 100644 index 8893b50..0000000 --- a/backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommandHandler.cs +++ /dev/null @@ -1,35 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Broadcast.Scheduling; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.RegenerateSchedule; - -public sealed class RegenerateChannelScheduleCommandHandler( - IAppDbContext dbContext, - ScheduleGenerator generator -) : ICommandHandler -{ - public async Task Handle( - RegenerateChannelScheduleCommand command, - CancellationToken cancellationToken - ) - { - var exists = await dbContext.Channels.AnyAsync( - c => c.Id == command.ChannelId, - cancellationToken - ); - if (!exists) - return Result.Failure(ChannelErrors.NotFound); - - // Генератор сам сохраняет изменения (удаление хвоста + новые записи + курсоры). - await generator.GenerateAsync( - command.ChannelId, - DateTimeOffset.UtcNow, - regenerate: true, - cancellationToken - ); - return Result.Success(); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommand.cs b/backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommand.cs deleted file mode 100644 index 979b0fc..0000000 --- a/backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.RemoveChannelAd; - -public sealed record RemoveChannelAdCommand(Guid ChannelId, Guid ChannelAdId) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommandHandler.cs deleted file mode 100644 index 58430b8..0000000 --- a/backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommandHandler.cs +++ /dev/null @@ -1,26 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.RemoveChannelAd; - -public sealed class RemoveChannelAdCommandHandler(IAppDbContext dbContext) - : ICommandHandler -{ - public async Task Handle( - RemoveChannelAdCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext - .Channels.Include(c => c.Ads) - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - return channel.RemoveAd(command.ChannelAdId) - ? Result.Success() - : Result.Failure(ChannelErrors.AdNotFound); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommand.cs b/backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommand.cs deleted file mode 100644 index e963b31..0000000 --- a/backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.RemoveChannelShow; - -public sealed record RemoveChannelShowCommand(Guid ChannelId, Guid ChannelShowId) - : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommandHandler.cs deleted file mode 100644 index 8d121bd..0000000 --- a/backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommandHandler.cs +++ /dev/null @@ -1,26 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.RemoveChannelShow; - -public sealed class RemoveChannelShowCommandHandler(IAppDbContext dbContext) - : ICommandHandler -{ - public async Task Handle( - RemoveChannelShowCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext - .Channels.Include(c => c.Shows) - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - return channel.RemoveShow(command.ChannelShowId) - ? Result.Success() - : Result.Failure(ChannelErrors.ChannelShowNotFound); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleBumperResolver.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleBumperResolver.cs deleted file mode 100644 index d20c14f..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleBumperResolver.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using TeleWave.Application.Broadcast.Bumpers; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Streaming; -using TeleWave.Domain.Broadcast; -using TeleWave.Domain.Broadcast.Scheduling; -using TeleWave.Domain.Media; - -namespace TeleWave.Application.Broadcast.Scheduling; - -/// -/// Резолвит ассеты ТВ-заставок для запланированных переходов: для каждой уникальной тройки -/// «из→в→подблок» возвращает id ассета-заставки. Сам ffmpeg НЕ запускает — если готового (или уже -/// рендерящегося) ассета в кэше нет, создаёт в статусе Pending, кэш-строку -/// (с id канала/блока/подблока для восстановления спеки) и ставит в очередь -/// фонового рендерера. Так тик планировщика не блокируется на ffmpeg, а его транзакция не держится во -/// время рендера. До готовности ассета плейлист отдаёт филлер (см. GetLivePlaylistQueryHandler). -/// -public sealed class ScheduleBumperResolver( - IAppDbContext dbContext, - IBumperRenderQueue renderQueue, - IOptions bumperOptions, - IOptions streamingOptions -) -{ - private readonly BumperOptions _bumper = bumperOptions.Value; - private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); - - /// - /// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id ассета: - /// готового/рендерящегося из кэша либо только что созданного Pending (поставлен в очередь рендера). - /// - public async Task> ResolveAsync( - Channel channel, - IReadOnlyList entries, - IReadOnlyDictionary showNames, - CancellationToken cancellationToken - ) - { - var result = new Dictionary<(Guid, Guid, Guid), Guid>(); - var variantsById = channel - .BumperTemplates.SelectMany(t => t.Variants.Select(v => (Variant: v, Template: t))) - .ToDictionary(x => x.Variant.Id); - var combos = entries - .Where(e => - e.Kind == ScheduleEntryKind.Bumper - && e.FromShowId is not null - && e.ToShowId is not null - && e.BumperVariantId is not null - ) - .Select(e => - ( - From: e.FromShowId!.Value, - To: e.ToShowId!.Value, - Variant: e.BumperVariantId!.Value - ) - ) - .Distinct() - .ToList(); - if (combos.Count == 0) - return result; - - var fromIds = combos.Select(c => c.From).Distinct().ToList(); - var toIds = combos.Select(c => c.To).Distinct().ToList(); - - // Постер шоу-получателя участвует в сигнатуре (как токен id) — грузим только id картинки; - // абсолютный путь для рендера резолвит уже фоновый рендерер. - var showIds = fromIds.Concat(toIds).Distinct().ToList(); - var posterImageByShow = await dbContext - .Shows.AsNoTracking() - .Where(s => showIds.Contains(s.Id) && s.PosterImageId != null) - .Select(s => new { s.Id, ImageId = s.PosterImageId!.Value }) - .ToDictionaryAsync(s => s.Id, s => s.ImageId, cancellationToken); - - // Кэш заставок (новейшие первыми) + статусы их ассетов: Ready/Pending/Processing переиспользуем, - // Failed — рендерим заново. - var cached = await dbContext - .BumperAssets.AsNoTracking() - .Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId)) - .OrderByDescending(b => b.CreatedAt) - .Select(b => new - { - b.FromShowId, - b.ToShowId, - b.Signature, - b.MediaAssetId, - }) - .ToListAsync(cancellationToken); - var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList(); - var statusById = await dbContext - .MediaAssets.AsNoTracking() - .Where(a => cachedAssetIds.Contains(a.Id)) - .Select(a => new { a.Id, a.Status }) - .ToDictionaryAsync(a => a.Id, a => a.Status, cancellationToken); - - foreach (var combo in combos) - { - if (result.ContainsKey(combo)) - continue; - if (!variantsById.TryGetValue(combo.Variant, out var pair)) - continue; - var (variant, template) = pair; - - var fromName = showNames.GetValueOrDefault(combo.From, "…"); - var toName = showNames.GetValueOrDefault(combo.To, "…"); - var usePoster = variant.Kind == BumperTextKind.NowNext; - var posterToken = - usePoster && posterImageByShow.TryGetValue(combo.To, out var pid) - ? pid.ToString() - : "-"; - var aligned = BumperDuration.Aligned( - BumperDuration.TemplateSeconds(template), - _segmentSeconds - ); - var signature = ComputeSignature( - channel, - template, - variant, - fromName, - toName, - aligned, - posterToken - ); - - // Новейшая кэш-строка с этой сигнатурой (список упорядочен по CreatedAt DESC). - var hit = cached.FirstOrDefault(c => - c.FromShowId == combo.From && c.ToShowId == combo.To && c.Signature == signature - ); - if ( - hit is not null - && statusById.TryGetValue(hit.MediaAssetId, out var status) - && status != MediaAssetStatus.Failed - ) - { - result[combo] = hit.MediaAssetId; // Ready / Pending / Processing — переиспользуем - continue; - } - - // Нет кэша либо прошлый рендер провалился — создаём Pending-ассет + кэш-строку, ставим в очередь. - var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}"); - dbContext.MediaAssets.Add(asset); - dbContext.BumperAssets.Add( - BumperAsset.Create( - channel.Id, - template.Id, - variant.Id, - combo.From, - combo.To, - signature, - asset.Id - ) - ); - // Сигнал — лучший случай (строка станет видимой после коммита транзакции планировщика); - // гарантия подхвата — периодический опрос БД фоновым рендерером. - renderQueue.Enqueue(asset.Id); - result[combo] = asset.Id; - } - - return result; - } - - /// - /// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия - /// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется — - /// заставка пересобирается. Разделитель — unit separator (U+001F), чтобы поля не слипались. - /// - private string ComputeSignature( - Channel channel, - BumperTemplate template, - BumperTextVariant variant, - string fromName, - string toName, - int alignedDurationSeconds, - string poster - ) - { - var raw = string.Join( - '', - _bumper.TemplateVersion, - _bumper.Width, - _bumper.Height, - alignedDurationSeconds, - channel.BumperFont, - variant.Kind, - variant.NowLabel, - variant.NextLabel, - variant.Line1, - variant.Line2, - template.BackgroundColor, - template.BackgroundColor2, - template.AccentColor, - template.TextColor, - template.Revision, - template.BackgroundImageId?.ToString() ?? "-", - template.AudioExtension ?? "-", - fromName, - toName, - poster - ); - var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); - return Convert.ToHexString(hash); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs deleted file mode 100644 index 4ad2a41..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs +++ /dev/null @@ -1,304 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Streaming; -using TeleWave.Domain.Broadcast; -using TeleWave.Domain.Broadcast.Scheduling; -using TeleWave.Domain.Media; - -namespace TeleWave.Application.Broadcast.Scheduling; - -/// -/// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый -/// , материализует записи и двигает курсоры. Используется фоновым -/// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала). -/// Ассеты заставок-переходов резолвит/рендерит (общий контекст). -/// -public sealed class ScheduleGenerator( - IAppDbContext dbContext, - IRandomSource random, - ScheduleBumperResolver bumperResolver, - IOptions options, - IOptions streamingOptions -) -{ - private readonly SchedulerOptions _options = options.Value; - private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); - - /// - /// Достраивает (или, при , перестраивает будущий хвост) расписание - /// канала до горизонта. Возвращает число добавленных записей (-1 — канал не найден/выключен). - /// - public async Task GenerateAsync( - Guid channelId, - DateTimeOffset now, - bool regenerate, - CancellationToken cancellationToken - ) - { - var channel = await dbContext - .Channels.Include(c => c.Shows) - .ThenInclude(s => s.PreferredHours) - .Include(c => c.Ads) - .Include(c => c.BumperTemplates) - .ThenInclude(t => t.Variants) - .Include(c => c.Overrides) - .ThenInclude(o => o.Shows) - .AsSplitQuery() - .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken); - - if (channel is null || !channel.IsEnabled) - return -1; - - // Сериализуем генерацию одного канала: фоновый тик и ручная перегенерация не должны читать - // одну точку продолжения и оба дописывать хвост (иначе дубли/перекрытия записей). Advisory-lock - // держится до коммита/отката транзакции ниже; при сбое рендера заставок весь прогон откатится. - await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken); - await dbContext.AcquireChannelLockAsync(channelId, cancellationToken); - - var horizonEnd = now.AddDays(_options.HorizonDays); - - // Чистим прошлое сверх окна ретеншна. - var retentionCutoff = now.AddHours(-_options.RetentionHours); - await dbContext - .ScheduleEntries.Where(e => e.ChannelId == channelId && e.EndsAtUtc < retentionCutoff) - .ExecuteDeleteAsync(cancellationToken); - - // Точка продолжения: конец последней сохранённой записи (для regenerate — только уже стартовавшей). - var lastEnd = await dbContext - .ScheduleEntries.Where(e => - e.ChannelId == channelId && (!regenerate || e.StartsAtUtc < now) - ) - .MaxAsync(e => (DateTimeOffset?)e.EndsAtUtc, cancellationToken); - - var startTime = lastEnd ?? now; - if (startTime < now) - startTime = now; - - if (regenerate) - await dbContext - .ScheduleEntries.Where(e => e.ChannelId == channelId && e.StartsAtUtc >= now) - .ExecuteDeleteAsync(cancellationToken); - - if (startTime >= horizonEnd) - { - await dbContext.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - return 0; - } - - var showNames = await LoadShowNamesAsync(channel, cancellationToken); - var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken); - var result = SchedulePlanner.Plan(input, random); - - // Рендерим/достаём из кэша ассеты заставок для всех переходов плана (по паре шоу + подблоку). - var bumperAssets = await bumperResolver.ResolveAsync( - channel, - result.Entries, - showNames, - cancellationToken - ); - - var added = 0; - foreach (var entry in result.Entries) - { - ScheduleEntry? scheduleEntry = entry.Kind switch - { - ScheduleEntryKind.Program => ScheduleEntry.Program( - channel.Id, - entry.MediaAssetId, - entry.StartsAtUtc, - entry.EndsAtUtc, - entry.ShowId!.Value, - entry.EpisodeIndex!.Value - ), - ScheduleEntryKind.Ad => ScheduleEntry.Ad( - channel.Id, - entry.MediaAssetId, - entry.StartsAtUtc, - entry.EndsAtUtc - ), - ScheduleEntryKind.Bumper => BuildBumperEntry(channel.Id, entry, bumperAssets), - _ => null, - }; - - if (scheduleEntry is null) - continue; - - dbContext.ScheduleEntries.Add(scheduleEntry); - added++; - } - - foreach (var channelShow in channel.Shows) - if (result.NextEpisodeIndexByChannelShow.TryGetValue(channelShow.Id, out var idx)) - channelShow.SetNextEpisodeIndex(idx); - - channel.SetNextAdIndex(result.NextAdIndex); - channel.SetNextBumperIndex(result.NextBumperIndex); - - await dbContext.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - return added; - } - - private static ScheduleEntry? BuildBumperEntry( - Guid channelId, - PlannedEntry entry, - IReadOnlyDictionary<(Guid From, Guid To, Guid Variant), Guid> bumperAssets - ) - { - // Заставка резолвится по паре шоу + выбранному подблоку (отрендерена/из кэша). Если рендер не - // удался — пропускаем запись (слот заполнит филлер/следующая программа; длину планировщик учёл). - if ( - entry.FromShowId is not { } from - || entry.ToShowId is not { } to - || entry.BumperVariantId is not { } variant - || !bumperAssets.TryGetValue((from, to, variant), out var assetId) - ) - return null; - - return ScheduleEntry.Bumper( - channelId, - assetId, - entry.StartsAtUtc, - entry.EndsAtUtc, - entry.ToShowId, - entry.BumperVariantId - ); - } - - private async Task> LoadShowNamesAsync( - Channel channel, - CancellationToken cancellationToken - ) - { - var showIds = channel.Shows.Select(s => s.ShowId).Distinct().ToList(); - if (showIds.Count == 0) - return new Dictionary(); - - return await dbContext - .Shows.AsNoTracking() - .Where(s => showIds.Contains(s.Id)) - .Select(s => new { s.Id, s.Name }) - .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); - } - - private async Task BuildInputAsync( - Channel channel, - DateTimeOffset startTime, - DateTimeOffset horizonEnd, - CancellationToken cancellationToken - ) - { - var enabledShows = channel.Shows.Where(s => s.IsEnabled).ToList(); - var showIds = enabledShows.Select(s => s.ShowId).Distinct().ToList(); - - var shows = await dbContext - .Shows.Include(s => s.Episodes) - .Where(s => showIds.Contains(s.Id)) - .ToListAsync(cancellationToken); - - var episodesByShow = shows.ToDictionary( - s => s.Id, - s => s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList() - ); - - var candidateAssetIds = episodesByShow - .Values.SelectMany(x => x) - .Concat(channel.Ads.Select(a => a.MediaAssetId)) - .Distinct() - .ToList(); - - var durations = await dbContext - .MediaAssets.Where(a => - candidateAssetIds.Contains(a.Id) - && a.Status == MediaAssetStatus.Ready - && a.Duration != null - ) - .Select(a => new { a.Id, a.Duration }) - .ToDictionaryAsync(x => x.Id, x => x.Duration!.Value, cancellationToken); - - var plannerShows = new List(); - foreach (var channelShow in enabledShows) - { - if (!episodesByShow.TryGetValue(channelShow.ShowId, out var episodeIds)) - continue; - var ready = episodeIds.Where(durations.ContainsKey).ToList(); - if (ready.Count == 0) - continue; - - plannerShows.Add( - new PlannerShow( - channelShow.Id, - channelShow.ShowId, - channelShow.Weight, - channelShow.BlockMode, - channelShow.BlockValue, - ready, - channelShow.NextEpisodeIndex, - channelShow - .PreferredHours.Select(h => new PlannerHourWindow(h.StartHour, h.EndHour)) - .ToList(), - channelShow.PreferredWeightMultiplier - ) - ); - } - - var adPool = channel - .Ads.OrderBy(a => a.Position) - .Select(a => a.MediaAssetId) - .Where(durations.ContainsKey) - .ToList(); - - // Подблоки заставок (плоский список): длительность слота — по звуку блока, выровнена на сегмент. - var bumperVariants = channel - .BumperTemplates.OrderBy(t => t.Position) - .SelectMany(t => - { - var dur = TimeSpan.FromSeconds( - BumperDuration.Aligned(BumperDuration.TemplateSeconds(t), _segmentSeconds) - ); - return t - .Variants.OrderBy(v => v.Position) - .Select(v => new PlannerBumperVariant(v.Id, t.Id, dur, v.Trigger, v.Weight)); - }) - .ToList(); - - var overrides = channel - .Overrides.Select(o => new PlannerOverride( - o.Mode, - o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList(), - o.Recurrence, - o.StartsAtUtc, - o.EndsAtUtc, - o.DayOfWeek, - o.StartMinute, - o.EndMinute - )) - .ToList(); - - var bumpers = new PlannerBumperConfig( - channel.BumpersEnabled, - TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes), - channel.BumperSelection, - bumperVariants, - channel.BumperShowChangeChance, - channel.BumperEpisodeChangeChance - ); - - return new PlannerInput( - channel.Id, - channel.AdInsertion, - channel.AdsPerBreak, - channel.NextAdIndex, - plannerShows, - adPool, - durations, - overrides, - startTime, - horizonEnd, - bumpers, - channel.NextBumperIndex - ); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/SchedulerOptions.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/SchedulerOptions.cs index c7dcb50..27f98bc 100644 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/SchedulerOptions.cs +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/SchedulerOptions.cs @@ -1,15 +1,21 @@ -namespace TeleWave.Application.Broadcast.Scheduling; - -public sealed class SchedulerOptions -{ - public const string SectionName = "Scheduler"; - - /// На сколько дней вперёд держать материализованное расписание. - public int HorizonDays { get; init; } = 3; - - /// Сколько часов прошедшего расписания хранить (для EPG «что было»), затем чистить. - public int RetentionHours { get; init; } = 24; - - /// Период тика фонового планировщика, минуты. - public int TickMinutes { get; init; } = 30; -} +namespace TeleWave.Application.Broadcast.Scheduling; + +public sealed class SchedulerOptions +{ + public const string SectionName = "Scheduler"; + + /// + /// На сколько дней вперёд держать материализованное расписание. Неделя — часть замысла: + /// «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее. + /// + public int HorizonDays { get; init; } = 7; + + /// + /// Сколько дней прошедшего расписания хранить. Должно покрывать самое долгое остывание среди правил + /// канала и самый глубокий повтор: история показов берётся из самой ленты, отдельного журнала нет. + /// + public int RetentionDays { get; init; } = 90; + + /// Период тика фонового планировщика, минуты. + public int TickMinutes { get; init; } = 30; +} diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs index e31920d..b923620 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs @@ -1,25 +1,23 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; -using TeleWave.Domain.Broadcast; - -namespace TeleWave.Application.Broadcast.UpdateChannelSettings; - -public sealed record UpdateChannelSettingsCommand( - Guid ChannelId, - string Name, - bool IsEnabled, - AdInsertion AdInsertion, - int AdsPerBreak, - bool BumpersEnabled, - BumperSettingsInput Bumper, - Guid? FillerAssetId -) : ICommand; - -/// Общие настройки ТВ-заставок канала (см. Channel.UpdateBumperSettings). -public sealed record BumperSettingsInput( - BumperFont Font, - int MinIntervalMinutes, - BumperSelection Selection, - double ShowChangeChance, - double EpisodeChangeChance -); +using LiteCqrs; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Broadcast; + +namespace TeleWave.Application.Broadcast.UpdateChannelSettings; + +public sealed record UpdateChannelSettingsCommand( + Guid ChannelId, + string Name, + bool IsEnabled, + bool BumpersEnabled, + BumperSettingsInput Bumper, + Guid? FillerAssetId +) : ICommand; + +/// Общие настройки ТВ-заставок канала (см. Channel.UpdateBumperSettings). +public sealed record BumperSettingsInput( + BumperFont Font, + int MinIntervalMinutes, + BumperSelection Selection, + double ShowChangeChance, + double EpisodeChangeChance +); diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs index c4b01cf..1df2f03 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs @@ -1,50 +1,48 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.UpdateChannelSettings; - -public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext) - : ICommandHandler -{ - public async Task Handle( - UpdateChannelSettingsCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext.Channels.FirstOrDefaultAsync( - c => c.Id == command.ChannelId, - cancellationToken - ); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - if (command.FillerAssetId is { } fillerId) - { - var exists = await dbContext.MediaAssets.AnyAsync( - a => a.Id == fillerId, - cancellationToken - ); - if (!exists) - return Result.Failure(ChannelErrors.AssetNotFound); - } - - channel.UpdateSettings( - command.Name, - command.IsEnabled, - command.AdInsertion, - command.AdsPerBreak, - command.BumpersEnabled, - command.FillerAssetId - ); - channel.UpdateBumperSettings( - command.Bumper.Font, - command.Bumper.MinIntervalMinutes, - command.Bumper.Selection, - command.Bumper.ShowChangeChance, - command.Bumper.EpisodeChangeChance - ); - return Result.Success(); - } -} +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.UpdateChannelSettings; + +public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + UpdateChannelSettingsCommand command, + CancellationToken cancellationToken + ) + { + var channel = await dbContext.Channels.FirstOrDefaultAsync( + c => c.Id == command.ChannelId, + cancellationToken + ); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + if (command.FillerAssetId is { } fillerId) + { + var exists = await dbContext.MediaAssets.AnyAsync( + a => a.Id == fillerId, + cancellationToken + ); + if (!exists) + return Result.Failure(ChannelErrors.AssetNotFound); + } + + channel.UpdateSettings( + command.Name, + command.IsEnabled, + command.BumpersEnabled, + command.FillerAssetId + ); + channel.UpdateBumperSettings( + command.Bumper.Font, + command.Bumper.MinIntervalMinutes, + command.Bumper.Selection, + command.Bumper.ShowChangeChance, + command.Bumper.EpisodeChangeChance + ); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs index 21d5eb3..15941fb 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs @@ -1,17 +1,16 @@ -using FluentValidation; - -namespace TeleWave.Application.Broadcast.UpdateChannelSettings; - -public sealed class UpdateChannelSettingsCommandValidator - : AbstractValidator -{ - public UpdateChannelSettingsCommandValidator() - { - RuleFor(x => x.Name).NotEmpty().MaximumLength(256); - RuleFor(x => x.AdsPerBreak).InclusiveBetween(0, 10); - - RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440); - RuleFor(x => x.Bumper.ShowChangeChance).InclusiveBetween(0.0, 1.0); - RuleFor(x => x.Bumper.EpisodeChangeChance).InclusiveBetween(0.0, 1.0); - } -} +using FluentValidation; + +namespace TeleWave.Application.Broadcast.UpdateChannelSettings; + +public sealed class UpdateChannelSettingsCommandValidator + : AbstractValidator +{ + public UpdateChannelSettingsCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(256); + + RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440); + RuleFor(x => x.Bumper.ShowChangeChance).InclusiveBetween(0.0, 1.0); + RuleFor(x => x.Bumper.EpisodeChangeChance).InclusiveBetween(0.0, 1.0); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommand.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommand.cs deleted file mode 100644 index 8dc101f..0000000 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommand.cs +++ /dev/null @@ -1,19 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; -using TeleWave.Domain.Broadcast; - -namespace TeleWave.Application.Broadcast.UpdateChannelShow; - -public sealed record UpdateChannelShowCommand( - Guid ChannelId, - Guid ChannelShowId, - int Weight, - BlockMode BlockMode, - int BlockValue, - bool IsEnabled, - int PreferredWeightMultiplier, - IReadOnlyList PreferredHours -) : ICommand; - -/// Окно предпочтительных часов [StartHour, EndHour) суток (UTC). -public sealed record HourWindowInput(int StartHour, int EndHour); diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandHandler.cs deleted file mode 100644 index a026e19..0000000 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandHandler.cs +++ /dev/null @@ -1,39 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.UpdateChannelShow; - -public sealed class UpdateChannelShowCommandHandler(IAppDbContext dbContext) - : ICommandHandler -{ - public async Task Handle( - UpdateChannelShowCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext - .Channels.Include(c => c.Shows) - .ThenInclude(s => s.PreferredHours) - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var channelShow = channel.FindShow(command.ChannelShowId); - if (channelShow is null) - return Result.Failure(ChannelErrors.ChannelShowNotFound); - - channelShow.Update( - command.Weight, - command.BlockMode, - command.BlockValue, - command.IsEnabled - ); - channelShow.SetPreferredHours( - command.PreferredWeightMultiplier, - command.PreferredHours.Select(h => (h.StartHour, h.EndHour)) - ); - return Result.Success(); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandValidator.cs deleted file mode 100644 index 726b198..0000000 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandValidator.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FluentValidation; - -namespace TeleWave.Application.Broadcast.UpdateChannelShow; - -public sealed class UpdateChannelShowCommandValidator : AbstractValidator -{ - public UpdateChannelShowCommandValidator() - { - RuleFor(x => x.Weight).InclusiveBetween(1, 1000); - RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000); - RuleFor(x => x.PreferredWeightMultiplier).InclusiveBetween(1, 100); - RuleForEach(x => x.PreferredHours) - .ChildRules(w => - { - w.RuleFor(h => h.StartHour).InclusiveBetween(0, 23); - w.RuleFor(h => h.EndHour).InclusiveBetween(1, 24); - w.RuleFor(h => h) - .Must(h => h.StartHour < h.EndHour) - .WithMessage("Начало окна должно быть раньше конца."); - }); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelTime/UpdateChannelTimeCommand.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelTime/UpdateChannelTimeCommand.cs new file mode 100644 index 0000000..8ffc6f7 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelTime/UpdateChannelTimeCommand.cs @@ -0,0 +1,15 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.UpdateChannelTime; + +/// +/// Номер канала и его время: смещение от UTC и начало вещательных суток. Отдельно от общих настроек +/// канала, потому что те завязаны на старую ротацию и уйдут вместе с ней. +/// +public sealed record UpdateChannelTimeCommand( + Guid ChannelId, + int? Number, + int UtcOffsetMinutes, + TimeOnly DayStartTime +) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelTime/UpdateChannelTimeCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelTime/UpdateChannelTimeCommandHandler.cs new file mode 100644 index 0000000..670523e --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelTime/UpdateChannelTimeCommandHandler.cs @@ -0,0 +1,38 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.UpdateChannelTime; + +public sealed class UpdateChannelTimeCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + UpdateChannelTimeCommand command, + CancellationToken cancellationToken + ) + { + var channel = await dbContext.Channels.FirstOrDefaultAsync( + c => c.Id == command.ChannelId, + cancellationToken + ); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + // Номер уникален среди заданных — проверяем заранее, иначе получим нарушение индекса вместо + // управляемой ошибки. + if (command.Number is { } number) + { + var taken = await dbContext.Channels.AnyAsync( + c => c.Id != channel.Id && c.Number == number, + cancellationToken + ); + if (taken) + return Result.Failure(ChannelErrors.NumberTaken); + } + + channel.UpdateTimeSettings(command.Number, command.UtcOffsetMinutes, command.DayStartTime); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs index e6b5a3f..24f9844 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs @@ -1,34 +1,48 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage; -using TeleWave.Domain.Auth; -using TeleWave.Domain.Broadcast; -using TeleWave.Domain.Images; -using TeleWave.Domain.Library; -using TeleWave.Domain.Media; -using TeleWave.Domain.Settings; - -namespace TeleWave.Application.Common.Interfaces; - -public interface IAppDbContext -{ - DbSet RefreshTokens { get; } - DbSet MediaAssets { get; } - DbSet Shows { get; } - DbSet Channels { get; } - DbSet ScheduleEntries { get; } - DbSet BumperTextVariants { get; } - DbSet BumperAssets { get; } - DbSet AppSettings { get; } - DbSet Images { get; } - - Task SaveChangesAsync(CancellationToken cancellationToken); - - /// Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч. - /// ExecuteDelete в обход change-tracker), которые должны быть атомарны. - Task BeginTransactionAsync(CancellationToken cancellationToken); - - /// Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате). - /// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией. - /// Вызывать внутри открытой транзакции. - Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken); -} +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using TeleWave.Domain.Auth; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Images; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using TeleWave.Domain.Programming; +using TeleWave.Domain.Settings; + +namespace TeleWave.Application.Common.Interfaces; + +public interface IAppDbContext +{ + DbSet RefreshTokens { get; } + DbSet MediaAssets { get; } + DbSet Shows { get; } + DbSet Genres { get; } + DbSet GenreAliases { get; } + DbSet ShowGenres { get; } + DbSet Collections { get; } + DbSet CollectionItems { get; } + DbSet Groups { get; } + DbSet GroupItems { get; } + DbSet ScheduleTemplates { get; } + DbSet GridLayers { get; } + DbSet Slots { get; } + DbSet SlotStates { get; } + DbSet JunctionTemplates { get; } + DbSet JunctionElements { get; } + DbSet Channels { get; } + DbSet ScheduleEntries { get; } + DbSet BumperTextVariants { get; } + DbSet BumperAssets { get; } + DbSet AppSettings { get; } + DbSet Images { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken); + + /// Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч. + /// ExecuteDelete в обход change-tracker), которые должны быть атомарны. + Task BeginTransactionAsync(CancellationToken cancellationToken); + + /// Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате). + /// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией. + /// Вызывать внутри открытой транзакции. + Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Library/Collections/AddCollectionShow/AddCollectionShowCommand.cs b/backend/src/TeleWave.Application/Library/Collections/AddCollectionShow/AddCollectionShowCommand.cs new file mode 100644 index 0000000..2ad2bac --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/AddCollectionShow/AddCollectionShowCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.AddCollectionShow; + +public sealed record AddCollectionShowCommand(Guid CollectionId, Guid ShowId) : ICommand; diff --git a/backend/src/TeleWave.Application/Library/Collections/AddCollectionShow/AddCollectionShowCommandHandler.cs b/backend/src/TeleWave.Application/Library/Collections/AddCollectionShow/AddCollectionShowCommandHandler.cs new file mode 100644 index 0000000..ad721c9 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/AddCollectionShow/AddCollectionShowCommandHandler.cs @@ -0,0 +1,29 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.AddCollectionShow; + +public sealed class AddCollectionShowCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + AddCollectionShowCommand command, + CancellationToken cancellationToken + ) + { + var collection = await dbContext + .Collections.Include(c => c.Items) + .FirstOrDefaultAsync(c => c.Id == command.CollectionId, cancellationToken); + if (collection is null) + return Result.Failure(CollectionErrors.NotFound); + + if (!await dbContext.Shows.AnyAsync(s => s.Id == command.ShowId, cancellationToken)) + return Result.Failure(ShowErrors.NotFound); + + return collection.AddShow(command.ShowId) is null + ? Result.Failure(CollectionErrors.ShowAlreadyAdded) + : Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Library/Collections/CollectionDtos.cs b/backend/src/TeleWave.Application/Library/Collections/CollectionDtos.cs new file mode 100644 index 0000000..3985fc8 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/CollectionDtos.cs @@ -0,0 +1,39 @@ +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Library.Collections; + +/// Коллекция в списке: без состава, но со сводкой — сколько частей и сколько в них единиц +/// воспроизведения (у сериала внутри коллекции их больше одной). +public sealed record CollectionSummaryDto( + Guid Id, + string Name, + string? Description, + Guid? PosterImageId, + int ItemCount, + int UnitCount, + DateTimeOffset CreatedAt +); + +/// Позиция коллекции с данными шоу — чтобы список правился без второго запроса. +public sealed record CollectionItemDto( + Guid ShowId, + int Position, + string ShowName, + ShowKind ShowKind, + ShowAudience ShowAudience, + int EpisodeCount, + int? Year, + Guid? PosterImageId +); + +public sealed record CollectionDto( + Guid Id, + string Name, + string? Description, + Guid? PosterImageId, + DateTimeOffset CreatedAt, + IReadOnlyList Items +); + +/// Коллекция, в которую входит шоу — для блока «входит в коллекции» на экране шоу. +public sealed record ShowCollectionRefDto(Guid Id, string Name, int Position); diff --git a/backend/src/TeleWave.Application/Library/Collections/CollectionErrors.cs b/backend/src/TeleWave.Application/Library/Collections/CollectionErrors.cs new file mode 100644 index 0000000..feb2b63 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/CollectionErrors.cs @@ -0,0 +1,21 @@ +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections; + +public static class CollectionErrors +{ + public static readonly Error NotFound = Error.NotFound( + "Collections.NotFound", + "Коллекция не найдена." + ); + + public static readonly Error ShowAlreadyAdded = Error.Conflict( + "Collections.ShowAlreadyAdded", + "Это шоу уже входит в коллекцию." + ); + + public static readonly Error ShowNotInCollection = Error.NotFound( + "Collections.ShowNotInCollection", + "Шоу не входит в коллекцию." + ); +} diff --git a/backend/src/TeleWave.Application/Library/Collections/CreateCollection/CreateCollectionCommand.cs b/backend/src/TeleWave.Application/Library/Collections/CreateCollection/CreateCollectionCommand.cs new file mode 100644 index 0000000..7a9b715 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/CreateCollection/CreateCollectionCommand.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.CreateCollection; + +public sealed record CreateCollectionCommand(string Name, string? Description = null) + : ICommand>; diff --git a/backend/src/TeleWave.Application/Library/Collections/CreateCollection/CreateCollectionCommandHandler.cs b/backend/src/TeleWave.Application/Library/Collections/CreateCollection/CreateCollectionCommandHandler.cs new file mode 100644 index 0000000..7f6d139 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/CreateCollection/CreateCollectionCommandHandler.cs @@ -0,0 +1,20 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Library.Collections.CreateCollection; + +public sealed class CreateCollectionCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public Task> Handle( + CreateCollectionCommand command, + CancellationToken cancellationToken + ) + { + var collection = Collection.Create(command.Name, command.Description); + dbContext.Collections.Add(collection); + return Task.FromResult(Result.Success(collection.Id)); + } +} diff --git a/backend/src/TeleWave.Application/Library/Collections/CreateCollection/CreateCollectionCommandValidator.cs b/backend/src/TeleWave.Application/Library/Collections/CreateCollection/CreateCollectionCommandValidator.cs new file mode 100644 index 0000000..5602b76 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/CreateCollection/CreateCollectionCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace TeleWave.Application.Library.Collections.CreateCollection; + +public sealed class CreateCollectionCommandValidator : AbstractValidator +{ + public CreateCollectionCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(256); + RuleFor(x => x.Description).MaximumLength(2048); + } +} diff --git a/backend/src/TeleWave.Application/Library/Collections/DeleteCollection/DeleteCollectionCommand.cs b/backend/src/TeleWave.Application/Library/Collections/DeleteCollection/DeleteCollectionCommand.cs new file mode 100644 index 0000000..6267f34 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/DeleteCollection/DeleteCollectionCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.DeleteCollection; + +public sealed record DeleteCollectionCommand(Guid CollectionId) : ICommand; diff --git a/backend/src/TeleWave.Application/Library/Collections/DeleteCollection/DeleteCollectionCommandHandler.cs b/backend/src/TeleWave.Application/Library/Collections/DeleteCollection/DeleteCollectionCommandHandler.cs new file mode 100644 index 0000000..08242cc --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/DeleteCollection/DeleteCollectionCommandHandler.cs @@ -0,0 +1,37 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Programming.Groups; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Library.Collections.DeleteCollection; + +public sealed class DeleteCollectionCommandHandler( + IAppDbContext dbContext, + GroupMembershipCleaner groupCleaner +) : ICommandHandler +{ + public async Task Handle( + DeleteCollectionCommand command, + CancellationToken cancellationToken + ) + { + var collection = await dbContext.Collections.FirstOrDefaultAsync( + c => c.Id == command.CollectionId, + cancellationToken + ); + if (collection is null) + return Result.Failure(CollectionErrors.NotFound); + + // Ссылка группы полиморфна — каскад БД её не снимет. + await groupCleaner.RemoveElementAsync( + GroupElementKind.Collection, + command.CollectionId, + cancellationToken + ); + + dbContext.Collections.Remove(collection); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Library/Collections/GetCollection/GetCollectionQuery.cs b/backend/src/TeleWave.Application/Library/Collections/GetCollection/GetCollectionQuery.cs new file mode 100644 index 0000000..885891b --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/GetCollection/GetCollectionQuery.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.GetCollection; + +public sealed record GetCollectionQuery(Guid Id) : IQuery>; diff --git a/backend/src/TeleWave.Application/Library/Collections/GetCollection/GetCollectionQueryHandler.cs b/backend/src/TeleWave.Application/Library/Collections/GetCollection/GetCollectionQueryHandler.cs new file mode 100644 index 0000000..c6a1ef6 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/GetCollection/GetCollectionQueryHandler.cs @@ -0,0 +1,68 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.GetCollection; + +public sealed class GetCollectionQueryHandler(IAppDbContext dbContext) + : IQueryHandler> +{ + public async Task> Handle( + GetCollectionQuery query, + CancellationToken cancellationToken + ) + { + var collection = await dbContext + .Collections.AsNoTracking() + .Include(c => c.Items) + .FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken); + if (collection is null) + return Result.Failure(CollectionErrors.NotFound); + + var showIds = collection.Items.Select(i => i.ShowId).ToList(); + var shows = await dbContext + .Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id)) + .Select(s => new + { + s.Id, + s.Name, + s.Kind, + s.Audience, + s.Year, + s.PosterImageId, + EpisodeCount = s.Episodes.Count, + }) + .ToDictionaryAsync(s => s.Id, cancellationToken); + + var items = collection + .Items.OrderBy(i => i.Position) + .Select(i => + { + shows.TryGetValue(i.ShowId, out var show); + return new CollectionItemDto( + i.ShowId, + i.Position, + show?.Name ?? "—", + show?.Kind ?? default, + show?.Audience ?? default, + show?.EpisodeCount ?? 0, + show?.Year, + show?.PosterImageId + ); + }) + .ToList(); + + return Result.Success( + new CollectionDto( + collection.Id, + collection.Name, + collection.Description, + collection.PosterImageId, + collection.CreatedAt, + items + ) + ); + } +} diff --git a/backend/src/TeleWave.Application/Library/Collections/ListCollections/ListCollectionsQuery.cs b/backend/src/TeleWave.Application/Library/Collections/ListCollections/ListCollectionsQuery.cs new file mode 100644 index 0000000..09cd8f7 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/ListCollections/ListCollectionsQuery.cs @@ -0,0 +1,5 @@ +using LiteCqrs; + +namespace TeleWave.Application.Library.Collections.ListCollections; + +public sealed record ListCollectionsQuery : IQuery>; diff --git a/backend/src/TeleWave.Application/Library/Collections/ListCollections/ListCollectionsQueryHandler.cs b/backend/src/TeleWave.Application/Library/Collections/ListCollections/ListCollectionsQueryHandler.cs new file mode 100644 index 0000000..52bd584 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/ListCollections/ListCollectionsQueryHandler.cs @@ -0,0 +1,42 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Application.Library.Collections.ListCollections; + +public sealed class ListCollectionsQueryHandler(IAppDbContext dbContext) + : IQueryHandler> +{ + public async Task> Handle( + ListCollectionsQuery query, + CancellationToken cancellationToken + ) + { + var collections = await dbContext + .Collections.AsNoTracking() + .Include(c => c.Items) + .OrderBy(c => c.Name) + .ToListAsync(cancellationToken); + + // Единиц воспроизведения может быть больше, чем частей: сериал внутри коллекции + // разворачивается в свои серии. + var showIds = collections.SelectMany(c => c.Items.Select(i => i.ShowId)).Distinct().ToList(); + var episodeCounts = await dbContext + .Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id)) + .Select(s => new { s.Id, Count = s.Episodes.Count }) + .ToDictionaryAsync(x => x.Id, x => x.Count, cancellationToken); + + return collections + .Select(c => new CollectionSummaryDto( + c.Id, + c.Name, + c.Description, + c.PosterImageId, + c.Items.Count, + c.Items.Sum(i => episodeCounts.TryGetValue(i.ShowId, out var n) ? n : 0), + c.CreatedAt + )) + .ToList(); + } +} diff --git a/backend/src/TeleWave.Application/Library/Collections/RemoveCollectionShow/RemoveCollectionShowCommand.cs b/backend/src/TeleWave.Application/Library/Collections/RemoveCollectionShow/RemoveCollectionShowCommand.cs new file mode 100644 index 0000000..13c8276 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/RemoveCollectionShow/RemoveCollectionShowCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.RemoveCollectionShow; + +public sealed record RemoveCollectionShowCommand(Guid CollectionId, Guid ShowId) : ICommand; diff --git a/backend/src/TeleWave.Application/Library/Collections/RemoveCollectionShow/RemoveCollectionShowCommandHandler.cs b/backend/src/TeleWave.Application/Library/Collections/RemoveCollectionShow/RemoveCollectionShowCommandHandler.cs new file mode 100644 index 0000000..bd096ca --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/RemoveCollectionShow/RemoveCollectionShowCommandHandler.cs @@ -0,0 +1,26 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.RemoveCollectionShow; + +public sealed class RemoveCollectionShowCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + RemoveCollectionShowCommand command, + CancellationToken cancellationToken + ) + { + var collection = await dbContext + .Collections.Include(c => c.Items) + .FirstOrDefaultAsync(c => c.Id == command.CollectionId, cancellationToken); + if (collection is null) + return Result.Failure(CollectionErrors.NotFound); + + return collection.RemoveShow(command.ShowId) + ? Result.Success() + : Result.Failure(CollectionErrors.ShowNotInCollection); + } +} diff --git a/backend/src/TeleWave.Application/Library/Collections/ReorderCollection/ReorderCollectionCommand.cs b/backend/src/TeleWave.Application/Library/Collections/ReorderCollection/ReorderCollectionCommand.cs new file mode 100644 index 0000000..797094a --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/ReorderCollection/ReorderCollectionCommand.cs @@ -0,0 +1,9 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.ReorderCollection; + +/// Переставляет части коллекции в порядке . Не упомянутые +/// остаются после них, сохраняя относительный порядок. +public sealed record ReorderCollectionCommand(Guid CollectionId, IReadOnlyList ShowIdsInOrder) + : ICommand; diff --git a/backend/src/TeleWave.Application/Library/Collections/ReorderCollection/ReorderCollectionCommandHandler.cs b/backend/src/TeleWave.Application/Library/Collections/ReorderCollection/ReorderCollectionCommandHandler.cs new file mode 100644 index 0000000..d0d73c2 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/ReorderCollection/ReorderCollectionCommandHandler.cs @@ -0,0 +1,25 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.ReorderCollection; + +public sealed class ReorderCollectionCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + ReorderCollectionCommand command, + CancellationToken cancellationToken + ) + { + var collection = await dbContext + .Collections.Include(c => c.Items) + .FirstOrDefaultAsync(c => c.Id == command.CollectionId, cancellationToken); + if (collection is null) + return Result.Failure(CollectionErrors.NotFound); + + collection.Reorder(command.ShowIdsInOrder); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Library/Collections/SetCollectionPoster/SetCollectionPosterCommand.cs b/backend/src/TeleWave.Application/Library/Collections/SetCollectionPoster/SetCollectionPosterCommand.cs new file mode 100644 index 0000000..b2fd3b0 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/SetCollectionPoster/SetCollectionPosterCommand.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.SetCollectionPoster; + +/// Привязать/снять постер коллекции ( = null — отвязать). +public sealed record SetCollectionPosterCommand(Guid CollectionId, Guid? ImageId) : ICommand; diff --git a/backend/src/TeleWave.Application/Library/Collections/SetCollectionPoster/SetCollectionPosterCommandHandler.cs b/backend/src/TeleWave.Application/Library/Collections/SetCollectionPoster/SetCollectionPosterCommandHandler.cs new file mode 100644 index 0000000..bbf740c --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/SetCollectionPoster/SetCollectionPosterCommandHandler.cs @@ -0,0 +1,33 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Images; + +namespace TeleWave.Application.Library.Collections.SetCollectionPoster; + +public sealed class SetCollectionPosterCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + SetCollectionPosterCommand command, + CancellationToken cancellationToken + ) + { + var collection = await dbContext.Collections.FirstOrDefaultAsync( + c => c.Id == command.CollectionId, + cancellationToken + ); + if (collection is null) + return Result.Failure(CollectionErrors.NotFound); + + if ( + command.ImageId is { } imageId + && !await dbContext.Images.AnyAsync(i => i.Id == imageId, cancellationToken) + ) + return Result.Failure(ImageErrors.NotFound); + + collection.SetPosterImage(command.ImageId); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Library/Collections/UpdateCollection/UpdateCollectionCommand.cs b/backend/src/TeleWave.Application/Library/Collections/UpdateCollection/UpdateCollectionCommand.cs new file mode 100644 index 0000000..8c7353e --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/UpdateCollection/UpdateCollectionCommand.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.UpdateCollection; + +public sealed record UpdateCollectionCommand(Guid CollectionId, string Name, string? Description) + : ICommand; diff --git a/backend/src/TeleWave.Application/Library/Collections/UpdateCollection/UpdateCollectionCommandHandler.cs b/backend/src/TeleWave.Application/Library/Collections/UpdateCollection/UpdateCollectionCommandHandler.cs new file mode 100644 index 0000000..6f52db3 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/UpdateCollection/UpdateCollectionCommandHandler.cs @@ -0,0 +1,26 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.UpdateCollection; + +public sealed class UpdateCollectionCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + UpdateCollectionCommand command, + CancellationToken cancellationToken + ) + { + var collection = await dbContext.Collections.FirstOrDefaultAsync( + c => c.Id == command.CollectionId, + cancellationToken + ); + if (collection is null) + return Result.Failure(CollectionErrors.NotFound); + + collection.Rename(command.Name, command.Description); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Library/Collections/UpdateCollection/UpdateCollectionCommandValidator.cs b/backend/src/TeleWave.Application/Library/Collections/UpdateCollection/UpdateCollectionCommandValidator.cs new file mode 100644 index 0000000..fcfea2b --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/UpdateCollection/UpdateCollectionCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace TeleWave.Application.Library.Collections.UpdateCollection; + +public sealed class UpdateCollectionCommandValidator : AbstractValidator +{ + public UpdateCollectionCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(256); + RuleFor(x => x.Description).MaximumLength(2048); + } +} diff --git a/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs b/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs index 0d83e6f..9122ff0 100644 --- a/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs +++ b/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs @@ -2,11 +2,15 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; +using TeleWave.Application.Programming.Groups; +using TeleWave.Domain.Programming; namespace TeleWave.Application.Library.DeleteShow; -public sealed class DeleteShowCommandHandler(IAppDbContext dbContext) - : ICommandHandler +public sealed class DeleteShowCommandHandler( + IAppDbContext dbContext, + GroupMembershipCleaner groupCleaner +) : ICommandHandler { public async Task Handle(DeleteShowCommand command, CancellationToken cancellationToken) { @@ -17,6 +21,13 @@ public sealed class DeleteShowCommandHandler(IAppDbContext dbContext) if (show is null) return Result.Failure(ShowErrors.NotFound); + // Позиции коллекций уходят каскадом БД, позиции групп — вручную: ссылка группы полиморфна. + await groupCleaner.RemoveElementAsync( + GroupElementKind.Show, + command.ShowId, + cancellationToken + ); + // TODO(этап 2+): запретить удаление шоу, пока оно привязано к каналу или будущему расписанию. dbContext.Shows.Remove(show); return Result.Success(); diff --git a/backend/src/TeleWave.Application/Library/Genres/CreateGenre/CreateGenreCommand.cs b/backend/src/TeleWave.Application/Library/Genres/CreateGenre/CreateGenreCommand.cs new file mode 100644 index 0000000..bcbb22b --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Genres/CreateGenre/CreateGenreCommand.cs @@ -0,0 +1,12 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Genres.CreateGenre; + +/// — варианты написания для сопоставления с метаданными +/// провайдеров; само название и ключ добавляются автоматически. +public sealed record CreateGenreCommand( + string Name, + string Slug, + IReadOnlyList? Aliases = null +) : ICommand>; diff --git a/backend/src/TeleWave.Application/Library/Genres/CreateGenre/CreateGenreCommandHandler.cs b/backend/src/TeleWave.Application/Library/Genres/CreateGenre/CreateGenreCommandHandler.cs new file mode 100644 index 0000000..18b8953 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Genres/CreateGenre/CreateGenreCommandHandler.cs @@ -0,0 +1,36 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Library.Genres.CreateGenre; + +public sealed class CreateGenreCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle( + CreateGenreCommand command, + CancellationToken cancellationToken + ) + { + var slug = GenreAlias.Normalize(command.Slug); + if (await dbContext.Genres.AnyAsync(g => g.Slug == slug, cancellationToken)) + return Result.Failure(GenreErrors.SlugTaken); + + var nextSortOrder = await dbContext.Genres.AnyAsync(cancellationToken) + ? await dbContext.Genres.MaxAsync(g => g.SortOrder, cancellationToken) + 1 + : 0; + + var genre = Genre.Create(command.Name, slug, nextSortOrder); + foreach (var alias in GenreAliasInput.Collect(command.Name, slug, command.Aliases)) + genre.AddAlias(alias); + + var values = genre.Aliases.Select(a => a.Value).ToList(); + if (await dbContext.GenreAliases.AnyAsync(a => values.Contains(a.Value), cancellationToken)) + return Result.Failure(GenreErrors.AliasTaken); + + dbContext.Genres.Add(genre); + return Result.Success(genre.Id); + } +} diff --git a/backend/src/TeleWave.Application/Library/Genres/CreateGenre/CreateGenreCommandValidator.cs b/backend/src/TeleWave.Application/Library/Genres/CreateGenre/CreateGenreCommandValidator.cs new file mode 100644 index 0000000..de0336d --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Genres/CreateGenre/CreateGenreCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace TeleWave.Application.Library.Genres.CreateGenre; + +public sealed class CreateGenreCommandValidator : AbstractValidator +{ + public CreateGenreCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(128); + RuleFor(x => x.Slug).NotEmpty().MaximumLength(64); + RuleForEach(x => x.Aliases).NotEmpty().MaximumLength(128); + } +} diff --git a/backend/src/TeleWave.Application/Library/Genres/DeleteGenre/DeleteGenreCommand.cs b/backend/src/TeleWave.Application/Library/Genres/DeleteGenre/DeleteGenreCommand.cs new file mode 100644 index 0000000..9d9f88f --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Genres/DeleteGenre/DeleteGenreCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Genres.DeleteGenre; + +public sealed record DeleteGenreCommand(Guid GenreId) : ICommand; diff --git a/backend/src/TeleWave.Application/Library/Genres/DeleteGenre/DeleteGenreCommandHandler.cs b/backend/src/TeleWave.Application/Library/Genres/DeleteGenre/DeleteGenreCommandHandler.cs new file mode 100644 index 0000000..76d592d --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Genres/DeleteGenre/DeleteGenreCommandHandler.cs @@ -0,0 +1,34 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Genres.DeleteGenre; + +public sealed class DeleteGenreCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + DeleteGenreCommand command, + CancellationToken cancellationToken + ) + { + var genre = await dbContext.Genres.FirstOrDefaultAsync( + g => g.Id == command.GenreId, + cancellationToken + ); + if (genre is null) + return Result.Failure(GenreErrors.NotFound); + + if (genre.IsSystem) + return Result.Failure(GenreErrors.SystemCannotBeDeleted); + + // Связь ShowGenre→Genre настроена как Restrict: без этой проверки удаление упало бы + // исключением БД вместо управляемой ошибки. + if (await dbContext.ShowGenres.AnyAsync(sg => sg.GenreId == genre.Id, cancellationToken)) + return Result.Failure(GenreErrors.InUse); + + dbContext.Genres.Remove(genre); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Library/Genres/GenreAliasInput.cs b/backend/src/TeleWave.Application/Library/Genres/GenreAliasInput.cs new file mode 100644 index 0000000..18ef4d0 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Genres/GenreAliasInput.cs @@ -0,0 +1,21 @@ +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Library.Genres; + +/// Сборка набора вариантов написания из пользовательского ввода: к явно заданным всегда +/// добавляются само название и ключ жанра — по ним провайдеры отдают жанр чаще всего. +public static class GenreAliasInput +{ + public static IReadOnlyList Collect( + string name, + string slug, + IReadOnlyList? aliases + ) => + (aliases ?? []) + .Append(name) + .Append(slug) + .Select(GenreAlias.Normalize) + .Where(value => value.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToList(); +} diff --git a/backend/src/TeleWave.Application/Library/Genres/GenreDto.cs b/backend/src/TeleWave.Application/Library/Genres/GenreDto.cs new file mode 100644 index 0000000..d3a38cf --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Genres/GenreDto.cs @@ -0,0 +1,13 @@ +namespace TeleWave.Application.Library.Genres; + +/// Жанр справочника для админки. — сколько шоу его используют +/// (нужен, чтобы админ видел, что удаление заблокировано, ещё до попытки удалить). +public sealed record GenreDto( + Guid Id, + string Name, + string Slug, + int SortOrder, + bool IsSystem, + IReadOnlyList Aliases, + int ShowCount +); diff --git a/backend/src/TeleWave.Application/Library/Genres/GenreErrors.cs b/backend/src/TeleWave.Application/Library/Genres/GenreErrors.cs new file mode 100644 index 0000000..f92c860 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Genres/GenreErrors.cs @@ -0,0 +1,28 @@ +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Genres; + +public static class GenreErrors +{ + public static readonly Error NotFound = Error.NotFound("Genres.NotFound", "Жанр не найден."); + + public static readonly Error SlugTaken = Error.Conflict( + "Genres.SlugTaken", + "Жанр с таким ключом уже существует." + ); + + public static readonly Error AliasTaken = Error.Conflict( + "Genres.AliasTaken", + "Один из вариантов написания уже закреплён за другим жанром." + ); + + public static readonly Error InUse = Error.Conflict( + "Genres.InUse", + "Жанр проставлен у шоу — сначала снимите его." + ); + + public static readonly Error SystemCannotBeDeleted = Error.Conflict( + "Genres.SystemCannotBeDeleted", + "Системный жанр нельзя удалить." + ); +} diff --git a/backend/src/TeleWave.Application/Library/Genres/GenreMatcher.cs b/backend/src/TeleWave.Application/Library/Genres/GenreMatcher.cs new file mode 100644 index 0000000..20e8487 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Genres/GenreMatcher.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Library.Genres; + +/// +/// Сопоставляет сырые обозначения жанров от внешних источников со справочником. Порядок исходного +/// списка сохраняется: первый распознанный жанр становится основным у шоу, а провайдеры отдают +/// жанры по убыванию значимости. +/// +/// Нераспознанные обозначения молча отбрасываются: у шоу останутся те жанры, которые справочник +/// знает, а расширить справочник — задача администратора (см. псевдонимы жанра). +/// +public sealed class GenreMatcher(IAppDbContext dbContext) +{ + public async Task> MatchAsync( + IEnumerable? rawGenres, + CancellationToken cancellationToken + ) + { + var normalized = (rawGenres ?? []) + .Select(GenreAlias.Normalize) + .Where(value => value.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (normalized.Count == 0) + return []; + + var byValue = await dbContext + .GenreAliases.AsNoTracking() + .Where(a => normalized.Contains(a.Value)) + .ToDictionaryAsync(a => a.Value, a => a.GenreId, cancellationToken); + + var result = new List(); + foreach (var value in normalized) + { + // Один жанр приходит несколькими обозначениями (идентификатор + название) — берём первое + // распознанное, дубликаты пропускаем. + if (byValue.TryGetValue(value, out var genreId) && !result.Contains(genreId)) + result.Add(genreId); + } + + return result; + } +} diff --git a/backend/src/TeleWave.Application/Library/Genres/ListGenres/ListGenresQuery.cs b/backend/src/TeleWave.Application/Library/Genres/ListGenres/ListGenresQuery.cs new file mode 100644 index 0000000..a8c1da3 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Genres/ListGenres/ListGenresQuery.cs @@ -0,0 +1,5 @@ +using LiteCqrs; + +namespace TeleWave.Application.Library.Genres.ListGenres; + +public sealed record ListGenresQuery : IQuery>; diff --git a/backend/src/TeleWave.Application/Library/Genres/ListGenres/ListGenresQueryHandler.cs b/backend/src/TeleWave.Application/Library/Genres/ListGenres/ListGenresQueryHandler.cs new file mode 100644 index 0000000..3b625f4 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Genres/ListGenres/ListGenresQueryHandler.cs @@ -0,0 +1,40 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Application.Library.Genres.ListGenres; + +public sealed class ListGenresQueryHandler(IAppDbContext dbContext) + : IQueryHandler> +{ + public async Task> Handle( + ListGenresQuery query, + CancellationToken cancellationToken + ) + { + var genres = await dbContext + .Genres.AsNoTracking() + .Include(g => g.Aliases) + .OrderBy(g => g.SortOrder) + .ThenBy(g => g.Name) + .ToListAsync(cancellationToken); + + var usage = await dbContext + .ShowGenres.AsNoTracking() + .GroupBy(sg => sg.GenreId) + .Select(g => new { GenreId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.GenreId, x => x.Count, cancellationToken); + + return genres + .Select(g => new GenreDto( + g.Id, + g.Name, + g.Slug, + g.SortOrder, + g.IsSystem, + g.Aliases.Select(a => a.Value).OrderBy(v => v, StringComparer.Ordinal).ToList(), + usage.TryGetValue(g.Id, out var count) ? count : 0 + )) + .ToList(); + } +} diff --git a/backend/src/TeleWave.Application/Library/Genres/UpdateGenre/UpdateGenreCommand.cs b/backend/src/TeleWave.Application/Library/Genres/UpdateGenre/UpdateGenreCommand.cs new file mode 100644 index 0000000..ea9ae2e --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Genres/UpdateGenre/UpdateGenreCommand.cs @@ -0,0 +1,11 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Genres.UpdateGenre; + +public sealed record UpdateGenreCommand( + Guid GenreId, + string Name, + int SortOrder, + IReadOnlyList? Aliases = null +) : ICommand; diff --git a/backend/src/TeleWave.Application/Library/Genres/UpdateGenre/UpdateGenreCommandHandler.cs b/backend/src/TeleWave.Application/Library/Genres/UpdateGenre/UpdateGenreCommandHandler.cs new file mode 100644 index 0000000..6ac1bdc --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Genres/UpdateGenre/UpdateGenreCommandHandler.cs @@ -0,0 +1,36 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Genres.UpdateGenre; + +public sealed class UpdateGenreCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + UpdateGenreCommand command, + CancellationToken cancellationToken + ) + { + var genre = await dbContext + .Genres.Include(g => g.Aliases) + .FirstOrDefaultAsync(g => g.Id == command.GenreId, cancellationToken); + if (genre is null) + return Result.Failure(GenreErrors.NotFound); + + var aliases = GenreAliasInput.Collect(command.Name, genre.Slug, command.Aliases); + + // Псевдоним уникален по всему справочнику — проверяем, не занят ли он другим жанром. + var taken = await dbContext + .GenreAliases.Where(a => aliases.Contains(a.Value) && a.GenreId != genre.Id) + .AnyAsync(cancellationToken); + if (taken) + return Result.Failure(GenreErrors.AliasTaken); + + genre.Rename(command.Name); + genre.SetSortOrder(command.SortOrder); + genre.ReplaceAliases(aliases); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Library/Genres/UpdateGenre/UpdateGenreCommandValidator.cs b/backend/src/TeleWave.Application/Library/Genres/UpdateGenre/UpdateGenreCommandValidator.cs new file mode 100644 index 0000000..8793bce --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Genres/UpdateGenre/UpdateGenreCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace TeleWave.Application.Library.Genres.UpdateGenre; + +public sealed class UpdateGenreCommandValidator : AbstractValidator +{ + public UpdateGenreCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(128); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + RuleForEach(x => x.Aliases).NotEmpty().MaximumLength(128); + } +} diff --git a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs index 77bf6c4..86e18bf 100644 --- a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs @@ -2,6 +2,7 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; +using TeleWave.Application.Library.Collections; namespace TeleWave.Application.Library.GetShow; @@ -16,10 +17,28 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext) var show = await dbContext .Shows.AsNoTracking() .Include(s => s.Episodes) + .Include(s => s.Genres) .FirstOrDefaultAsync(s => s.Id == query.Id, cancellationToken); if (show is null) return Result.Failure(ShowErrors.NotFound); + var genreIds = show.Genres.Select(g => g.GenreId).ToList(); + var genreNames = await dbContext + .Genres.AsNoTracking() + .Where(g => genreIds.Contains(g.Id)) + .Select(g => new { g.Id, g.Name, g.SortOrder }) + .ToListAsync(cancellationToken); + + var genreDtos = genreNames + .OrderByDescending(g => show.Genres.First(sg => sg.GenreId == g.Id).IsPrimary) + .ThenBy(g => g.SortOrder) + .Select(g => new ShowGenreDto( + g.Id, + g.Name, + show.Genres.First(sg => sg.GenreId == g.Id).IsPrimary + )) + .ToList(); + var episodes = show.Episodes.OrderBy(e => e.Position).ToList(); var assetIds = episodes.Select(e => e.MediaAssetId).ToList(); var assets = await dbContext @@ -55,6 +74,19 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext) }) .ToList(); + var collections = await dbContext + .CollectionItems.AsNoTracking() + .Where(i => i.ShowId == show.Id) + .Join( + dbContext.Collections.AsNoTracking(), + item => item.CollectionId, + collection => collection.Id, + (item, collection) => + new ShowCollectionRefDto(collection.Id, collection.Name, item.Position) + ) + .OrderBy(c => c.Name) + .ToListAsync(cancellationToken); + return Result.Success( new ShowDto( show.Id, @@ -67,7 +99,9 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext) show.MetadataExternalId, show.Year, show.PosterImageId, - episodeDtos + episodeDtos, + genreDtos, + collections ) ); } diff --git a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQuery.cs b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQuery.cs index acf0b80..e3fe9dc 100644 --- a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQuery.cs +++ b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQuery.cs @@ -2,4 +2,10 @@ using LiteCqrs; namespace TeleWave.Application.Library.ListShows; -public sealed record ListShowsQuery : IQuery>; +/// +/// — оставить только шоу с этим жанром (основным или нет). +/// — вернуть ролики-врезки вместо контента: у них своя страница, +/// и в общей библиотеке они только мешали бы. +/// +public sealed record ListShowsQuery(Guid? GenreId = null, bool Interstitials = false) + : IQuery>; diff --git a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs index 12a66e3..5c59a78 100644 --- a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs @@ -1,59 +1,85 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; - -namespace TeleWave.Application.Library.ListShows; - -public sealed class ListShowsQueryHandler(IAppDbContext dbContext) - : IQueryHandler> -{ - public async Task> Handle( - ListShowsQuery query, - CancellationToken cancellationToken - ) - { - var shows = await dbContext - .Shows.AsNoTracking() - .Include(s => s.Episodes) - .OrderBy(s => s.Name) - .ToListAsync(cancellationToken); - - // Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся). - var assetIds = shows - .SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)) - .Distinct() - .ToList(); - var names = await dbContext - .MediaAssets.AsNoTracking() - .Where(a => assetIds.Contains(a.Id)) - .Select(a => new { a.Id, a.OriginalFileName }) - .ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken); - - return shows - .Select(s => - { - var seasons = s - .Episodes.Select(e => - names.TryGetValue(e.MediaAssetId, out var n) - ? EpisodeName.ParseSeason(n) - : null - ) - .Where(season => season is not null) - .Distinct() - .Count(); - return new ShowSummaryDto( - s.Id, - s.Name, - s.OriginalName, - s.Kind, - s.Audience, - s.Episodes.Count, - seasons, - s.Year, - s.PosterImageId is not null, - s.CreatedAt - ); - }) - .ToList(); - } -} +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Library.ListShows; + +public sealed class ListShowsQueryHandler(IAppDbContext dbContext) + : IQueryHandler> +{ + public async Task> Handle( + ListShowsQuery query, + CancellationToken cancellationToken + ) + { + var source = dbContext + .Shows.AsNoTracking() + .Include(s => s.Episodes) + .Include(s => s.Genres) + .Where(s => + query.Interstitials + ? s.Kind == ShowKind.Interstitial + : s.Kind != ShowKind.Interstitial + ); + + var filtered = query.GenreId is { } genreId + ? source.Where(s => s.Genres.Any(g => g.GenreId == genreId)) + : source; + + var shows = await filtered.OrderBy(s => s.Name).ToListAsync(cancellationToken); + + // Названия только для основных жанров — в списке показывается один. + var primaryIds = shows + .Select(s => s.PrimaryGenreId) + .Where(id => id is not null) + .Select(id => id!.Value) + .Distinct() + .ToList(); + var genreNames = await dbContext + .Genres.AsNoTracking() + .Where(g => primaryIds.Contains(g.Id)) + .ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken); + + // Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся). + var assetIds = shows + .SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)) + .Distinct() + .ToList(); + var names = await dbContext + .MediaAssets.AsNoTracking() + .Where(a => assetIds.Contains(a.Id)) + .Select(a => new { a.Id, a.OriginalFileName }) + .ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken); + + return shows + .Select(s => + { + var seasons = s + .Episodes.Select(e => + names.TryGetValue(e.MediaAssetId, out var n) + ? EpisodeName.ParseSeason(n) + : null + ) + .Where(season => season is not null) + .Distinct() + .Count(); + return new ShowSummaryDto( + s.Id, + s.Name, + s.OriginalName, + s.Kind, + s.Audience, + s.Episodes.Count, + seasons, + s.Year, + s.PosterImageId is not null, + s.CreatedAt, + s.PrimaryGenreId is { } primaryId && genreNames.TryGetValue(primaryId, out var g) + ? g + : null + ); + }) + .ToList(); + } +} diff --git a/backend/src/TeleWave.Application/Library/SetShowGenres/SetShowGenresCommand.cs b/backend/src/TeleWave.Application/Library/SetShowGenres/SetShowGenresCommand.cs new file mode 100644 index 0000000..429a81c --- /dev/null +++ b/backend/src/TeleWave.Application/Library/SetShowGenres/SetShowGenresCommand.cs @@ -0,0 +1,12 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.SetShowGenres; + +/// Полностью заменяет набор жанров шоу. — какой считать +/// основным; если он не входит в набор, основным станет первый из списка. +public sealed record SetShowGenresCommand( + Guid ShowId, + IReadOnlyList GenreIds, + Guid? PrimaryGenreId = null +) : ICommand; diff --git a/backend/src/TeleWave.Application/Library/SetShowGenres/SetShowGenresCommandHandler.cs b/backend/src/TeleWave.Application/Library/SetShowGenres/SetShowGenresCommandHandler.cs new file mode 100644 index 0000000..bfb7bd2 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/SetShowGenres/SetShowGenresCommandHandler.cs @@ -0,0 +1,37 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Library.Genres; + +namespace TeleWave.Application.Library.SetShowGenres; + +public sealed class SetShowGenresCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + SetShowGenresCommand command, + CancellationToken cancellationToken + ) + { + var show = await dbContext + .Shows.Include(s => s.Genres) + .FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken); + if (show is null) + return Result.Failure(ShowErrors.NotFound); + + var ids = command.GenreIds.Distinct().ToList(); + if (ids.Count > 0) + { + // Ссылка на несуществующий жанр упала бы нарушением внешнего ключа — проверяем заранее. + var known = await dbContext + .Genres.Where(g => ids.Contains(g.Id)) + .CountAsync(cancellationToken); + if (known != ids.Count) + return Result.Failure(GenreErrors.NotFound); + } + + show.SetGenres(ids, command.PrimaryGenreId); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Library/ShowDtos.cs b/backend/src/TeleWave.Application/Library/ShowDtos.cs index a544561..f2f48d1 100644 --- a/backend/src/TeleWave.Application/Library/ShowDtos.cs +++ b/backend/src/TeleWave.Application/Library/ShowDtos.cs @@ -1,3 +1,4 @@ +using TeleWave.Application.Library.Collections; using TeleWave.Domain.Library; using TeleWave.Domain.Media; @@ -13,9 +14,14 @@ public sealed record ShowSummaryDto( int SeasonCount, int? Year, bool HasPoster, - DateTimeOffset CreatedAt + DateTimeOffset CreatedAt, + /// Название основного жанра — в списке показываем только его, остальные видны в карточке шоу. + string? PrimaryGenre = null ); +/// Жанр, проставленный шоу. +public sealed record ShowGenreDto(Guid Id, string Name, bool IsPrimary); + public sealed record EpisodeDto( Guid Id, Guid MediaAssetId, @@ -42,5 +48,8 @@ public sealed record ShowDto( string? MetadataExternalId, int? Year, Guid? PosterImageId, - IReadOnlyList Episodes + IReadOnlyList Episodes, + IReadOnlyList Genres, + /// Коллекции (франшизы), в которые входит шоу, с его позицией в каждой. + IReadOnlyList Collections ); diff --git a/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs index 3e312c2..53b492c 100644 --- a/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs +++ b/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; using TeleWave.Application.Library; +using TeleWave.Application.Library.Genres; using TeleWave.Domain.Images; namespace TeleWave.Application.Metadata.ApplyShowMetadata; @@ -11,7 +12,8 @@ public sealed class ApplyShowMetadataCommandHandler( IAppDbContext dbContext, IMetadataProviderResolver resolver, IImageDownloader downloader, - IImageStore imageStore + IImageStore imageStore, + GenreMatcher genreMatcher ) : ICommandHandler { public async Task Handle( @@ -19,10 +21,9 @@ public sealed class ApplyShowMetadataCommandHandler( CancellationToken cancellationToken ) { - var show = await dbContext.Shows.FirstOrDefaultAsync( - s => s.Id == command.ShowId, - cancellationToken - ); + var show = await dbContext + .Shows.Include(s => s.Genres) + .FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken); if (show is null) return Result.Failure(ShowErrors.NotFound); @@ -60,6 +61,13 @@ public sealed class ApplyShowMetadataCommandHandler( meta.Year, posterImageId ); + + // Жанры источника перезаписывают текущие, только если хоть один распознан справочником: + // иначе применение метаданных к шоу с руками проставленными жанрами обнуляло бы их. + var genreIds = await genreMatcher.MatchAsync(meta.Genres, cancellationToken); + if (genreIds.Count > 0) + show.SetGenres(genreIds); + return Result.Success(); } } diff --git a/backend/src/TeleWave.Application/Metadata/MetadataModels.cs b/backend/src/TeleWave.Application/Metadata/MetadataModels.cs index 9c5a93e..a5ce0e2 100644 --- a/backend/src/TeleWave.Application/Metadata/MetadataModels.cs +++ b/backend/src/TeleWave.Application/Metadata/MetadataModels.cs @@ -9,13 +9,19 @@ public sealed record MetadataCandidate( string? PosterUrl ); -/// Метаданные шоу из источника (PosterUrl — полный URL, скачивается локально при применении). +/// +/// Метаданные шоу из источника (PosterUrl — полный URL, скачивается локально при применении). +/// — сырые обозначения жанров в том порядке, в каком их отдал источник: +/// идентификаторы (tmdb:28) и названия вперемешку. Сопоставление со справочником — +/// на стороне приложения (см. GenreMatcher); первый распознанный становится основным жанром шоу. +/// public sealed record ShowMetadata( string ExternalId, string Title, int? Year, string? Overview, - string? PosterUrl + string? PosterUrl, + IReadOnlyList? Genres = null ); /// Метаданные серии (для этапа 2). diff --git a/backend/src/TeleWave.Application/Programming/Groups/AddGroupElements/AddGroupElementsCommand.cs b/backend/src/TeleWave.Application/Programming/Groups/AddGroupElements/AddGroupElementsCommand.cs new file mode 100644 index 0000000..1a35150 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/AddGroupElements/AddGroupElementsCommand.cs @@ -0,0 +1,15 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Groups.AddGroupElements; + +public sealed record GroupElementRef(GroupElementKind ElementKind, Guid ElementId); + +/// +/// Добавляет элементы в конец группы. Массовая — так же добавляется и найденное правилом набора. +/// Уже входящие в группу молча пропускаются: при добавлении полусотни позиций падать из-за одного +/// повтора бессмысленно. Возвращает число реально добавленных. +/// +public sealed record AddGroupElementsCommand(Guid GroupId, IReadOnlyList Elements) + : ICommand>; diff --git a/backend/src/TeleWave.Application/Programming/Groups/AddGroupElements/AddGroupElementsCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/AddGroupElements/AddGroupElementsCommandHandler.cs new file mode 100644 index 0000000..9ba415a --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/AddGroupElements/AddGroupElementsCommandHandler.cs @@ -0,0 +1,57 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Groups.AddGroupElements; + +public sealed class AddGroupElementsCommandHandler( + IAppDbContext dbContext, + GroupStatsService stats +) : ICommandHandler> +{ + public async Task> Handle( + AddGroupElementsCommand command, + CancellationToken cancellationToken + ) + { + var group = await dbContext + .Groups.Include(g => g.Items) + .FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken); + if (group is null) + return Result.Failure(GroupErrors.NotFound); + + var requested = command.Elements.Distinct().ToList(); + + // Ссылки полиморфные, внешнего ключа нет — проверяем существование сами, иначе в группе + // осядут позиции, которые никогда не развернутся в контент. + var showIds = requested + .Where(e => e.ElementKind == GroupElementKind.Show) + .Select(e => e.ElementId) + .ToList(); + var collectionIds = requested + .Where(e => e.ElementKind == GroupElementKind.Collection) + .Select(e => e.ElementId) + .ToList(); + + var knownShows = await dbContext + .Shows.Where(s => showIds.Contains(s.Id)) + .Select(s => s.Id) + .ToListAsync(cancellationToken); + var knownCollections = await dbContext + .Collections.Where(c => collectionIds.Contains(c.Id)) + .Select(c => c.Id) + .ToListAsync(cancellationToken); + + if (knownShows.Count != showIds.Count || knownCollections.Count != collectionIds.Count) + return Result.Failure(GroupErrors.ElementNotFound); + + var added = requested.Count(element => + group.AddElement(element.ElementKind, element.ElementId) is not null + ); + + await stats.RecomputeAsync(group, cancellationToken); + return Result.Success(added); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/CreateGroup/CreateGroupCommand.cs b/backend/src/TeleWave.Application/Programming/Groups/CreateGroup/CreateGroupCommand.cs new file mode 100644 index 0000000..ea6abb3 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/CreateGroup/CreateGroupCommand.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Groups.CreateGroup; + +public sealed record CreateGroupCommand(string Name, string? Description = null) + : ICommand>; diff --git a/backend/src/TeleWave.Application/Programming/Groups/CreateGroup/CreateGroupCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/CreateGroup/CreateGroupCommandHandler.cs new file mode 100644 index 0000000..e7381b5 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/CreateGroup/CreateGroupCommandHandler.cs @@ -0,0 +1,17 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Groups.CreateGroup; + +public sealed class CreateGroupCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public Task> Handle(CreateGroupCommand command, CancellationToken cancellationToken) + { + var group = Group.Create(command.Name, command.Description); + dbContext.Groups.Add(group); + return Task.FromResult(Result.Success(group.Id)); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/CreateGroup/CreateGroupCommandValidator.cs b/backend/src/TeleWave.Application/Programming/Groups/CreateGroup/CreateGroupCommandValidator.cs new file mode 100644 index 0000000..95fb7fc --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/CreateGroup/CreateGroupCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace TeleWave.Application.Programming.Groups.CreateGroup; + +public sealed class CreateGroupCommandValidator : AbstractValidator +{ + public CreateGroupCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(256); + RuleFor(x => x.Description).MaximumLength(2048); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommand.cs b/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommand.cs new file mode 100644 index 0000000..9d7a469 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Groups.DeleteGroup; + +public sealed record DeleteGroupCommand(Guid GroupId) : ICommand; diff --git a/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs new file mode 100644 index 0000000..6eea134 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs @@ -0,0 +1,24 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Groups.DeleteGroup; + +public sealed class DeleteGroupCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle(DeleteGroupCommand command, CancellationToken cancellationToken) + { + var group = await dbContext.Groups.FirstOrDefaultAsync( + g => g.Id == command.GroupId, + cancellationToken + ); + if (group is null) + return Result.Failure(GroupErrors.NotFound); + + // TODO(срез 1C): запретить удаление, пока на группу ссылается слот сетки. + dbContext.Groups.Remove(group); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/FindGroupCandidates/FindGroupCandidatesQuery.cs b/backend/src/TeleWave.Application/Programming/Groups/FindGroupCandidates/FindGroupCandidatesQuery.cs new file mode 100644 index 0000000..da9db2a --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/FindGroupCandidates/FindGroupCandidatesQuery.cs @@ -0,0 +1,11 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Groups.FindGroupCandidates; + +/// +/// Подбирает позиции по правилу набора. = null — берём правило, сохранённое +/// у группы; передача фильтра явно нужна редактору, где его крутят до сохранения. +/// +public sealed record FindGroupCandidatesQuery(Guid GroupId, GroupFilter? Filter = null) + : IQuery>>; diff --git a/backend/src/TeleWave.Application/Programming/Groups/FindGroupCandidates/FindGroupCandidatesQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/FindGroupCandidates/FindGroupCandidatesQueryHandler.cs new file mode 100644 index 0000000..7bde2e5 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/FindGroupCandidates/FindGroupCandidatesQueryHandler.cs @@ -0,0 +1,139 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Groups.FindGroupCandidates; + +public sealed class FindGroupCandidatesQueryHandler( + IAppDbContext dbContext, + GroupElementResolver resolver +) : IQueryHandler>> +{ + public async Task>> Handle( + FindGroupCandidatesQuery query, + CancellationToken cancellationToken + ) + { + var group = await dbContext + .Groups.AsNoTracking() + .Include(g => g.Items) + .FirstOrDefaultAsync(g => g.Id == query.GroupId, cancellationToken); + if (group is null) + return Result.Failure>(GroupErrors.NotFound); + + var filter = query.Filter ?? GroupFilter.FromJson(group.FilterJson); + if (filter is null) + return Result.Failure>(GroupErrors.FilterNotSet); + + var wantsShows = + filter.ElementKinds is null or { Count: 0 } + || filter.ElementKinds.Contains(GroupElementKind.Show); + var wantsCollections = + filter.ElementKinds is null or { Count: 0 } + || filter.ElementKinds.Contains(GroupElementKind.Collection); + + var matchingShowIds = await MatchShowIdsAsync(filter, cancellationToken); + + var elements = new List<(GroupElementKind Kind, Guid Id)>(); + if (wantsShows) + elements.AddRange(matchingShowIds.Select(id => (GroupElementKind.Show, id))); + + if (wantsCollections) + { + // Коллекция подходит, только если подходят все её части: иначе во «взрослую ночь» через + // франшизу просочилось бы детское, а в «боевики» — комедия из той же серии фильмов. + var collections = await dbContext + .Collections.AsNoTracking() + .Select(c => new { c.Id, ShowIds = c.Items.Select(i => i.ShowId).ToList() }) + .ToListAsync(cancellationToken); + + var matching = matchingShowIds.ToHashSet(); + elements.AddRange( + collections + .Where(c => c.ShowIds.Count > 0 && c.ShowIds.All(matching.Contains)) + .Select(c => (GroupElementKind.Collection, c.Id)) + ); + } + + var info = await resolver.ResolveAsync(elements, cancellationToken); + var inGroup = group + .Items.Select(i => (i.ElementKind, i.ElementId)) + .ToHashSet(); + + var result = new List(); + foreach (var (kind, id) in elements) + { + if (!info.TryGetValue((kind, id), out var element)) + continue; + if (!MatchesUnitDuration(filter, element)) + continue; + + result.Add( + new GroupCandidateDto( + kind, + id, + element.Name, + element.UnitCount, + element.ShowKind, + element.Audience, + element.Year, + element.PosterImageId, + inGroup.Contains((kind, id)) + ) + ); + } + + return Result.Success>( + result.OrderBy(c => c.ElementName, StringComparer.CurrentCultureIgnoreCase).ToList() + ); + } + + private async Task> MatchShowIdsAsync( + GroupFilter filter, + CancellationToken cancellationToken + ) + { + var shows = dbContext.Shows.AsNoTracking(); + + if (filter.ShowKinds is { Count: > 0 } kinds) + shows = shows.Where(s => kinds.Contains(s.Kind)); + + // Категории упорядочены по возрастанию строгости, поэтому «не строже» — обычное сравнение. + if (filter.MaxAudience is { } maxAudience) + shows = shows.Where(s => s.Audience <= maxAudience); + + if (filter.YearMin is { } yearMin) + shows = shows.Where(s => s.Year != null && s.Year >= yearMin); + if (filter.YearMax is { } yearMax) + shows = shows.Where(s => s.Year != null && s.Year <= yearMax); + + // Жанры — «любой из»: шоу редко подходит под все перечисленные сразу. + if (filter.GenreIds is { Count: > 0 } genreIds) + shows = shows.Where(s => s.Genres.Any(g => genreIds.Contains(g.GenreId))); + + return await shows.Select(s => s.Id).ToListAsync(cancellationToken); + } + + /// + /// Длительность проверяется по средней единице, а не по сумме: у сериала в фильтре «серии + /// по 20–25 минут» осмысленна именно длина серии. Элементы без готовых ассетов длительности + /// не имеют — их этот фильтр не отбрасывает, иначе из группы выпадало бы всё, что ещё + /// обрабатывается. + /// + private static bool MatchesUnitDuration(GroupFilter filter, GroupElementInfo element) + { + if (filter.UnitMinutesMin is null && filter.UnitMinutesMax is null) + return true; + if (element.UnitCount == 0 || element.TotalDuration <= TimeSpan.Zero) + return true; + + var averageMinutes = element.TotalDuration.TotalMinutes / element.UnitCount; + if (filter.UnitMinutesMin is { } min && averageMinutes < min) + return false; + if (filter.UnitMinutesMax is { } max && averageMinutes > max) + return false; + return true; + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/GetGroup/GetGroupQuery.cs b/backend/src/TeleWave.Application/Programming/Groups/GetGroup/GetGroupQuery.cs new file mode 100644 index 0000000..94211ad --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/GetGroup/GetGroupQuery.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Groups.GetGroup; + +public sealed record GetGroupQuery(Guid Id) : IQuery>; diff --git a/backend/src/TeleWave.Application/Programming/Groups/GetGroup/GetGroupQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/GetGroup/GetGroupQueryHandler.cs new file mode 100644 index 0000000..9f39590 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/GetGroup/GetGroupQueryHandler.cs @@ -0,0 +1,65 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Groups.GetGroup; + +public sealed class GetGroupQueryHandler(IAppDbContext dbContext, GroupElementResolver resolver) + : IQueryHandler> +{ + public async Task> Handle( + GetGroupQuery query, + CancellationToken cancellationToken + ) + { + var group = await dbContext + .Groups.AsNoTracking() + .Include(g => g.Items) + .FirstOrDefaultAsync(g => g.Id == query.Id, cancellationToken); + if (group is null) + return Result.Failure(GroupErrors.NotFound); + + var info = await resolver.ResolveAsync( + group.Items.Select(i => (i.ElementKind, i.ElementId)), + cancellationToken + ); + + var items = group + .Items.OrderBy(i => i.Position) + .Select(i => + { + info.TryGetValue((i.ElementKind, i.ElementId), out var element); + return new GroupItemDto( + i.Id, + i.ElementKind, + i.ElementId, + // Элемент мог исчезнуть из библиотеки между чисткой и чтением — показываем прочерк, + // а не роняем весь экран группы. + element?.Name ?? "—", + i.Weight, + i.Position, + element?.UnitCount ?? 0, + element?.ShowKind, + element?.Audience, + element?.Year, + element?.PosterImageId + ); + }) + .ToList(); + + return Result.Success( + new GroupDto( + group.Id, + group.Name, + group.Description, + GroupFilter.FromJson(group.FilterJson), + group.ItemCount, + group.UnitCount, + group.TotalDuration.TotalSeconds, + group.StatsComputedAt, + items + ) + ); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupDtos.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupDtos.cs new file mode 100644 index 0000000..9b63a2d --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupDtos.cs @@ -0,0 +1,60 @@ +using TeleWave.Domain.Library; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Groups; + +/// Группа в списке — со сводкой, без состава. +public sealed record GroupSummaryDto( + Guid Id, + string Name, + string? Description, + int ItemCount, + int UnitCount, + double TotalDurationSeconds, + bool HasFilter, + DateTimeOffset? StatsComputedAt, + DateTimeOffset CreatedAt +); + +/// +/// Позиция группы с данными элемента. — сколько единиц воспроизведения +/// она даёт: у фильма одна, у сериала и коллекции больше. +/// +public sealed record GroupItemDto( + Guid Id, + GroupElementKind ElementKind, + Guid ElementId, + string ElementName, + int Weight, + int Position, + int UnitCount, + ShowKind? ShowKind, + ShowAudience? Audience, + int? Year, + Guid? PosterImageId +); + +public sealed record GroupDto( + Guid Id, + string Name, + string? Description, + GroupFilter? Filter, + int ItemCount, + int UnitCount, + double TotalDurationSeconds, + DateTimeOffset? StatsComputedAt, + IReadOnlyList Items +); + +/// Кандидат, найденный правилом набора: ещё не в группе, но подходит под фильтр. +public sealed record GroupCandidateDto( + GroupElementKind ElementKind, + Guid ElementId, + string ElementName, + int UnitCount, + ShowKind? ShowKind, + ShowAudience? Audience, + int? Year, + Guid? PosterImageId, + bool AlreadyInGroup +); diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupElementResolver.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupElementResolver.cs new file mode 100644 index 0000000..2b91ed1 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupElementResolver.cs @@ -0,0 +1,137 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Groups; + +/// Сводка по элементу группы: как показать и сколько эфира он даёт. +public sealed record GroupElementInfo( + string Name, + int UnitCount, + TimeSpan TotalDuration, + ShowKind? ShowKind, + ShowAudience? Audience, + int? Year, + Guid? PosterImageId +); + +/// +/// Разворачивает ссылки группы (шоу либо коллекция) в данные для отображения и статистики. +/// Единица воспроизведения — серия: у фильма она одна, у сериала их столько же, сколько серий, +/// у коллекции — сумма по всем её частям. +/// +/// Длительность считается только по готовым ассетам: в группе может лежать шоу, чьё медиа ещё +/// обрабатывается, и включать его в «118 часов» значило бы обещать эфир, которого пока нет. +/// +public sealed class GroupElementResolver(IAppDbContext dbContext) +{ + public async Task> ResolveAsync( + IEnumerable<(GroupElementKind Kind, Guid Id)> elements, + CancellationToken cancellationToken + ) + { + var requested = elements.Distinct().ToList(); + if (requested.Count == 0) + return new Dictionary<(GroupElementKind, Guid), GroupElementInfo>(); + + var collectionIds = requested + .Where(e => e.Kind == GroupElementKind.Collection) + .Select(e => e.Id) + .ToList(); + + var collections = await dbContext + .Collections.AsNoTracking() + .Where(c => collectionIds.Contains(c.Id)) + .Select(c => new + { + c.Id, + c.Name, + c.PosterImageId, + ShowIds = c.Items.OrderBy(i => i.Position).Select(i => i.ShowId).ToList(), + }) + .ToListAsync(cancellationToken); + + var showIds = requested + .Where(e => e.Kind == GroupElementKind.Show) + .Select(e => e.Id) + .Concat(collections.SelectMany(c => c.ShowIds)) + .Distinct() + .ToList(); + + var shows = await dbContext + .Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id)) + .Select(s => new + { + s.Id, + s.Name, + s.Kind, + s.Audience, + s.Year, + s.PosterImageId, + AssetIds = s.Episodes.Select(e => e.MediaAssetId).ToList(), + }) + .ToDictionaryAsync(s => s.Id, cancellationToken); + + var assetIds = shows.Values.SelectMany(s => s.AssetIds).Distinct().ToList(); + var durations = await dbContext + .MediaAssets.AsNoTracking() + .Where(a => + assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null + ) + .Select(a => new { a.Id, a.Duration }) + .ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken); + + TimeSpan DurationOf(IEnumerable ids) => + ids.Aggregate( + TimeSpan.Zero, + (sum, id) => durations.TryGetValue(id, out var d) ? sum + d : sum + ); + + var result = new Dictionary<(GroupElementKind, Guid), GroupElementInfo>(); + + foreach (var (kind, id) in requested) + { + if (kind == GroupElementKind.Show) + { + if (!shows.TryGetValue(id, out var show)) + continue; + result[(kind, id)] = new GroupElementInfo( + show.Name, + show.AssetIds.Count, + DurationOf(show.AssetIds), + show.Kind, + show.Audience, + show.Year, + show.PosterImageId + ); + continue; + } + + var collection = collections.FirstOrDefault(c => c.Id == id); + if (collection is null) + continue; + + var parts = collection + .ShowIds.Select(showId => shows.TryGetValue(showId, out var s) ? s : null) + .Where(s => s is not null) + .ToList(); + var partAssets = parts.SelectMany(p => p!.AssetIds).ToList(); + + result[(kind, id)] = new GroupElementInfo( + collection.Name, + partAssets.Count, + DurationOf(partAssets), + null, + // Категория коллекции — самая строгая среди частей: по ней отбирают в детское время. + parts.Count == 0 ? null : parts.Max(p => p!.Audience), + parts.Count == 0 ? null : parts.Min(p => p!.Year), + collection.PosterImageId + ); + } + + return result; + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs new file mode 100644 index 0000000..8a1e7e0 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs @@ -0,0 +1,28 @@ +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Groups; + +public static class GroupErrors +{ + public static readonly Error NotFound = Error.NotFound("Groups.NotFound", "Группа не найдена."); + + public static readonly Error ItemNotFound = Error.NotFound( + "Groups.ItemNotFound", + "Позиция в группе не найдена." + ); + + public static readonly Error ElementNotFound = Error.NotFound( + "Groups.ElementNotFound", + "Шоу или коллекция не найдены." + ); + + public static readonly Error ElementAlreadyAdded = Error.Conflict( + "Groups.ElementAlreadyAdded", + "Этот элемент уже входит в группу." + ); + + public static readonly Error FilterNotSet = Error.Validation( + "Groups.FilterNotSet", + "У группы не задано правило набора." + ); +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupFilter.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupFilter.cs new file mode 100644 index 0000000..3ba8962 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupFilter.cs @@ -0,0 +1,48 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using TeleWave.Domain.Library; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Groups; + +/// +/// Правило быстрого набора состава группы. Это не запрос, выполняемый при генерации: фильтр находит +/// подходящие позиции, а добавляет их в группу отдельная команда. Пустые поля не ограничивают. +/// +public sealed record GroupFilter( + IReadOnlyList? ElementKinds = null, + IReadOnlyList? ShowKinds = null, + IReadOnlyList? GenreIds = null, + ShowAudience? MaxAudience = null, + int? YearMin = null, + int? YearMax = null, + int? UnitMinutesMin = null, + int? UnitMinutesMax = null +) +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + }; + + public string ToJson() => JsonSerializer.Serialize(this, Options); + + /// Разбирает сохранённое правило. Повреждённый JSON трактуется как отсутствие фильтра: + /// группа продолжает работать своим явным составом, а админ переустановит правило. + public static GroupFilter? FromJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + return JsonSerializer.Deserialize(json, Options); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupMembershipCleaner.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupMembershipCleaner.cs new file mode 100644 index 0000000..2bbd0d2 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupMembershipCleaner.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Groups; + +/// +/// Снимает элемент со всех групп при его удалении из библиотеки. Нужен потому, что ссылка группы +/// полиморфна (шоу либо коллекция) и внешнего ключа под неё нет — каскад БД тут не сработает, +/// а висячая позиция при генерации развернулась бы в пустоту. +/// +/// Затронутым группам пересчитывается статистика: иначе в инспекторе слота осталось бы обещание +/// эфира, которого больше нет. +/// +public sealed class GroupMembershipCleaner(IAppDbContext dbContext, GroupStatsService stats) +{ + public async Task RemoveElementAsync( + GroupElementKind kind, + Guid elementId, + CancellationToken cancellationToken + ) + { + var affectedGroupIds = await dbContext + .GroupItems.Where(i => i.ElementKind == kind && i.ElementId == elementId) + .Select(i => i.GroupId) + .Distinct() + .ToListAsync(cancellationToken); + + if (affectedGroupIds.Count == 0) + return; + + var groups = await dbContext + .Groups.Include(g => g.Items) + .Where(g => affectedGroupIds.Contains(g.Id)) + .ToListAsync(cancellationToken); + + foreach (var group in groups) + { + group.RemoveElement(kind, elementId); + await stats.RecomputeAsync(group, cancellationToken); + } + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupStatsService.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupStatsService.cs new file mode 100644 index 0000000..e900068 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupStatsService.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Groups; + +/// +/// Пересчёт кэша статистики группы. Вызывается при каждой правке состава: цифры «342 позиции · +/// 118 ч» видны прямо в инспекторе слота, и устаревшая статистика там хуже, чем её отсутствие. +/// +public sealed class GroupStatsService(IAppDbContext dbContext, GroupElementResolver resolver) +{ + public async Task RecomputeAsync(Group group, CancellationToken cancellationToken) + { + var info = await resolver.ResolveAsync( + group.Items.Select(i => (i.ElementKind, i.ElementId)), + cancellationToken + ); + + var unitCount = 0; + var totalDuration = TimeSpan.Zero; + foreach (var item in group.Items) + { + if (!info.TryGetValue((item.ElementKind, item.ElementId), out var element)) + continue; + unitCount += element.UnitCount; + totalDuration += element.TotalDuration; + } + + group.UpdateStats(group.Items.Count, unitCount, totalDuration, DateTimeOffset.UtcNow); + } + + /// Пересчитать по идентификатору, загрузив состав. Возвращает false, если группы нет. + public async Task RecomputeAsync(Guid groupId, CancellationToken cancellationToken) + { + var group = await dbContext + .Groups.Include(g => g.Items) + .FirstOrDefaultAsync(g => g.Id == groupId, cancellationToken); + if (group is null) + return false; + + await RecomputeAsync(group, cancellationToken); + return true; + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/ListGroups/ListGroupsQuery.cs b/backend/src/TeleWave.Application/Programming/Groups/ListGroups/ListGroupsQuery.cs new file mode 100644 index 0000000..47fff76 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/ListGroups/ListGroupsQuery.cs @@ -0,0 +1,5 @@ +using LiteCqrs; + +namespace TeleWave.Application.Programming.Groups.ListGroups; + +public sealed record ListGroupsQuery : IQuery>; diff --git a/backend/src/TeleWave.Application/Programming/Groups/ListGroups/ListGroupsQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/ListGroups/ListGroupsQueryHandler.cs new file mode 100644 index 0000000..5fb47f8 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/ListGroups/ListGroupsQueryHandler.cs @@ -0,0 +1,32 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Application.Programming.Groups.ListGroups; + +public sealed class ListGroupsQueryHandler(IAppDbContext dbContext) + : IQueryHandler> +{ + public async Task> Handle( + ListGroupsQuery query, + CancellationToken cancellationToken + ) + { + // Список читает кэш статистики, не пересчитывая его: пересчёт идёт при правке состава. + return await dbContext + .Groups.AsNoTracking() + .OrderBy(g => g.Name) + .Select(g => new GroupSummaryDto( + g.Id, + g.Name, + g.Description, + g.ItemCount, + g.UnitCount, + g.TotalDuration.TotalSeconds, + g.FilterJson != null, + g.StatsComputedAt, + g.CreatedAt + )) + .ToListAsync(cancellationToken); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/RemoveGroupItem/RemoveGroupItemCommand.cs b/backend/src/TeleWave.Application/Programming/Groups/RemoveGroupItem/RemoveGroupItemCommand.cs new file mode 100644 index 0000000..318d30f --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/RemoveGroupItem/RemoveGroupItemCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Groups.RemoveGroupItem; + +public sealed record RemoveGroupItemCommand(Guid GroupId, Guid ItemId) : ICommand; diff --git a/backend/src/TeleWave.Application/Programming/Groups/RemoveGroupItem/RemoveGroupItemCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/RemoveGroupItem/RemoveGroupItemCommandHandler.cs new file mode 100644 index 0000000..7af4ee8 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/RemoveGroupItem/RemoveGroupItemCommandHandler.cs @@ -0,0 +1,28 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Groups.RemoveGroupItem; + +public sealed class RemoveGroupItemCommandHandler(IAppDbContext dbContext, GroupStatsService stats) + : ICommandHandler +{ + public async Task Handle( + RemoveGroupItemCommand command, + CancellationToken cancellationToken + ) + { + var group = await dbContext + .Groups.Include(g => g.Items) + .FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken); + if (group is null) + return Result.Failure(GroupErrors.NotFound); + + if (!group.RemoveItem(command.ItemId)) + return Result.Failure(GroupErrors.ItemNotFound); + + await stats.RecomputeAsync(group, cancellationToken); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/ReorderGroup/ReorderGroupCommand.cs b/backend/src/TeleWave.Application/Programming/Groups/ReorderGroup/ReorderGroupCommand.cs new file mode 100644 index 0000000..365cd9b --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/ReorderGroup/ReorderGroupCommand.cs @@ -0,0 +1,9 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Groups.ReorderGroup; + +/// Порядок позиций (важен для последовательных стратегий). Не упомянутые остаются после +/// перечисленных, сохраняя относительный порядок. +public sealed record ReorderGroupCommand(Guid GroupId, IReadOnlyList ItemIdsInOrder) + : ICommand; diff --git a/backend/src/TeleWave.Application/Programming/Groups/ReorderGroup/ReorderGroupCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/ReorderGroup/ReorderGroupCommandHandler.cs new file mode 100644 index 0000000..6b0f92d --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/ReorderGroup/ReorderGroupCommandHandler.cs @@ -0,0 +1,25 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Groups.ReorderGroup; + +public sealed class ReorderGroupCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + ReorderGroupCommand command, + CancellationToken cancellationToken + ) + { + var group = await dbContext + .Groups.Include(g => g.Items) + .FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken); + if (group is null) + return Result.Failure(GroupErrors.NotFound); + + group.Reorder(command.ItemIdsInOrder); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/SetGroupItemWeight/SetGroupItemWeightCommand.cs b/backend/src/TeleWave.Application/Programming/Groups/SetGroupItemWeight/SetGroupItemWeightCommand.cs new file mode 100644 index 0000000..8e50871 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/SetGroupItemWeight/SetGroupItemWeightCommand.cs @@ -0,0 +1,8 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Groups.SetGroupItemWeight; + +/// Вес позиции при случайном выборе (0 — исключить, не удаляя из группы). +public sealed record SetGroupItemWeightCommand(Guid GroupId, Guid ItemId, int Weight) + : ICommand; diff --git a/backend/src/TeleWave.Application/Programming/Groups/SetGroupItemWeight/SetGroupItemWeightCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/SetGroupItemWeight/SetGroupItemWeightCommandHandler.cs new file mode 100644 index 0000000..99689b5 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/SetGroupItemWeight/SetGroupItemWeightCommandHandler.cs @@ -0,0 +1,27 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Groups.SetGroupItemWeight; + +public sealed class SetGroupItemWeightCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + SetGroupItemWeightCommand command, + CancellationToken cancellationToken + ) + { + var group = await dbContext + .Groups.Include(g => g.Items) + .FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken); + if (group is null) + return Result.Failure(GroupErrors.NotFound); + + // Вес не влияет на объём контента — статистику пересчитывать незачем. + return group.SetWeight(command.ItemId, command.Weight) + ? Result.Success() + : Result.Failure(GroupErrors.ItemNotFound); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommand.cs b/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommand.cs new file mode 100644 index 0000000..3ec34c9 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommand.cs @@ -0,0 +1,13 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Groups.UpdateGroup; + +/// Имя, описание и правило набора. = null снимает правило; +/// состав группы при этом не меняется. +public sealed record UpdateGroupCommand( + Guid GroupId, + string Name, + string? Description, + GroupFilter? Filter +) : ICommand; diff --git a/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommandHandler.cs new file mode 100644 index 0000000..347ff5e --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommandHandler.cs @@ -0,0 +1,24 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Groups.UpdateGroup; + +public sealed class UpdateGroupCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle(UpdateGroupCommand command, CancellationToken cancellationToken) + { + var group = await dbContext.Groups.FirstOrDefaultAsync( + g => g.Id == command.GroupId, + cancellationToken + ); + if (group is null) + return Result.Failure(GroupErrors.NotFound); + + group.Rename(command.Name, command.Description); + group.SetFilter(command.Filter?.ToJson()); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommandValidator.cs b/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommandValidator.cs new file mode 100644 index 0000000..1bd6dc4 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/UpdateGroup/UpdateGroupCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; + +namespace TeleWave.Application.Programming.Groups.UpdateGroup; + +public sealed class UpdateGroupCommandValidator : AbstractValidator +{ + public UpdateGroupCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(256); + RuleFor(x => x.Description).MaximumLength(2048); + + When( + x => x.Filter is not null, + () => + { + RuleFor(x => x.Filter!.YearMin).InclusiveBetween(1870, 2200).When(x => x.Filter!.YearMin is not null); + RuleFor(x => x.Filter!.YearMax).InclusiveBetween(1870, 2200).When(x => x.Filter!.YearMax is not null); + RuleFor(x => x.Filter!.UnitMinutesMin).GreaterThanOrEqualTo(0).When(x => x.Filter!.UnitMinutesMin is not null); + RuleFor(x => x.Filter!.UnitMinutesMax).GreaterThanOrEqualTo(0).When(x => x.Filter!.UnitMinutesMax is not null); + } + ); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Planning/ApplyTemplate/ApplyChannelTemplateCommand.cs b/backend/src/TeleWave.Application/Programming/Planning/ApplyTemplate/ApplyChannelTemplateCommand.cs new file mode 100644 index 0000000..2d857fa --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Planning/ApplyTemplate/ApplyChannelTemplateCommand.cs @@ -0,0 +1,20 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming.Planning; + +namespace TeleWave.Application.Programming.Planning.ApplyTemplate; + +/// +/// Применяет правила к эфиру: пересобирает будущий хвост от текущего момента. Правка слотов сама +/// по себе эфир не двигает — иначе десять правок подряд дали бы десять пересборок, и при живых +/// зрителях это было бы заметно. +/// +public sealed record ApplyChannelTemplateCommand(Guid ChannelId) : ICommand>; + +/// Итог применения: сколько записей получилось и что стоит показать админу. +public sealed record ApplyResultDto( + int Added, + IReadOnlyList Warnings +); + +public sealed record PlanningWarningDto(PlanningWarningKind Kind, Guid? SlotId, string Details); diff --git a/backend/src/TeleWave.Application/Programming/Planning/ApplyTemplate/ApplyChannelTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Planning/ApplyTemplate/ApplyChannelTemplateCommandHandler.cs new file mode 100644 index 0000000..9d6b4f1 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Planning/ApplyTemplate/ApplyChannelTemplateCommandHandler.cs @@ -0,0 +1,36 @@ +using LiteCqrs; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Planning.ApplyTemplate; + +public sealed class ApplyChannelTemplateCommandHandler(GridScheduleGenerator generator) + : ICommandHandler> +{ + public async Task> Handle( + ApplyChannelTemplateCommand command, + CancellationToken cancellationToken + ) + { + // Генератор держит собственную транзакцию с advisory-локом канала, поэтому команда + // ограничивается вызовом и переводом результата в DTO. + var report = await generator.GenerateAsync( + command.ChannelId, + DateTimeOffset.UtcNow, + rebuildFuture: true, + cancellationToken + ); + + if (report.ChannelSkipped) + return Result.Failure(ChannelErrors.TemplateNotFound); + + return Result.Success( + new ApplyResultDto( + report.Added, + report + .Warnings.Select(w => new PlanningWarningDto(w.Kind, w.SlotId, w.Details)) + .ToList() + ) + ); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs b/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs new file mode 100644 index 0000000..549c964 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs @@ -0,0 +1,192 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Broadcast.Scheduling; +using TeleWave.Domain.Media; +using TeleWave.Domain.Programming.Planning; + +namespace TeleWave.Application.Programming.Planning; + +/// Ключ отрендеренной заставки: блок, подблок и пара шоу, между которыми она стоит. +public readonly record struct BumperKey(Guid TemplateId, Guid VariantId, Guid FromShowId, Guid ToShowId); + +/// +/// Подставляет ассеты заставкам, которые планировщик зарезервировал. Резерв и рендер разделены +/// намеренно: ассет зависит от пары соседей, а пара известна только после того, как слоты наполнены. +/// +/// Готовый ассет переиспользуется по сигнатуре (пара названий + версия блока), недостающий +/// регистрируется в и уходит фоновому рендереру. Запись при +/// этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится. +/// +public sealed class BumperResolver( + IAppDbContext dbContext, + IBumperRenderQueue renderQueue, + IRandomSource random +) +{ + public async Task> ResolveAsync( + Channel channel, + IReadOnlyList items, + CancellationToken cancellationToken + ) + { + var reserved = items.Where(i => i.Kind == PlannedItemKind.Bumper).ToList(); + if (reserved.Count == 0) + return new Dictionary(); + + var showNames = await LoadShowNamesAsync(reserved, cancellationToken); + var result = new Dictionary(); + + // Кэш существующих заставок канала: одна пара шоу встречается в горизонте многократно. + var existing = await dbContext + .BumperAssets.Where(b => b.ChannelId == channel.Id) + .ToDictionaryAsync(b => b.Signature, b => b.MediaAssetId, cancellationToken); + + foreach (var item in reserved) + { + if ( + item.BumperTemplateId is not { } templateId + || channel.FindBumperTemplate(templateId) is not { } template + ) + continue; + + var fromShowId = item.FromShowId ?? Guid.Empty; + var toShowId = item.ToShowId ?? Guid.Empty; + var variant = PickVariant(template, fromShowId != toShowId, channel, random); + if (variant is null) + continue; + + var key = new BumperKey(templateId, variant.Id, fromShowId, toShowId); + if (result.ContainsKey(key)) + continue; + + var fromName = showNames.GetValueOrDefault(fromShowId, "—"); + var toName = showNames.GetValueOrDefault(toShowId, "—"); + var signature = Signature(template, variant.Id, fromName, toName); + + if (existing.TryGetValue(signature, out var assetId)) + { + result[key] = assetId; + continue; + } + + var asset = MediaAsset.RegisterGenerated($"{template.Name}: {fromName} → {toName}"); + dbContext.MediaAssets.Add(asset); + dbContext.BumperAssets.Add( + BumperAsset.Create( + channel.Id, + templateId, + variant.Id, + fromShowId, + toShowId, + signature, + asset.Id + ) + ); + + existing[signature] = asset.Id; + result[key] = asset.Id; + renderQueue.Enqueue(asset.Id); + } + + return result; + } + + /// + /// Подблок, подходящий под контекст перехода: на смене шоу и между сериями одного играют разные + /// тексты. Стратегия выбора — общая настройка канала. + /// + private static BumperTextVariant? PickVariant( + BumperTemplate template, + bool isShowChange, + Channel channel, + IRandomSource random + ) + { + var eligible = template + .Variants.Where(v => + v.Trigger switch + { + BumperTrigger.OnShowChange => isShowChange, + BumperTrigger.BetweenEpisodes => !isShowChange, + _ => true, + } + ) + .OrderBy(v => v.Position) + .ToList(); + + if (eligible.Count == 0) + return null; + + return channel.BumperSelection switch + { + BumperSelection.AlwaysFirst => eligible[0], + BumperSelection.Random => eligible[random.Next(eligible.Count)], + BumperSelection.WeightedRandom => WeightedPick(eligible, random), + _ => eligible[random.Next(eligible.Count)], + }; + } + + private static BumperTextVariant WeightedPick( + IReadOnlyList eligible, + IRandomSource random + ) + { + var total = eligible.Sum(v => (long)Math.Max(0, v.Weight)); + if (total <= 0) + return eligible[random.Next(eligible.Count)]; + + var roll = random.Next((int)Math.Min(total, int.MaxValue)); + long accumulated = 0; + foreach (var variant in eligible) + { + accumulated += Math.Max(0, variant.Weight); + if (roll < accumulated) + return variant; + } + + return eligible[^1]; + } + + /// + /// Сигнатура включает версию блока: замена звука или фона обязана пересобрать заставки, иначе + /// в эфире осталась бы старая картинка с новым оформлением рядом. + /// + private static string Signature( + BumperTemplate template, + Guid variantId, + string fromName, + string toName + ) + { + var raw = string.Create( + CultureInfo.InvariantCulture, + $"{template.Id}|{variantId}|{template.Revision}|{fromName}|{toName}" + ); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..32]; + } + + private async Task> LoadShowNamesAsync( + IReadOnlyList reserved, + CancellationToken cancellationToken + ) + { + var showIds = reserved + .SelectMany(i => new[] { i.FromShowId, i.ToShowId }) + .Where(id => id is not null && id != Guid.Empty) + .Select(id => id!.Value) + .Distinct() + .ToList(); + + if (showIds.Count == 0) + return []; + + return await dbContext + .Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Planning/EffectiveGridBuilder.cs b/backend/src/TeleWave.Application/Programming/Planning/EffectiveGridBuilder.cs new file mode 100644 index 0000000..ef01fd7 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Planning/EffectiveGridBuilder.cs @@ -0,0 +1,122 @@ +using TeleWave.Application.Programming.Templates; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Planning; + +/// Слот, привязанный к конкретным вещательным суткам и переведённый в UTC. +public sealed record ScheduledSlot(Slot Slot, DateOnly BroadcastDate, DateTimeOffset StartUtc); + +/// +/// Разворачивает шаблон в эффективную сетку на диапазон дат: какие слоты и в какие моменты реально +/// действуют. Применимость слоёв разрешается здесь, потому что её схема (JSON) принадлежит +/// прикладному слою; чистый планировщик получает уже готовые окна в UTC. +/// +public static class EffectiveGridBuilder +{ + public static IReadOnlyList Build( + ScheduleTemplate template, + int utcOffsetMinutes, + TimeOnly dayStartTime, + DateTimeOffset fromUtc, + DateTimeOffset toUtc + ) + { + var offset = TimeSpan.FromMinutes(utcOffsetMinutes); + var result = new List(); + + // Захватываем сутки с запасом с обеих сторон: слот вещательных суток может начаться + // до fromUtc (ночной блок предыдущего дня) либо после полуночи следующих. + var firstDate = DateOnly.FromDateTime(fromUtc.ToOffset(offset).DateTime).AddDays(-1); + var lastDate = DateOnly.FromDateTime(toUtc.ToOffset(offset).DateTime).AddDays(1); + + var layers = template + .Layers.Where(l => l.IsEnabled) + .OrderByDescending(l => l.Priority) + .ToList(); + + for (var date = firstDate; date <= lastDate; date = date.AddDays(1)) + { + var applicable = layers + .Where(l => + LayerApplicability.FromJson(l.ApplicabilityJson) is not { } applicability + || applicability.Covers(date) + ) + .ToList(); + + foreach (var scheduled in ResolveDay(applicable, date, offset, dayStartTime)) + { + if (scheduled.StartUtc >= fromUtc.AddDays(-1) && scheduled.StartUtc < toUtc) + result.Add(scheduled); + } + } + + return result.OrderBy(s => s.StartUtc).ToList(); + } + + /// + /// Слоты одних вещательных суток с разрешённым перекрытием слоёв. Слои идут от старшего + /// приоритета к младшему; слот младшего слоя пропускается, если его окно уже занято старшим. + /// + /// Именно пропускается, а не обрезается: слот — атомарная единица со своей группой и стратегией, + /// и половина слота означала бы половину настройки. + /// + private static IEnumerable ResolveDay( + IReadOnlyList layers, + DateOnly date, + TimeSpan offset, + TimeOnly dayStartTime + ) + { + var taken = new List<(TimeSpan From, TimeSpan To)>(); + var result = new List(); + + foreach (var layer in layers) + { + var slots = layer + .Slots.Where(s => s.Weekday is null || s.Weekday == (int)date.DayOfWeek) + .OrderBy(s => s.TargetStart) + .ToList(); + + // Слоты одного слоя добавляются все разом: внутри слоя перекрытий не бывает по построению, + // а занятые интервалы должны учитываться только от более приоритетных слоёв. + var added = new List<(TimeSpan From, TimeSpan To)>(); + + foreach (var slot in slots) + { + var from = OffsetInBroadcastDay(slot.TargetStart, dayStartTime); + var to = from + TimeSpan.FromMinutes(slot.TargetDurationMinutes); + + if (taken.Any(t => from < t.To && t.From < to)) + continue; + + result.Add(new ScheduledSlot(slot, date, ToUtc(date, slot.TargetStart, offset, dayStartTime))); + added.Add((from, to)); + } + + taken.AddRange(added); + } + + return result; + } + + /// + /// Смещение времени от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток + /// в 06:00) относится к предыдущему дню, поэтому его смещение больше суточного, а не отрицательное. + /// + private static TimeSpan OffsetInBroadcastDay(TimeOnly time, TimeOnly dayStartTime) => + time >= dayStartTime + ? time.ToTimeSpan() - dayStartTime.ToTimeSpan() + : time.ToTimeSpan() - dayStartTime.ToTimeSpan() + TimeSpan.FromDays(1); + + /// Момент слота в UTC. Время до начала вещательных суток попадает на следующий календарный день. + public static DateTimeOffset ToUtc( + DateOnly broadcastDate, + TimeOnly time, + TimeSpan offset, + TimeOnly dayStartTime + ) + { + var calendarDate = time >= dayStartTime ? broadcastDate : broadcastDate.AddDays(1); + return new DateTimeOffset(calendarDate.ToDateTime(time), offset).ToUniversalTime(); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs new file mode 100644 index 0000000..3c5812b --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs @@ -0,0 +1,508 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using TeleWave.Application.Broadcast.Scheduling; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Streaming; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Broadcast.Scheduling; +using TeleWave.Domain.Media; +using TeleWave.Domain.Programming; +using TeleWave.Domain.Programming.Planning; + +namespace TeleWave.Application.Programming.Planning; + +/// Что дала генерация: сколько записей добавлено и с какими предупреждениями. +public sealed record GenerationReport( + int Added, + IReadOnlyList Warnings, + bool ChannelSkipped = false +); + +/// +/// Оркестратор генерации по сетке: собирает конфигурацию канала, вызывает чистый планировщик, +/// материализует ленту и двигает курсоры слотов. +/// +/// Генерация одного канала сериализуется advisory-блокировкой: фоновый тик и ручное применение +/// не должны читать одну точку продолжения и оба дописывать хвост. +/// +public sealed class GridScheduleGenerator( + IAppDbContext dbContext, + GroupExpander expander, + BumperResolver bumperResolver, + IRandomSource random, + IOptions options, + IOptions streamingOptions +) +{ + private readonly SchedulerOptions _options = options.Value; + private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); + + private static readonly JsonSerializerOptions TraceJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + /// + /// Достраивает горизонт канала, а при — пересобирает будущий + /// хвост от текущего момента. Прошлое и идущая сейчас запись не трогаются никогда: зритель + /// не должен обнаружить, что у него из-под носа вырезали программу. + /// + public async Task GenerateAsync( + Guid channelId, + DateTimeOffset now, + bool rebuildFuture, + CancellationToken cancellationToken + ) + { + var channel = await dbContext + .Channels.Include(c => c.BumperTemplates) + .ThenInclude(t => t.Variants) + .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken); + if (channel is null || !channel.IsEnabled || channel.TemplateId is null) + return new GenerationReport(0, [], ChannelSkipped: true); + + await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken); + await dbContext.AcquireChannelLockAsync(channelId, cancellationToken); + + var template = await dbContext + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .FirstOrDefaultAsync(t => t.Id == channel.TemplateId, cancellationToken); + if (template is null) + return new GenerationReport(0, [], ChannelSkipped: true); + + await CleanupAsync(channelId, now, cancellationToken); + + if (rebuildFuture) + await dbContext + .ScheduleEntries.Where(e => e.ChannelId == channelId && e.StartsAtUtc >= now) + .ExecuteDeleteAsync(cancellationToken); + + // Точка продолжения — конец последней сохранённой записи. При пересборке будущее уже удалено, + // поэтому она укажет на границу неизменяемого прошлого. + var lastEnd = await dbContext + .ScheduleEntries.Where(e => e.ChannelId == channelId) + .MaxAsync(e => (DateTimeOffset?)e.EndsAtUtc, cancellationToken); + + var startUtc = lastEnd is { } end && end > now ? end : now; + var horizonEnd = now.AddDays(Math.Max(1, _options.HorizonDays)); + + if (startUtc >= horizonEnd) + { + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return new GenerationReport(0, []); + } + + var input = await BuildInputAsync( + channel, + template, + startUtc, + horizonEnd, + cancellationToken + ); + var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random); + + // Заставки резолвятся после сборки ленты: пара соседей известна только теперь. + var bumperAssets = await bumperResolver.ResolveAsync( + channel, + result.Items, + cancellationToken + ); + + var added = 0; + foreach (var item in result.Items) + { + var assetId = item.MediaAssetId; + if (item.Kind == PlannedItemKind.Bumper && !TryResolveBumper(item, bumperAssets, out assetId)) + continue; // Без ассета запись стала бы дырой в ленте. + + dbContext.ScheduleEntries.Add( + ScheduleEntry.FromSlot( + channel.Id, + assetId, + ToEntryKind(item.Kind), + item.StartsAtUtc, + item.EndsAtUtc, + item.ShowId, + item.UnitIndex, + item.SlotId, + item.Trace is null + ? null + : JsonSerializer.Serialize(item.Trace, TraceJsonOptions) + ) + ); + added++; + } + + await SaveCursorsAsync(result.Cursors, cancellationToken); + + template.MarkApplied(); + + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return new GenerationReport(added, result.Warnings); + } + + /// + /// Чистит прошлое сверх окна хранения. Окно должно покрывать самое долгое остывание среди правил — + /// история показов берётся из самой ленты, отдельного журнала нет. + /// + private Task CleanupAsync(Guid channelId, DateTimeOffset now, CancellationToken cancellationToken) + { + var cutoff = now.AddDays(-Math.Max(1, _options.RetentionDays)); + return dbContext + .ScheduleEntries.Where(e => e.ChannelId == channelId && e.EndsAtUtc < cutoff) + .ExecuteDeleteAsync(cancellationToken); + } + + private async Task SaveCursorsAsync( + IReadOnlyList cursors, + CancellationToken cancellationToken + ) + { + if (cursors.Count == 0) + return; + + var slotIds = cursors.Select(c => c.SlotId).Distinct().ToList(); + var states = await dbContext + .SlotStates.Where(s => slotIds.Contains(s.SlotId)) + .ToDictionaryAsync(s => s.SlotId, cancellationToken); + + foreach (var cursor in cursors) + { + if (!states.TryGetValue(cursor.SlotId, out var state)) + { + state = SlotState.Create(cursor.SlotId); + dbContext.SlotStates.Add(state); + states[cursor.SlotId] = state; + } + + if (cursor.ElementKind is { } kind && cursor.ElementId is { } elementId) + state.MoveTo(kind, elementId, cursor.NextUnitIndex); + else + state.Reset(); + } + } + + private async Task BuildInputAsync( + Channel channel, + ScheduleTemplate template, + DateTimeOffset startUtc, + DateTimeOffset horizonEnd, + CancellationToken cancellationToken + ) + { + var scheduled = EffectiveGridBuilder.Build( + template, + channel.UtcOffsetMinutes, + channel.DayStartTime, + startUtc, + horizonEnd + ); + + // Стыки грузим целиком: их немного, а группы врезок надо развернуть тем же проходом, + // что и группы контента. + var junctions = await dbContext + .JunctionTemplates.AsNoTracking() + .Include(j => j.Elements) + .Where(j => j.ChannelId == channel.Id) + .ToDictionaryAsync(j => j.Id, cancellationToken); + + var groupIds = scheduled + .Select(s => s.Slot.GroupId) + .Where(id => id is not null) + .Select(id => id!.Value) + .Concat( + junctions + .Values.SelectMany(j => j.Elements) + .Select(e => e.GroupId) + .Where(id => id is not null) + .Select(id => id!.Value) + ) + .Distinct() + .ToList(); + + var elementsByGroup = await expander.ExpandAsync(groupIds, channel.Id, cancellationToken); + + var slotIds = scheduled.Select(s => s.Slot.Id).Distinct().ToList(); + var states = await dbContext + .SlotStates.AsNoTracking() + .Where(s => slotIds.Contains(s.SlotId)) + .ToDictionaryAsync(s => s.SlotId, cancellationToken); + + var slots = new List(); + foreach (var item in scheduled) + { + var slot = item.Slot; + var elements = + slot.GroupId is { } groupId + && elementsByGroup.TryGetValue(groupId, out var groupElements) + ? groupElements + : []; + + var repeatUnits = + slot.SlotKind == SlotKind.Repeat + ? await LoadRepeatUnitsAsync(channel, slot, item, cancellationToken) + : null; + + var strategy = ToPlanningStrategy(SlotStrategy.FromJson(slot.StrategyJson)); + var cursor = states.TryGetValue(slot.Id, out var state) + ? new PlanningCursor(state.CurrentElementKind, state.CurrentElementId, state.NextUnitIndex) + : null; + + slots.Add( + new PlanningSlot( + slot.Id, + item.StartUtc, + slot.TargetDurationMinutes, + slot.SlotKind, + slot.IsAnchor, + slot.MaxDriftMinutes, + slot.SnapToMinutes, + slot.BlockMode, + slot.BlockValue, + slot.OverflowPolicy, + strategy, + elements, + cursor, + repeatUnits, + BuildJunction(slot.JunctionBetweenId, junctions, elementsByGroup, channel), + BuildJunction( + slot.JunctionAfterId ?? template.DefaultJunctionId, + junctions, + elementsByGroup, + channel + ) + ) + ); + } + + var fallback = await LoadFallbackUnitsAsync(channel, template, cancellationToken); + + return new PlanningInput( + channel.Id, + startUtc, + horizonEnd, + slots, + fallback, + _segmentSeconds + ); + } + + /// Ассет заставки по зарезервированной записи; false — подобрать не удалось. + private static bool TryResolveBumper( + PlannedItem item, + IReadOnlyDictionary bumperAssets, + out Guid assetId + ) + { + assetId = Guid.Empty; + if (item.BumperTemplateId is not { } templateId) + return false; + + // Подблок выбирает резолвер, поэтому ищем по блоку и паре шоу. + foreach (var pair in bumperAssets) + { + if ( + pair.Key.TemplateId == templateId + && pair.Key.FromShowId == (item.FromShowId ?? Guid.Empty) + && pair.Key.ToShowId == (item.ToShowId ?? Guid.Empty) + ) + { + assetId = pair.Value; + return true; + } + } + + return false; + } + + /// Разворачивает шаблон стыка для планировщика, включая резерв под заставки. + private PlanningJunction? BuildJunction( + Guid? junctionId, + IReadOnlyDictionary junctions, + IReadOnlyDictionary> elementsByGroup, + Channel channel + ) + { + if (junctionId is not { } id || !junctions.TryGetValue(id, out var template)) + return null; + + var elements = new List(); + foreach (var element in template.Elements.OrderBy(e => e.Position)) + { + var conditions = + JunctionConditions.FromJson(element.ConditionsJson) ?? new JunctionConditions(); + + if (element.Kind == JunctionElementKind.Bumper) + { + // Длительность задаётся блоком (по звуку) и выровнена на сегмент: планировщик + // резервирует именно её, ассет подставит резолвер после сборки ленты. + if ( + element.BumperTemplateId is not { } bumperTemplateId + || channel.FindBumperTemplate(bumperTemplateId) is not { } bumperTemplate + ) + continue; + + var seconds = BumperDuration.Aligned( + BumperDuration.TemplateSeconds(bumperTemplate), + _segmentSeconds + ); + + elements.Add( + new PlanningJunctionElement( + element.Kind, + [], + element.AmountMode, + element.AmountValue, + element.IsRequired, + conditions.OnlyOnElementChange, + conditions.MinMinutesBetween, + bumperTemplateId, + TimeSpan.FromSeconds(seconds) + ) + ); + continue; + } + + if ( + element.GroupId is not { } groupId + || !elementsByGroup.TryGetValue(groupId, out var groupElements) + ) + continue; + + elements.Add( + new PlanningJunctionElement( + element.Kind, + groupElements.SelectMany(e => e.Units).ToList(), + element.AmountMode, + element.AmountValue, + element.IsRequired, + conditions.OnlyOnElementChange, + conditions.MinMinutesBetween + ) + ); + } + + return elements.Count == 0 ? null : new PlanningJunction(id, elements); + } + + /// + /// Что играло в точке, на которую ссылается слот-повтор. Читается уже записанная лента того же + /// канала — стратегий и состояния повтору не нужно. + /// + private async Task> LoadRepeatUnitsAsync( + Channel channel, + Slot slot, + ScheduledSlot scheduled, + CancellationToken cancellationToken + ) + { + var source = RepeatSource.FromJson(slot.RepeatSourceJson); + if (source is null) + return []; + + var offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes); + var sourceDate = scheduled.BroadcastDate.AddDays(-Math.Max(1, source.DaysAgo)); + var from = EffectiveGridBuilder.ToUtc(sourceDate, source.Time, offset, channel.DayStartTime); + var to = from.AddMinutes(Math.Max(1, source.DurationMinutes)); + + var entries = await dbContext + .ScheduleEntries.AsNoTracking() + .Where(e => + e.ChannelId == channel.Id + && e.Kind == ScheduleEntryKind.Program + && e.StartsAtUtc >= from + && e.StartsAtUtc < to + ) + .OrderBy(e => e.StartsAtUtc) + .Select(e => new + { + e.MediaAssetId, + e.ShowId, + e.EpisodeIndex, + Duration = e.EndsAtUtc - e.StartsAtUtc, + }) + .ToListAsync(cancellationToken); + + return entries + .Select(e => new PlanningUnit( + e.MediaAssetId, + e.Duration, + e.ShowId ?? Guid.Empty, + e.EpisodeIndex ?? 0 + )) + .ToList(); + } + + /// + /// Чем закрывать паузы. Сначала аварийная группа шаблона, затем филлер канала — последний + /// зациклен, поэтому короткая единица закрывает любой остаток лучше длинной. + /// + private async Task> LoadFallbackUnitsAsync( + Channel channel, + ScheduleTemplate template, + CancellationToken cancellationToken + ) + { + var units = new List(); + + if (template.FallbackGroupId is { } groupId) + { + var expanded = await expander.ExpandAsync([groupId], channel.Id, cancellationToken); + if (expanded.TryGetValue(groupId, out var elements)) + units.AddRange(elements.SelectMany(e => e.Units)); + } + + if (channel.FillerAssetId is { } fillerId) + { + var filler = await dbContext + .MediaAssets.AsNoTracking() + .Where(a => + a.Id == fillerId && a.Status == MediaAssetStatus.Ready && a.Duration != null + ) + .Select(a => new { a.Id, a.Duration }) + .FirstOrDefaultAsync(cancellationToken); + + if (filler is not null) + units.Add(new PlanningUnit(filler.Id, filler.Duration!.Value, Guid.Empty, 0)); + } + + // Короткие вперёд: чем короче единица, тем меньше остаётся незакрытым остаток паузы. + return units.OrderBy(u => u.Duration).ToList(); + } + + private static PlanningStrategy ToPlanningStrategy(SlotStrategy? strategy) + { + if (strategy is null) + return new PlanningStrategy(SlotStrategyKind.Sequential); + + var kind = strategy.Type switch + { + SlotStrategyType.RandomWithCooldown => SlotStrategyKind.RandomWithCooldown, + SlotStrategyType.Fixed => SlotStrategyKind.Fixed, + _ => SlotStrategyKind.Sequential, + }; + + return new PlanningStrategy( + kind, + strategy.RestartOnEnd, + strategy.CooldownDays, + strategy.Fallback == CooldownFallback.IgnoreCooldown, + strategy.FixedElementId + ); + } + + private static ScheduleEntryKind ToEntryKind(PlannedItemKind kind) => + kind switch + { + PlannedItemKind.SignOff => ScheduleEntryKind.SignOff, + PlannedItemKind.Fallback => ScheduleEntryKind.Fallback, + PlannedItemKind.Ad or PlannedItemKind.Promo => ScheduleEntryKind.Ad, + PlannedItemKind.Bumper => ScheduleEntryKind.Bumper, + _ => ScheduleEntryKind.Program, + }; +} diff --git a/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs b/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs new file mode 100644 index 0000000..c0befa6 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs @@ -0,0 +1,171 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Media; +using TeleWave.Domain.Programming; +using TeleWave.Domain.Programming.Planning; + +namespace TeleWave.Application.Programming.Planning; + +/// +/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал — +/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается), +/// фильм — в одну единицу. +/// +/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё +/// обрабатывается, значит получить дыру в раздаче. +/// +public sealed class GroupExpander(IAppDbContext dbContext) +{ + public async Task>> ExpandAsync( + IReadOnlyCollection groupIds, + Guid channelId, + CancellationToken cancellationToken + ) + { + var result = new Dictionary>(); + if (groupIds.Count == 0) + return result; + + var groups = await dbContext + .Groups.AsNoTracking() + .Include(g => g.Items) + .Where(g => groupIds.Contains(g.Id)) + .ToListAsync(cancellationToken); + + var collectionIds = groups + .SelectMany(g => g.Items) + .Where(i => i.ElementKind == GroupElementKind.Collection) + .Select(i => i.ElementId) + .Distinct() + .ToList(); + + var collectionParts = await dbContext + .CollectionItems.AsNoTracking() + .Where(i => collectionIds.Contains(i.CollectionId)) + .OrderBy(i => i.Position) + .Select(i => new { i.CollectionId, i.ShowId }) + .ToListAsync(cancellationToken); + + var showIds = groups + .SelectMany(g => g.Items) + .Where(i => i.ElementKind == GroupElementKind.Show) + .Select(i => i.ElementId) + .Concat(collectionParts.Select(p => p.ShowId)) + .Distinct() + .ToList(); + + var episodes = await dbContext + .Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id)) + .Select(s => new + { + s.Id, + Episodes = s + .Episodes.OrderBy(e => e.Position) + .Select(e => new { e.MediaAssetId, e.Position }) + .ToList(), + }) + .ToDictionaryAsync(s => s.Id, cancellationToken); + + var assetIds = episodes.Values.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)).Distinct().ToList(); + var durations = await dbContext + .MediaAssets.AsNoTracking() + .Where(a => + assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null + ) + .Select(a => new { a.Id, a.Duration }) + .ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken); + + var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken); + + List UnitsOfShow(Guid showId) + { + if (!episodes.TryGetValue(showId, out var show)) + return []; + + return show + .Episodes.Where(e => durations.ContainsKey(e.MediaAssetId)) + .Select((e, index) => new PlanningUnit( + e.MediaAssetId, + durations[e.MediaAssetId], + showId, + index + )) + .ToList(); + } + + foreach (var group in groups) + { + var elements = new List(); + + foreach (var item in group.Items.OrderBy(i => i.Position)) + { + if (item.ElementKind == GroupElementKind.Show) + { + elements.Add( + new PlanningElement( + item.ElementKind, + item.ElementId, + item.Weight, + item.Position, + UnitsOfShow(item.ElementId), + lastPlayed.TryGetValue(item.ElementId, out var played) ? played : null + ) + ); + continue; + } + + var partShowIds = collectionParts + .Where(p => p.CollectionId == item.ElementId) + .Select(p => p.ShowId) + .ToList(); + + var units = partShowIds.SelectMany(UnitsOfShow).ToList(); + + elements.Add( + new PlanningElement( + item.ElementKind, + item.ElementId, + item.Weight, + item.Position, + units, + // Остывание коллекции считается по самой свежей из её частей: показ любой + // из них означает, что франшиза недавно была в эфире. + partShowIds + .Select(id => lastPlayed.TryGetValue(id, out var p) ? p : (DateTimeOffset?)null) + .Where(p => p is not null) + .DefaultIfEmpty(null) + .Max() + ) + ); + } + + result[group.Id] = elements; + } + + return result; + } + + /// + /// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного + /// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное + /// остывание среди правил. + /// + private async Task> LoadLastPlayedAsync( + Guid channelId, + IReadOnlyCollection showIds, + CancellationToken cancellationToken + ) => + await dbContext + .ScheduleEntries.AsNoTracking() + .Where(e => + e.ChannelId == channelId + && e.Kind == ScheduleEntryKind.Program + && e.ShowId != null + && showIds.Contains(e.ShowId!.Value) + ) + .GroupBy(e => e.ShowId!.Value) + .Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) }) + .ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/CreateSlot/CreateSlotCommand.cs b/backend/src/TeleWave.Application/Programming/Templates/CreateSlot/CreateSlotCommand.cs new file mode 100644 index 0000000..960d765 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/CreateSlot/CreateSlotCommand.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.CreateSlot; + +public sealed record CreateSlotCommand(Guid LayerId, SlotInput Input) : ICommand>; + +public sealed class CreateSlotCommandValidator : AbstractValidator +{ + public CreateSlotCommandValidator() => + RuleFor(x => x.Input).NotNull().SetValidator(new SlotInputValidator()); +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/CreateSlot/CreateSlotCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/CreateSlot/CreateSlotCommandHandler.cs new file mode 100644 index 0000000..e934c75 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/CreateSlot/CreateSlotCommandHandler.cs @@ -0,0 +1,31 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.CreateSlot; + +public sealed class CreateSlotCommandHandler(SlotWriter writer) + : ICommandHandler> +{ + public async Task> Handle( + CreateSlotCommand command, + CancellationToken cancellationToken + ) + { + var template = await writer.LoadTemplateByLayerAsync(command.LayerId, cancellationToken); + var layer = template?.FindLayer(command.LayerId); + if (template is null || layer is null) + return Result.Failure(TemplateErrors.LayerNotFound); + + var before = layer.Slots.Select(s => s.Id).ToHashSet(); + + var applied = await writer.ApplyAsync(layer, null, command.Input, cancellationToken); + if (!applied.IsSuccess) + return Result.Failure(applied.Error); + + // Правка правил эфир не двигает — только помечает шаблон изменённым (см. применение по кнопке). + template.MarkChanged(); + + var created = layer.Slots.First(s => !before.Contains(s.Id)); + return Result.Success(created.Id); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/DeleteSlot/DeleteSlotCommand.cs b/backend/src/TeleWave.Application/Programming/Templates/DeleteSlot/DeleteSlotCommand.cs new file mode 100644 index 0000000..b94863e --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/DeleteSlot/DeleteSlotCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.DeleteSlot; + +public sealed record DeleteSlotCommand(Guid SlotId) : ICommand; diff --git a/backend/src/TeleWave.Application/Programming/Templates/DeleteSlot/DeleteSlotCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/DeleteSlot/DeleteSlotCommandHandler.cs new file mode 100644 index 0000000..e3ab628 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/DeleteSlot/DeleteSlotCommandHandler.cs @@ -0,0 +1,19 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.DeleteSlot; + +public sealed class DeleteSlotCommandHandler(SlotWriter writer) + : ICommandHandler +{ + public async Task Handle(DeleteSlotCommand command, CancellationToken cancellationToken) + { + var template = await writer.LoadTemplateBySlotAsync(command.SlotId, cancellationToken); + var layer = template?.FindLayerOfSlot(command.SlotId); + if (template is null || layer is null || !layer.RemoveSlot(command.SlotId)) + return Result.Failure(TemplateErrors.SlotNotFound); + + template.MarkChanged(); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQuery.cs b/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQuery.cs new file mode 100644 index 0000000..c4bd6d4 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQuery.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.GetTemplate; + +public sealed record GetChannelTemplateQuery(Guid ChannelId) : IQuery>; diff --git a/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQueryHandler.cs new file mode 100644 index 0000000..a1eeaa7 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQueryHandler.cs @@ -0,0 +1,100 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.GetTemplate; + +public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext) + : IQueryHandler> +{ + public async Task> Handle( + GetChannelTemplateQuery query, + CancellationToken cancellationToken + ) + { + var channel = await dbContext + .Channels.AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + var template = await dbContext + .ScheduleTemplates.AsNoTracking() + .Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken); + if (template is null) + return Result.Failure(ChannelErrors.TemplateNotFound); + + // Имена групп резолвим одним запросом: инспектор слота показывает их сразу, без второго обхода. + var groupIds = template + .Layers.SelectMany(l => l.Slots) + .Select(s => s.GroupId) + .Where(id => id is not null) + .Select(id => id!.Value) + .Distinct() + .ToList(); + var groupNames = await dbContext + .Groups.AsNoTracking() + .Where(g => groupIds.Contains(g.Id)) + .ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken); + + var layers = template + .Layers.OrderByDescending(l => l.Priority) + .Select(layer => new GridLayerDto( + layer.Id, + layer.Name, + layer.Priority, + layer.IsEnabled, + layer.IsBackground, + LayerApplicability.FromJson(layer.ApplicabilityJson), + layer + .Slots.OrderBy(s => s.Weekday ?? -1) + .ThenBy(s => s.TargetStart) + .Select(slot => new SlotDto( + slot.Id, + slot.LayerId, + slot.Weekday, + slot.TargetStart, + slot.TargetDurationMinutes, + slot.Title, + slot.Daypart, + slot.SlotKind, + slot.GroupId, + slot.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname) + ? gname + : null, + SlotStrategy.FromJson(slot.StrategyJson), + RepeatSource.FromJson(slot.RepeatSourceJson), + slot.BlockMode, + slot.BlockValue, + slot.OverflowPolicy, + slot.IsAnchor, + slot.MaxDriftMinutes, + slot.SnapToMinutes, + slot.JunctionBetweenId, + slot.JunctionAfterId + )) + .ToList() + )) + .ToList(); + + return Result.Success( + new ScheduleTemplateDto( + template.Id, + template.ChannelId, + template.Name, + template.FallbackGroupId, + template.DefaultJunctionId, + template.Revision, + template.AppliedRevision, + template.HasPendingChanges, + channel.UtcOffsetMinutes, + channel.DayStartTime, + layers + ) + ); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/JunctionConditions.cs b/backend/src/TeleWave.Application/Programming/Templates/JunctionConditions.cs new file mode 100644 index 0000000..050f558 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/JunctionConditions.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TeleWave.Application.Programming.Templates; + +/// +/// Условия показа врезки. Хранятся структурно, а не выражением: парсер выражений, его валидация +/// и отдельный UI обошлись бы дорого, а покрывают ровно те же несколько реальных случаев. +/// +public sealed record JunctionConditions( + /// Ставить только при смене шоу, а не между сериями одного. + bool OnlyOnElementChange = false, + /// Не ставить чаще, чем раз в N минут (0 — без ограничения). + int MinMinutesBetween = 0 +) +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + }; + + public string ToJson() => JsonSerializer.Serialize(this, Options); + + public static JunctionConditions? FromJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + return JsonSerializer.Deserialize(json, Options); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionCommands.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionCommands.cs new file mode 100644 index 0000000..63d43c8 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionCommands.cs @@ -0,0 +1,64 @@ +using FluentValidation; +using LiteCqrs; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates.Junctions; + +public sealed record ListJunctionsQuery(Guid ChannelId) + : IQuery>; + +public sealed record CreateJunctionCommand(Guid ChannelId, string Name) : ICommand>; + +public sealed record RenameJunctionCommand(Guid JunctionId, string Name) : ICommand; + +public sealed record DeleteJunctionCommand(Guid JunctionId) : ICommand; + +public sealed record AddJunctionElementCommand(Guid JunctionId, JunctionElementKind Kind) + : ICommand>; + +/// Полный набор настроек врезки — то же тело для правки любого элемента стыка. +public sealed record JunctionElementInput( + JunctionElementKind Kind, + Guid? GroupId, + Guid? BumperTemplateId, + JunctionAmountMode AmountMode, + int AmountValue, + bool IsRequired, + JunctionConditions? Conditions +); + +public sealed record UpdateJunctionElementCommand( + Guid JunctionId, + Guid ElementId, + JunctionElementInput Input +) : ICommand; + +public sealed record RemoveJunctionElementCommand(Guid JunctionId, Guid ElementId) + : ICommand; + +public sealed record ReorderJunctionCommand(Guid JunctionId, IReadOnlyList ElementIdsInOrder) + : ICommand; + +public sealed class CreateJunctionCommandValidator : AbstractValidator +{ + public CreateJunctionCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(128); +} + +public sealed class RenameJunctionCommandValidator : AbstractValidator +{ + public RenameJunctionCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(128); +} + +public sealed class UpdateJunctionElementCommandValidator + : AbstractValidator +{ + public UpdateJunctionElementCommandValidator() + { + // Верхняя граница — сутки: врезка длиннее вещательного дня бессмысленна. + RuleFor(x => x.Input.AmountValue).InclusiveBetween(1, 24 * 60); + RuleFor(x => x.Input.Conditions!.MinMinutesBetween) + .InclusiveBetween(0, 24 * 60) + .When(x => x.Input.Conditions is not null); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionDtos.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionDtos.cs new file mode 100644 index 0000000..542cedc --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionDtos.cs @@ -0,0 +1,23 @@ +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates.Junctions; + +public sealed record JunctionElementDto( + Guid Id, + int Position, + JunctionElementKind Kind, + Guid? GroupId, + string? GroupName, + Guid? BumperTemplateId, + string? BumperTemplateName, + JunctionAmountMode AmountMode, + int AmountValue, + bool IsRequired, + JunctionConditions? Conditions +); + +public sealed record JunctionTemplateDto( + Guid Id, + string Name, + IReadOnlyList Elements +); diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionHandlers.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionHandlers.cs new file mode 100644 index 0000000..3f6f193 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionHandlers.cs @@ -0,0 +1,297 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates.Junctions; + +public sealed class ListJunctionsQueryHandler(IAppDbContext dbContext) + : IQueryHandler> +{ + public async Task> Handle( + ListJunctionsQuery query, + CancellationToken cancellationToken + ) + { + var junctions = await dbContext + .JunctionTemplates.AsNoTracking() + .Include(j => j.Elements) + .Where(j => j.ChannelId == query.ChannelId) + .OrderBy(j => j.Name) + .ToListAsync(cancellationToken); + + // Имена групп и блоков заставок резолвим одним проходом — редактор показывает их сразу. + var groupIds = junctions + .SelectMany(j => j.Elements) + .Select(e => e.GroupId) + .Where(id => id is not null) + .Select(id => id!.Value) + .Distinct() + .ToList(); + var groupNames = await dbContext + .Groups.AsNoTracking() + .Where(g => groupIds.Contains(g.Id)) + .ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken); + + var bumperNames = await dbContext + .Channels.AsNoTracking() + .Where(c => c.Id == query.ChannelId) + .SelectMany(c => c.BumperTemplates) + .ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken); + + return junctions + .Select(j => new JunctionTemplateDto( + j.Id, + j.Name, + j.Elements.OrderBy(e => e.Position) + .Select(e => new JunctionElementDto( + e.Id, + e.Position, + e.Kind, + e.GroupId, + e.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname) + ? gname + : null, + e.BumperTemplateId, + e.BumperTemplateId is { } bid && bumperNames.TryGetValue(bid, out var bname) + ? bname + : null, + e.AmountMode, + e.AmountValue, + e.IsRequired, + JunctionConditions.FromJson(e.ConditionsJson) + )) + .ToList() + )) + .ToList(); + } +} + +public sealed class CreateJunctionCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle( + CreateJunctionCommand command, + CancellationToken cancellationToken + ) + { + if (!await dbContext.Channels.AnyAsync(c => c.Id == command.ChannelId, cancellationToken)) + return Result.Failure(ChannelErrors.NotFound); + + var junction = JunctionTemplate.Create(command.ChannelId, command.Name); + dbContext.JunctionTemplates.Add(junction); + return Result.Success(junction.Id); + } +} + +public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + RenameJunctionCommand command, + CancellationToken cancellationToken + ) + { + var junction = await JunctionLoader.LoadAsync( + dbContext, + command.JunctionId, + cancellationToken + ); + if (junction is null) + return Result.Failure(TemplateErrors.JunctionNotFound); + + junction.Rename(command.Name); + return await MarkTemplateChangedAsync(dbContext, junction, cancellationToken); + } + + /// Правка стыка — тоже правка правил эфира: шаблон канала помечается изменённым. + internal static async Task MarkTemplateChangedAsync( + IAppDbContext dbContext, + JunctionTemplate junction, + CancellationToken cancellationToken + ) + { + var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync( + t => t.ChannelId == junction.ChannelId, + cancellationToken + ); + template?.MarkChanged(); + return Result.Success(); + } +} + +public sealed class DeleteJunctionCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + DeleteJunctionCommand command, + CancellationToken cancellationToken + ) + { + var junction = await JunctionLoader.LoadAsync( + dbContext, + command.JunctionId, + cancellationToken + ); + if (junction is null) + return Result.Failure(TemplateErrors.JunctionNotFound); + + // Слот, ссылающийся на удалённый стык, молча остался бы без врезок — проверяем заранее. + var used = await dbContext.Slots.AnyAsync( + s => s.JunctionBetweenId == junction.Id || s.JunctionAfterId == junction.Id, + cancellationToken + ); + if (used) + return Result.Failure(TemplateErrors.JunctionInUse); + + dbContext.JunctionTemplates.Remove(junction); + return await RenameJunctionCommandHandler.MarkTemplateChangedAsync( + dbContext, + junction, + cancellationToken + ); + } +} + +public sealed class AddJunctionElementCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle( + AddJunctionElementCommand command, + CancellationToken cancellationToken + ) + { + var junction = await JunctionLoader.LoadAsync( + dbContext, + command.JunctionId, + cancellationToken + ); + if (junction is null) + return Result.Failure(TemplateErrors.JunctionNotFound); + + var element = junction.AddElement(command.Kind); + await RenameJunctionCommandHandler.MarkTemplateChangedAsync( + dbContext, + junction, + cancellationToken + ); + return Result.Success(element.Id); + } +} + +public sealed class UpdateJunctionElementCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + UpdateJunctionElementCommand command, + CancellationToken cancellationToken + ) + { + var junction = await JunctionLoader.LoadAsync( + dbContext, + command.JunctionId, + cancellationToken + ); + var element = junction?.FindElement(command.ElementId); + if (junction is null || element is null) + return Result.Failure(TemplateErrors.JunctionElementNotFound); + + var input = command.Input; + + if (input.Kind == JunctionElementKind.Bumper) + { + var known = await dbContext + .Channels.Where(c => c.Id == junction.ChannelId) + .SelectMany(c => c.BumperTemplates) + .AnyAsync(t => t.Id == input.BumperTemplateId, cancellationToken); + if (!known) + return Result.Failure(ChannelErrors.BumperTemplateNotFound); + } + else + { + if (input.GroupId is not { } groupId) + return Result.Failure(TemplateErrors.JunctionGroupRequired); + if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)) + return Result.Failure(TemplateErrors.GroupNotFound); + } + + element.Update( + input.Kind, + input.GroupId, + input.BumperTemplateId, + input.AmountMode, + input.AmountValue, + input.IsRequired, + input.Conditions?.ToJson() + ); + + return await RenameJunctionCommandHandler.MarkTemplateChangedAsync( + dbContext, + junction, + cancellationToken + ); + } +} + +public sealed class RemoveJunctionElementCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + RemoveJunctionElementCommand command, + CancellationToken cancellationToken + ) + { + var junction = await JunctionLoader.LoadAsync( + dbContext, + command.JunctionId, + cancellationToken + ); + if (junction is null || !junction.RemoveElement(command.ElementId)) + return Result.Failure(TemplateErrors.JunctionElementNotFound); + + return await RenameJunctionCommandHandler.MarkTemplateChangedAsync( + dbContext, + junction, + cancellationToken + ); + } +} + +public sealed class ReorderJunctionCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + ReorderJunctionCommand command, + CancellationToken cancellationToken + ) + { + var junction = await JunctionLoader.LoadAsync( + dbContext, + command.JunctionId, + cancellationToken + ); + if (junction is null) + return Result.Failure(TemplateErrors.JunctionNotFound); + + junction.Reorder(command.ElementIdsInOrder); + return await RenameJunctionCommandHandler.MarkTemplateChangedAsync( + dbContext, + junction, + cancellationToken + ); + } +} + +internal static class JunctionLoader +{ + public static Task LoadAsync( + IAppDbContext dbContext, + Guid junctionId, + CancellationToken cancellationToken + ) => + dbContext + .JunctionTemplates.Include(j => j.Elements) + .FirstOrDefaultAsync(j => j.Id == junctionId, cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/LayerApplicability.cs b/backend/src/TeleWave.Application/Programming/Templates/LayerApplicability.cs new file mode 100644 index 0000000..bb1be68 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/LayerApplicability.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TeleWave.Application.Programming.Templates; + +/// Разовый диапазон дат. +public sealed record DateRange(DateOnly From, DateOnly To); + +/// Ежегодно повторяющийся период — «перед Новым годом» задаётся один раз, а не каждый год. +public sealed record AnnualRange(int FromMonth, int FromDay, int ToMonth, int ToDay); + +/// +/// Когда действует слой сетки. Пустая применимость — слой действует всегда. Заполненные разделы +/// объединяются по ИЛИ: слой применим, если дата подходит хотя бы под одно из условий. +/// +public sealed record LayerApplicability( + /// Дни недели вещательных суток (0=Вс..6=Сб). + IReadOnlyList? Weekdays = null, + IReadOnlyList? DateRanges = null, + IReadOnlyList? AnnualRanges = null, + IReadOnlyList? SpecificDates = null +) +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + }; + + public bool IsEmpty => + Weekdays is null or { Count: 0 } + && DateRanges is null or { Count: 0 } + && AnnualRanges is null or { Count: 0 } + && SpecificDates is null or { Count: 0 }; + + public string ToJson() => JsonSerializer.Serialize(this, Options); + + public static LayerApplicability? FromJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + return JsonSerializer.Deserialize(json, Options); + } + catch (JsonException) + { + return null; + } + } + + /// + /// Действует ли слой в эту дату (дата вещательных суток, не календарная — ночь после полуночи + /// принадлежит предыдущему дню). + /// + public bool Covers(DateOnly date) + { + if (IsEmpty) + return true; + + if (Weekdays is { Count: > 0 } weekdays && weekdays.Contains((int)date.DayOfWeek)) + return true; + + if (SpecificDates is { Count: > 0 } dates && dates.Contains(date)) + return true; + + if (DateRanges is { Count: > 0 } ranges && ranges.Any(r => date >= r.From && date <= r.To)) + return true; + + return AnnualRanges is { Count: > 0 } annual && annual.Any(r => CoversAnnual(r, date)); + } + + /// + /// Попадает ли дата в ежегодный период. Период может пересекать Новый год («20 декабря — + /// 8 января»), поэтому сравнение идёт по паре (месяц, день), а не по датам. + /// + private static bool CoversAnnual(AnnualRange range, DateOnly date) + { + var value = date.Month * 100 + date.Day; + var from = range.FromMonth * 100 + range.FromDay; + var to = range.ToMonth * 100 + range.ToDay; + + return from <= to ? value >= from && value <= to : value >= from || value <= to; + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs b/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs new file mode 100644 index 0000000..855d23e --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs @@ -0,0 +1,122 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates.Layers; + +public sealed class CreateLayerCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle( + CreateLayerCommand command, + CancellationToken cancellationToken + ) + { + var template = await LayerLoader.ByTemplateAsync( + dbContext, + command.TemplateId, + cancellationToken + ); + if (template is null) + return Result.Failure(TemplateErrors.NotFound); + + var layer = template.AddLayer(command.Name, command.Priority); + template.MarkChanged(); + return Result.Success(layer.Id); + } +} + +public sealed class UpdateLayerCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle(UpdateLayerCommand command, CancellationToken cancellationToken) + { + var template = await LayerLoader.ByLayerAsync(dbContext, command.LayerId, cancellationToken); + var layer = template?.FindLayer(command.LayerId); + if (template is null || layer is null) + return Result.Failure(TemplateErrors.LayerNotFound); + + layer.Update( + command.Name, + command.Priority, + command.Applicability?.ToJson(), + command.IsEnabled + ); + template.MarkChanged(); + return Result.Success(); + } +} + +public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle(DeleteLayerCommand command, CancellationToken cancellationToken) + { + var template = await LayerLoader.ByLayerAsync(dbContext, command.LayerId, cancellationToken); + var layer = template?.FindLayer(command.LayerId); + if (template is null || layer is null) + return Result.Failure(TemplateErrors.LayerNotFound); + + if (layer.IsBackground) + return Result.Failure(TemplateErrors.BackgroundLayerCannotBeDeleted); + + template.RemoveLayer(command.LayerId); + template.MarkChanged(); + return Result.Success(); + } +} + +public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + UpdateTemplateCommand command, + CancellationToken cancellationToken + ) + { + var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync( + t => t.Id == command.TemplateId, + cancellationToken + ); + if (template is null) + return Result.Failure(TemplateErrors.NotFound); + + if ( + command.FallbackGroupId is { } groupId + && !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken) + ) + return Result.Failure(TemplateErrors.GroupNotFound); + + template.Rename(command.Name); + template.SetFallbackGroup(command.FallbackGroupId); + template.SetDefaultJunction(command.DefaultJunctionId); + template.MarkChanged(); + return Result.Success(); + } +} + +/// Загрузка шаблона со слоями и слотами: любая правка слоя меняет ревизию всего шаблона. +internal static class LayerLoader +{ + public static Task ByTemplateAsync( + IAppDbContext dbContext, + Guid templateId, + CancellationToken cancellationToken + ) => + dbContext + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken); + + public static Task ByLayerAsync( + IAppDbContext dbContext, + Guid layerId, + CancellationToken cancellationToken + ) => + dbContext + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommands.cs b/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommands.cs new file mode 100644 index 0000000..4c45572 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommands.cs @@ -0,0 +1,49 @@ +using FluentValidation; +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.Layers; + +public sealed record CreateLayerCommand(Guid TemplateId, string Name, int Priority) + : ICommand>; + +public sealed record UpdateLayerCommand( + Guid LayerId, + string Name, + int Priority, + LayerApplicability? Applicability, + bool IsEnabled +) : ICommand; + +public sealed record DeleteLayerCommand(Guid LayerId) : ICommand; + +/// Имя шаблона и аварийная группа, играющая, когда пуст даже фоновый слой. +public sealed record UpdateTemplateCommand( + Guid TemplateId, + string Name, + Guid? FallbackGroupId, + Guid? DefaultJunctionId +) : ICommand; + +public sealed class CreateLayerCommandValidator : AbstractValidator +{ + public CreateLayerCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(128); + RuleFor(x => x.Priority).InclusiveBetween(1, 1000); + } +} + +public sealed class UpdateLayerCommandValidator : AbstractValidator +{ + public UpdateLayerCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(128); + RuleFor(x => x.Priority).InclusiveBetween(1, 1000); + } +} + +public sealed class UpdateTemplateCommandValidator : AbstractValidator +{ + public UpdateTemplateCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(256); +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/RepeatSource.cs b/backend/src/TeleWave.Application/Programming/Templates/RepeatSource.cs new file mode 100644 index 0000000..b184131 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/RepeatSource.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TeleWave.Application.Programming.Templates; + +/// +/// Откуда слот-повтор берёт содержимое: точка в уже записанной ленте того же канала. «Вечерний фильм +/// утром в субботу» — это daysAgo = 1, time = 20:00. +/// +public sealed record RepeatSource(int DaysAgo, TimeOnly Time, int DurationMinutes) +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + }; + + public string ToJson() => JsonSerializer.Serialize(this, Options); + + public static RepeatSource? FromJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + return JsonSerializer.Deserialize(json, Options); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/SlotInput.cs b/backend/src/TeleWave.Application/Programming/Templates/SlotInput.cs new file mode 100644 index 0000000..cccbe9f --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/SlotInput.cs @@ -0,0 +1,59 @@ +using FluentValidation; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates; + +/// Полный набор настроек слота — общий для создания и правки. +public sealed record SlotInput( + string Title, + int? Weekday, + TimeOnly TargetStart, + int TargetDurationMinutes, + Daypart Daypart, + SlotKind SlotKind, + Guid? GroupId, + SlotStrategy? Strategy, + RepeatSource? RepeatSource, + SlotBlockMode BlockMode, + int BlockValue, + OverflowPolicy OverflowPolicy, + bool IsAnchor, + int MaxDriftMinutes, + int? SnapToMinutes, + /// Стык между единицами внутри блока. + Guid? JunctionBetweenId = null, + /// Стык в конце блока (null — берётся стык шаблона по умолчанию). + Guid? JunctionAfterId = null +); + +public sealed class SlotInputValidator : AbstractValidator +{ + /// Округлять старт можно только до значений, которые читаются как «круглое время». + private static readonly int[] AllowedSnap = [5, 10, 15, 30]; + + public SlotInputValidator() + { + RuleFor(x => x.Title).NotEmpty().MaximumLength(256); + RuleFor(x => x.Weekday).InclusiveBetween(0, 6).When(x => x.Weekday is not null); + + // Сутки — верхняя граница осмысленного слота: длиннее он всё равно перекроет сам себя. + RuleFor(x => x.TargetDurationMinutes).InclusiveBetween(1, 24 * 60); + RuleFor(x => x.BlockValue).GreaterThan(0); + RuleFor(x => x.MaxDriftMinutes).InclusiveBetween(0, 12 * 60); + + RuleFor(x => x.SnapToMinutes) + .Must(value => value is null || AllowedSnap.Contains(value.Value)) + .WithMessage("Округление старта допустимо до 5, 10, 15 или 30 минут."); + + RuleFor(x => x.Strategy!.CooldownDays) + .InclusiveBetween(0, 3650) + .When(x => x.Strategy is not null); + + RuleFor(x => x.RepeatSource!.DaysAgo) + .InclusiveBetween(1, 365) + .When(x => x.RepeatSource is not null); + RuleFor(x => x.RepeatSource!.DurationMinutes) + .InclusiveBetween(1, 24 * 60) + .When(x => x.RepeatSource is not null); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/SlotStrategy.cs b/backend/src/TeleWave.Application/Programming/Templates/SlotStrategy.cs new file mode 100644 index 0000000..33cd8b9 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/SlotStrategy.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TeleWave.Application.Programming.Templates; + +/// Как слот выбирает элемент группы. +public enum SlotStrategyType +{ + /// Элементы группы по порядку позиций. + Sequential = 0, + + /// Случайный элемент, не показанный последние N дней. + RandomWithCooldown = 1, + + /// Всегда один и тот же элемент. + Fixed = 2, +} + +/// Что делать, когда остывание отсекло всех кандидатов. +public enum CooldownFallback +{ + /// Взять элемент, не игравший дольше всех. + OldestFirst = 0, + + /// Проигнорировать остывание на этот выход. + IgnoreCooldown = 1, +} + +/// +/// Стратегия выбора элемента внутри слота. Отвечает только за выбор элемента: единицы внутри элемента +/// всегда идут по порядку от курсора — сериал не должен прыгать по сериям. +/// +/// Размер блока живёт на слоте отдельно и ортогонален стратегии: любую можно скомбинировать +/// с «одна единица», «четыре подряд» или «пока не наберётся два часа». +/// +public sealed record SlotStrategy( + SlotStrategyType Type = SlotStrategyType.Sequential, + /// Начинать группу заново, дойдя до конца (иначе слот останавливается). + bool RestartOnEnd = true, + /// Дней остывания для . + int CooldownDays = 0, + CooldownFallback Fallback = CooldownFallback.OldestFirst, + /// Закреплённый элемент для . + Guid? FixedElementId = null +) +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + }; + + public string ToJson() => JsonSerializer.Serialize(this, Options); + + /// Разбирает сохранённую стратегию. Повреждённый JSON — как отсутствие стратегии: + /// слот получит поведение по умолчанию, а не уронит генерацию всего канала. + public static SlotStrategy? FromJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + return JsonSerializer.Deserialize(json, Options); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs b/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs new file mode 100644 index 0000000..731caac --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates; + +/// +/// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что +/// им нужны соседние слоты и справочник групп. +/// +public sealed class SlotWriter(IAppDbContext dbContext) +{ + /// + /// Проверяет вход и применяет его к слоту. = null — проверка перед + /// созданием. + /// + public async Task ApplyAsync( + GridLayer layer, + Slot? slot, + SlotInput input, + CancellationToken cancellationToken + ) + { + // Перекрытие внутри слоя запрещено: два слота на одну минуту сделали бы выбор неоднозначным. + if ( + layer.HasOverlap( + input.Weekday, + input.TargetStart, + input.TargetDurationMinutes, + slot?.Id + ) + ) + return Result.Failure(TemplateErrors.SlotsOverlap); + + if (input.SlotKind == SlotKind.Content) + { + if (input.GroupId is not { } groupId) + return Result.Failure(TemplateErrors.GroupRequired); + + if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)) + return Result.Failure(TemplateErrors.GroupNotFound); + } + + if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null) + return Result.Failure(TemplateErrors.RepeatSourceRequired); + + var target = + slot + ?? layer.AddSlot( + input.Title, + input.TargetStart, + input.TargetDurationMinutes, + input.Daypart, + input.SlotKind, + input.Weekday + ); + + target.UpdateTiming( + input.Weekday, + input.TargetStart, + input.TargetDurationMinutes, + input.Daypart, + input.IsAnchor, + input.MaxDriftMinutes, + input.SnapToMinutes + ); + target.UpdateContent( + input.Title, + input.SlotKind, + input.GroupId, + input.Strategy?.ToJson(), + input.RepeatSource?.ToJson(), + input.BlockMode, + input.BlockValue, + input.OverflowPolicy, + input.JunctionBetweenId, + input.JunctionAfterId + ); + + return Result.Success(); + } + + /// Загружает шаблон целиком по слою — правка слота меняет ревизию всего шаблона. + public Task LoadTemplateByLayerAsync( + Guid layerId, + CancellationToken cancellationToken + ) => + dbContext + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken); + + /// Загружает шаблон по слоту. + public Task LoadTemplateBySlotAsync( + Guid slotId, + CancellationToken cancellationToken + ) => + dbContext + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .FirstOrDefaultAsync( + t => t.Layers.Any(l => l.Slots.Any(s => s.Id == slotId)), + cancellationToken + ); +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/TemplateDtos.cs b/backend/src/TeleWave.Application/Programming/Templates/TemplateDtos.cs new file mode 100644 index 0000000..edb0488 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/TemplateDtos.cs @@ -0,0 +1,52 @@ +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates; + +public sealed record SlotDto( + Guid Id, + Guid LayerId, + int? Weekday, + TimeOnly TargetStart, + int TargetDurationMinutes, + string Title, + Daypart Daypart, + SlotKind SlotKind, + Guid? GroupId, + string? GroupName, + SlotStrategy? Strategy, + RepeatSource? RepeatSource, + SlotBlockMode BlockMode, + int BlockValue, + OverflowPolicy OverflowPolicy, + bool IsAnchor, + int MaxDriftMinutes, + int? SnapToMinutes, + Guid? JunctionBetweenId, + Guid? JunctionAfterId +); + +public sealed record GridLayerDto( + Guid Id, + string Name, + int Priority, + bool IsEnabled, + bool IsBackground, + LayerApplicability? Applicability, + IReadOnlyList Slots +); + +public sealed record ScheduleTemplateDto( + Guid Id, + Guid ChannelId, + string Name, + Guid? FallbackGroupId, + Guid? DefaultJunctionId, + int Revision, + int AppliedRevision, + /// Есть ли правки правил, ещё не применённые к эфиру. + bool HasPendingChanges, + /// Время канала — сетка задаётся в нём, а не в UTC. + int UtcOffsetMinutes, + TimeOnly DayStartTime, + IReadOnlyList Layers +); diff --git a/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs b/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs new file mode 100644 index 0000000..fdfc104 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs @@ -0,0 +1,66 @@ +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates; + +public static class TemplateErrors +{ + public static readonly Error NotFound = Error.NotFound( + "Templates.NotFound", + "Шаблон сетки не найден." + ); + + public static readonly Error LayerNotFound = Error.NotFound( + "Templates.LayerNotFound", + "Слой сетки не найден." + ); + + public static readonly Error SlotNotFound = Error.NotFound( + "Templates.SlotNotFound", + "Слот не найден." + ); + + public static readonly Error BackgroundLayerCannotBeDeleted = Error.Validation( + "Templates.BackgroundLayerCannotBeDeleted", + "Фоновый слой удалить нельзя — без него в сетке появятся дыры." + ); + + public static readonly Error SlotsOverlap = Error.Conflict( + "Templates.SlotsOverlap", + "Слоты одного слоя пересекаются по времени." + ); + + public static readonly Error GroupRequired = Error.Validation( + "Templates.GroupRequired", + "Слоту с контентом нужна группа." + ); + + public static readonly Error GroupNotFound = Error.NotFound( + "Templates.GroupNotFound", + "Группа не найдена." + ); + + public static readonly Error JunctionNotFound = Error.NotFound( + "Templates.JunctionNotFound", + "Шаблон стыка не найден." + ); + + public static readonly Error JunctionElementNotFound = Error.NotFound( + "Templates.JunctionElementNotFound", + "Врезка не найдена." + ); + + public static readonly Error JunctionInUse = Error.Conflict( + "Templates.JunctionInUse", + "Стык используется слотами — сначала отвяжите его." + ); + + public static readonly Error JunctionGroupRequired = Error.Validation( + "Templates.JunctionGroupRequired", + "Врезке нужна группа, откуда брать ролики." + ); + + public static readonly Error RepeatSourceRequired = Error.Validation( + "Templates.RepeatSourceRequired", + "Слоту-повтору нужно указать, что повторять." + ); +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/UpdateSlot/UpdateSlotCommand.cs b/backend/src/TeleWave.Application/Programming/Templates/UpdateSlot/UpdateSlotCommand.cs new file mode 100644 index 0000000..2aab239 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/UpdateSlot/UpdateSlotCommand.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.UpdateSlot; + +public sealed record UpdateSlotCommand(Guid SlotId, SlotInput Input) : ICommand; + +public sealed class UpdateSlotCommandValidator : AbstractValidator +{ + public UpdateSlotCommandValidator() => + RuleFor(x => x.Input).NotNull().SetValidator(new SlotInputValidator()); +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/UpdateSlot/UpdateSlotCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/UpdateSlot/UpdateSlotCommandHandler.cs new file mode 100644 index 0000000..037b64f --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/UpdateSlot/UpdateSlotCommandHandler.cs @@ -0,0 +1,24 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.UpdateSlot; + +public sealed class UpdateSlotCommandHandler(SlotWriter writer) + : ICommandHandler +{ + public async Task Handle(UpdateSlotCommand command, CancellationToken cancellationToken) + { + var template = await writer.LoadTemplateBySlotAsync(command.SlotId, cancellationToken); + var layer = template?.FindLayerOfSlot(command.SlotId); + var slot = layer?.FindSlot(command.SlotId); + if (template is null || layer is null || slot is null) + return Result.Failure(TemplateErrors.SlotNotFound); + + var applied = await writer.ApplyAsync(layer, slot, command.Input, cancellationToken); + if (!applied.IsSuccess) + return applied; + + template.MarkChanged(); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Domain/Broadcast/AdInsertion.cs b/backend/src/TeleWave.Domain/Broadcast/AdInsertion.cs deleted file mode 100644 index 46b0f0d..0000000 --- a/backend/src/TeleWave.Domain/Broadcast/AdInsertion.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace TeleWave.Domain.Broadcast; - -/// Политика вставки рекламы на канале. -public enum AdInsertion -{ - /// Реклама после целого блока серий. - BetweenBlocks, - - /// Реклама после каждой серии. - BetweenEpisodes, -} diff --git a/backend/src/TeleWave.Domain/Broadcast/BlockMode.cs b/backend/src/TeleWave.Domain/Broadcast/BlockMode.cs deleted file mode 100644 index 3e9cd2b..0000000 --- a/backend/src/TeleWave.Domain/Broadcast/BlockMode.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace TeleWave.Domain.Broadcast; - -/// Как измеряется блок серий одного шоу за один выбор ротации. -public enum BlockMode -{ - /// Ровно N серий подряд. - Count, - - /// Набор серий подряд, пока не наберётся ~M минут (последняя входит целиком). - Duration, -} diff --git a/backend/src/TeleWave.Domain/Broadcast/Channel.cs b/backend/src/TeleWave.Domain/Broadcast/Channel.cs index bffcd78..518d399 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Channel.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Channel.cs @@ -1,15 +1,12 @@ namespace TeleWave.Domain.Broadcast; /// -/// Канал линейного эфира: базовая взвешенная ротация шоу (), пул рекламы -/// (), временные override'ы () и политика вставки рекламы. -/// Планировщик разворачивает всё это в расписание встык на несколько дней вперёд. +/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (, +/// см. Domain/Programming); канал хранит только собственные свойства: время, номер, аварийный +/// филлер и общие настройки заставок. /// public class Channel { - private readonly List _shows = new(); - private readonly List _ads = new(); - private readonly List _overrides = new(); private readonly List _bumperTemplates = new(); public Guid Id { get; private set; } @@ -20,14 +17,37 @@ public class Channel /// Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи. public DateTimeOffset EpochUtc { get; private set; } - public AdInsertion AdInsertion { get; private set; } - public int AdsPerBreak { get; private set; } + /// + /// Номер канала — на телевизоре канал это номер, а не карточка в сетке. Переключение по номерам + /// включается глобальным флагом настроек сайта; null — номер не задан. + /// + public int? Number { get; private set; } + + /// + /// Смещение времени канала от UTC в минутах (по умолчанию 180 — московское). Фиксированный + /// оффсет, а не IANA-зона: с переводом часов сутки становятся 23- или 25-часовыми, и сетке + /// понадобилось бы отдельное правило подрезки. Появятся каналы в зонах с DST — перейдём на IANA. + /// + public int UtcOffsetMinutes { get; private set; } + + /// + /// Начало вещательных суток в времени канала (по умолчанию 06:00). Ночной блок с 00:00 до 06:00 + /// относится к предыдущему дню: «ночь с пятницы на субботу» — это пятница. + /// + public TimeOnly DayStartTime { get; private set; } + + /// Активный шаблон сетки канала (один на канал). + public Guid? TemplateId { get; private set; } + + public const int DefaultUtcOffsetMinutes = 180; + public static readonly TimeOnly DefaultDayStartTime = new(6, 0); + + // ── Настройки ТВ-заставок. В срезе 2 условия показа переезжают в элементы стыка, + // здесь останется только общий для канала шрифт. ── /// Вставлять ли ТВ-заставки на переходах между разными шоу. public bool BumpersEnabled { get; private set; } - // ── Общие для канала настройки ТВ-заставок (стиль/звук — на каждом блоке, см. BumperTemplates) ── - /// Как выбирать блок заставки на каждом переходе (по кругу/случайно/всегда первый). public BumperSelection BumperSelection { get; private set; } @@ -50,17 +70,8 @@ public class Channel /// Ассет-заглушка на случай пустого расписания (аварийная подстраховка). public Guid? FillerAssetId { get; private set; } - /// Курсор ротации рекламного пула. - public int NextAdIndex { get; private set; } - public DateTimeOffset CreatedAt { get; private set; } - public IReadOnlyList Shows => _shows; - - /// Пул рекламы (backing-field для EF); порядок ротации — по . - public IReadOnlyList Ads => _ads; - public IReadOnlyList Overrides => _overrides; - /// Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position. public IReadOnlyList BumperTemplates => _bumperTemplates; @@ -75,8 +86,6 @@ public class Channel Slug = slug, IsEnabled = true, EpochUtc = epochUtc, - AdInsertion = AdInsertion.BetweenBlocks, - AdsPerBreak = 1, BumpersEnabled = false, BumperSelection = BumperSelection.Rotation, NextBumperIndex = 0, @@ -84,7 +93,8 @@ public class Channel BumperMinIntervalMinutes = 0, BumperShowChangeChance = 1.0, BumperEpisodeChangeChance = 1.0, - NextAdIndex = 0, + UtcOffsetMinutes = DefaultUtcOffsetMinutes, + DayStartTime = DefaultDayStartTime, CreatedAt = DateTimeOffset.UtcNow, }; // На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл). @@ -95,16 +105,12 @@ public class Channel public void UpdateSettings( string name, bool isEnabled, - AdInsertion adInsertion, - int adsPerBreak, bool bumpersEnabled, Guid? fillerAssetId ) { Name = name; IsEnabled = isEnabled; - AdInsertion = adInsertion; - AdsPerBreak = adsPerBreak; BumpersEnabled = bumpersEnabled; FillerAssetId = fillerAssetId; } @@ -154,79 +160,17 @@ public class Channel /// Планировщик двигает курсор ротации блоков заставок по мере вставки. public void SetNextBumperIndex(int index) => NextBumperIndex = index; - public ChannelShow? FindShow(Guid channelShowId) => - _shows.FirstOrDefault(s => s.Id == channelShowId); + /// Привязать активный шаблон сетки. + public void SetTemplate(Guid? templateId) => TemplateId = templateId; - public ChannelShow AddShow(Guid showId, int weight, BlockMode blockMode, int blockValue) + /// + /// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение + /// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём. + /// + public void UpdateTimeSettings(int? number, int utcOffsetMinutes, TimeOnly dayStartTime) { - var channelShow = ChannelShow.Create(Id, showId, weight, blockMode, blockValue); - _shows.Add(channelShow); - return channelShow; + Number = number is > 0 ? number : null; + UtcOffsetMinutes = Math.Clamp(utcOffsetMinutes, -12 * 60, 14 * 60); + DayStartTime = dayStartTime; } - - public bool RemoveShow(Guid channelShowId) - { - var channelShow = _shows.FirstOrDefault(s => s.Id == channelShowId); - if (channelShow is null) - return false; - _shows.Remove(channelShow); - return true; - } - - public bool HasShow(Guid showId) => _shows.Any(s => s.ShowId == showId); - - public ChannelAd AddAd(Guid mediaAssetId) - { - var nextPosition = _ads.Count == 0 ? 0 : _ads.Max(a => a.Position) + 1; - var ad = ChannelAd.Create(Id, mediaAssetId, nextPosition); - _ads.Add(ad); - return ad; - } - - public bool RemoveAd(Guid channelAdId) - { - var ad = _ads.FirstOrDefault(a => a.Id == channelAdId); - if (ad is null) - return false; - _ads.Remove(ad); - return true; - } - - public bool HasAd(Guid mediaAssetId) => _ads.Any(a => a.MediaAssetId == mediaAssetId); - - public ProgrammingOverride AddOverride( - OverrideMode mode, - DateTimeOffset startsAtUtc, - DateTimeOffset endsAtUtc - ) - { - var ovr = ProgrammingOverride.CreateOneTime(Id, mode, startsAtUtc, endsAtUtc); - _overrides.Add(ovr); - return ovr; - } - - /// Еженедельный override: день недели (0=Вс..6=Сб) + окно минут суток (UTC). - public ProgrammingOverride AddWeeklyOverride( - OverrideMode mode, - int dayOfWeek, - int startMinute, - int endMinute - ) - { - var ovr = ProgrammingOverride.CreateWeekly(Id, mode, dayOfWeek, startMinute, endMinute); - _overrides.Add(ovr); - return ovr; - } - - public bool RemoveOverride(Guid overrideId) - { - var ovr = _overrides.FirstOrDefault(o => o.Id == overrideId); - if (ovr is null) - return false; - _overrides.Remove(ovr); - return true; - } - - /// Планировщик двигает курсор рекламы по мере вставки врезок. - public void SetNextAdIndex(int index) => NextAdIndex = index; } diff --git a/backend/src/TeleWave.Domain/Broadcast/ChannelAd.cs b/backend/src/TeleWave.Domain/Broadcast/ChannelAd.cs deleted file mode 100644 index ded9bbd..0000000 --- a/backend/src/TeleWave.Domain/Broadcast/ChannelAd.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace TeleWave.Domain.Broadcast; - -/// Рекламный ассет в пуле канала. Врезки крутятся по кругу в порядке . -public class ChannelAd -{ - public Guid Id { get; private set; } - public Guid ChannelId { get; private set; } - public Guid MediaAssetId { get; private set; } - public int Position { get; private set; } - - private ChannelAd() { } - - internal static ChannelAd Create(Guid channelId, Guid mediaAssetId, int position) => - new() - { - Id = Guid.NewGuid(), - ChannelId = channelId, - MediaAssetId = mediaAssetId, - Position = position, - }; -} diff --git a/backend/src/TeleWave.Domain/Broadcast/ChannelShow.cs b/backend/src/TeleWave.Domain/Broadcast/ChannelShow.cs deleted file mode 100644 index 0f872fe..0000000 --- a/backend/src/TeleWave.Domain/Broadcast/ChannelShow.cs +++ /dev/null @@ -1,87 +0,0 @@ -namespace TeleWave.Domain.Broadcast; - -/// -/// Связка канал↔шоу: вес в случайной ротации, режим и размер блока, а также персональный для этого -/// канала курсор серий () — индекс следующей серии в упорядоченном -/// списке шоу. -/// -public class ChannelShow -{ - private readonly List _preferredHours = new(); - - public Guid Id { get; private set; } - public Guid ChannelId { get; private set; } - public Guid ShowId { get; private set; } - public int Weight { get; private set; } - public BlockMode BlockMode { get; private set; } - - /// Число серий () или минут (). - public int BlockValue { get; private set; } - - public bool IsEnabled { get; private set; } - - /// Индекс следующей серии для этого канала (0-based в упорядоченном списке серий шоу). - public int NextEpisodeIndex { get; private set; } - - /// Во сколько раз усиливать вес шоу в предпочтительные часы (1 — без буста). - public int PreferredWeightMultiplier { get; private set; } = DefaultPreferredWeightMultiplier; - - /// Окна предпочтительных часов (пусто — шоу без предпочтений, вес не меняется). - public IReadOnlyList PreferredHours => _preferredHours; - - public const int DefaultPreferredWeightMultiplier = 3; - - private ChannelShow() { } - - internal static ChannelShow Create( - Guid channelId, - Guid showId, - int weight, - BlockMode blockMode, - int blockValue - ) => - new() - { - Id = Guid.NewGuid(), - ChannelId = channelId, - ShowId = showId, - Weight = weight, - BlockMode = blockMode, - BlockValue = blockValue, - IsEnabled = true, - NextEpisodeIndex = 0, - PreferredWeightMultiplier = DefaultPreferredWeightMultiplier, - }; - - public void Update(int weight, BlockMode blockMode, int blockValue, bool isEnabled) - { - Weight = weight; - BlockMode = blockMode; - BlockValue = blockValue; - IsEnabled = isEnabled; - } - - /// - /// Задаёт множитель веса и полностью заменяет набор окон предпочтительных часов. Окна нормализуются: - /// отбрасываются некорректные ([0,24], start < end), совпадающие схлопываются. - /// - public void SetPreferredHours(int multiplier, IEnumerable<(int StartHour, int EndHour)> windows) - { - PreferredWeightMultiplier = Math.Max(1, multiplier); - _preferredHours.Clear(); - foreach ( - var (start, end) in windows - .Where(w => w.StartHour >= 0 && w.EndHour <= 24 && w.StartHour < w.EndHour) - .Distinct() - ) - { - _preferredHours.Add(ChannelShowHour.Create(Id, start, end)); - } - } - - /// Час суток (0..23) попадает в одно из окон предпочтительных часов. - public bool IsPreferredAt(int hour) => _preferredHours.Any(w => w.Contains(hour)); - - /// Планировщик двигает курсор по мере постановки серий в расписание. - public void SetNextEpisodeIndex(int index) => NextEpisodeIndex = index; -} diff --git a/backend/src/TeleWave.Domain/Broadcast/ChannelShowHour.cs b/backend/src/TeleWave.Domain/Broadcast/ChannelShowHour.cs deleted file mode 100644 index 529908e..0000000 --- a/backend/src/TeleWave.Domain/Broadcast/ChannelShowHour.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace TeleWave.Domain.Broadcast; - -/// -/// Окно предпочтительных часов для шоу на канале: полуинтервал часов суток [StartHour, EndHour) -/// в UTC. В эти часы вес шоу в ротации умножается на . -/// Ночные окна задаются двумя записями (напр. 22–24 и 0–2), заворот через полночь не поддерживается. -/// -public class ChannelShowHour -{ - public Guid Id { get; private set; } - public Guid ChannelShowId { get; private set; } - - /// Начало окна — час суток (0..23). - public int StartHour { get; private set; } - - /// Конец окна (исключительно) — час суток (1..24). - public int EndHour { get; private set; } - - private ChannelShowHour() { } - - internal static ChannelShowHour Create(Guid channelShowId, int startHour, int endHour) => - new() - { - Id = Guid.NewGuid(), - ChannelShowId = channelShowId, - StartHour = startHour, - EndHour = endHour, - }; - - /// Попадает ли час суток (0..23) в это окно. - public bool Contains(int hour) => hour >= StartHour && hour < EndHour; -} diff --git a/backend/src/TeleWave.Domain/Broadcast/OverrideMode.cs b/backend/src/TeleWave.Domain/Broadcast/OverrideMode.cs deleted file mode 100644 index 2e55a2a..0000000 --- a/backend/src/TeleWave.Domain/Broadcast/OverrideMode.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace TeleWave.Domain.Broadcast; - -/// Режим временного override (марафон / кампания) поверх базовой ротации. -public enum OverrideMode -{ - /// В окне играет только одно шоу (марафон). - Exclusive, - - /// В окне действуют подменённые веса перечисленных шоу (остальные не участвуют). - Boost, -} diff --git a/backend/src/TeleWave.Domain/Broadcast/OverrideRecurrence.cs b/backend/src/TeleWave.Domain/Broadcast/OverrideRecurrence.cs deleted file mode 100644 index 5310823..0000000 --- a/backend/src/TeleWave.Domain/Broadcast/OverrideRecurrence.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace TeleWave.Domain.Broadcast; - -/// Как повторяется override программирования канала. -public enum OverrideRecurrence -{ - /// Разовое окно [StartsAtUtc, EndsAtUtc). - OneTime, - - /// Еженедельно в заданный день недели на окне часов суток (UTC). - Weekly, -} diff --git a/backend/src/TeleWave.Domain/Broadcast/OverrideShow.cs b/backend/src/TeleWave.Domain/Broadcast/OverrideShow.cs deleted file mode 100644 index d5eff62..0000000 --- a/backend/src/TeleWave.Domain/Broadcast/OverrideShow.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace TeleWave.Domain.Broadcast; - -/// Шоу внутри override с его подменённым весом (для Boost) или единственное шоу (для Exclusive). -public class OverrideShow -{ - public Guid Id { get; private set; } - public Guid ProgrammingOverrideId { get; private set; } - public Guid ShowId { get; private set; } - public int Weight { get; private set; } - - private OverrideShow() { } - - internal static OverrideShow Create(Guid overrideId, Guid showId, int weight) => - new() - { - Id = Guid.NewGuid(), - ProgrammingOverrideId = overrideId, - ShowId = showId, - Weight = weight, - }; -} diff --git a/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs b/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs deleted file mode 100644 index ece36c1..0000000 --- a/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs +++ /dev/null @@ -1,108 +0,0 @@ -namespace TeleWave.Domain.Broadcast; - -/// -/// Временный override программирования канала. Разовый () — -/// на окне [, ). Еженедельный -/// () — каждую неделю в на окне минут -/// суток [, ) в UTC. Марафон = обычно -/// с одним шоу; пересекающийся с генерируемым временем override -/// заменяет базовую ротацию. -/// -public class ProgrammingOverride -{ - private readonly List _shows = new(); - - public Guid Id { get; private set; } - public Guid ChannelId { get; private set; } - public OverrideMode Mode { get; private set; } - - public OverrideRecurrence Recurrence { get; private set; } - - // ── OneTime ── - public DateTimeOffset? StartsAtUtc { get; private set; } - public DateTimeOffset? EndsAtUtc { get; private set; } - - // ── Weekly ── (день недели 0=Вс..6=Сб как System.DayOfWeek/JS getDay; минуты суток 0..1440, UTC) - public int? DayOfWeek { get; private set; } - public int? StartMinute { get; private set; } - public int? EndMinute { get; private set; } - - public IReadOnlyList Shows => _shows; - - private ProgrammingOverride() { } - - internal static ProgrammingOverride CreateOneTime( - Guid channelId, - OverrideMode mode, - DateTimeOffset startsAtUtc, - DateTimeOffset endsAtUtc - ) => - new() - { - Id = Guid.NewGuid(), - ChannelId = channelId, - Mode = mode, - Recurrence = OverrideRecurrence.OneTime, - StartsAtUtc = startsAtUtc, - EndsAtUtc = endsAtUtc, - }; - - internal static ProgrammingOverride CreateWeekly( - Guid channelId, - OverrideMode mode, - int dayOfWeek, - int startMinute, - int endMinute - ) => - new() - { - Id = Guid.NewGuid(), - ChannelId = channelId, - Mode = mode, - Recurrence = OverrideRecurrence.Weekly, - DayOfWeek = dayOfWeek, - StartMinute = startMinute, - EndMinute = endMinute, - }; - - public OverrideShow AddShow(Guid showId, int weight) - { - var entry = OverrideShow.Create(Id, showId, weight); - _shows.Add(entry); - return entry; - } - - /// Действует ли override в этот момент (по типу повторения). - public bool Covers(DateTimeOffset moment) - { - if (Recurrence == OverrideRecurrence.Weekly) - { - if ( - DayOfWeek is not { } day - || StartMinute is not { } start - || EndMinute is not { } end - ) - return false; - - var utc = moment.UtcDateTime; - var minuteOfDay = utc.Hour * 60 + utc.Minute; - var today = (int)utc.DayOfWeek; - - if (end > start) - // Обычное окно в пределах одних суток [start, end). - return today == day && minuteOfDay >= start && minuteOfDay < end; - - if (end < start) - { - // Окно пересекает полночь: [start, 24:00) в день `day` и [00:00, end) на следующий день. - var nextDay = (day + 1) % 7; - return (today == day && minuteOfDay >= start) - || (today == nextDay && minuteOfDay < end); - } - - return false; // end == start — пустое окно - } - - return moment >= StartsAtUtc && moment < EndsAtUtc; - } -} diff --git a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs index 39aa52c..cbeb1a6 100644 --- a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs +++ b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs @@ -22,8 +22,44 @@ public class ScheduleEntry /// Подблок заставки (), которым отрендерена запись — для метки в админ-расписании. public Guid? BumperVariantId { get; private set; } + /// Слот сетки, породивший запись (null — старая ротация либо служебная запись). + public Guid? SlotId { get; private set; } + + /// + /// Цепочка происхождения (JSON): слой, слот, группа, стратегия, дрейф. Пишется в момент + /// генерации — восстановить её потом невозможно, а без неё отладка сетки превращается + /// в угадывание. + /// + public string? TraceJson { get; private set; } + private ScheduleEntry() { } + /// Запись, порождённая слотом сетки: программа, заполнитель или конец вещания. + public static ScheduleEntry FromSlot( + Guid channelId, + Guid mediaAssetId, + ScheduleEntryKind kind, + DateTimeOffset startsAtUtc, + DateTimeOffset endsAtUtc, + Guid? showId, + int? episodeIndex, + Guid? slotId, + string? traceJson + ) => + new() + { + Id = Guid.NewGuid(), + ChannelId = channelId, + MediaAssetId = mediaAssetId, + Kind = kind, + StartsAtUtc = startsAtUtc, + EndsAtUtc = endsAtUtc, + ShowId = showId, + EpisodeIndex = episodeIndex, + SlotId = slotId, + TraceJson = traceJson, + }; + public static ScheduleEntry Program( Guid channelId, Guid mediaAssetId, diff --git a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntryKind.cs b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntryKind.cs index aa4e1e9..a33ccf6 100644 --- a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntryKind.cs +++ b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntryKind.cs @@ -1,6 +1,6 @@ namespace TeleWave.Domain.Broadcast; -/// Тип записи расписания. +/// Тип записи расписания. Одна лента на всё: EPG — это фильтр по . public enum ScheduleEntryKind { /// Программа (серия шоу). @@ -11,4 +11,10 @@ public enum ScheduleEntryKind /// ТВ-заставка на переходе между шоу («Сейчас: X · Далее: Y»). Bumper, + + /// Заполнитель: место, не покрытое слотами либо не добранное контентом. + Fallback, + + /// Конец вещания — настроечная таблица вместо эфира. + SignOff, } diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs deleted file mode 100644 index 839686f..0000000 --- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs +++ /dev/null @@ -1,352 +0,0 @@ -namespace TeleWave.Domain.Broadcast.Scheduling; - -/// -/// Чистая эфирная математика: разворачивает конфигурацию канала в последовательность записей встык -/// от до . Без БД, ФС и -/// ffmpeg — полностью юнит-тестируемо (см. SchedulePlannerTests). -/// -/// Инварианты: серии одного шоу идут по порядку (курсор ), -/// на конце сериала — заворот на первую серию; выбор шоу — взвешенно-случайный; override на окне -/// заменяет базовую ротацию; реклама вставляется по политике канала. -/// -public static class SchedulePlanner -{ - private const int IterationBackstop = 1_000_000; - - public static PlannerResult Plan(PlannerInput input, IRandomSource random) - { - var entries = new List(); - var byShowId = input.Shows.ToDictionary(s => s.ShowId); - var nextEpisode = input.Shows.ToDictionary(s => s.ChannelShowId, s => s.NextEpisodeIndex); - var nextAd = input.NextAdIndex; - var nextBumper = input.NextBumperIndex; - - // Есть ли вообще из чего строить эфир. - var anyPlayable = input.Shows.Any(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0); - if (!anyPlayable) - return new PlannerResult(entries, nextEpisode, nextAd, nextBumper); - - var cursor = input.StartTime; - var iterations = 0; - Guid? prevShowId = null; - DateTimeOffset? lastBumperAt = null; - - while (cursor < input.HorizonEnd && iterations++ < IterationBackstop) - { - var candidates = ResolvePolicy(cursor, input, byShowId); - if (candidates.Count == 0) - break; - - var pick = WeightedPick(candidates, random); - - // ТВ-заставка на переходе. Из подходящих подблоков (по правилу показа vs контексту) - // резервируем слот выбранного блока — ассет подставит оркестратор. Само появление - // ограничено мин. интервалом и вероятностью для типа перехода (смена шоу / между блоками). - if ( - prevShowId is { } prev - && input.Bumpers is { Enabled: true } bumper - && ( - bumper.MinInterval <= TimeSpan.Zero - || lastBumperAt is not { } last - || cursor - last >= bumper.MinInterval - ) - ) - { - var isShowChange = prev != pick.ShowId; - var chance = isShowChange ? bumper.ShowChangeChance : bumper.EpisodeChangeChance; - if (RollChance(chance, random)) - { - var bumperStart = cursor; - if ( - TryPlaceBumper( - entries, - bumper, - prev, - pick.ShowId, - random, - ref nextBumper, - ref cursor - ) - ) - lastBumperAt = bumperStart; - } - } - - var blockStart = cursor; - - var block = CollectBlock(pick, nextEpisode, input, cursor); - foreach (var episode in block) - { - var duration = DurationOf(episode.AssetId, input); - var end = cursor + duration; - entries.Add( - new PlannedEntry( - episode.AssetId, - ScheduleEntryKind.Program, - cursor, - end, - pick.ShowId, - episode.Index - ) - ); - cursor = end; - - if (input.AdInsertion == AdInsertion.BetweenEpisodes) - cursor = InsertAds(entries, input, cursor, ref nextAd); - } - - if (input.AdInsertion == AdInsertion.BetweenBlocks) - cursor = InsertAds(entries, input, cursor, ref nextAd); - - // Защита от зацикливания, если длительности нулевые/отсутствуют — эфир не сдвинулся. - if (cursor <= blockStart) - break; - - prevShowId = pick.ShowId; - } - - return new PlannerResult(entries, nextEpisode, nextAd, nextBumper); - } - - /// - /// Ставит на переходе заставку выбранного подблока: из подходящих по правилу показа (контекст — - /// сменилось ли шоу) выбирает один по стратегии канала и резервирует слот длины его блока. Оставляет - /// плейсхолдер с парой шоу + id блока/варианта (ассет отрендерит оркестратор). Возвращает true, если - /// заставка добавлена (курсор сдвинут). - /// - private static bool TryPlaceBumper( - List entries, - PlannerBumperConfig bumper, - Guid fromShowId, - Guid toShowId, - IRandomSource random, - ref int nextBumper, - ref DateTimeOffset cursor - ) - { - var isShowChange = fromShowId != toShowId; - var eligible = bumper - .Variants.Where(v => - v.Duration > TimeSpan.Zero && MatchesTrigger(v.Trigger, isShowChange) - ) - .ToList(); - if (eligible.Count == 0) - return false; - - PlannerBumperVariant variant; - switch (bumper.Selection) - { - case BumperSelection.Random: - variant = eligible[random.Next(eligible.Count)]; - break; - case BumperSelection.WeightedRandom: - variant = WeightedPickVariant(eligible, random); - break; - case BumperSelection.AlwaysFirst: - variant = eligible[0]; - break; - default: // Rotation - var idx = ((nextBumper % eligible.Count) + eligible.Count) % eligible.Count; - variant = eligible[idx]; - nextBumper++; - break; - } - - var end = cursor + variant.Duration; - entries.Add( - new PlannedEntry( - Guid.Empty, - ScheduleEntryKind.Bumper, - cursor, - end, - toShowId, - null, - fromShowId, - toShowId, - variant.TemplateId, - variant.VariantId - ) - ); - cursor = end; - return true; - } - - private static bool MatchesTrigger(BumperTrigger trigger, bool isShowChange) => - trigger switch - { - BumperTrigger.OnShowChange => isShowChange, - BumperTrigger.BetweenEpisodes => !isShowChange, - _ => true, - }; - - /// Прошла ли проверка вероятности появления (chance 0..1). 1 — всегда, 0 — никогда. - private static bool RollChance(double chance, IRandomSource random) - { - if (chance >= 1.0) - return true; - if (chance <= 0.0) - return false; - return random.Next(10000) < (int)Math.Round(chance * 10000); - } - - /// Взвешенный случайный выбор подблока по (нулевые веса → равновероятно). - private static PlannerBumperVariant WeightedPickVariant( - List eligible, - IRandomSource random - ) - { - var total = eligible.Sum(v => (long)Math.Max(0, v.Weight)); - if (total <= 0) - return eligible[random.Next(eligible.Count)]; - - var roll = random.Next((int)Math.Min(total, int.MaxValue)); - long acc = 0; - foreach (var v in eligible) - { - acc += Math.Max(0, v.Weight); - if (roll < acc) - return v; - } - - return eligible[^1]; - } - - private static List<(PlannerShow Show, int Weight)> ResolvePolicy( - DateTimeOffset moment, - PlannerInput input, - IReadOnlyDictionary byShowId - ) - { - var ovr = input.Overrides.FirstOrDefault(o => o.Covers(moment)); - if (ovr is not null) - { - var overridden = new List<(PlannerShow, int)>(); - foreach (var os in ovr.Shows) - { - if ( - !byShowId.TryGetValue(os.ShowId, out var show) - || show.EpisodeAssetIds.Count == 0 - ) - continue; - var weight = ovr.Mode == OverrideMode.Exclusive ? 1 : os.Weight; - if (weight > 0) - overridden.Add((show, weight)); - } - - if (overridden.Count > 0) - return overridden; - // Override ссылается на пустые/неготовые шоу — откатываемся к базовой ротации. - } - - var hour = moment.UtcDateTime.Hour; - return input - .Shows.Where(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0) - .Select(s => (s, EffectiveWeight(s, hour))) - .ToList(); - } - - /// Вес шоу с учётом предпочтительных часов: в окне — усиливается множителем, иначе базовый. - private static int EffectiveWeight(PlannerShow show, int hour) - { - if ( - show.PreferredWeightMultiplier > 1 - && show.PreferredHours is { Count: > 0 } windows - && windows.Any(w => w.Contains(hour)) - ) - // long-умножение + clamp: экстремальные вес/множитель не переполняют int - // (иначе отрицательный total молча вырождает взвешенный выбор в первого кандидата). - return (int)Math.Min((long)show.Weight * show.PreferredWeightMultiplier, int.MaxValue); - return show.Weight; - } - - private static PlannerShow WeightedPick( - List<(PlannerShow Show, int Weight)> candidates, - IRandomSource random - ) - { - var total = candidates.Sum(c => (long)c.Weight); - if (total <= 0) - return candidates[0].Show; - - var roll = random.Next((int)Math.Min(total, int.MaxValue)); - long acc = 0; - foreach (var (show, weight) in candidates) - { - acc += weight; - if (roll < acc) - return show; - } - - return candidates[^1].Show; - } - - private static List<(Guid AssetId, int Index)> CollectBlock( - PlannerShow show, - Dictionary nextEpisode, - PlannerInput input, - DateTimeOffset cursor - ) - { - var result = new List<(Guid, int)>(); - var count = show.EpisodeAssetIds.Count; - var idx = ((nextEpisode[show.ChannelShowId] % count) + count) % count; - - if (show.BlockMode == BlockMode.Count) - { - var n = Math.Max(1, show.BlockValue); - for (var i = 0; i < n; i++) - { - result.Add((show.EpisodeAssetIds[idx], idx)); - idx = (idx + 1) % count; - } - } - else - { - var budget = TimeSpan.FromMinutes(Math.Max(1, show.BlockValue)); - var accumulated = TimeSpan.Zero; - var guard = 0; - do - { - var assetId = show.EpisodeAssetIds[idx]; - result.Add((assetId, idx)); - accumulated += DurationOf(assetId, input); - idx = (idx + 1) % count; - guard++; - } while ( - accumulated < budget - && cursor + accumulated < input.HorizonEnd - && guard < IterationBackstop - ); - } - - nextEpisode[show.ChannelShowId] = idx; - return result; - } - - private static DateTimeOffset InsertAds( - List entries, - PlannerInput input, - DateTimeOffset cursor, - ref int nextAd - ) - { - if (input.AdPool.Count == 0 || input.AdsPerBreak <= 0) - return cursor; - - for (var i = 0; i < input.AdsPerBreak; i++) - { - var assetId = input.AdPool[ - ((nextAd % input.AdPool.Count) + input.AdPool.Count) % input.AdPool.Count - ]; - nextAd++; - var end = cursor + DurationOf(assetId, input); - entries.Add(new PlannedEntry(assetId, ScheduleEntryKind.Ad, cursor, end, null, null)); - cursor = end; - } - - return cursor; - } - - private static TimeSpan DurationOf(Guid assetId, PlannerInput input) => - input.Durations.TryGetValue(assetId, out var duration) ? duration : TimeSpan.Zero; -} diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs deleted file mode 100644 index 2b0a40d..0000000 --- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs +++ /dev/null @@ -1,143 +0,0 @@ -namespace TeleWave.Domain.Broadcast.Scheduling; - -/// Шоу канала, подготовленное для планировщика: только готовые серии, с курсором. -public sealed record PlannerShow( - Guid ChannelShowId, - Guid ShowId, - int Weight, - BlockMode BlockMode, - int BlockValue, - IReadOnlyList EpisodeAssetIds, - int NextEpisodeIndex, - IReadOnlyList? PreferredHours = null, - int PreferredWeightMultiplier = 1 -); - -/// Окно предпочтительных часов [StartHour, EndHour) суток (UTC) для планировщика. -public sealed record PlannerHourWindow(int StartHour, int EndHour) -{ - public bool Contains(int hour) => hour >= StartHour && hour < EndHour; -} - -/// -/// Override в терминах планировщика: режим + шоу с весами + правило действия (разовое окно либо -/// еженедельно по дню недели на окне минут суток UTC). -/// -public sealed record PlannerOverride( - OverrideMode Mode, - IReadOnlyList Shows, - OverrideRecurrence Recurrence = OverrideRecurrence.OneTime, - DateTimeOffset? StartsAtUtc = null, - DateTimeOffset? EndsAtUtc = null, - int? DayOfWeek = null, - int? StartMinute = null, - int? EndMinute = null -) -{ - /// Действует ли override в этот момент. - public bool Covers(DateTimeOffset moment) - { - if (Recurrence == OverrideRecurrence.Weekly) - { - if ( - DayOfWeek is not { } day - || StartMinute is not { } start - || EndMinute is not { } end - ) - return false; - - var utc = moment.UtcDateTime; - var minuteOfDay = utc.Hour * 60 + utc.Minute; - var today = (int)utc.DayOfWeek; - - if (end > start) - // Обычное окно в пределах одних суток [start, end). - return today == day && minuteOfDay >= start && minuteOfDay < end; - - if (end < start) - { - // Окно пересекает полночь: [start, 24:00) в день `day` и [00:00, end) на следующий день. - var nextDay = (day + 1) % 7; - return (today == day && minuteOfDay >= start) - || (today == nextDay && minuteOfDay < end); - } - - return false; // end == start — пустое окно - } - - return moment >= StartsAtUtc && moment < EndsAtUtc; - } -} - -public sealed record PlannerOverrideShow(Guid ShowId, int Weight); - -/// -/// Политика ТВ-заставок на переходах. Планировщик из подходящих подблоков (, -/// фильтр по и контексту перехода) выбирает один по стратегии -/// и резервирует слот длины его блока. Ассет подставляет оркестратор. -/// / — вероятность самого появления -/// заставки на смене шоу / между блоками одного шоу (0..1). -/// -public sealed record PlannerBumperConfig( - bool Enabled, - TimeSpan MinInterval, - BumperSelection Selection, - IReadOnlyList Variants, - double ShowChangeChance = 1.0, - double EpisodeChangeChance = 1.0 -); - -/// -/// Подблок заставки в терминах планировщика: id варианта + id родительского блока (стиль/звук) + -/// длительность слота (кратна сегменту) + правило показа + вес (для ). -/// -public sealed record PlannerBumperVariant( - Guid VariantId, - Guid TemplateId, - TimeSpan Duration, - BumperTrigger Trigger, - int Weight = 1 -); - -/// Полный вход планировщика для одного прогона по каналу. -public sealed record PlannerInput( - Guid ChannelId, - AdInsertion AdInsertion, - int AdsPerBreak, - int NextAdIndex, - IReadOnlyList Shows, - IReadOnlyList AdPool, - IReadOnlyDictionary Durations, - IReadOnlyList Overrides, - DateTimeOffset StartTime, - DateTimeOffset HorizonEnd, - PlannerBumperConfig? Bumpers = null, - int NextBumperIndex = 0 -); - -/// -/// Одна запланированная запись (ещё не доменная сущность). Для заставок ( == -/// ) пуст — его подставит -/// оркестратор после рендера по паре () и выбранному -/// блоку (). -/// -public sealed record PlannedEntry( - Guid MediaAssetId, - ScheduleEntryKind Kind, - DateTimeOffset StartsAtUtc, - DateTimeOffset EndsAtUtc, - Guid? ShowId, - int? EpisodeIndex, - Guid? FromShowId = null, - Guid? ToShowId = null, - Guid? BumperTemplateId = null, - Guid? BumperVariantId = null -); - -/// Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow, рекламы, заставок). -public sealed record PlannerResult( - IReadOnlyList Entries, - IReadOnlyDictionary NextEpisodeIndexByChannelShow, - int NextAdIndex, - int NextBumperIndex -); diff --git a/backend/src/TeleWave.Domain/Library/Collection.cs b/backend/src/TeleWave.Domain/Library/Collection.cs new file mode 100644 index 0000000..9a40875 --- /dev/null +++ b/backend/src/TeleWave.Domain/Library/Collection.cs @@ -0,0 +1,89 @@ +namespace TeleWave.Domain.Library; + +/// +/// Франшиза: упорядоченный набор шоу, который играется как одно целое. «Терминатор» — это три +/// полнометражки в порядке 1→2→3. +/// +/// Живёт в библиотеке, а не в планировщике, потому что «одно произведение из трёх частей» — факт +/// о контенте, а не о канале. Планировщику коллекция видна как обычная последовательность +/// воспроизводимых единиц, ровно как сериал со своими сериями: за счёт этого «три части подряд +/// в один вечер» и «три дня по одному фильму» получаются одним и тем же слотом с разным размером +/// блока, без спецлогики для коллекций. +/// +public class Collection +{ + private readonly List _items = new(); + + public Guid Id { get; private set; } + public string Name { get; private set; } = string.Empty; + public string? Description { get; private set; } + + /// Постер коллекции — ссылка на запись общего реестра изображений или null. + public Guid? PosterImageId { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + /// Позиции коллекции (backing-field для EF); порядок показа — по . + public IReadOnlyList Items => _items; + + private Collection() { } + + public static Collection Create(string name, string? description = null) => + new() + { + Id = Guid.NewGuid(), + Name = name.Trim(), + Description = description, + CreatedAt = DateTimeOffset.UtcNow, + }; + + public void Rename(string name, string? description) + { + Name = name.Trim(); + Description = description; + } + + /// Привязать/снять постер (ссылка на реестр изображений; сама картинка остаётся в галерее). + public void SetPosterImage(Guid? imageId) => PosterImageId = imageId; + + public bool HasShow(Guid showId) => _items.Any(i => i.ShowId == showId); + + /// Добавляет шоу в конец. Повторное добавление игнорируется — возвращает null. + public CollectionItem? AddShow(Guid showId) + { + if (HasShow(showId)) + return null; + + var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1; + var item = CollectionItem.Create(Id, showId, nextPosition); + _items.Add(item); + return item; + } + + public bool RemoveShow(Guid showId) + { + var item = _items.FirstOrDefault(i => i.ShowId == showId); + if (item is null) + return false; + _items.Remove(item); + return true; + } + + /// + /// Переставляет позиции в порядке переданных идентификаторов. Не упомянутые остаются после них, + /// сохраняя относительный порядок, а неизвестные игнорируются — так частичный ввод (перетащили + /// один элемент) не теряет остальные. + /// + public void Reorder(IEnumerable showIdsInOrder) + { + var requested = showIdsInOrder.Distinct().Where(HasShow).ToList(); + var rest = _items + .Where(i => !requested.Contains(i.ShowId)) + .OrderBy(i => i.Position) + .Select(i => i.ShowId); + + var position = 0; + foreach (var showId in requested.Concat(rest)) + _items.First(i => i.ShowId == showId).SetPosition(position++); + } +} diff --git a/backend/src/TeleWave.Domain/Library/CollectionItem.cs b/backend/src/TeleWave.Domain/Library/CollectionItem.cs new file mode 100644 index 0000000..a6abc0c --- /dev/null +++ b/backend/src/TeleWave.Domain/Library/CollectionItem.cs @@ -0,0 +1,25 @@ +namespace TeleWave.Domain.Library; + +/// Шоу в составе коллекции. Порядок показа — по . +public class CollectionItem +{ + public Guid Id { get; private set; } + public Guid CollectionId { get; private set; } + public Guid ShowId { get; private set; } + + /// Порядковый номер внутри коллекции (может иметь разрывы после удалений). + public int Position { get; private set; } + + private CollectionItem() { } + + internal static CollectionItem Create(Guid collectionId, Guid showId, int position) => + new() + { + Id = Guid.NewGuid(), + CollectionId = collectionId, + ShowId = showId, + Position = position, + }; + + internal void SetPosition(int position) => Position = position; +} diff --git a/backend/src/TeleWave.Domain/Library/Genre.cs b/backend/src/TeleWave.Domain/Library/Genre.cs new file mode 100644 index 0000000..f0b4cd2 --- /dev/null +++ b/backend/src/TeleWave.Domain/Library/Genre.cs @@ -0,0 +1,70 @@ +namespace TeleWave.Domain.Library; + +/// +/// Жанр из общего справочника. Справочник, а не свободные строки: по нему собираются группы контента +/// для планировщика («один случайный боевик по пятницам»), а жанры от внешних провайдеров +/// (TMDb/OMDb) приводятся к нему через . +/// +/// Свои произвольные метки («ретро», «новогоднее») сюда не заводятся — их роль выполняют группы +/// с ручным составом. +/// +public class Genre +{ + private readonly List _aliases = new(); + + public Guid Id { get; private set; } + + /// Отображаемое название («Боевик»). + public string Name { get; private set; } = string.Empty; + + /// Стабильный ключ («action») — по нему жанр находится при повторном сидинге. + public string Slug { get; private set; } = string.Empty; + + /// Порядок в списках UI (меньше — выше). + public int SortOrder { get; private set; } + + /// Признак жанра из стартового сида — такой нельзя удалить, только переименовать. + public bool IsSystem { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + /// Варианты написания для сопоставления с внешними источниками (backing-field для EF). + public IReadOnlyList Aliases => _aliases; + + private Genre() { } + + public static Genre Create(string name, string slug, int sortOrder = 0, bool isSystem = false) => + new() + { + Id = Guid.NewGuid(), + Name = name.Trim(), + Slug = GenreAlias.Normalize(slug), + SortOrder = sortOrder, + IsSystem = isSystem, + CreatedAt = DateTimeOffset.UtcNow, + }; + + public void Rename(string name) => Name = name.Trim(); + + public void SetSortOrder(int sortOrder) => SortOrder = sortOrder; + + /// Добавляет вариант написания, если такого ещё нет. Пустые значения игнорируются. + public void AddAlias(string alias) + { + var normalized = GenreAlias.Normalize(alias); + if (normalized.Length == 0 || _aliases.Any(a => a.Value == normalized)) + return; + _aliases.Add(GenreAlias.Create(Id, normalized)); + } + + /// + /// Полностью заменяет набор вариантов написания. Снятые псевдонимы из сида вернутся при следующем + /// старте приложения — сид дописывает недостающие, но только если их не занял другой жанр. + /// + public void ReplaceAliases(IEnumerable aliases) + { + _aliases.Clear(); + foreach (var alias in aliases) + AddAlias(alias); + } +} diff --git a/backend/src/TeleWave.Domain/Library/GenreAlias.cs b/backend/src/TeleWave.Domain/Library/GenreAlias.cs new file mode 100644 index 0000000..9617d05 --- /dev/null +++ b/backend/src/TeleWave.Domain/Library/GenreAlias.cs @@ -0,0 +1,46 @@ +namespace TeleWave.Domain.Library; + +/// +/// Вариант написания жанра для сопоставления с внешними источниками. Хранит уже нормализованное +/// значение (см. ), поэтому поиск — это точное сравнение, без LIKE и без +/// приведения регистра на стороне БД. +/// +/// Формы значения: числовой идентификатор провайдера (tmdb:28) либо имя жанра на любом языке +/// (action, боевик, sci-fi). Один жанр справочника собирает под собой сколько +/// угодно вариантов — так «Action» и «Action & Adventure» из TMDb сводятся к одному «Боевику». +/// +public class GenreAlias +{ + public Guid Id { get; private set; } + public Guid GenreId { get; private set; } + + /// Нормализованное значение — уникально по всему справочнику. + public string Value { get; private set; } = string.Empty; + + private GenreAlias() { } + + internal static GenreAlias Create(Guid genreId, string value) => + new() + { + Id = Guid.NewGuid(), + GenreId = genreId, + Value = Normalize(value), + }; + + /// Приводит написание к каноничному виду: нижний регистр, схлопнутые пробелы, без краёв. + public static string Normalize(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var trimmed = value.Trim().ToLowerInvariant(); + return string.Join( + ' ', + trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + ); + } + + /// Псевдоним по идентификатору жанра во внешнем источнике (tmdb:28). + public static string ProviderKey(string provider, string externalId) => + Normalize($"{provider}:{externalId}"); +} diff --git a/backend/src/TeleWave.Domain/Library/Show.cs b/backend/src/TeleWave.Domain/Library/Show.cs index 86687da..23dfc5a 100644 --- a/backend/src/TeleWave.Domain/Library/Show.cs +++ b/backend/src/TeleWave.Domain/Library/Show.cs @@ -8,6 +8,7 @@ namespace TeleWave.Domain.Library; public class Show { private readonly List _episodes = new(); + private readonly List _genres = new(); public Guid Id { get; private set; } public string Name { get; private set; } = string.Empty; @@ -40,6 +41,9 @@ public class Show /// потребители сортируют явно (см. загрузчик планировщика). public IReadOnlyList Episodes => _episodes; + /// Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст. + public IReadOnlyList Genres => _genres; + private Show() { } public static Show Create( @@ -60,9 +64,29 @@ public class Show CreatedAt = DateTimeOffset.UtcNow, }; - /// Задать категорию аудитории (обычное/детское/взрослое). + /// Задать категорию аудитории. public void SetAudience(ShowAudience audience) => Audience = audience; + /// Основной жанр или null, если жанры не проставлены. + public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId; + + /// + /// Полностью заменяет набор жанров; дубликаты и пустые идентификаторы отбрасываются. Основным + /// становится , если он попал в набор, иначе первый в списке — + /// так шоу с жанрами никогда не остаётся без основного. + /// + public void SetGenres(IEnumerable genreIds, Guid? primaryGenreId = null) + { + var ids = genreIds.Where(id => id != Guid.Empty).Distinct().ToList(); + _genres.Clear(); + if (ids.Count == 0) + return; + + var primary = primaryGenreId is { } candidate && ids.Contains(candidate) ? candidate : ids[0]; + foreach (var id in ids) + _genres.Add(ShowGenre.Create(Id, id, id == primary)); + } + public void Rename(string name, string? description) { Name = name; diff --git a/backend/src/TeleWave.Domain/Library/ShowAudience.cs b/backend/src/TeleWave.Domain/Library/ShowAudience.cs index 111cd8e..b800308 100644 --- a/backend/src/TeleWave.Domain/Library/ShowAudience.cs +++ b/backend/src/TeleWave.Domain/Library/ShowAudience.cs @@ -1,14 +1,27 @@ namespace TeleWave.Domain.Library; -/// Категория аудитории шоу (пригодится для фильтров/разграничения показа). +/// +/// Возрастная категория шоу. Значения упорядочены по возрастанию строгости — на этом порядке строятся +/// правила планировщика вида «до 23:00 не строже подросткового». Новые категории можно вставлять +/// только с сохранением монотонности и с миграцией данных. +/// +/// Это не гейт для зрителя: что показывает канал, то и смотрится. Категория нужна планировщику, +/// чтобы не поставить взрослое в детский эфир. +/// public enum ShowAudience { - /// Обычное — без ограничений (по умолчанию). - General, - /// Детское. - Kids, + Kids = 0, + + /// Семейное. + Family = 1, + + /// Подростковое. + Teen = 2, + + /// Общее — без ограничений (по умолчанию). + General = 3, /// Взрослое. - Adult, + Adult = 4, } diff --git a/backend/src/TeleWave.Domain/Library/ShowGenre.cs b/backend/src/TeleWave.Domain/Library/ShowGenre.cs new file mode 100644 index 0000000..f8dbd4e --- /dev/null +++ b/backend/src/TeleWave.Domain/Library/ShowGenre.cs @@ -0,0 +1,23 @@ +namespace TeleWave.Domain.Library; + +/// +/// Жанр, проставленный шоу. Ровно один из жанров шоу — основной (): он +/// показывается в списках, остальные участвуют в фильтрах наравне с ним. Инвариант «основной ровно +/// один» держит агрегат . +/// +public class ShowGenre +{ + public Guid ShowId { get; private set; } + public Guid GenreId { get; private set; } + public bool IsPrimary { get; private set; } + + private ShowGenre() { } + + internal static ShowGenre Create(Guid showId, Guid genreId, bool isPrimary) => + new() + { + ShowId = showId, + GenreId = genreId, + IsPrimary = isPrimary, + }; +} diff --git a/backend/src/TeleWave.Domain/Library/ShowKind.cs b/backend/src/TeleWave.Domain/Library/ShowKind.cs index 31db02f..de209af 100644 --- a/backend/src/TeleWave.Domain/Library/ShowKind.cs +++ b/backend/src/TeleWave.Domain/Library/ShowKind.cs @@ -4,8 +4,18 @@ namespace TeleWave.Domain.Library; public enum ShowKind { /// Сериал: упорядоченный список серий, идут по порядку. - Series, + Series = 0, /// Разовый выпуск/полнометражка: ровно одна «серия». - Single, + Single = 1, + + /// + /// Ролик-врезка: реклама, промо, джингл. Технически то же шоу с одной «серией», поэтому попадает + /// в группы и коллекции наравне с контентом — так рекламный блок собирается обычной коллекцией, + /// а остывание («не крутить один ролик дважды подряд») достаётся ему бесплатно. + /// + /// В общей библиотеке не показывается и метаданными не обогащается: у ролика нет ни года, + /// ни постера, а сотня роликов на экране шоу только мешала бы. + /// + Interstitial = 2, } diff --git a/backend/src/TeleWave.Domain/Programming/Daypart.cs b/backend/src/TeleWave.Domain/Programming/Daypart.cs new file mode 100644 index 0000000..dba8e2f --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/Daypart.cs @@ -0,0 +1,15 @@ +namespace TeleWave.Domain.Programming; + +/// +/// Метка времени суток. Границ дейпартов нигде нет: у детского канала прайм в 17:00, у развлекательного +/// в 21:00, и хранить на канале четыре пары времён значило бы дублировать . +/// Метка нужна для цвета в календаре, группировки в UI и области действия правил — что считать праймом, +/// решает администратор, ставя её на нужные слоты. +/// +public enum Daypart +{ + Morning = 0, + Day = 1, + Prime = 2, + Night = 3, +} diff --git a/backend/src/TeleWave.Domain/Programming/GridLayer.cs b/backend/src/TeleWave.Domain/Programming/GridLayer.cs new file mode 100644 index 0000000..4e416b9 --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/GridLayer.cs @@ -0,0 +1,112 @@ +namespace TeleWave.Domain.Programming; + +/// +/// Слой сетки: набор слотов, действующий при выполнении условия применимости. Для каждого момента +/// активен слот из применимого слоя с наибольшим . +/// +/// Фоновый слой ( = 0) покрывает сутки целиком и заменяет собой отдельную +/// сущность «заполнитель»: любая дыра в расписании — просто место, где сверху ничего не легло. +/// +public class GridLayer +{ + private readonly List _slots = new(); + + public Guid Id { get; private set; } + public Guid TemplateId { get; private set; } + public string Name { get; private set; } = string.Empty; + + /// Больше — специфичнее, побеждает. Фоновый слой имеет 0. + public int Priority { get; private set; } + + /// Когда слой действует (JSON). Домен его не интерпретирует — схема в Application. + public string? ApplicabilityJson { get; private set; } + + public bool IsEnabled { get; private set; } + + /// Фоновый слой удалять нельзя — без него в сетке появляются дыры. + public bool IsBackground { get; private set; } + + public IReadOnlyList Slots => _slots; + + public const int BackgroundPriority = 0; + + private GridLayer() { } + + internal static GridLayer Create( + Guid templateId, + string name, + int priority, + bool isBackground = false + ) => + new() + { + Id = Guid.NewGuid(), + TemplateId = templateId, + Name = name.Trim(), + Priority = isBackground ? BackgroundPriority : Math.Max(1, priority), + IsEnabled = true, + IsBackground = isBackground, + }; + + public void Update(string name, int priority, string? applicabilityJson, bool isEnabled) + { + Name = name.Trim(); + // Приоритет фонового слоя не двигается: он обязан оставаться под всеми остальными. + if (!IsBackground) + Priority = Math.Max(1, priority); + ApplicabilityJson = string.IsNullOrWhiteSpace(applicabilityJson) ? null : applicabilityJson; + IsEnabled = isEnabled; + } + + public Slot AddSlot( + string title, + TimeOnly targetStart, + int targetDurationMinutes, + Daypart daypart = Daypart.Day, + SlotKind slotKind = SlotKind.Content, + int? weekday = null + ) + { + var slot = Slot.Create( + Id, + title, + targetStart, + targetDurationMinutes, + daypart, + slotKind, + weekday + ); + _slots.Add(slot); + return slot; + } + + public Slot? FindSlot(Guid slotId) => _slots.FirstOrDefault(s => s.Id == slotId); + + public bool RemoveSlot(Guid slotId) + { + var slot = _slots.FirstOrDefault(s => s.Id == slotId); + if (slot is null) + return false; + _slots.Remove(slot); + return true; + } + + /// + /// Пересекается ли интервал с уже существующими слотами того же дня. Внутри одного слоя + /// перекрытие запрещено: два слота на одну минуту сделали бы выбор недетерминированным. + /// Слоты на разные дни недели не конфликтуют; слот без дня недели («каждый день») конфликтует + /// со всеми. + /// + public bool HasOverlap(int? weekday, TimeOnly start, int durationMinutes, Guid? exceptSlotId) + { + var from = start.ToTimeSpan(); + var to = from + TimeSpan.FromMinutes(durationMinutes); + + return _slots.Any(other => + other.Id != exceptSlotId + && (weekday is null || other.Weekday is null || other.Weekday == weekday) + && from < other.TargetEndOffset + && other.TargetStart.ToTimeSpan() < to + ); + } +} diff --git a/backend/src/TeleWave.Domain/Programming/Group.cs b/backend/src/TeleWave.Domain/Programming/Group.cs new file mode 100644 index 0000000..d63e32b --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/Group.cs @@ -0,0 +1,127 @@ +namespace TeleWave.Domain.Programming; + +/// +/// Группа контента — «что может попасть в эфир». Слот планировщика ссылается на группу, стратегия +/// выбирает из неё элемент. Группы общие для всех каналов и переиспользуются. +/// +/// Состав — всегда явный список (), а — лишь правило +/// быстрого набора: «применить фильтр» добавляет найденное в список, дальше состав правится руками. +/// Живой запрос при генерации был бы проще в коде, но тогда добавление одного шоу в библиотеку +/// перетасовывало бы всё будущее расписание всех каналов. +/// +public class Group +{ + private readonly List _items = new(); + + public Guid Id { get; private set; } + public string Name { get; private set; } = string.Empty; + public string? Description { get; private set; } + + /// Правило набора (JSON) или null. Домен его не интерпретирует — схема живёт в Application. + public string? FilterJson { get; private set; } + + // ── Кэш статистики: считается при правке состава и фоново, нужен UI («342 позиции · 118 ч») ── + + /// Число позиций в группе. + public int ItemCount { get; private set; } + + /// Число единиц воспроизведения: у сериала и коллекции их больше одной. + public int UnitCount { get; private set; } + + /// Суммарная длительность готовых единиц. + public TimeSpan TotalDuration { get; private set; } + + public DateTimeOffset? StatsComputedAt { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + /// Позиции группы (backing-field для EF); порядок — по . + public IReadOnlyList Items => _items; + + private Group() { } + + public static Group Create(string name, string? description = null) => + new() + { + Id = Guid.NewGuid(), + Name = name.Trim(), + Description = description, + CreatedAt = DateTimeOffset.UtcNow, + }; + + public void Rename(string name, string? description) + { + Name = name.Trim(); + Description = description; + } + + /// Сохранить правило набора (null — снять). Состав при этом не меняется. + public void SetFilter(string? filterJson) => + FilterJson = string.IsNullOrWhiteSpace(filterJson) ? null : filterJson; + + /// Записать пересчитанную статистику. + public void UpdateStats(int itemCount, int unitCount, TimeSpan totalDuration, DateTimeOffset at) + { + ItemCount = itemCount; + UnitCount = unitCount; + TotalDuration = totalDuration; + StatsComputedAt = at; + } + + public bool Contains(GroupElementKind kind, Guid elementId) => + _items.Any(i => i.ElementKind == kind && i.ElementId == elementId); + + /// Добавляет элемент в конец. Повторное добавление игнорируется — возвращает null. + public GroupItem? AddElement(GroupElementKind kind, Guid elementId) + { + if (Contains(kind, elementId)) + return null; + + var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1; + var item = GroupItem.Create(Id, kind, elementId, nextPosition); + _items.Add(item); + return item; + } + + public bool RemoveItem(Guid itemId) + { + var item = _items.FirstOrDefault(i => i.Id == itemId); + if (item is null) + return false; + _items.Remove(item); + return true; + } + + /// Убирает все позиции, ссылающиеся на элемент. Вызывается при удалении шоу/коллекции + /// из библиотеки — внешнего ключа на полиморфную ссылку нет. Возвращает число снятых позиций. + public int RemoveElement(GroupElementKind kind, Guid elementId) => + _items.RemoveAll(i => i.ElementKind == kind && i.ElementId == elementId); + + public bool SetWeight(Guid itemId, int weight) + { + var item = _items.FirstOrDefault(i => i.Id == itemId); + if (item is null) + return false; + item.SetWeight(weight); + return true; + } + + /// + /// Переставляет позиции в порядке переданных идентификаторов. Не упомянутые остаются после них, + /// сохраняя относительный порядок, а неизвестные игнорируются — так перетаскивание одного + /// элемента не теряет остальные. + /// + public void Reorder(IEnumerable itemIdsInOrder) + { + var known = _items.Select(i => i.Id).ToHashSet(); + var requested = itemIdsInOrder.Distinct().Where(known.Contains).ToList(); + var rest = _items + .Where(i => !requested.Contains(i.Id)) + .OrderBy(i => i.Position) + .Select(i => i.Id); + + var position = 0; + foreach (var itemId in requested.Concat(rest)) + _items.First(i => i.Id == itemId).SetPosition(position++); + } +} diff --git a/backend/src/TeleWave.Domain/Programming/GroupElementKind.cs b/backend/src/TeleWave.Domain/Programming/GroupElementKind.cs new file mode 100644 index 0000000..7f54772 --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/GroupElementKind.cs @@ -0,0 +1,8 @@ +namespace TeleWave.Domain.Programming; + +/// Что именно лежит в группе: шоу из библиотеки либо коллекция (франшиза) целиком. +public enum GroupElementKind +{ + Show = 0, + Collection = 1, +} diff --git a/backend/src/TeleWave.Domain/Programming/GroupItem.cs b/backend/src/TeleWave.Domain/Programming/GroupItem.cs new file mode 100644 index 0000000..69c5cab --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/GroupItem.cs @@ -0,0 +1,49 @@ +namespace TeleWave.Domain.Programming; + +/// +/// Позиция группы: ссылка на шоу или коллекцию плюс вес и порядок. +/// +/// — полиморфная ссылка, поэтому внешнего ключа на неё нет: удаление шоу +/// или коллекции из библиотеки чистит позиции команд удаления, а не каскадом БД. +/// +public class GroupItem +{ + public Guid Id { get; private set; } + public Guid GroupId { get; private set; } + public GroupElementKind ElementKind { get; private set; } + public Guid ElementId { get; private set; } + + /// + /// Вес при случайном выборе внутри группы (по умолчанию 1). Не конфликтует с остыванием, если + /// применять их в правильном порядке: сначала остывание отсекает недавно показанное, затем + /// взвешенный выбор работает среди оставшихся. + /// + public int Weight { get; private set; } + + /// Порядок для последовательных стратегий (может иметь разрывы после удалений). + public int Position { get; private set; } + + public const int DefaultWeight = 1; + + private GroupItem() { } + + internal static GroupItem Create( + Guid groupId, + GroupElementKind elementKind, + Guid elementId, + int position + ) => + new() + { + Id = Guid.NewGuid(), + GroupId = groupId, + ElementKind = elementKind, + ElementId = elementId, + Weight = DefaultWeight, + Position = position, + }; + + internal void SetPosition(int position) => Position = position; + + internal void SetWeight(int weight) => Weight = Math.Max(0, weight); +} diff --git a/backend/src/TeleWave.Domain/Programming/JunctionElement.cs b/backend/src/TeleWave.Domain/Programming/JunctionElement.cs new file mode 100644 index 0000000..08ef25b --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/JunctionElement.cs @@ -0,0 +1,102 @@ +namespace TeleWave.Domain.Programming; + +/// Что за врезка стоит в стыке. +public enum JunctionElementKind +{ + /// Реклама — ролики или готовые рекламные блоки из группы. + Ad = 0, + + /// Анонс: «смотрите в четверг». + Promo = 1, + + /// ТВ-заставка «Сейчас — Далее»: ассет рендерится под конкретную пару шоу. + Bumper = 2, + + /// Заполнитель: закрывает остаток, когда врезки не добрали до цели. + Filler = 3, +} + +/// Чем меряется врезка. +public enum JunctionAmountMode +{ + /// Ровно N единиц. Предсказуемо по числу, но не по времени. + Count = 0, + + /// + /// Пока не наберётся M минут. Нужен для смешанных групп, где рядом лежат и целые рекламные + /// блоки, и отдельные ролики: «одна единица» там означает то ли 20 секунд, то ли три минуты. + /// + Duration = 1, +} + +/// +/// Врезка в шаблоне стыка. Условия показа хранятся структурно (), +/// а не выражением: парсер, его валидация и отдельный UI обошлись бы дорого, а покрывают ровно +/// те же три-четыре реальных случая. +/// +public class JunctionElement +{ + public Guid Id { get; private set; } + public Guid JunctionTemplateId { get; private set; } + public int Position { get; private set; } + public JunctionElementKind Kind { get; private set; } + + /// Откуда брать единицы — для , , . + public Guid? GroupId { get; private set; } + + /// Какой блок заставки рендерить — для . + public Guid? BumperTemplateId { get; private set; } + + public JunctionAmountMode AmountMode { get; private set; } + + /// Единиц либо минут. + public int AmountValue { get; private set; } + + /// Обязательную врезку нельзя выбросить при нехватке времени. + public bool IsRequired { get; private set; } + + /// Условия показа (JSON). Домен их не интерпретирует — схема живёт в Application. + public string? ConditionsJson { get; private set; } + + private JunctionElement() { } + + internal static JunctionElement Create( + Guid junctionTemplateId, + int position, + JunctionElementKind kind + ) => + new() + { + Id = Guid.NewGuid(), + JunctionTemplateId = junctionTemplateId, + Position = position, + Kind = kind, + AmountMode = JunctionAmountMode.Count, + AmountValue = 1, + IsRequired = false, + }; + + public void Update( + JunctionElementKind kind, + Guid? groupId, + Guid? bumperTemplateId, + JunctionAmountMode amountMode, + int amountValue, + bool isRequired, + string? conditionsJson + ) + { + Kind = kind; + AmountMode = amountMode; + AmountValue = Math.Max(1, amountValue); + IsRequired = isRequired; + ConditionsJson = string.IsNullOrWhiteSpace(conditionsJson) ? null : conditionsJson; + + // Источник зависит от типа врезки: у заставки нет группы, у остальных нет блока заставки. + // Оставленная от прежнего типа ссылка потом читалась бы генератором как настройка. + GroupId = kind == JunctionElementKind.Bumper ? null : groupId; + BumperTemplateId = kind == JunctionElementKind.Bumper ? bumperTemplateId : null; + } + + internal void SetPosition(int position) => Position = position; +} diff --git a/backend/src/TeleWave.Domain/Programming/JunctionTemplate.cs b/backend/src/TeleWave.Domain/Programming/JunctionTemplate.cs new file mode 100644 index 0000000..ccfcca5 --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/JunctionTemplate.cs @@ -0,0 +1,70 @@ +namespace TeleWave.Domain.Programming; + +/// +/// Шаблон стыка: что играет между программами. Реклама и заставки перестают быть свойствами канала +/// и становятся врезками стыка — за счёт этого в прайм можно поставить три ролика и заставку, +/// а ночью один длинный ролик, чего одной настройкой на канал не сделать. +/// +public class JunctionTemplate +{ + private readonly List _elements = new(); + + public Guid Id { get; private set; } + public Guid ChannelId { get; private set; } + public string Name { get; private set; } = string.Empty; + public DateTimeOffset CreatedAt { get; private set; } + + /// Врезки в порядке показа (backing-field для EF). + public IReadOnlyList Elements => _elements; + + private JunctionTemplate() { } + + public static JunctionTemplate Create(Guid channelId, string name) => + new() + { + Id = Guid.NewGuid(), + ChannelId = channelId, + Name = name.Trim(), + CreatedAt = DateTimeOffset.UtcNow, + }; + + public void Rename(string name) => Name = name.Trim(); + + public JunctionElement AddElement(JunctionElementKind kind) + { + var nextPosition = _elements.Count == 0 ? 0 : _elements.Max(e => e.Position) + 1; + var element = JunctionElement.Create(Id, nextPosition, kind); + _elements.Add(element); + return element; + } + + public JunctionElement? FindElement(Guid elementId) => + _elements.FirstOrDefault(e => e.Id == elementId); + + public bool RemoveElement(Guid elementId) + { + var element = _elements.FirstOrDefault(e => e.Id == elementId); + if (element is null) + return false; + _elements.Remove(element); + return true; + } + + /// + /// Переставляет врезки в порядке переданных идентификаторов; не упомянутые остаются после них, + /// сохраняя относительный порядок. Порядок здесь — это порядок показа в эфире. + /// + public void Reorder(IEnumerable elementIdsInOrder) + { + var known = _elements.Select(e => e.Id).ToHashSet(); + var requested = elementIdsInOrder.Distinct().Where(known.Contains).ToList(); + var rest = _elements + .Where(e => !requested.Contains(e.Id)) + .OrderBy(e => e.Position) + .Select(e => e.Id); + + var position = 0; + foreach (var id in requested.Concat(rest)) + _elements.First(e => e.Id == id).SetPosition(position++); + } +} diff --git a/backend/src/TeleWave.Domain/Programming/OverflowPolicy.cs b/backend/src/TeleWave.Domain/Programming/OverflowPolicy.cs new file mode 100644 index 0000000..e01aacd --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/OverflowPolicy.cs @@ -0,0 +1,18 @@ +namespace TeleWave.Domain.Programming; + +/// +/// Что делать, когда последовательность (обычно коллекция) не помещается в бюджет слота. Настройка +/// слота, а не коллекции: одна и та же трилогия в вечернем блоке может доигрываться по частям, +/// а в выходном марафоне — растягивать слот. +/// +public enum OverflowPolicy +{ + /// Играем сколько влезло, курсор помнит середину, доигрываем в следующий выход. + ContinueNext = 0, + + /// Играем целиком, слот залезает на соседей — ближайший якорь подберёт разбег. + ExtendSlot = 1, + + /// Если целиком не влезает, берём из группы что-то другое. + SkipIfNotFits = 2, +} diff --git a/backend/src/TeleWave.Domain/Programming/Planning/ElementSelector.cs b/backend/src/TeleWave.Domain/Programming/Planning/ElementSelector.cs new file mode 100644 index 0000000..586d0fc --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/Planning/ElementSelector.cs @@ -0,0 +1,151 @@ +using TeleWave.Domain.Broadcast.Scheduling; + +namespace TeleWave.Domain.Programming.Planning; + +/// Что выбрано и почему — второе нужно для трейса «почему это здесь». +public sealed record ElementPick( + PlanningElement Element, + int StartUnitIndex, + int? CandidatesAfterCooldown, + bool CooldownExhausted +); + +/// +/// Выбор элемента группы по стратегии слота. Отвечает только за выбор элемента: единицы внутри +/// элемента всегда идут по порядку от курсора — сериал не должен прыгать по сериям. +/// +public static class ElementSelector +{ + public static ElementPick? Select( + PlanningSlot slot, + DateTimeOffset moment, + IRandomSource random + ) + { + var playable = slot.Elements.Where(e => e.Units.Count > 0).ToList(); + if (playable.Count == 0) + return null; + + // Курсор указывает на элемент, а не на индекс в группе: удаление позиции не сдвигает всё. + var current = slot.Cursor?.ElementId is { } currentId + ? playable.FirstOrDefault(e => + e.ElementId == currentId && e.Kind == slot.Cursor.ElementKind + ) + : null; + + return slot.Strategy.Kind switch + { + SlotStrategyKind.Fixed => SelectFixed(slot, playable), + SlotStrategyKind.Sequential => SelectSequential(slot, playable, current), + _ => SelectRandom(slot, playable, current, moment, random), + }; + } + + private static ElementPick? SelectFixed(PlanningSlot slot, List playable) + { + var fixedElement = playable.FirstOrDefault(e => + e.ElementId == slot.Strategy.FixedElementId + ); + return fixedElement is null ? null : Continue(slot, fixedElement); + } + + /// + /// Последовательная стратегия: доигрываем текущий элемент, а исчерпав его — берём следующий + /// по позиции. Дойдя до конца группы, начинаем сначала либо останавливаемся. + /// + private static ElementPick? SelectSequential( + PlanningSlot slot, + List playable, + PlanningElement? current + ) + { + var ordered = playable.OrderBy(e => e.Position).ToList(); + + if (current is not null && HasUnitsLeft(slot, current)) + return Continue(slot, current); + + var currentIndex = current is null ? -1 : ordered.FindIndex(e => e.ElementId == current.ElementId); + var nextIndex = currentIndex + 1; + + if (nextIndex >= ordered.Count) + { + if (!slot.Strategy.RestartOnEnd) + return null; + nextIndex = 0; + } + + return new ElementPick(ordered[nextIndex], 0, null, false); + } + + /// + /// Случайный выбор с остыванием. Порядок принципиален: сначала остывание отсекает недавно + /// показанное (жёсткий фильтр «нельзя»), затем взвешенный выбор работает среди оставшихся. + /// В обратном порядке вес постоянно упирался бы в кулдаун и не работал. + /// + private static ElementPick? SelectRandom( + PlanningSlot slot, + List playable, + PlanningElement? current, + DateTimeOffset moment, + IRandomSource random + ) + { + // Начатый элемент доигрывается до конца: иначе блок «4 серии подряд» рассыпался бы + // на серии из разных сериалов. + if (current is not null && HasUnitsLeft(slot, current)) + return Continue(slot, current); + + var cooldown = TimeSpan.FromDays(Math.Max(0, slot.Strategy.CooldownDays)); + var eligible = cooldown <= TimeSpan.Zero + ? playable + : playable + .Where(e => e.LastPlayedUtc is not { } last || moment - last >= cooldown) + .ToList(); + + var exhausted = eligible.Count == 0; + if (exhausted) + { + // Остывание отсекло всех. Либо игнорируем его на этот выход, либо берём самый давний — + // пустой эфир хуже раннего повтора в обоих случаях. + eligible = slot.Strategy.IgnoreCooldownWhenExhausted + ? playable + : [playable.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()]; + } + + var picked = WeightedPick(eligible, random); + return new ElementPick(picked, 0, eligible.Count, exhausted); + } + + /// Продолжение текущего элемента с позиции курсора. + private static ElementPick Continue(PlanningSlot slot, PlanningElement element) + { + var index = slot.Cursor?.ElementId == element.ElementId ? slot.Cursor.NextUnitIndex : 0; + return new ElementPick(element, Math.Clamp(index, 0, element.Units.Count), null, false); + } + + private static bool HasUnitsLeft(PlanningSlot slot, PlanningElement element) => + slot.Cursor is { } cursor + && cursor.ElementId == element.ElementId + && cursor.NextUnitIndex < element.Units.Count; + + private static PlanningElement WeightedPick( + IReadOnlyList candidates, + IRandomSource random + ) + { + var total = candidates.Sum(c => (long)Math.Max(0, c.Weight)); + if (total <= 0) + return candidates[random.Next(candidates.Count)]; + + var roll = random.Next((int)Math.Min(total, int.MaxValue)); + long accumulated = 0; + foreach (var candidate in candidates) + { + accumulated += Math.Max(0, candidate.Weight); + if (roll < accumulated) + return candidate; + } + + return candidates[^1]; + } +} diff --git a/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs b/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs new file mode 100644 index 0000000..4233d09 --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs @@ -0,0 +1,172 @@ +namespace TeleWave.Domain.Programming.Planning; + +/// Состояние стыков в рамках прогона: когда какая врезка ставилась последний раз. +public sealed class JunctionHistory +{ + private readonly Dictionary _lastPlaced = new(); + + public bool Allows(PlanningJunctionElement element, DateTimeOffset moment) + { + if (element.MinMinutesBetween <= 0) + return true; + if (!_lastPlaced.TryGetValue(element.Kind, out var last)) + return true; + return moment - last >= TimeSpan.FromMinutes(element.MinMinutesBetween); + } + + public void Record(PlanningJunctionElement element, DateTimeOffset moment) => + _lastPlaced[element.Kind] = moment; +} + +/// +/// Раскладка врезок стыка: реклама, промо, заставка, заполнитель. Ставит только то, что влезает +/// целиком до предела (якорь или горизонт) — обрезать врезку нельзя, а перехлёст сдвинул бы якорь. +/// +/// Обязательные врезки () идут первыми: при нехватке +/// времени выбрасываются необязательные, а не то, ради чего стык и заведён. +/// +public static class JunctionFiller +{ + /// + /// Раскладывает стык от . / + /// нужны заставке: её ассет зависит от пары соседей и рендерится после сборки ленты. + /// Возвращает курсор после стыка. + /// + public static DateTimeOffset Fill( + PlanningJunction? junction, + DateTimeOffset cursor, + DateTimeOffset limit, + Guid slotId, + Guid? fromShowId, + Guid? toShowId, + bool elementChanged, + JunctionHistory history, + List items, + PlanTrace? trace + ) + { + if (junction is null || junction.Elements.Count == 0) + return cursor; + + var ordered = junction + .Elements.OrderByDescending(e => e.IsRequired) + .ThenBy(e => junction.Elements.ToList().IndexOf(e)) + .ToList(); + + foreach (var element in ordered) + { + if (element.OnlyOnElementChange && !elementChanged) + continue; + if (!history.Allows(element, cursor)) + continue; + + var placedAt = cursor; + cursor = + element.Kind == JunctionElementKind.Bumper + ? PlaceBumper(element, cursor, limit, slotId, fromShowId, toShowId, items, trace) + : PlaceUnits(element, cursor, limit, slotId, items, trace); + + if (cursor > placedAt) + history.Record(element, placedAt); + } + + return cursor; + } + + /// + /// Резервирует место под заставку. Ассет пуст: он рендерится под конкретную пару «из/в» уже после + /// того, как лента собрана, — до наполнения слотов пара попросту неизвестна. + /// + private static DateTimeOffset PlaceBumper( + PlanningJunctionElement element, + DateTimeOffset cursor, + DateTimeOffset limit, + Guid slotId, + Guid? fromShowId, + Guid? toShowId, + List items, + PlanTrace? trace + ) + { + if (element.BumperDuration <= TimeSpan.Zero || cursor + element.BumperDuration > limit) + return cursor; + + var end = cursor + element.BumperDuration; + items.Add( + new PlannedItem( + Guid.Empty, + cursor, + end, + toShowId, + null, + slotId, + PlannedItemKind.Bumper, + trace, + element.BumperTemplateId, + fromShowId, + toShowId + ) + ); + return end; + } + + /// Ставит единицы врезки по её бюджету: N штук либо пока не наберётся M минут. + private static DateTimeOffset PlaceUnits( + PlanningJunctionElement element, + DateTimeOffset cursor, + DateTimeOffset limit, + Guid slotId, + List items, + PlanTrace? trace + ) + { + if (element.Units.Count == 0) + return cursor; + + var kind = element.Kind switch + { + JunctionElementKind.Ad => PlannedItemKind.Ad, + JunctionElementKind.Promo => PlannedItemKind.Promo, + _ => PlannedItemKind.Fallback, + }; + + var placed = 0; + var accumulated = TimeSpan.Zero; + var index = 0; + + while (index < element.Units.Count) + { + var unit = element.Units[index]; + index++; + + if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > limit) + break; + + var enough = + element.AmountMode == JunctionAmountMode.Count + ? placed >= Math.Max(1, element.AmountValue) + // Последняя единица входит целиком: рекламный блок не разрезается. + : accumulated >= TimeSpan.FromMinutes(Math.Max(1, element.AmountValue)); + if (enough) + break; + + items.Add( + new PlannedItem( + unit.MediaAssetId, + cursor, + cursor + unit.Duration, + null, + null, + slotId, + kind, + trace + ) + ); + cursor += unit.Duration; + accumulated += unit.Duration; + placed++; + } + + return cursor; + } +} diff --git a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs new file mode 100644 index 0000000..4c600b9 --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs @@ -0,0 +1,192 @@ +namespace TeleWave.Domain.Programming.Planning; + +/// +/// Единица воспроизведения — серия или фильм с готовым ассетом. Планировщик оперирует ими, а не +/// шоу: у фильма единица одна, у сериала их столько же, сколько серий, у коллекции — сумма по частям. +/// +public sealed record PlanningUnit(Guid MediaAssetId, TimeSpan Duration, Guid ShowId, int UnitIndex); + +/// +/// Элемент группы, развёрнутый в последовательность единиц. — когда он +/// в последний раз выходил в этом канале; по нему работает остывание. +/// +public sealed record PlanningElement( + GroupElementKind Kind, + Guid ElementId, + int Weight, + int Position, + IReadOnlyList Units, + DateTimeOffset? LastPlayedUtc = null +); + +/// Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику. +public sealed record PlanningStrategy( + SlotStrategyKind Kind, + bool RestartOnEnd = true, + int CooldownDays = 0, + bool IgnoreCooldownWhenExhausted = false, + Guid? FixedElementId = null +); + +/// Как слот выбирает элемент. Дублирует прикладной enum, чтобы домен не зависел от Application. +public enum SlotStrategyKind +{ + Sequential = 0, + RandomWithCooldown = 1, + Fixed = 2, +} + +/// Где остановились в текущем элементе на момент начала прогона. +public sealed record PlanningCursor( + GroupElementKind? ElementKind, + Guid? ElementId, + int NextUnitIndex +); + +/// +/// Слот, привязанный к конкретному моменту эфира. Применимость слоёв уже разрешена: сюда попадают +/// только те слоты, которые реально действуют в эти сутки, каждый со своим целевым временем в UTC. +/// +public sealed record PlanningSlot( + Guid SlotId, + DateTimeOffset TargetStartUtc, + int TargetDurationMinutes, + SlotKind SlotKind, + bool IsAnchor, + int MaxDriftMinutes, + int? SnapToMinutes, + SlotBlockMode BlockMode, + int BlockValue, + OverflowPolicy OverflowPolicy, + PlanningStrategy Strategy, + IReadOnlyList Elements, + PlanningCursor? Cursor, + /// Готовые записи для — что играло в источнике повтора. + IReadOnlyList? RepeatUnits = null, + /// Врезки между единицами внутри блока. + PlanningJunction? JunctionBetween = null, + /// Врезки в конце блока. + PlanningJunction? JunctionAfter = null +) +{ + public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes); +} + +/// +/// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся +/// решить, сколько их поставить и влезают ли они. +/// +public sealed record PlanningJunctionElement( + JunctionElementKind Kind, + IReadOnlyList Units, + JunctionAmountMode AmountMode, + int AmountValue, + bool IsRequired, + /// Ставить только при смене элемента (иначе — и между единицами одного). + bool OnlyOnElementChange = false, + /// Не ставить чаще, чем раз в N минут (0 — без ограничения). + int MinMinutesBetween = 0, + /// Блок заставки — ассет рендерится позже, планировщик резервирует длительность. + Guid? BumperTemplateId = null, + /// Длительность резерва под заставку. + TimeSpan BumperDuration = default +); + +/// Стык: последовательность врезок между программами. +public sealed record PlanningJunction( + Guid JunctionId, + IReadOnlyList Elements +); + +/// Полный вход одного прогона генератора. +public sealed record PlanningInput( + Guid ChannelId, + DateTimeOffset StartUtc, + DateTimeOffset HorizonEndUtc, + IReadOnlyList Slots, + /// Чем закрывать место, не покрытое слотами и не заполненное контентом. + IReadOnlyList FallbackUnits, + int SegmentSeconds +); + +/// Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно. +public sealed record PlannedItem( + Guid MediaAssetId, + DateTimeOffset StartsAtUtc, + DateTimeOffset EndsAtUtc, + Guid? ShowId, + int? UnitIndex, + Guid? SlotId, + PlannedItemKind Kind, + PlanTrace? Trace = null, + /// Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже. + Guid? BumperTemplateId = null, + Guid? FromShowId = null, + Guid? ToShowId = null +); + +public enum PlannedItemKind +{ + Program = 0, + Fallback = 1, + SignOff = 2, + Ad = 3, + Promo = 4, + + /// + /// Заставка-переход. Ассет пуст: он зависит от пары соседей и рендерится после того, как лента + /// собрана, — планировщик лишь резервирует под неё длительность. + /// + Bumper = 5, +} + +/// Цепочка происхождения записи — питает экран «почему это здесь». +public sealed record PlanTrace( + Guid? SlotId, + SlotKind SlotKind, + GroupElementKind? ElementKind, + Guid? ElementId, + SlotStrategyKind? Strategy, + /// Сколько кандидатов осталось после остывания (null — выбор без остывания). + int? CandidatesAfterCooldown, + /// Насколько фактический старт разошёлся с целевым, минуты. + int DriftMinutes, + /// Старт сдвинут вперёд округлением до круглого времени. + bool Snapped +); + +/// Новое состояние слота после прогона — оркестратор сохраняет его в БД. +public sealed record PlanningCursorUpdate( + Guid SlotId, + GroupElementKind? ElementKind, + Guid? ElementId, + int NextUnitIndex +); + +/// Результат прогона: лента, новые курсоры и предупреждения для админа. +public sealed record PlanningResult( + IReadOnlyList Items, + IReadOnlyList Cursors, + IReadOnlyList Warnings +); + +/// Предупреждение по результату генерации: не ошибка, но админу это видеть нужно. +public sealed record PlanningWarning(PlanningWarningKind Kind, Guid? SlotId, string Details); + +public enum PlanningWarningKind +{ + /// Слот не дал контента — место закрыл фон. + SlotEmpty = 0, + + /// Фактический старт ушёл дальше допуска. + DriftExceeded = 1, + + /// Остывание отсекло всех кандидатов. + CooldownExhausted = 2, + + /// Не нашлось, что повторить. + RepeatSourceEmpty = 3, + + /// Пусто даже в фоне — в ленте образуется дыра. + FallbackEmpty = 4, +} diff --git a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs new file mode 100644 index 0000000..a897447 --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs @@ -0,0 +1,487 @@ +using TeleWave.Domain.Broadcast.Scheduling; + +namespace TeleWave.Domain.Programming.Planning; + +/// +/// Чистая эфирная математика: разворачивает сетку в непрерывную ленту от +/// до горизонта. Без БД, ФС и ffmpeg — полностью юнит-тестируемо. +/// +/// Сетка эластичная: времена слотов — цели, а не границы. Контент идёт встык, слот исчерпывается по +/// бюджету, расхождение переносится дальше. Опорные точки держат якоря (жёсткий старт, перед которым +/// не начинают то, что через него перелезет) и мягкое округление (сдвиг старта до круглого времени). +/// +public static class SchedulePlanner +{ + private const int IterationBackstop = 100_000; + + public static PlanningResult Plan(PlanningInput input, IRandomSource random) + { + var items = new List(); + var cursors = new List(); + var warnings = new List(); + + var slots = input.Slots.OrderBy(s => s.TargetStartUtc).ToList(); + var cursor = input.StartUtc; + var iterations = 0; + + // История врезок общая на прогон: «не чаще раза в полчаса» должно работать и через границу слота. + var junctions = new JunctionHistory(); + Guid? previousShowId = null; + + for (var i = 0; i < slots.Count && cursor < input.HorizonEndUtc; i++) + { + if (iterations++ > IterationBackstop) + break; + + var slot = slots[i]; + + // Слот, чьё окно целиком в прошлом относительно курсора, пропускаем: догонять уже нечего, + // а поставив его сейчас, мы сдвинули бы всё последующее ещё дальше. + if (slot.TargetEndUtc <= cursor) + continue; + + var nextAnchor = FindNextAnchor(slots, i + 1); + + cursor = OpenSlot(slot, cursor, input, items, warnings, out var trace); + if (cursor >= input.HorizonEndUtc) + break; + + cursor = FillSlot( + slot, + cursor, + nextAnchor, + input, + random, + items, + cursors, + warnings, + trace, + junctions, + ref previousShowId + ); + } + + // Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край + // упрётся в дыру. + if (cursor < input.HorizonEndUtc) + cursor = FillWithFallback(cursor, input.HorizonEndUtc, input, items, null); + + if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0) + warnings.Add( + new PlanningWarning( + PlanningWarningKind.FallbackEmpty, + null, + "Нет ни одной единицы для заполнения пауз — в ленте останутся дыры." + ) + ); + + return new PlanningResult(items, cursors, warnings); + } + + /// + /// Подводит курсор к старту слота: добирает фоном до якоря либо до круглой отметки. Возвращает + /// фактический старт и заполняет трейс сведениями о дрейфе. + /// + private static DateTimeOffset OpenSlot( + PlanningSlot slot, + DateTimeOffset cursor, + PlanningInput input, + List items, + List warnings, + out PlanTrace trace + ) + { + var snapped = false; + + if (cursor < slot.TargetStartUtc) + { + // До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно, + // для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени. + cursor = FillWithFallback(cursor, slot.TargetStartUtc, input, items, slot.SlotId); + } + else if (slot.SnapToMinutes is { } snap && snap > 0) + { + // Слот опаздывает. Округление мягкое: если добирать пришлось бы дольше допуска, лучше + // начать в 19:47, чем девять минут крутить фон. + var target = RoundUp(cursor, TimeSpan.FromMinutes(snap)); + if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes)) + { + var afterFill = FillWithFallback(cursor, target, input, items, slot.SlotId); + snapped = afterFill > cursor; + cursor = afterFill; + } + } + + var drift = (int)Math.Round((cursor - slot.TargetStartUtc).TotalMinutes); + if (Math.Abs(drift) > slot.MaxDriftMinutes) + warnings.Add( + new PlanningWarning( + PlanningWarningKind.DriftExceeded, + slot.SlotId, + $"Фактический старт разошёлся с целевым на {drift} мин." + ) + ); + + trace = new PlanTrace( + slot.SlotId, + slot.SlotKind, + null, + null, + null, + null, + drift, + snapped + ); + return cursor; + } + + /// Наполняет слот по его типу и возвращает курсор после него. + private static DateTimeOffset FillSlot( + PlanningSlot slot, + DateTimeOffset cursor, + DateTimeOffset? nextAnchor, + PlanningInput input, + IRandomSource random, + List items, + List cursors, + List warnings, + PlanTrace trace, + JunctionHistory junctions, + ref Guid? previousShowId + ) + { + var limit = Min(input.HorizonEndUtc, nextAnchor); + + switch (slot.SlotKind) + { + case SlotKind.SignOff: + // Конец вещания: место занимает зацикленный фон, но в программе это помечено особо. + return FillWithFallback( + cursor, + Min(slot.TargetEndUtc, limit), + input, + items, + slot.SlotId, + PlannedItemKind.SignOff, + trace + ); + + case SlotKind.Repeat: + return FillRepeat(slot, cursor, limit, input, items, warnings, trace); + + default: + return FillContent( + slot, + cursor, + limit, + input, + random, + items, + cursors, + warnings, + trace, + junctions, + ref previousShowId + ); + } + } + + private static DateTimeOffset FillRepeat( + PlanningSlot slot, + DateTimeOffset cursor, + DateTimeOffset limit, + PlanningInput input, + List items, + List warnings, + PlanTrace trace + ) + { + var units = slot.RepeatUnits ?? []; + if (units.Count == 0) + { + warnings.Add( + new PlanningWarning( + PlanningWarningKind.RepeatSourceEmpty, + slot.SlotId, + "В источнике повтора ничего не нашлось — слот закрыт фоном." + ) + ); + return FillWithFallback( + cursor, + Min(slot.TargetEndUtc, limit), + input, + items, + slot.SlotId + ); + } + + var slotEnd = Min(slot.TargetEndUtc, limit); + foreach (var unit in units) + { + if (cursor + unit.Duration > slotEnd) + break; + items.Add(Program(unit, cursor, slot.SlotId, trace)); + cursor += unit.Duration; + } + + return cursor; + } + + private static DateTimeOffset FillContent( + PlanningSlot slot, + DateTimeOffset cursor, + DateTimeOffset limit, + PlanningInput input, + IRandomSource random, + List items, + List cursors, + List warnings, + PlanTrace trace, + JunctionHistory junctions, + ref Guid? previousShowId + ) + { + var pick = ElementSelector.Select(slot, cursor, random); + if (pick is null) + { + warnings.Add( + new PlanningWarning( + PlanningWarningKind.SlotEmpty, + slot.SlotId, + "Слот не дал контента — место закрыл фон." + ) + ); + return FillWithFallback( + cursor, + Min(slot.TargetEndUtc, limit), + input, + items, + slot.SlotId + ); + } + + if (pick.CooldownExhausted) + warnings.Add( + new PlanningWarning( + PlanningWarningKind.CooldownExhausted, + slot.SlotId, + "Остывание отсекло всех кандидатов — взят самый давний." + ) + ); + + var element = pick.Element; + var slotTrace = trace with + { + ElementKind = element.Kind, + ElementId = element.ElementId, + Strategy = slot.Strategy.Kind, + CandidatesAfterCooldown = pick.CandidatesAfterCooldown, + }; + + var unitIndex = pick.StartUnitIndex; + var budgetEnd = Min(slot.TargetEndUtc, limit); + var placed = 0; + var accumulated = TimeSpan.Zero; + + // SkipIfNotFits решается до постановки: если элемент целиком не помещается, слот не начинают. + if ( + slot.OverflowPolicy == OverflowPolicy.SkipIfNotFits + && !FitsEntirely(element, unitIndex, cursor, budgetEnd) + ) + { + cursors.Add(CursorUpdate(slot, element, unitIndex)); + return FillWithFallback(cursor, budgetEnd, input, items, slot.SlotId); + } + + while (unitIndex < element.Units.Count && cursor < limit) + { + var unit = element.Units[unitIndex]; + + // Врезки между единицами: перед каждой, кроме первой в блоке. + if (placed > 0) + cursor = JunctionFiller.Fill( + slot.JunctionBetween, + cursor, + limit, + slot.SlotId, + previousShowId, + unit.ShowId, + elementChanged: previousShowId != unit.ShowId, + junctions, + items, + slotTrace + ); + + // Через якорь не перелезаем: то, что не влезает до него, не начинают вовсе. + if (cursor + unit.Duration > limit) + break; + + if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd)) + break; + + items.Add(Program(unit, cursor, slot.SlotId, slotTrace)); + cursor += unit.Duration; + accumulated += unit.Duration; + unitIndex++; + placed++; + previousShowId = unit.ShowId; + } + + // Врезки в конце блока ставятся до добора фоном: иначе реклама оказалась бы после заполнителя. + if (placed > 0) + cursor = JunctionFiller.Fill( + slot.JunctionAfter, + cursor, + limit, + slot.SlotId, + previousShowId, + null, + elementChanged: true, + junctions, + items, + slotTrace + ); + + cursors.Add(CursorUpdate(slot, element, unitIndex)); + + // Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени. + if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd) + cursor = FillWithFallback(cursor, budgetEnd, input, items, slot.SlotId); + + return cursor; + } + + /// + /// Влезает ли ещё одна единица в бюджет слота. бюджет + /// игнорирует: элемент доигрывается целиком, а разбег подберёт ближайший якорь. + /// + private static bool WithinBudget( + PlanningSlot slot, + int placed, + TimeSpan accumulated, + DateTimeOffset cursor, + PlanningUnit unit, + DateTimeOffset budgetEnd + ) + { + if (slot.OverflowPolicy == OverflowPolicy.ExtendSlot) + return true; + + return slot.BlockMode switch + { + SlotBlockMode.Count => placed < Math.Max(1, slot.BlockValue), + // Последняя единица входит целиком: обрезать видеофайл нельзя. + SlotBlockMode.Duration => accumulated < TimeSpan.FromMinutes(Math.Max(1, slot.BlockValue)), + _ => cursor + unit.Duration <= budgetEnd, + }; + } + + private static bool FitsEntirely( + PlanningElement element, + int fromUnitIndex, + DateTimeOffset cursor, + DateTimeOffset budgetEnd + ) + { + var total = element + .Units.Skip(fromUnitIndex) + .Aggregate(TimeSpan.Zero, (sum, unit) => sum + unit.Duration); + return cursor + total <= budgetEnd; + } + + /// + /// Закрывает интервал зацикленными единицами фона. Ставит только те, что влезают целиком: + /// обрезать нельзя, а перехлёст сдвинул бы следующий якорь. Остаток короче одной единицы + /// остаётся незакрытым — раздача покажет там аварийный филлер канала. + /// + private static DateTimeOffset FillWithFallback( + DateTimeOffset from, + DateTimeOffset until, + PlanningInput input, + List items, + Guid? slotId, + PlannedItemKind kind = PlannedItemKind.Fallback, + PlanTrace? trace = null + ) + { + if (input.FallbackUnits.Count == 0 || until <= from) + return from; + + var cursor = from; + var index = 0; + var guard = 0; + + while (cursor < until && guard++ < IterationBackstop) + { + var unit = input.FallbackUnits[index % input.FallbackUnits.Count]; + index++; + + if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > until) + break; + + items.Add( + new PlannedItem( + unit.MediaAssetId, + cursor, + cursor + unit.Duration, + null, + null, + slotId, + kind, + trace + ) + ); + cursor += unit.Duration; + } + + return cursor; + } + + private static PlannedItem Program( + PlanningUnit unit, + DateTimeOffset start, + Guid slotId, + PlanTrace trace + ) => + new( + unit.MediaAssetId, + start, + start + unit.Duration, + unit.ShowId, + unit.UnitIndex, + slotId, + PlannedItemKind.Program, + trace + ); + + private static PlanningCursorUpdate CursorUpdate( + PlanningSlot slot, + PlanningElement element, + int nextUnitIndex + ) => new(slot.SlotId, element.Kind, element.ElementId, nextUnitIndex); + + /// Ближайший якорь среди последующих слотов — до него нельзя перелезать контентом. + private static DateTimeOffset? FindNextAnchor(IReadOnlyList slots, int fromIndex) + { + for (var i = fromIndex; i < slots.Count; i++) + if (slots[i].IsAnchor) + return slots[i].TargetStartUtc; + return null; + } + + private static DateTimeOffset Min(DateTimeOffset value, DateTimeOffset? other) => + other is { } o && o < value ? o : value; + + private static DateTimeOffset Min(DateTimeOffset a, DateTimeOffset b) => a < b ? a : b; + + /// Округление момента вверх до кратного шага — от начала суток UTC. + private static DateTimeOffset RoundUp(DateTimeOffset moment, TimeSpan step) + { + if (step <= TimeSpan.Zero) + return moment; + + var ticks = step.Ticks; + var remainder = moment.UtcTicks % ticks; + return remainder == 0 ? moment : moment.AddTicks(ticks - remainder); + } +} diff --git a/backend/src/TeleWave.Domain/Programming/ScheduleTemplate.cs b/backend/src/TeleWave.Domain/Programming/ScheduleTemplate.cs new file mode 100644 index 0000000..7cc3af9 --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/ScheduleTemplate.cs @@ -0,0 +1,96 @@ +namespace TeleWave.Domain.Programming; + +/// +/// Шаблон сетки канала: слои со слотами плюс аварийные настройки. Один активный шаблон на канал — +/// сезонность выражается слоями внутри него, а не вторым шаблоном, иначе получились бы два механизма +/// для одного и того же. На другой канал переносится глубокой копией. +/// +/// растёт при любой правке правил. Эфир при этом не меняется: правка помечает +/// шаблон изменённым, а хвост пересобирается отдельной командой применения. +/// +public class ScheduleTemplate +{ + private readonly List _layers = new(); + + public Guid Id { get; private set; } + public Guid ChannelId { get; private set; } + public string Name { get; private set; } = string.Empty; + + /// Аварийная группа, если пуст даже фоновый слой. + public Guid? FallbackGroupId { get; private set; } + + /// Стык по умолчанию — для слотов, у которых свой не задан. + public Guid? DefaultJunctionId { get; private set; } + + /// Номер правки правил; входит в кэш-ключи и историю. + public int Revision { get; private set; } + + /// Ревизия, по которой собрано текущее будущее расписание. + public int AppliedRevision { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public IReadOnlyList Layers => _layers; + + /// Есть ли правки, не применённые к эфиру. + public bool HasPendingChanges => Revision != AppliedRevision; + + private const string BackgroundLayerName = "Фон"; + + private ScheduleTemplate() { } + + public static ScheduleTemplate Create(Guid channelId, string name) + { + var template = new ScheduleTemplate + { + Id = Guid.NewGuid(), + ChannelId = channelId, + Name = name.Trim(), + Revision = 0, + AppliedRevision = 0, + CreatedAt = DateTimeOffset.UtcNow, + }; + // Фоновый слой заводится сразу: без него первая же дыра в сетке осталась бы нечем закрыть. + template._layers.Add( + GridLayer.Create(template.Id, BackgroundLayerName, GridLayer.BackgroundPriority, isBackground: true) + ); + return template; + } + + public void Rename(string name) => Name = name.Trim(); + + public void SetFallbackGroup(Guid? groupId) => FallbackGroupId = groupId; + + public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId; + + /// Отметить, что правила изменились — эфир пойдёт по старым до применения. + public void MarkChanged() => Revision++; + + /// Отметить, что хвост пересобран по текущей ревизии. + public void MarkApplied() => AppliedRevision = Revision; + + public GridLayer? FindLayer(Guid layerId) => _layers.FirstOrDefault(l => l.Id == layerId); + + public GridLayer? Background => _layers.FirstOrDefault(l => l.IsBackground); + + public GridLayer AddLayer(string name, int priority) + { + var layer = GridLayer.Create(Id, name, priority); + _layers.Add(layer); + return layer; + } + + /// Удаляет слой. Фоновый удалить нельзя — вернёт false. + public bool RemoveLayer(Guid layerId) + { + var layer = _layers.FirstOrDefault(l => l.Id == layerId); + if (layer is null || layer.IsBackground) + return false; + _layers.Remove(layer); + return true; + } + + /// Слой, содержащий слот, или null. + public GridLayer? FindLayerOfSlot(Guid slotId) => + _layers.FirstOrDefault(l => l.FindSlot(slotId) is not null); +} diff --git a/backend/src/TeleWave.Domain/Programming/Slot.cs b/backend/src/TeleWave.Domain/Programming/Slot.cs new file mode 100644 index 0000000..5ce08f1 --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/Slot.cs @@ -0,0 +1,142 @@ +namespace TeleWave.Domain.Programming; + +/// +/// Слот сетки: когда и чем заполнять эфир. Времена — цели, а не жёсткие границы: контент идёт встык, +/// слот считается исчерпанным по бюджету, расхождение переносится на следующий. Опорные точки держат +/// якоря () и мягкое округление (). +/// +public class Slot +{ + public Guid Id { get; private set; } + public Guid LayerId { get; private set; } + + /// День недели вещательных суток (0=Вс..6=Сб) или null — каждый день. + public int? Weekday { get; private set; } + + /// Целевое время старта в сутках канала. + public TimeOnly TargetStart { get; private set; } + + /// Бюджет слота в минутах. + public int TargetDurationMinutes { get; private set; } + + public string Title { get; private set; } = string.Empty; + public Daypart Daypart { get; private set; } + public SlotKind SlotKind { get; private set; } + + /// Группа контента — для . + public Guid? GroupId { get; private set; } + + /// Стратегия выбора элемента (JSON). Домен её не интерпретирует — схема в Application. + public string? StrategyJson { get; private set; } + + /// Откуда брать повтор (JSON) — для . + public string? RepeatSourceJson { get; private set; } + + public SlotBlockMode BlockMode { get; private set; } + + /// Единиц () или минут (). + public int BlockValue { get; private set; } + + public OverflowPolicy OverflowPolicy { get; private set; } + + /// Старт жёсткий: генератор не начнёт единицу, которая через него перелезет. + public bool IsAnchor { get; private set; } + + /// Допуск отклонения фактического старта от целевого, минуты. + public int MaxDriftMinutes { get; private set; } + + /// + /// Округлять старт до кратного N минут (5/10/15/30) или null. Мягкое, в отличие от якоря: если + /// добирать пришлось бы дольше , округление пропускается. + /// + public int? SnapToMinutes { get; private set; } + + /// Стык между единицами внутри блока (null — врезок внутри блока нет). + public Guid? JunctionBetweenId { get; private set; } + + /// Стык в конце блока (null — берётся стык шаблона по умолчанию). + public Guid? JunctionAfterId { get; private set; } + + public const int DefaultMaxDriftMinutes = 5; + + private Slot() { } + + public static Slot Create( + Guid layerId, + string title, + TimeOnly targetStart, + int targetDurationMinutes, + Daypart daypart = Daypart.Day, + SlotKind slotKind = SlotKind.Content, + int? weekday = null + ) => + new() + { + Id = Guid.NewGuid(), + LayerId = layerId, + Title = title.Trim(), + TargetStart = targetStart, + TargetDurationMinutes = Math.Max(1, targetDurationMinutes), + Daypart = daypart, + SlotKind = slotKind, + Weekday = weekday, + BlockMode = SlotBlockMode.FillSlot, + BlockValue = 1, + OverflowPolicy = OverflowPolicy.ContinueNext, + IsAnchor = false, + MaxDriftMinutes = DefaultMaxDriftMinutes, + }; + + /// Правит расписание слота: когда, сколько и как выравнивать. + public void UpdateTiming( + int? weekday, + TimeOnly targetStart, + int targetDurationMinutes, + Daypart daypart, + bool isAnchor, + int maxDriftMinutes, + int? snapToMinutes + ) + { + Weekday = weekday is >= 0 and <= 6 ? weekday : null; + TargetStart = targetStart; + TargetDurationMinutes = Math.Max(1, targetDurationMinutes); + Daypart = daypart; + IsAnchor = isAnchor; + MaxDriftMinutes = Math.Max(0, maxDriftMinutes); + SnapToMinutes = snapToMinutes is > 0 ? snapToMinutes : null; + } + + /// Правит наполнение слота: чем, в каком объёме и с какими врезками. + public void UpdateContent( + string title, + SlotKind slotKind, + Guid? groupId, + string? strategyJson, + string? repeatSourceJson, + SlotBlockMode blockMode, + int blockValue, + OverflowPolicy overflowPolicy, + Guid? junctionBetweenId = null, + Guid? junctionAfterId = null + ) + { + JunctionBetweenId = junctionBetweenId; + JunctionAfterId = junctionAfterId; + Title = title.Trim(); + SlotKind = slotKind; + BlockMode = blockMode; + BlockValue = Math.Max(1, blockValue); + OverflowPolicy = overflowPolicy; + + // Поля, не относящиеся к типу слота, гасим: повтор и конец вещания стратегии не имеют, + // и оставленный от прежнего типа мусор потом читался бы генератором как настройка. + GroupId = slotKind == SlotKind.Content ? groupId : null; + StrategyJson = slotKind == SlotKind.Content ? strategyJson : null; + RepeatSourceJson = slotKind == SlotKind.Repeat ? repeatSourceJson : null; + } + + /// Конец слота в сутках канала. Может выйти за полночь — вещательные сутки длиннее суток. + public TimeSpan TargetEndOffset => + TargetStart.ToTimeSpan() + TimeSpan.FromMinutes(TargetDurationMinutes); +} diff --git a/backend/src/TeleWave.Domain/Programming/SlotBlockMode.cs b/backend/src/TeleWave.Domain/Programming/SlotBlockMode.cs new file mode 100644 index 0000000..6edded4 --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/SlotBlockMode.cs @@ -0,0 +1,18 @@ +namespace TeleWave.Domain.Programming; + +/// +/// Чем меряется блок, который слот берёт за один выход. Свойство слота, ортогональное стратегии: +/// любую стратегию можно скомбинировать с «одна единица», «четыре подряд» или «пока не наберётся +/// два часа». Марафон — это , отдельной стратегии не нужно. +/// +public enum SlotBlockMode +{ + /// Ровно N единиц воспроизведения. + Count = 0, + + /// Единицы, пока сумма не превысит M минут (последняя входит целиком). + Duration = 1, + + /// Пока не исчерпан бюджет слота. + FillSlot = 2, +} diff --git a/backend/src/TeleWave.Domain/Programming/SlotKind.cs b/backend/src/TeleWave.Domain/Programming/SlotKind.cs new file mode 100644 index 0000000..2a7172c --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/SlotKind.cs @@ -0,0 +1,20 @@ +namespace TeleWave.Domain.Programming; + +/// Тип слота: обычный контент, повтор уже сыгранного или конец вещания. +public enum SlotKind +{ + /// Контент из группы по стратегии слота. + Content = 0, + + /// + /// Повтор того, что уже играло в этой же ленте (вечерний фильм утром в субботу). Стратегии + /// и состояния не требует — читает записанное расписание. + /// + Repeat = 1, + + /// + /// Конец вещания: настроечная таблица или гимн вместо эфира. Сделано слотом, а не флагом канала, + /// чтобы конец вещания можно было поставить только в будни либо не ставить вовсе. + /// + SignOff = 2, +} diff --git a/backend/src/TeleWave.Domain/Programming/SlotState.cs b/backend/src/TeleWave.Domain/Programming/SlotState.cs new file mode 100644 index 0000000..41959b9 --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/SlotState.cs @@ -0,0 +1,42 @@ +namespace TeleWave.Domain.Programming; + +/// +/// Состояние слота: где остановились в текущем элементе. Курсор хранит ссылку на элемент, а не +/// числовой индекс в группе, — при удалении позиции из группы он корректно переезжает на следующую, +/// а не сдвигает всё. +/// +/// Состояние существует потому, что сетка эластичная: сколько единиц уйдёт за один выход слота, +/// зависит от фактических длительностей, и вычислить это арифметически от эпохи нельзя. +/// +public class SlotState +{ + public Guid SlotId { get; private set; } + public GroupElementKind? CurrentElementKind { get; private set; } + public Guid? CurrentElementId { get; private set; } + + /// Позиция следующей единицы внутри текущего элемента. + public int NextUnitIndex { get; private set; } + + private SlotState() { } + + public static SlotState Create(Guid slotId) => new() { SlotId = slotId, NextUnitIndex = 0 }; + + /// Продолжить текущий элемент со следующей единицы. + public void Advance(int nextUnitIndex) => NextUnitIndex = Math.Max(0, nextUnitIndex); + + /// Перейти к другому элементу, начав его с указанной единицы. + public void MoveTo(GroupElementKind kind, Guid elementId, int nextUnitIndex = 0) + { + CurrentElementKind = kind; + CurrentElementId = elementId; + NextUnitIndex = Math.Max(0, nextUnitIndex); + } + + /// Забыть текущий элемент — например, когда он исчез из группы. + public void Reset() + { + CurrentElementKind = null; + CurrentElementId = null; + NextUnitIndex = 0; + } +} diff --git a/backend/src/TeleWave.Infrastructure/Broadcast/MaintenanceBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Broadcast/MaintenanceBackgroundService.cs new file mode 100644 index 0000000..6e42ad6 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Broadcast/MaintenanceBackgroundService.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Infrastructure.Broadcast; + +/// +/// Обслуживание, которое раньше не делалось вовсе: чистка отрендеренных заставок, на которые больше +/// никто не ссылается. Они копятся при каждой смене пары шоу и при каждой правке блока заставки, +/// а после перехода на длинное хранение расписания их станет заметно больше. +/// +/// Идёт по расписанию отдельно от генерации: та держит advisory-лок канала, и подмешивать в неё +/// удаление по всей таблице не нужно. +/// +public sealed class MaintenanceBackgroundService( + IServiceScopeFactory scopeFactory, + ILogger logger +) : BackgroundService +{ + private static readonly TimeSpan Interval = TimeSpan.FromHours(6); + private static readonly TimeSpan StartupDelay = TimeSpan.FromMinutes(2); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await Task.Delay(StartupDelay, stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + + using var timer = new PeriodicTimer(Interval); + + do + { + try + { + await TickAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка обслуживания"); + } + } while (await timer.WaitForNextTickAsync(stoppingToken)); + } + + private async Task TickAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Осиротевшей считается заставка, чей ассет не встречается ни в одной записи расписания. + // Границу по времени не ставим: окно хранения расписания уже определяет, что живо. + var removed = await db + .BumperAssets.Where(b => + !db.ScheduleEntries.Any(e => e.MediaAssetId == b.MediaAssetId) + ) + .ExecuteDeleteAsync(cancellationToken); + + if (removed > 0) + logger.LogInformation("Обслуживание: удалено осиротевших заставок — {Count}", removed); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Broadcast/SchedulingBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Broadcast/SchedulingBackgroundService.cs index ad2f2bc..0843abc 100644 --- a/backend/src/TeleWave.Infrastructure/Broadcast/SchedulingBackgroundService.cs +++ b/backend/src/TeleWave.Infrastructure/Broadcast/SchedulingBackgroundService.cs @@ -1,84 +1,96 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using TeleWave.Application.Broadcast.Scheduling; -using TeleWave.Application.Common.Interfaces; - -namespace TeleWave.Infrastructure.Broadcast; - -/// -/// Периодически достраивает расписание каждого включённого канала до горизонта. Расширение хвоста — -/// без удаления существующих записей, поэтому эфир не «дёргается». -/// -public sealed class SchedulingBackgroundService( - IServiceScopeFactory scopeFactory, - IOptions options, - ILogger logger -) : BackgroundService -{ - private readonly SchedulerOptions _options = options.Value; - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - // Небольшая задержка на старте — дать примениться миграциям/сидингу. - try - { - await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); - } - catch (OperationCanceledException) - { - return; - } - - using var timer = new PeriodicTimer( - TimeSpan.FromMinutes(Math.Max(1, _options.TickMinutes)) - ); - - do - { - try - { - await TickAsync(stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - break; - } - catch (Exception ex) - { - logger.LogError(ex, "Ошибка тика планировщика"); - } - } while (await timer.WaitForNextTickAsync(stoppingToken)); - } - - private async Task TickAsync(CancellationToken cancellationToken) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var generator = scope.ServiceProvider.GetRequiredService(); - - var channelIds = await db - .Channels.Where(c => c.IsEnabled) - .Select(c => c.Id) - .ToListAsync(cancellationToken); - - var now = DateTimeOffset.UtcNow; - foreach (var channelId in channelIds) - { - var added = await generator.GenerateAsync( - channelId, - now, - regenerate: false, - cancellationToken - ); - if (added > 0) - logger.LogInformation( - "Канал {ChannelId}: добавлено {Count} записей расписания", - channelId, - added - ); - } - } -} +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TeleWave.Application.Broadcast.Scheduling; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Programming.Planning; + +namespace TeleWave.Infrastructure.Broadcast; + +/// +/// Периодически достраивает расписание каждого включённого канала до горизонта. Расширение хвоста — +/// без удаления существующих записей, поэтому эфир не «дёргается». +/// +public sealed class SchedulingBackgroundService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger +) : BackgroundService +{ + private readonly SchedulerOptions _options = options.Value; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Небольшая задержка на старте — дать примениться миграциям/сидингу. + try + { + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + + using var timer = new PeriodicTimer( + TimeSpan.FromMinutes(Math.Max(1, _options.TickMinutes)) + ); + + do + { + try + { + await TickAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка тика планировщика"); + } + } while (await timer.WaitForNextTickAsync(stoppingToken)); + } + + private async Task TickAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var generator = scope.ServiceProvider.GetRequiredService(); + + // Эфир строит только шаблон сетки: канал без шаблона вещать не может и молча пропускается. + var channelIds = await db + .Channels.Where(c => c.IsEnabled && c.TemplateId != null) + .Select(c => c.Id) + .ToListAsync(cancellationToken); + + var now = DateTimeOffset.UtcNow; + foreach (var channelId in channelIds) + { + var report = await generator.GenerateAsync( + channelId, + now, + rebuildFuture: false, + cancellationToken + ); + + if (report.Added > 0) + logger.LogInformation( + "Канал {ChannelId}: добавлено {Count} записей расписания", + channelId, + report.Added + ); + + foreach (var warning in report.Warnings) + logger.LogWarning( + "Канал {ChannelId}, слот {SlotId}: {Warning} — {Details}", + channelId, + warning.SlotId, + warning.Kind, + warning.Details + ); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index 33774d9..bc53f66 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -1,167 +1,180 @@ -using System.Text; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.IdentityModel.Tokens; -using TeleWave.Application.Broadcast.Bumpers; -using TeleWave.Application.Broadcast.Scheduling; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Streaming; -using TeleWave.Domain.Broadcast.Scheduling; -using TeleWave.Infrastructure.Broadcast; -using TeleWave.Infrastructure.Identity; -using TeleWave.Infrastructure.Media; -using TeleWave.Infrastructure.Metadata; -using TeleWave.Infrastructure.Persistence; -using TeleWave.Infrastructure.Settings; -using TeleWave.Infrastructure.Streaming; - -namespace TeleWave.Infrastructure; - -/// -/// Регистрация сервисов инфраструктуры: EF Core (PostgreSQL), Identity/JWT-аутентификация. -/// -public static class DependencyInjection -{ - public static IServiceCollection AddInfrastructure( - this IServiceCollection services, - IConfiguration configuration - ) - { - services.AddDbContext(options => - options.UseNpgsql( - configuration["ConnectionStrings:Default"] - ?? throw new InvalidOperationException( - "Строка подключения 'ConnectionStrings:Default' не сконфигурирована." - ) - ) - ); - services.AddScoped(sp => sp.GetRequiredService()); - - services - .AddIdentityCore(options => - { - options.User.RequireUniqueEmail = false; - options.Password.RequiredLength = 8; - options.Password.RequireDigit = true; - options.Password.RequireUppercase = true; - options.Password.RequireNonAlphanumeric = false; - options.Lockout.MaxFailedAccessAttempts = 5; - options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); - options.Lockout.AllowedForNewUsers = true; - }) - .AddRoles() - .AddEntityFrameworkStores() - .AddSignInManager() - .AddDefaultTokenProviders(); - - services.Configure(configuration.GetSection(JwtOptions.SectionName)); - services.Configure( - configuration.GetSection(AdminSeedOptions.SectionName) - ); - - var jwtOptions = - configuration.GetSection(JwtOptions.SectionName).Get() - ?? throw new InvalidOperationException("Секция конфигурации 'Jwt' не задана."); - - // Fail-fast на подписывающем ключе: этот же ключ подписывает и JWT, и stream-токены - // (StreamTokenService), поэтому placeholder/короткий ключ из appsettings.json = полный обход - // авторизации (можно сфорджить admin-JWT). Лучше не стартовать вовсе, чем стартовать уязвимым. - // HMAC-SHA256 требует ключ не короче размера хеша (32 байта), иначе он ослаблен нулевым паддингом. - if (Encoding.UTF8.GetByteCount(jwtOptions.SigningKey) < 32) - throw new InvalidOperationException( - "Jwt:SigningKey должен быть не короче 32 байт. Задайте криптостойкий секрет через конфигурацию/переменную окружения Jwt__SigningKey." - ); - if (jwtOptions.SigningKey.Contains("change-me", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException( - "Jwt:SigningKey использует значение-заглушку из appsettings.json. Переопределите его криптостойким секретом (Jwt__SigningKey)." - ); - - services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = jwtOptions.Issuer, - ValidateAudience = true, - ValidAudience = jwtOptions.Audience, - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey( - Encoding.UTF8.GetBytes(jwtOptions.SigningKey) - ), - ValidateLifetime = true, - ClockSkew = TimeSpan.FromSeconds(30), - }; - }); - - services.AddAuthorization(); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - AddMedia(services, configuration); - AddBroadcast(services, configuration); - AddMetadata(services, configuration); - - return services; - } - - /// Метаданные шоу/серий: провайдеры TMDb/OMDb, резолвер, локальное хранилище картинок. - private static void AddMetadata(IServiceCollection services, IConfiguration configuration) - { - services.Configure(configuration.GetSection(MetadataOptions.SectionName)); - services.AddHttpClient("metadata", client => client.Timeout = TimeSpan.FromSeconds(15)); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - } - - /// Планировщик расписания: генератор, источник случайности и фоновый сервис горизонта. - private static void AddBroadcast(IServiceCollection services, IConfiguration configuration) - { - services.Configure( - configuration.GetSection(SchedulerOptions.SectionName) - ); - services.Configure( - configuration.GetSection(StreamingOptions.SectionName) - ); - services.Configure(configuration.GetSection(BumperOptions.SectionName)); - - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddHostedService(); - } - - /// Хранилище медиа, обработка ffmpeg, очередь и фоновые сервисы (очередь + inbox-сканер). - private static void AddMedia(IServiceCollection services, IConfiguration configuration) - { - services.Configure(configuration.GetSection(StorageOptions.SectionName)); - services.Configure(configuration.GetSection(MediaOptions.SectionName)); - - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - services.AddHostedService(); - services.AddHostedService(); - services.AddHostedService(); - } -} +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using TeleWave.Application.Broadcast.Bumpers; +using TeleWave.Application.Broadcast.Scheduling; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Library.Genres; +using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Planning; +using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Streaming; +using TeleWave.Domain.Broadcast.Scheduling; +using TeleWave.Infrastructure.Broadcast; +using TeleWave.Infrastructure.Identity; +using TeleWave.Infrastructure.Library; +using TeleWave.Infrastructure.Media; +using TeleWave.Infrastructure.Metadata; +using TeleWave.Infrastructure.Persistence; +using TeleWave.Infrastructure.Settings; +using TeleWave.Infrastructure.Streaming; + +namespace TeleWave.Infrastructure; + +/// +/// Регистрация сервисов инфраструктуры: EF Core (PostgreSQL), Identity/JWT-аутентификация. +/// +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration + ) + { + services.AddDbContext(options => + options.UseNpgsql( + configuration["ConnectionStrings:Default"] + ?? throw new InvalidOperationException( + "Строка подключения 'ConnectionStrings:Default' не сконфигурирована." + ) + ) + ); + services.AddScoped(sp => sp.GetRequiredService()); + + services + .AddIdentityCore(options => + { + options.User.RequireUniqueEmail = false; + options.Password.RequiredLength = 8; + options.Password.RequireDigit = true; + options.Password.RequireUppercase = true; + options.Password.RequireNonAlphanumeric = false; + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); + options.Lockout.AllowedForNewUsers = true; + }) + .AddRoles() + .AddEntityFrameworkStores() + .AddSignInManager() + .AddDefaultTokenProviders(); + + services.Configure(configuration.GetSection(JwtOptions.SectionName)); + services.Configure( + configuration.GetSection(AdminSeedOptions.SectionName) + ); + + var jwtOptions = + configuration.GetSection(JwtOptions.SectionName).Get() + ?? throw new InvalidOperationException("Секция конфигурации 'Jwt' не задана."); + + // Fail-fast на подписывающем ключе: этот же ключ подписывает и JWT, и stream-токены + // (StreamTokenService), поэтому placeholder/короткий ключ из appsettings.json = полный обход + // авторизации (можно сфорджить admin-JWT). Лучше не стартовать вовсе, чем стартовать уязвимым. + // HMAC-SHA256 требует ключ не короче размера хеша (32 байта), иначе он ослаблен нулевым паддингом. + if (Encoding.UTF8.GetByteCount(jwtOptions.SigningKey) < 32) + throw new InvalidOperationException( + "Jwt:SigningKey должен быть не короче 32 байт. Задайте криптостойкий секрет через конфигурацию/переменную окружения Jwt__SigningKey." + ); + if (jwtOptions.SigningKey.Contains("change-me", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + "Jwt:SigningKey использует значение-заглушку из appsettings.json. Переопределите его криптостойким секретом (Jwt__SigningKey)." + ); + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtOptions.Issuer, + ValidateAudience = true, + ValidAudience = jwtOptions.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(jwtOptions.SigningKey) + ), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30), + }; + }); + + services.AddAuthorization(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + AddMedia(services, configuration); + AddBroadcast(services, configuration); + AddMetadata(services, configuration); + + return services; + } + + /// Метаданные шоу/серий: провайдеры TMDb/OMDb, резолвер, локальное хранилище картинок. + private static void AddMetadata(IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(MetadataOptions.SectionName)); + services.AddHttpClient("metadata", client => client.Timeout = TimeSpan.FromSeconds(15)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + /// Планировщик расписания: генератор, источник случайности и фоновый сервис горизонта. + private static void AddBroadcast(IServiceCollection services, IConfiguration configuration) + { + services.Configure( + configuration.GetSection(SchedulerOptions.SectionName) + ); + services.Configure( + configuration.GetSection(StreamingOptions.SectionName) + ); + services.Configure(configuration.GetSection(BumperOptions.SectionName)); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + services.AddHostedService(); + } + + /// Хранилище медиа, обработка ffmpeg, очередь и фоновые сервисы (очередь + inbox-сканер). + private static void AddMedia(IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(StorageOptions.SectionName)); + services.Configure(configuration.GetSection(MediaOptions.SectionName)); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Identity/DbInitializerExtensions.cs b/backend/src/TeleWave.Infrastructure/Identity/DbInitializerExtensions.cs index 8f05a99..73e4e2f 100644 --- a/backend/src/TeleWave.Infrastructure/Identity/DbInitializerExtensions.cs +++ b/backend/src/TeleWave.Infrastructure/Identity/DbInitializerExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using TeleWave.Infrastructure.Library; namespace TeleWave.Infrastructure.Identity; @@ -10,7 +11,11 @@ public static class DbInitializerExtensions ) { await using var scope = services.CreateAsyncScope(); + var initializer = scope.ServiceProvider.GetRequiredService(); await initializer.SeedAsync(cancellationToken); + + var genreSeeder = scope.ServiceProvider.GetRequiredService(); + await genreSeeder.SeedAsync(cancellationToken); } } diff --git a/backend/src/TeleWave.Infrastructure/Library/GenreSeeder.cs b/backend/src/TeleWave.Infrastructure/Library/GenreSeeder.cs new file mode 100644 index 0000000..a81e415 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Library/GenreSeeder.cs @@ -0,0 +1,111 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using TeleWave.Domain.Library; +using TeleWave.Infrastructure.Persistence; + +namespace TeleWave.Infrastructure.Library; + +/// +/// Идемпотентный сидинг справочника жанров. Жанр опознаётся по , поэтому +/// переименование администратором сид переживает; недостающие псевдонимы дописываются и к уже +/// существующим жанрам — так новые варианты написания приезжают с обновлением приложения. +/// +/// Псевдоним, уже занятый другим жанром, пропускается: уникальность псевдонима — условие +/// однозначного сопоставления метаданных, и ручная правка администратора имеет приоритет над сидом. +/// +public sealed class GenreSeeder(AppDbContext dbContext, ILogger logger) +{ + private sealed record GenreSeed(string Slug, string Name, string[] Aliases); + + // Порядок массива задаёт SortOrder — так список в UI выглядит осмысленно, а не по алфавиту. + // В псевдонимах: идентификаторы TMDb (точное сопоставление) и имена от TMDb/OMDb (запасное). + private static readonly GenreSeed[] Seeds = + [ + new("action", "Боевик", ["tmdb:28", "tmdb:10759", "action", "action & adventure"]), + new("adventure", "Приключения", ["tmdb:12", "adventure"]), + new("animation", "Мультфильм", ["tmdb:16", "animation", "анимация"]), + new("comedy", "Комедия", ["tmdb:35", "comedy"]), + new("crime", "Криминал", ["tmdb:80", "crime"]), + new("drama", "Драма", ["tmdb:18", "drama"]), + new("thriller", "Триллер", ["tmdb:53", "thriller"]), + new("horror", "Ужасы", ["tmdb:27", "horror"]), + new("sci-fi", "Фантастика", ["tmdb:878", "tmdb:10765", "science fiction", "sci-fi"]), + new("fantasy", "Фэнтези", ["tmdb:14", "fantasy"]), + new("mystery", "Детектив", ["tmdb:9648", "mystery"]), + new("romance", "Мелодрама", ["tmdb:10749", "romance", "романтика"]), + new("family", "Семейный", ["tmdb:10751", "family"]), + new("kids", "Детский", ["tmdb:10762", "kids"]), + new("music", "Музыкальный", ["tmdb:10402", "music", "musical"]), + new("history", "Исторический", ["tmdb:36", "history"]), + new("war", "Военный", ["tmdb:10752", "tmdb:10768", "war", "war & politics"]), + new("western", "Вестерн", ["tmdb:37", "western"]), + new("documentary", "Документальный", ["tmdb:99", "documentary"]), + new("biography", "Биография", ["biography"]), + new("sport", "Спорт", ["sport"]), + new("news", "Новости", ["tmdb:10763", "news"]), + new("reality", "Реалити", ["tmdb:10764", "reality"]), + new("talk-show", "Ток-шоу", ["tmdb:10767", "talk", "talk-show"]), + new("soap", "Мыльная опера", ["tmdb:10766", "soap"]), + new("tv-movie", "Телефильм", ["tmdb:10770", "tv movie"]), + new("short", "Короткометражка", ["short"]), + ]; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + var existing = await dbContext + .Genres.Include(g => g.Aliases) + .ToDictionaryAsync(g => g.Slug, cancellationToken); + + var takenAliases = existing + .Values.SelectMany(g => g.Aliases.Select(a => new { a.Value, g.Slug })) + .ToDictionary(x => x.Value, x => x.Slug); + + var createdGenres = 0; + var createdAliases = 0; + + for (var i = 0; i < Seeds.Length; i++) + { + var seed = Seeds[i]; + if (!existing.TryGetValue(seed.Slug, out var genre)) + { + genre = Genre.Create(seed.Name, seed.Slug, i, isSystem: true); + dbContext.Genres.Add(genre); + existing[seed.Slug] = genre; + createdGenres++; + } + + // Само название и slug тоже годятся как варианты написания — провайдеры отдают то одно, то другое. + foreach (var alias in seed.Aliases.Append(seed.Slug).Append(seed.Name)) + { + var normalized = GenreAlias.Normalize(alias); + if (normalized.Length == 0) + continue; + + if (takenAliases.TryGetValue(normalized, out var owner)) + { + if (owner != seed.Slug) + logger.LogDebug( + "Genre alias '{Alias}' already belongs to '{Owner}' — skipped", + normalized, + owner + ); + continue; + } + + genre.AddAlias(normalized); + takenAliases[normalized] = seed.Slug; + createdAliases++; + } + } + + if (createdGenres == 0 && createdAliases == 0) + return; + + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation( + "Seeded genres: {Genres} new, {Aliases} new aliases", + createdGenres, + createdAliases + ); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs index 3956cc0..a210725 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs @@ -62,10 +62,19 @@ public sealed class OmdbMetadataProvider( Clean(GetString(root, "Title")) ?? "—", YearFrom(GetString(root, "Year")), Clean(GetString(root, "Plot")), - Clean(GetString(root, "Poster")) + Clean(GetString(root, "Poster")), + GenresFrom(Clean(GetString(root, "Genre"))) ); } + /// OMDb отдаёт жанры одной строкой через запятую: «Action, Sci-Fi». + private static List GenresFrom(string? value) => + string.IsNullOrWhiteSpace(value) + ? [] + : value + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + public async Task GetEpisodeAsync( string externalId, int season, diff --git a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs index 7f529ff..0dd580c 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs @@ -66,10 +66,32 @@ public sealed class TmdbMetadataProvider( GetString(root, "name") ?? "—", YearFrom(GetString(root, "first_air_date")), GetString(root, "overview"), - PosterUrl(GetString(root, "poster_path")) + PosterUrl(GetString(root, "poster_path")), + GenresFrom(root) ); } + /// + /// Жанры из ответа TMDb в порядке источника: на каждый — сначала идентификатор + /// (tmdb:28), затем локализованное название. Идентификатор точнее, название — запасной + /// вариант, если справочник его ещё не знает. + /// + private static List GenresFrom(JsonElement root) + { + var genres = new List(); + if (!root.TryGetProperty("genres", out var array) || array.ValueKind != JsonValueKind.Array) + return genres; + + foreach (var item in array.EnumerateArray()) + { + if (GetInt(item, "id") is { } id) + genres.Add($"tmdb:{id.ToString(CultureInfo.InvariantCulture)}"); + if (GetString(item, "name") is { Length: > 0 } name) + genres.Add(name); + } + return genres; + } + public async Task GetEpisodeAsync( string externalId, int season, diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726012618_AddGenres.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726012618_AddGenres.Designer.cs new file mode 100644 index 0000000..c44c2b7 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726012618_AddGenres.Designer.cs @@ -0,0 +1,1130 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260726012618_AddGenres")] + partial class AddGenres + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperEpisodeChangeChance") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumperShowChangeChance") + .HasColumnType("double precision"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("PreferredWeightMultiplier") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelShowId") + .HasColumnType("uuid"); + + b.Property("EndHour") + .HasColumnType("integer"); + + b.Property("StartHour") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelShowId"); + + b.ToTable("ChannelShowHour"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("EndMinute") + .HasColumnType("integer"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Recurrence") + .HasColumnType("integer"); + + b.Property("StartMinute") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "ShowId"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ChannelShow", null) + .WithMany("PreferredHours") + .HasForeignKey("ChannelShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("BumperTemplates"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Navigation("PreferredHours"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726012618_AddGenres.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726012618_AddGenres.cs new file mode 100644 index 0000000..77d9915 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726012618_AddGenres.cs @@ -0,0 +1,110 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class AddGenres : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Genres", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Slug = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + IsSystem = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Genres", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "GenreAliases", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + GenreId = table.Column(type: "uuid", nullable: false), + Value = table.Column(type: "character varying(128)", maxLength: 128, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GenreAliases", x => x.Id); + table.ForeignKey( + name: "FK_GenreAliases_Genres_GenreId", + column: x => x.GenreId, + principalTable: "Genres", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ShowGenres", + columns: table => new + { + ShowId = table.Column(type: "uuid", nullable: false), + GenreId = table.Column(type: "uuid", nullable: false), + IsPrimary = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ShowGenres", x => new { x.ShowId, x.GenreId }); + table.ForeignKey( + name: "FK_ShowGenres_Genres_GenreId", + column: x => x.GenreId, + principalTable: "Genres", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ShowGenres_Shows_ShowId", + column: x => x.ShowId, + principalTable: "Shows", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GenreAliases_GenreId", + table: "GenreAliases", + column: "GenreId"); + + migrationBuilder.CreateIndex( + name: "IX_GenreAliases_Value", + table: "GenreAliases", + column: "Value", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Genres_Slug", + table: "Genres", + column: "Slug", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ShowGenres_GenreId", + table: "ShowGenres", + column: "GenreId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GenreAliases"); + + migrationBuilder.DropTable( + name: "ShowGenres"); + + migrationBuilder.DropTable( + name: "Genres"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726013755_ShowAudienceScale.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726013755_ShowAudienceScale.Designer.cs new file mode 100644 index 0000000..f93c6b9 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726013755_ShowAudienceScale.Designer.cs @@ -0,0 +1,1130 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260726013755_ShowAudienceScale")] + partial class ShowAudienceScale + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperEpisodeChangeChance") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumperShowChangeChance") + .HasColumnType("double precision"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("PreferredWeightMultiplier") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelShowId") + .HasColumnType("uuid"); + + b.Property("EndHour") + .HasColumnType("integer"); + + b.Property("StartHour") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelShowId"); + + b.ToTable("ChannelShowHour"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("EndMinute") + .HasColumnType("integer"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Recurrence") + .HasColumnType("integer"); + + b.Property("StartMinute") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "ShowId"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ChannelShow", null) + .WithMany("PreferredHours") + .HasForeignKey("ChannelShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("BumperTemplates"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Navigation("PreferredHours"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726013755_ShowAudienceScale.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726013755_ShowAudienceScale.cs new file mode 100644 index 0000000..e1aab05 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726013755_ShowAudienceScale.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + /// Переход возрастной категории на шкалу, упорядоченную по возрастанию строгости: + /// Kids(0) → Family(1) → Teen(2) → General(3) → Adult(4). Прежние значения (General=0, Kids=1, + /// Adult=2) отсортированы не были, а правила планировщика формулируются как «не строже X», + /// поэтому порядок стал значимым. + /// + public partial class ShowAudienceScale : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Одним CASE, а не серией UPDATE: последовательные обновления перепутали бы категории + // между собой (сначала General 0 → 3, а следом это же значение попало бы под правило + // для старого Adult). + migrationBuilder.Sql( + """ + UPDATE "Shows" SET "Audience" = CASE "Audience" + WHEN 0 THEN 3 + WHEN 1 THEN 0 + WHEN 2 THEN 4 + ELSE "Audience" + END; + """ + ); + + // Дефолт остался от первого добавления столбца и теперь означал бы «детское». + // В модели значения по умолчанию нет — снимаем и в схеме. + migrationBuilder.Sql("""ALTER TABLE "Shows" ALTER COLUMN "Audience" DROP DEFAULT;"""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Family и Teen в старой шкале не существуют — схлопываются в General. + migrationBuilder.Sql( + """ + UPDATE "Shows" SET "Audience" = CASE "Audience" + WHEN 0 THEN 1 + WHEN 4 THEN 2 + ELSE 0 + END; + """ + ); + + migrationBuilder.Sql("""ALTER TABLE "Shows" ALTER COLUMN "Audience" SET DEFAULT 0;"""); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726014301_AddCollections.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726014301_AddCollections.Designer.cs new file mode 100644 index 0000000..e0f03ee --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726014301_AddCollections.Designer.cs @@ -0,0 +1,1201 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260726014301_AddCollections")] + partial class AddCollections + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperEpisodeChangeChance") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumperShowChangeChance") + .HasColumnType("double precision"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("PreferredWeightMultiplier") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelShowId") + .HasColumnType("uuid"); + + b.Property("EndHour") + .HasColumnType("integer"); + + b.Property("StartHour") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelShowId"); + + b.ToTable("ChannelShowHour"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("EndMinute") + .HasColumnType("integer"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Recurrence") + .HasColumnType("integer"); + + b.Property("StartMinute") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "ShowId"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.HasIndex("CollectionId", "Position"); + + b.HasIndex("CollectionId", "ShowId") + .IsUnique(); + + b.ToTable("CollectionItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ChannelShow", null) + .WithMany("PreferredHours") + .HasForeignKey("ChannelShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("BumperTemplates"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Navigation("PreferredHours"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726014301_AddCollections.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726014301_AddCollections.cs new file mode 100644 index 0000000..bbd3d69 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726014301_AddCollections.cs @@ -0,0 +1,82 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class AddCollections : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Collections", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Description = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + PosterImageId = table.Column(type: "uuid", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Collections", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CollectionItems", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CollectionId = table.Column(type: "uuid", nullable: false), + ShowId = table.Column(type: "uuid", nullable: false), + Position = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CollectionItems", x => x.Id); + table.ForeignKey( + name: "FK_CollectionItems_Collections_CollectionId", + column: x => x.CollectionId, + principalTable: "Collections", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CollectionItems_Shows_ShowId", + column: x => x.ShowId, + principalTable: "Shows", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CollectionItems_CollectionId_Position", + table: "CollectionItems", + columns: new[] { "CollectionId", "Position" }); + + migrationBuilder.CreateIndex( + name: "IX_CollectionItems_CollectionId_ShowId", + table: "CollectionItems", + columns: new[] { "CollectionId", "ShowId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CollectionItems_ShowId", + table: "CollectionItems", + column: "ShowId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CollectionItems"); + + migrationBuilder.DropTable( + name: "Collections"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726014922_AddGroups.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726014922_AddGroups.Designer.cs new file mode 100644 index 0000000..1b70e97 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726014922_AddGroups.Designer.cs @@ -0,0 +1,1284 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260726014922_AddGroups")] + partial class AddGroups + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperEpisodeChangeChance") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumperShowChangeChance") + .HasColumnType("double precision"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("PreferredWeightMultiplier") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelShowId") + .HasColumnType("uuid"); + + b.Property("EndHour") + .HasColumnType("integer"); + + b.Property("StartHour") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelShowId"); + + b.ToTable("ChannelShowHour"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("EndMinute") + .HasColumnType("integer"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Recurrence") + .HasColumnType("integer"); + + b.Property("StartMinute") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "ShowId"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.HasIndex("CollectionId", "Position"); + + b.HasIndex("CollectionId", "ShowId") + .IsUnique(); + + b.ToTable("CollectionItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ChannelShow", null) + .WithMany("PreferredHours") + .HasForeignKey("ChannelShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("BumperTemplates"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Navigation("PreferredHours"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726014922_AddGroups.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726014922_AddGroups.cs new file mode 100644 index 0000000..b847fc9 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726014922_AddGroups.cs @@ -0,0 +1,82 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class AddGroups : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Groups", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Description = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + FilterJson = table.Column(type: "jsonb", nullable: true), + ItemCount = table.Column(type: "integer", nullable: false), + UnitCount = table.Column(type: "integer", nullable: false), + TotalDuration = table.Column(type: "interval", nullable: false), + StatsComputedAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Groups", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "GroupItems", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + GroupId = table.Column(type: "uuid", nullable: false), + ElementKind = table.Column(type: "integer", nullable: false), + ElementId = table.Column(type: "uuid", nullable: false), + Weight = table.Column(type: "integer", nullable: false), + Position = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GroupItems", x => x.Id); + table.ForeignKey( + name: "FK_GroupItems_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GroupItems_ElementKind_ElementId", + table: "GroupItems", + columns: new[] { "ElementKind", "ElementId" }); + + migrationBuilder.CreateIndex( + name: "IX_GroupItems_GroupId_ElementKind_ElementId", + table: "GroupItems", + columns: new[] { "GroupId", "ElementKind", "ElementId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_GroupItems_GroupId_Position", + table: "GroupItems", + columns: new[] { "GroupId", "Position" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GroupItems"); + + migrationBuilder.DropTable( + name: "Groups"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726020225_AddScheduleTemplate.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726020225_AddScheduleTemplate.Designer.cs new file mode 100644 index 0000000..ce4a474 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726020225_AddScheduleTemplate.Designer.cs @@ -0,0 +1,1489 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260726020225_AddScheduleTemplate")] + partial class AddScheduleTemplate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperEpisodeChangeChance") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumperShowChangeChance") + .HasColumnType("double precision"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DayStartTime") + .HasColumnType("time without time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UtcOffsetMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique() + .HasFilter("\"Number\" IS NOT NULL"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("PreferredWeightMultiplier") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelShowId") + .HasColumnType("uuid"); + + b.Property("EndHour") + .HasColumnType("integer"); + + b.Property("StartHour") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelShowId"); + + b.ToTable("ChannelShowHour"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("EndMinute") + .HasColumnType("integer"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Recurrence") + .HasColumnType("integer"); + + b.Property("StartMinute") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "ShowId"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.HasIndex("CollectionId", "Position"); + + b.HasIndex("CollectionId", "ShowId") + .IsUnique(); + + b.ToTable("CollectionItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicabilityJson") + .HasColumnType("jsonb"); + + b.Property("IsBackground") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Priority"); + + b.ToTable("GridLayers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FallbackGroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("ScheduleTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("Daypart") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAnchor") + .HasColumnType("boolean"); + + b.Property("LayerId") + .HasColumnType("uuid"); + + b.Property("MaxDriftMinutes") + .HasColumnType("integer"); + + b.Property("OverflowPolicy") + .HasColumnType("integer"); + + b.Property("RepeatSourceJson") + .HasColumnType("jsonb"); + + b.Property("SlotKind") + .HasColumnType("integer"); + + b.Property("SnapToMinutes") + .HasColumnType("integer"); + + b.Property("StrategyJson") + .HasColumnType("jsonb"); + + b.Property("TargetDurationMinutes") + .HasColumnType("integer"); + + b.Property("TargetStart") + .HasColumnType("time without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("LayerId", "TargetStart"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("CurrentElementId") + .HasColumnType("uuid"); + + b.Property("CurrentElementKind") + .HasColumnType("integer"); + + b.Property("NextUnitIndex") + .HasColumnType("integer"); + + b.HasKey("SlotId"); + + b.ToTable("SlotStates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ChannelShow", null) + .WithMany("PreferredHours") + .HasForeignKey("ChannelShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("BumperTemplates"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Navigation("PreferredHours"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726020225_AddScheduleTemplate.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726020225_AddScheduleTemplate.cs new file mode 100644 index 0000000..05b9522 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726020225_AddScheduleTemplate.cs @@ -0,0 +1,203 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class AddScheduleTemplate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DayStartTime", + table: "Channels", + type: "time without time zone", + nullable: false, + defaultValue: new TimeOnly(0, 0, 0)); + + migrationBuilder.AddColumn( + name: "Number", + table: "Channels", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "TemplateId", + table: "Channels", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "UtcOffsetMinutes", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "ScheduleTemplates", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChannelId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + FallbackGroupId = table.Column(type: "uuid", nullable: true), + Revision = table.Column(type: "integer", nullable: false), + AppliedRevision = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ScheduleTemplates", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "GridLayers", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TemplateId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Priority = table.Column(type: "integer", nullable: false), + ApplicabilityJson = table.Column(type: "jsonb", nullable: true), + IsEnabled = table.Column(type: "boolean", nullable: false), + IsBackground = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GridLayers", x => x.Id); + table.ForeignKey( + name: "FK_GridLayers_ScheduleTemplates_TemplateId", + column: x => x.TemplateId, + principalTable: "ScheduleTemplates", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Slots", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + LayerId = table.Column(type: "uuid", nullable: false), + Weekday = table.Column(type: "integer", nullable: true), + TargetStart = table.Column(type: "time without time zone", nullable: false), + TargetDurationMinutes = table.Column(type: "integer", nullable: false), + Title = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Daypart = table.Column(type: "integer", nullable: false), + SlotKind = table.Column(type: "integer", nullable: false), + GroupId = table.Column(type: "uuid", nullable: true), + StrategyJson = table.Column(type: "jsonb", nullable: true), + RepeatSourceJson = table.Column(type: "jsonb", nullable: true), + BlockMode = table.Column(type: "integer", nullable: false), + BlockValue = table.Column(type: "integer", nullable: false), + OverflowPolicy = table.Column(type: "integer", nullable: false), + IsAnchor = table.Column(type: "boolean", nullable: false), + MaxDriftMinutes = table.Column(type: "integer", nullable: false), + SnapToMinutes = table.Column(type: "integer", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Slots", x => x.Id); + table.ForeignKey( + name: "FK_Slots_GridLayers_LayerId", + column: x => x.LayerId, + principalTable: "GridLayers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Slots_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "SlotStates", + columns: table => new + { + SlotId = table.Column(type: "uuid", nullable: false), + CurrentElementKind = table.Column(type: "integer", nullable: true), + CurrentElementId = table.Column(type: "uuid", nullable: true), + NextUnitIndex = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SlotStates", x => x.SlotId); + table.ForeignKey( + name: "FK_SlotStates_Slots_SlotId", + column: x => x.SlotId, + principalTable: "Slots", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Channels_Number", + table: "Channels", + column: "Number", + unique: true, + filter: "\"Number\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_GridLayers_TemplateId_Priority", + table: "GridLayers", + columns: new[] { "TemplateId", "Priority" }); + + migrationBuilder.CreateIndex( + name: "IX_ScheduleTemplates_ChannelId", + table: "ScheduleTemplates", + column: "ChannelId"); + + migrationBuilder.CreateIndex( + name: "IX_Slots_GroupId", + table: "Slots", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_Slots_LayerId_TargetStart", + table: "Slots", + columns: new[] { "LayerId", "TargetStart" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SlotStates"); + + migrationBuilder.DropTable( + name: "Slots"); + + migrationBuilder.DropTable( + name: "GridLayers"); + + migrationBuilder.DropTable( + name: "ScheduleTemplates"); + + migrationBuilder.DropIndex( + name: "IX_Channels_Number", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "DayStartTime", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "Number", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "TemplateId", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "UtcOffsetMinutes", + table: "Channels"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726072637_ScheduleEntryTrace.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726072637_ScheduleEntryTrace.Designer.cs new file mode 100644 index 0000000..eb68f81 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726072637_ScheduleEntryTrace.Designer.cs @@ -0,0 +1,1495 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260726072637_ScheduleEntryTrace")] + partial class ScheduleEntryTrace + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperEpisodeChangeChance") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumperShowChangeChance") + .HasColumnType("double precision"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DayStartTime") + .HasColumnType("time without time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UtcOffsetMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique() + .HasFilter("\"Number\" IS NOT NULL"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("PreferredWeightMultiplier") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelShowId") + .HasColumnType("uuid"); + + b.Property("EndHour") + .HasColumnType("integer"); + + b.Property("StartHour") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelShowId"); + + b.ToTable("ChannelShowHour"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("EndMinute") + .HasColumnType("integer"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Recurrence") + .HasColumnType("integer"); + + b.Property("StartMinute") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TraceJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.HasIndex("ChannelId", "ShowId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.HasIndex("CollectionId", "Position"); + + b.HasIndex("CollectionId", "ShowId") + .IsUnique(); + + b.ToTable("CollectionItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicabilityJson") + .HasColumnType("jsonb"); + + b.Property("IsBackground") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Priority"); + + b.ToTable("GridLayers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FallbackGroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("ScheduleTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("Daypart") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAnchor") + .HasColumnType("boolean"); + + b.Property("LayerId") + .HasColumnType("uuid"); + + b.Property("MaxDriftMinutes") + .HasColumnType("integer"); + + b.Property("OverflowPolicy") + .HasColumnType("integer"); + + b.Property("RepeatSourceJson") + .HasColumnType("jsonb"); + + b.Property("SlotKind") + .HasColumnType("integer"); + + b.Property("SnapToMinutes") + .HasColumnType("integer"); + + b.Property("StrategyJson") + .HasColumnType("jsonb"); + + b.Property("TargetDurationMinutes") + .HasColumnType("integer"); + + b.Property("TargetStart") + .HasColumnType("time without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("LayerId", "TargetStart"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("CurrentElementId") + .HasColumnType("uuid"); + + b.Property("CurrentElementKind") + .HasColumnType("integer"); + + b.Property("NextUnitIndex") + .HasColumnType("integer"); + + b.HasKey("SlotId"); + + b.ToTable("SlotStates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ChannelShow", null) + .WithMany("PreferredHours") + .HasForeignKey("ChannelShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("BumperTemplates"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Navigation("PreferredHours"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726072637_ScheduleEntryTrace.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726072637_ScheduleEntryTrace.cs new file mode 100644 index 0000000..72bcfaf --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726072637_ScheduleEntryTrace.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class ScheduleEntryTrace : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ScheduleEntries_ChannelId_ShowId", + table: "ScheduleEntries"); + + migrationBuilder.AddColumn( + name: "SlotId", + table: "ScheduleEntries", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "TraceJson", + table: "ScheduleEntries", + type: "jsonb", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_ScheduleEntries_ChannelId_ShowId_StartsAtUtc", + table: "ScheduleEntries", + columns: new[] { "ChannelId", "ShowId", "StartsAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ScheduleEntries_ChannelId_ShowId_StartsAtUtc", + table: "ScheduleEntries"); + + migrationBuilder.DropColumn( + name: "SlotId", + table: "ScheduleEntries"); + + migrationBuilder.DropColumn( + name: "TraceJson", + table: "ScheduleEntries"); + + migrationBuilder.CreateIndex( + name: "IX_ScheduleEntries_ChannelId_ShowId", + table: "ScheduleEntries", + columns: new[] { "ChannelId", "ShowId" }); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726100547_DropLegacyRotation.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726100547_DropLegacyRotation.Designer.cs new file mode 100644 index 0000000..fea42c1 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726100547_DropLegacyRotation.Designer.cs @@ -0,0 +1,1288 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260726100547_DropLegacyRotation")] + partial class DropLegacyRotation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperEpisodeChangeChance") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumperShowChangeChance") + .HasColumnType("double precision"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DayStartTime") + .HasColumnType("time without time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UtcOffsetMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique() + .HasFilter("\"Number\" IS NOT NULL"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TraceJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.HasIndex("ChannelId", "ShowId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.HasIndex("CollectionId", "Position"); + + b.HasIndex("CollectionId", "ShowId") + .IsUnique(); + + b.ToTable("CollectionItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicabilityJson") + .HasColumnType("jsonb"); + + b.Property("IsBackground") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Priority"); + + b.ToTable("GridLayers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FallbackGroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("ScheduleTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("Daypart") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAnchor") + .HasColumnType("boolean"); + + b.Property("LayerId") + .HasColumnType("uuid"); + + b.Property("MaxDriftMinutes") + .HasColumnType("integer"); + + b.Property("OverflowPolicy") + .HasColumnType("integer"); + + b.Property("RepeatSourceJson") + .HasColumnType("jsonb"); + + b.Property("SlotKind") + .HasColumnType("integer"); + + b.Property("SnapToMinutes") + .HasColumnType("integer"); + + b.Property("StrategyJson") + .HasColumnType("jsonb"); + + b.Property("TargetDurationMinutes") + .HasColumnType("integer"); + + b.Property("TargetStart") + .HasColumnType("time without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("LayerId", "TargetStart"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("CurrentElementId") + .HasColumnType("uuid"); + + b.Property("CurrentElementKind") + .HasColumnType("integer"); + + b.Property("NextUnitIndex") + .HasColumnType("integer"); + + b.HasKey("SlotId"); + + b.ToTable("SlotStates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("BumperTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726100547_DropLegacyRotation.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726100547_DropLegacyRotation.cs new file mode 100644 index 0000000..2636fa0 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726100547_DropLegacyRotation.cs @@ -0,0 +1,202 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class DropLegacyRotation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChannelAd"); + + migrationBuilder.DropTable( + name: "ChannelShowHour"); + + migrationBuilder.DropTable( + name: "OverrideShow"); + + migrationBuilder.DropTable( + name: "ChannelShow"); + + migrationBuilder.DropTable( + name: "ProgrammingOverride"); + + migrationBuilder.DropColumn( + name: "AdInsertion", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "AdsPerBreak", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "NextAdIndex", + table: "Channels"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AdInsertion", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "AdsPerBreak", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "NextAdIndex", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "ChannelAd", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChannelId = table.Column(type: "uuid", nullable: false), + MediaAssetId = table.Column(type: "uuid", nullable: false), + Position = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChannelAd", x => x.Id); + table.ForeignKey( + name: "FK_ChannelAd_Channels_ChannelId", + column: x => x.ChannelId, + principalTable: "Channels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ChannelShow", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + BlockMode = table.Column(type: "integer", nullable: false), + BlockValue = table.Column(type: "integer", nullable: false), + ChannelId = table.Column(type: "uuid", nullable: false), + IsEnabled = table.Column(type: "boolean", nullable: false), + NextEpisodeIndex = table.Column(type: "integer", nullable: false), + PreferredWeightMultiplier = table.Column(type: "integer", nullable: false, defaultValue: 3), + ShowId = table.Column(type: "uuid", nullable: false), + Weight = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChannelShow", x => x.Id); + table.ForeignKey( + name: "FK_ChannelShow_Channels_ChannelId", + column: x => x.ChannelId, + principalTable: "Channels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ProgrammingOverride", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChannelId = table.Column(type: "uuid", nullable: false), + DayOfWeek = table.Column(type: "integer", nullable: true), + EndMinute = table.Column(type: "integer", nullable: true), + EndsAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + Mode = table.Column(type: "integer", nullable: false), + Recurrence = table.Column(type: "integer", nullable: false), + StartMinute = table.Column(type: "integer", nullable: true), + StartsAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ProgrammingOverride", x => x.Id); + table.ForeignKey( + name: "FK_ProgrammingOverride_Channels_ChannelId", + column: x => x.ChannelId, + principalTable: "Channels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ChannelShowHour", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChannelShowId = table.Column(type: "uuid", nullable: false), + EndHour = table.Column(type: "integer", nullable: false), + StartHour = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChannelShowHour", x => x.Id); + table.ForeignKey( + name: "FK_ChannelShowHour_ChannelShow_ChannelShowId", + column: x => x.ChannelShowId, + principalTable: "ChannelShow", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "OverrideShow", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProgrammingOverrideId = table.Column(type: "uuid", nullable: false), + ShowId = table.Column(type: "uuid", nullable: false), + Weight = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OverrideShow", x => x.Id); + table.ForeignKey( + name: "FK_OverrideShow_ProgrammingOverride_ProgrammingOverrideId", + column: x => x.ProgrammingOverrideId, + principalTable: "ProgrammingOverride", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChannelAd_ChannelId_Position", + table: "ChannelAd", + columns: new[] { "ChannelId", "Position" }); + + migrationBuilder.CreateIndex( + name: "IX_ChannelShow_ChannelId_ShowId", + table: "ChannelShow", + columns: new[] { "ChannelId", "ShowId" }); + + migrationBuilder.CreateIndex( + name: "IX_ChannelShowHour_ChannelShowId", + table: "ChannelShowHour", + column: "ChannelShowId"); + + migrationBuilder.CreateIndex( + name: "IX_OverrideShow_ProgrammingOverrideId", + table: "OverrideShow", + column: "ProgrammingOverrideId"); + + migrationBuilder.CreateIndex( + name: "IX_ProgrammingOverride_ChannelId_StartsAtUtc_EndsAtUtc", + table: "ProgrammingOverride", + columns: new[] { "ChannelId", "StartsAtUtc", "EndsAtUtc" }); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726102012_AddJunctionTemplates.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726102012_AddJunctionTemplates.Designer.cs new file mode 100644 index 0000000..e8e405b --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726102012_AddJunctionTemplates.Designer.cs @@ -0,0 +1,1380 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260726102012_AddJunctionTemplates")] + partial class AddJunctionTemplates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperEpisodeChangeChance") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumperShowChangeChance") + .HasColumnType("double precision"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DayStartTime") + .HasColumnType("time without time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UtcOffsetMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique() + .HasFilter("\"Number\" IS NOT NULL"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TraceJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.HasIndex("ChannelId", "ShowId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.HasIndex("CollectionId", "Position"); + + b.HasIndex("CollectionId", "ShowId") + .IsUnique(); + + b.ToTable("CollectionItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicabilityJson") + .HasColumnType("jsonb"); + + b.Property("IsBackground") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Priority"); + + b.ToTable("GridLayers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountMode") + .HasColumnType("integer"); + + b.Property("AmountValue") + .HasColumnType("integer"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("ConditionsJson") + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("JunctionTemplateId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("JunctionTemplateId", "Position"); + + b.ToTable("JunctionElements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("JunctionTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultJunctionId") + .HasColumnType("uuid"); + + b.Property("FallbackGroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("ScheduleTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("Daypart") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAnchor") + .HasColumnType("boolean"); + + b.Property("JunctionAfterId") + .HasColumnType("uuid"); + + b.Property("JunctionBetweenId") + .HasColumnType("uuid"); + + b.Property("LayerId") + .HasColumnType("uuid"); + + b.Property("MaxDriftMinutes") + .HasColumnType("integer"); + + b.Property("OverflowPolicy") + .HasColumnType("integer"); + + b.Property("RepeatSourceJson") + .HasColumnType("jsonb"); + + b.Property("SlotKind") + .HasColumnType("integer"); + + b.Property("SnapToMinutes") + .HasColumnType("integer"); + + b.Property("StrategyJson") + .HasColumnType("jsonb"); + + b.Property("TargetDurationMinutes") + .HasColumnType("integer"); + + b.Property("TargetStart") + .HasColumnType("time without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("LayerId", "TargetStart"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("CurrentElementId") + .HasColumnType("uuid"); + + b.Property("CurrentElementKind") + .HasColumnType("integer"); + + b.Property("NextUnitIndex") + .HasColumnType("integer"); + + b.HasKey("SlotId"); + + b.ToTable("SlotStates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.JunctionTemplate", null) + .WithMany("Elements") + .HasForeignKey("JunctionTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("BumperTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Navigation("Elements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726102012_AddJunctionTemplates.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726102012_AddJunctionTemplates.cs new file mode 100644 index 0000000..17e44a8 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726102012_AddJunctionTemplates.cs @@ -0,0 +1,116 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class AddJunctionTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "JunctionAfterId", + table: "Slots", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "JunctionBetweenId", + table: "Slots", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "DefaultJunctionId", + table: "ScheduleTemplates", + type: "uuid", + nullable: true); + + migrationBuilder.CreateTable( + name: "JunctionTemplates", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChannelId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_JunctionTemplates", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "JunctionElements", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + JunctionTemplateId = table.Column(type: "uuid", nullable: false), + Position = table.Column(type: "integer", nullable: false), + Kind = table.Column(type: "integer", nullable: false), + GroupId = table.Column(type: "uuid", nullable: true), + BumperTemplateId = table.Column(type: "uuid", nullable: true), + AmountMode = table.Column(type: "integer", nullable: false), + AmountValue = table.Column(type: "integer", nullable: false), + IsRequired = table.Column(type: "boolean", nullable: false), + ConditionsJson = table.Column(type: "jsonb", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_JunctionElements", x => x.Id); + table.ForeignKey( + name: "FK_JunctionElements_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_JunctionElements_JunctionTemplates_JunctionTemplateId", + column: x => x.JunctionTemplateId, + principalTable: "JunctionTemplates", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_JunctionElements_GroupId", + table: "JunctionElements", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_JunctionElements_JunctionTemplateId_Position", + table: "JunctionElements", + columns: new[] { "JunctionTemplateId", "Position" }); + + migrationBuilder.CreateIndex( + name: "IX_JunctionTemplates_ChannelId", + table: "JunctionTemplates", + column: "ChannelId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "JunctionElements"); + + migrationBuilder.DropTable( + name: "JunctionTemplates"); + + migrationBuilder.DropColumn( + name: "JunctionAfterId", + table: "Slots"); + + migrationBuilder.DropColumn( + name: "JunctionBetweenId", + table: "Slots"); + + migrationBuilder.DropColumn( + name: "DefaultJunctionId", + table: "ScheduleTemplates"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 45c2133..ef20ec9 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -318,12 +318,6 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Id") .HasColumnType("uuid"); - b.Property("AdInsertion") - .HasColumnType("integer"); - - b.Property("AdsPerBreak") - .HasColumnType("integer"); - b.Property("BumperEpisodeChangeChance") .HasColumnType("double precision"); @@ -345,6 +339,9 @@ namespace TeleWave.Infrastructure.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("DayStartTime") + .HasColumnType("time without time zone"); + b.Property("EpochUtc") .HasColumnType("timestamp with time zone"); @@ -359,10 +356,10 @@ namespace TeleWave.Infrastructure.Migrations .HasMaxLength(256) .HasColumnType("character varying(256)"); - b.Property("NextAdIndex") + b.Property("NextBumperIndex") .HasColumnType("integer"); - b.Property("NextBumperIndex") + b.Property("Number") .HasColumnType("integer"); b.Property("Slug") @@ -370,151 +367,24 @@ namespace TeleWave.Infrastructure.Migrations .HasMaxLength(128) .HasColumnType("character varying(128)"); + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UtcOffsetMinutes") + .HasColumnType("integer"); + b.HasKey("Id"); + b.HasIndex("Number") + .IsUnique() + .HasFilter("\"Number\" IS NOT NULL"); + b.HasIndex("Slug") .IsUnique(); b.ToTable("Channels"); }); - modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("ChannelId") - .HasColumnType("uuid"); - - b.Property("MediaAssetId") - .HasColumnType("uuid"); - - b.Property("Position") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("ChannelId", "Position"); - - b.ToTable("ChannelAd"); - }); - - modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("BlockMode") - .HasColumnType("integer"); - - b.Property("BlockValue") - .HasColumnType("integer"); - - b.Property("ChannelId") - .HasColumnType("uuid"); - - b.Property("IsEnabled") - .HasColumnType("boolean"); - - b.Property("NextEpisodeIndex") - .HasColumnType("integer"); - - b.Property("PreferredWeightMultiplier") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(3); - - b.Property("ShowId") - .HasColumnType("uuid"); - - b.Property("Weight") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("ChannelId", "ShowId"); - - b.ToTable("ChannelShow"); - }); - - modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("ChannelShowId") - .HasColumnType("uuid"); - - b.Property("EndHour") - .HasColumnType("integer"); - - b.Property("StartHour") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("ChannelShowId"); - - b.ToTable("ChannelShowHour"); - }); - - modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("ProgrammingOverrideId") - .HasColumnType("uuid"); - - b.Property("ShowId") - .HasColumnType("uuid"); - - b.Property("Weight") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("ProgrammingOverrideId"); - - b.ToTable("OverrideShow"); - }); - - modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("ChannelId") - .HasColumnType("uuid"); - - b.Property("DayOfWeek") - .HasColumnType("integer"); - - b.Property("EndMinute") - .HasColumnType("integer"); - - b.Property("EndsAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("Mode") - .HasColumnType("integer"); - - b.Property("Recurrence") - .HasColumnType("integer"); - - b.Property("StartMinute") - .HasColumnType("integer"); - - b.Property("StartsAtUtc") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); - - b.ToTable("ProgrammingOverride"); - }); - modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => { b.Property("Id") @@ -541,17 +411,23 @@ namespace TeleWave.Infrastructure.Migrations b.Property("ShowId") .HasColumnType("uuid"); + b.Property("SlotId") + .HasColumnType("uuid"); + b.Property("StartsAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("TraceJson") + .HasColumnType("jsonb"); + b.HasKey("Id"); b.HasIndex("ChannelId", "EndsAtUtc"); - b.HasIndex("ChannelId", "ShowId"); - b.HasIndex("ChannelId", "StartsAtUtc"); + b.HasIndex("ChannelId", "ShowId", "StartsAtUtc"); + b.ToTable("ScheduleEntries"); }); @@ -582,6 +458,112 @@ namespace TeleWave.Infrastructure.Migrations b.ToTable("Images"); }); + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.HasIndex("CollectionId", "Position"); + + b.HasIndex("CollectionId", "ShowId") + .IsUnique(); + + b.ToTable("CollectionItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => { b.Property("Id") @@ -674,6 +656,24 @@ namespace TeleWave.Infrastructure.Migrations b.ToTable("ShowEpisode"); }); + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => { b.Property("Id") @@ -747,6 +747,295 @@ namespace TeleWave.Infrastructure.Migrations b.ToTable("MediaAssets"); }); + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicabilityJson") + .HasColumnType("jsonb"); + + b.Property("IsBackground") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Priority"); + + b.ToTable("GridLayers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountMode") + .HasColumnType("integer"); + + b.Property("AmountValue") + .HasColumnType("integer"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("ConditionsJson") + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("JunctionTemplateId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("JunctionTemplateId", "Position"); + + b.ToTable("JunctionElements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("JunctionTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultJunctionId") + .HasColumnType("uuid"); + + b.Property("FallbackGroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("ScheduleTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("Daypart") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAnchor") + .HasColumnType("boolean"); + + b.Property("JunctionAfterId") + .HasColumnType("uuid"); + + b.Property("JunctionBetweenId") + .HasColumnType("uuid"); + + b.Property("LayerId") + .HasColumnType("uuid"); + + b.Property("MaxDriftMinutes") + .HasColumnType("integer"); + + b.Property("OverflowPolicy") + .HasColumnType("integer"); + + b.Property("RepeatSourceJson") + .HasColumnType("jsonb"); + + b.Property("SlotKind") + .HasColumnType("integer"); + + b.Property("SnapToMinutes") + .HasColumnType("integer"); + + b.Property("StrategyJson") + .HasColumnType("jsonb"); + + b.Property("TargetDurationMinutes") + .HasColumnType("integer"); + + b.Property("TargetStart") + .HasColumnType("time without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("LayerId", "TargetStart"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("CurrentElementId") + .HasColumnType("uuid"); + + b.Property("CurrentElementKind") + .HasColumnType("integer"); + + b.Property("NextUnitIndex") + .HasColumnType("integer"); + + b.HasKey("SlotId"); + + b.ToTable("SlotStates"); + }); + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => { b.Property("Key") @@ -933,47 +1222,26 @@ namespace TeleWave.Infrastructure.Migrations .IsRequired(); }); - modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => { - b.HasOne("TeleWave.Domain.Broadcast.Channel", null) - .WithMany("Ads") - .HasForeignKey("ChannelId") + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => { - b.HasOne("TeleWave.Domain.Broadcast.Channel", null) - .WithMany("Shows") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => - { - b.HasOne("TeleWave.Domain.Broadcast.ChannelShow", null) - .WithMany("PreferredHours") - .HasForeignKey("ChannelShowId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => - { - b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) - .WithMany("Shows") - .HasForeignKey("ProgrammingOverrideId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => - { - b.HasOne("TeleWave.Domain.Broadcast.Channel", null) - .WithMany("Overrides") - .HasForeignKey("ChannelId") + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); @@ -987,6 +1255,76 @@ namespace TeleWave.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.JunctionTemplate", null) + .WithMany("Elements") + .HasForeignKey("JunctionTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => { b.Navigation("Variants"); @@ -994,28 +1332,44 @@ namespace TeleWave.Infrastructure.Migrations modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => { - b.Navigation("Ads"); - b.Navigation("BumperTemplates"); - - b.Navigation("Overrides"); - - b.Navigation("Shows"); }); - modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => { - b.Navigation("PreferredHours"); + b.Navigation("Items"); }); - modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => { - b.Navigation("Shows"); + b.Navigation("Aliases"); }); modelBuilder.Entity("TeleWave.Domain.Library.Show", b => { b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Navigation("Elements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); }); #pragma warning restore 612, 618 } diff --git a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs index 3ce8eb7..f3bb9b1 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs @@ -1,69 +1,83 @@ -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Domain.Auth; -using TeleWave.Domain.Broadcast; -using TeleWave.Domain.Images; -using TeleWave.Domain.Library; -using TeleWave.Domain.Media; -using TeleWave.Domain.Settings; -using TeleWave.Infrastructure.Identity; - -namespace TeleWave.Infrastructure.Persistence; - -/// -/// Корневой DbContext приложения: Identity-схема (пользователи/роли) + сущности домена -/// (добавляются по мере реализации фич). -/// -public class AppDbContext(DbContextOptions options) - : IdentityDbContext(options), - IAppDbContext -{ - public DbSet RefreshTokens => Set(); - public DbSet MediaAssets => Set(); - public DbSet Shows => Set(); - public DbSet Channels => Set(); - public DbSet ScheduleEntries => Set(); - public DbSet BumperTextVariants => Set(); - public DbSet BumperAssets => Set(); - public DbSet AppSettings => Set(); - public DbSet Images => Set(); - - public Task BeginTransactionAsync(CancellationToken cancellationToken) => - Database.BeginTransactionAsync(cancellationToken); - - public Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken) - { - // pg_advisory_xact_lock(bigint) освобождается автоматически при завершении транзакции. - // Ключ — стабильный int64 из GUID канала; коллизии между каналами лишь сериализуют их генерацию, - // корректности не нарушают. - var key = BitConverter.ToInt64(channelId.ToByteArray()); - return Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock({key})", cancellationToken); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); - - // Guid-ключи доменных сущностей мы задаём сами в фабриках. Без этого EF считает выставленный - // ключ признаком уже существующей строки и при добавлении дочерней сущности через коллекцию - // отслеживаемого родителя (напр. show.AddEpisode) делает UPDATE вместо INSERT → «affected 0». - foreach (var entityType in modelBuilder.Model.GetEntityTypes()) - { - if ( - entityType.ClrType.Namespace?.StartsWith( - "TeleWave.Domain", - StringComparison.Ordinal - ) != true - ) - continue; - - var idProperty = entityType.FindProperty("Id"); - if (idProperty is not null && idProperty.ClrType == typeof(Guid)) - idProperty.ValueGenerated = ValueGenerated.Never; - } - } -} +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Auth; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Images; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using TeleWave.Domain.Programming; +using TeleWave.Domain.Settings; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Infrastructure.Persistence; + +/// +/// Корневой DbContext приложения: Identity-схема (пользователи/роли) + сущности домена +/// (добавляются по мере реализации фич). +/// +public class AppDbContext(DbContextOptions options) + : IdentityDbContext(options), + IAppDbContext +{ + public DbSet RefreshTokens => Set(); + public DbSet MediaAssets => Set(); + public DbSet Shows => Set(); + public DbSet Genres => Set(); + public DbSet GenreAliases => Set(); + public DbSet ShowGenres => Set(); + public DbSet Collections => Set(); + public DbSet CollectionItems => Set(); + public DbSet Groups => Set(); + public DbSet GroupItems => Set(); + public DbSet ScheduleTemplates => Set(); + public DbSet GridLayers => Set(); + public DbSet Slots => Set(); + public DbSet SlotStates => Set(); + public DbSet JunctionTemplates => Set(); + public DbSet JunctionElements => Set(); + public DbSet Channels => Set(); + public DbSet ScheduleEntries => Set(); + public DbSet BumperTextVariants => Set(); + public DbSet BumperAssets => Set(); + public DbSet AppSettings => Set(); + public DbSet Images => Set(); + + public Task BeginTransactionAsync(CancellationToken cancellationToken) => + Database.BeginTransactionAsync(cancellationToken); + + public Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken) + { + // pg_advisory_xact_lock(bigint) освобождается автоматически при завершении транзакции. + // Ключ — стабильный int64 из GUID канала; коллизии между каналами лишь сериализуют их генерацию, + // корректности не нарушают. + var key = BitConverter.ToInt64(channelId.ToByteArray()); + return Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock({key})", cancellationToken); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + + // Guid-ключи доменных сущностей мы задаём сами в фабриках. Без этого EF считает выставленный + // ключ признаком уже существующей строки и при добавлении дочерней сущности через коллекцию + // отслеживаемого родителя (напр. show.AddEpisode) делает UPDATE вместо INSERT → «affected 0». + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + if ( + entityType.ClrType.Namespace?.StartsWith( + "TeleWave.Domain", + StringComparison.Ordinal + ) != true + ) + continue; + + var idProperty = entityType.FindProperty("Id"); + if (idProperty is not null && idProperty.ClrType == typeof(Guid)) + idProperty.ValueGenerated = ValueGenerated.Never; + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs index 1d34c91..cab846d 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs @@ -1,133 +1,61 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using TeleWave.Domain.Broadcast; - -namespace TeleWave.Infrastructure.Persistence.Configurations; - -public class ChannelConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.Property(x => x.Name).IsRequired().HasMaxLength(256); - builder.Property(x => x.Slug).IsRequired().HasMaxLength(128); - builder.HasIndex(x => x.Slug).IsUnique(); - - builder - .HasMany(x => x.Shows) - .WithOne() - .HasForeignKey(s => s.ChannelId) - .OnDelete(DeleteBehavior.Cascade); - builder.Navigation(x => x.Shows).UsePropertyAccessMode(PropertyAccessMode.Field); - - builder - .HasMany(x => x.Ads) - .WithOne() - .HasForeignKey(a => a.ChannelId) - .OnDelete(DeleteBehavior.Cascade); - builder.Navigation(x => x.Ads).UsePropertyAccessMode(PropertyAccessMode.Field); - - builder - .HasMany(x => x.BumperTemplates) - .WithOne() - .HasForeignKey(t => t.ChannelId) - .OnDelete(DeleteBehavior.Cascade); - builder.Navigation(x => x.BumperTemplates).UsePropertyAccessMode(PropertyAccessMode.Field); - - builder - .HasMany(x => x.Overrides) - .WithOne() - .HasForeignKey(o => o.ChannelId) - .OnDelete(DeleteBehavior.Cascade); - builder.Navigation(x => x.Overrides).UsePropertyAccessMode(PropertyAccessMode.Field); - } -} - -public class ChannelShowConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasIndex(x => new { x.ChannelId, x.ShowId }); - builder - .Property(x => x.PreferredWeightMultiplier) - .HasDefaultValue(ChannelShow.DefaultPreferredWeightMultiplier); - - builder - .HasMany(x => x.PreferredHours) - .WithOne() - .HasForeignKey(h => h.ChannelShowId) - .OnDelete(DeleteBehavior.Cascade); - builder.Navigation(x => x.PreferredHours).UsePropertyAccessMode(PropertyAccessMode.Field); - } -} - -public class ChannelShowHourConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasIndex(x => x.ChannelShowId); - } -} - -public class ChannelAdConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasIndex(x => new { x.ChannelId, x.Position }); - } -} - -public class BumperTemplateConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasIndex(x => new { x.ChannelId, x.Position }); - builder.Property(x => x.Name).IsRequired().HasMaxLength(64); - builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32); - builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32); - builder.Property(x => x.AccentColor).IsRequired().HasMaxLength(32); - builder.Property(x => x.TextColor).IsRequired().HasMaxLength(32); - builder.Property(x => x.AudioExtension).HasMaxLength(16); - - builder - .HasMany(x => x.Variants) - .WithOne() - .HasForeignKey(v => v.BumperTemplateId) - .OnDelete(DeleteBehavior.Cascade); - builder.Navigation(x => x.Variants).UsePropertyAccessMode(PropertyAccessMode.Field); - } -} - -public class BumperTextVariantConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasIndex(x => new { x.BumperTemplateId, x.Position }); - builder.Property(x => x.Name).IsRequired().HasMaxLength(64); - builder.Property(x => x.NowLabel).IsRequired().HasMaxLength(64); - builder.Property(x => x.NextLabel).IsRequired().HasMaxLength(64); - builder.Property(x => x.Line1).IsRequired().HasMaxLength(120); - builder.Property(x => x.Line2).IsRequired().HasMaxLength(120); - builder.Property(x => x.Weight).HasDefaultValue(BumperTextVariant.DefaultWeight); - } -} - -public class ProgrammingOverrideConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasIndex(x => new - { - x.ChannelId, - x.StartsAtUtc, - x.EndsAtUtc, - }); - - builder - .HasMany(x => x.Shows) - .WithOne() - .HasForeignKey(s => s.ProgrammingOverrideId) - .OnDelete(DeleteBehavior.Cascade); - builder.Navigation(x => x.Shows).UsePropertyAccessMode(PropertyAccessMode.Field); - } -} +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TeleWave.Domain.Broadcast; + +namespace TeleWave.Infrastructure.Persistence.Configurations; + +public class ChannelConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(x => x.Name).IsRequired().HasMaxLength(256); + builder.Property(x => x.Slug).IsRequired().HasMaxLength(128); + builder.HasIndex(x => x.Slug).IsUnique(); + + // Номер канала уникален среди заданных: переключение вверх-вниз по номерам иначе неоднозначно. + builder.HasIndex(x => x.Number).IsUnique().HasFilter("\"Number\" IS NOT NULL"); + + builder + .HasMany(x => x.BumperTemplates) + .WithOne() + .HasForeignKey(t => t.ChannelId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(x => x.BumperTemplates).UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +public class BumperTemplateConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(x => new { x.ChannelId, x.Position }); + builder.Property(x => x.Name).IsRequired().HasMaxLength(64); + builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32); + builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32); + builder.Property(x => x.AccentColor).IsRequired().HasMaxLength(32); + builder.Property(x => x.TextColor).IsRequired().HasMaxLength(32); + builder.Property(x => x.AudioExtension).HasMaxLength(16); + + builder + .HasMany(x => x.Variants) + .WithOne() + .HasForeignKey(v => v.BumperTemplateId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(x => x.Variants).UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +public class BumperTextVariantConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(x => new { x.BumperTemplateId, x.Position }); + builder.Property(x => x.Name).IsRequired().HasMaxLength(64); + builder.Property(x => x.NowLabel).IsRequired().HasMaxLength(64); + builder.Property(x => x.NextLabel).IsRequired().HasMaxLength(64); + builder.Property(x => x.Line1).IsRequired().HasMaxLength(120); + builder.Property(x => x.Line2).IsRequired().HasMaxLength(120); + builder.Property(x => x.Weight).HasDefaultValue(BumperTextVariant.DefaultWeight); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/CollectionConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/CollectionConfiguration.cs new file mode 100644 index 0000000..e52cf9b --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/CollectionConfiguration.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TeleWave.Domain.Library; + +namespace TeleWave.Infrastructure.Persistence.Configurations; + +public class CollectionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(x => x.Name).IsRequired().HasMaxLength(256); + builder.Property(x => x.Description).HasMaxLength(2048); + + builder + .HasMany(x => x.Items) + .WithOne() + .HasForeignKey(i => i.CollectionId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(x => x.Items).UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +public class CollectionItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(x => new { x.CollectionId, x.Position }); + // Шоу входит в коллекцию не более одного раза — иначе порядок воспроизведения неоднозначен. + builder.HasIndex(x => new { x.CollectionId, x.ShowId }).IsUnique(); + builder.HasIndex(x => x.ShowId); + + // Удаление шоу из библиотеки убирает его и из коллекций: висячая позиция сломала бы + // разворачивание коллекции в последовательность серий при генерации. + builder + .HasOne() + .WithMany() + .HasForeignKey(x => x.ShowId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/GenreConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/GenreConfiguration.cs new file mode 100644 index 0000000..c42ae7d --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/GenreConfiguration.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TeleWave.Domain.Library; + +namespace TeleWave.Infrastructure.Persistence.Configurations; + +public class GenreConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.Slug).IsRequired().HasMaxLength(64); + builder.HasIndex(x => x.Slug).IsUnique(); + + builder + .HasMany(x => x.Aliases) + .WithOne() + .HasForeignKey(a => a.GenreId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(x => x.Aliases).UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +public class GenreAliasConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(x => x.Value).IsRequired().HasMaxLength(128); + // Псевдоним однозначно указывает на жанр: иначе сопоставление метаданных стало бы неопределённым. + builder.HasIndex(x => x.Value).IsUnique(); + } +} + +public class ShowGenreConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => new { x.ShowId, x.GenreId }); + builder.HasIndex(x => x.GenreId); + + // Restrict, а не Cascade: удаление жанра из справочника не должно молча снимать его со всех шоу. + // Команда удаления проверяет использование заранее и возвращает управляемый конфликт. + builder + .HasOne() + .WithMany() + .HasForeignKey(x => x.GenreId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/GroupConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/GroupConfiguration.cs new file mode 100644 index 0000000..73b44f2 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/GroupConfiguration.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TeleWave.Domain.Programming; + +namespace TeleWave.Infrastructure.Persistence.Configurations; + +public class GroupConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(x => x.Name).IsRequired().HasMaxLength(256); + builder.Property(x => x.Description).HasMaxLength(2048); + + // Правило набора хранится как jsonb: схему знает Application, а домен передаёт строку как есть. + builder.Property(x => x.FilterJson).HasColumnType("jsonb"); + + builder + .HasMany(x => x.Items) + .WithOne() + .HasForeignKey(i => i.GroupId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(x => x.Items).UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +public class GroupItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(x => new { x.GroupId, x.Position }); + // Элемент входит в группу не более одного раза — иначе вес и порядок становятся неоднозначны. + builder.HasIndex(x => new + { + x.GroupId, + x.ElementKind, + x.ElementId, + }).IsUnique(); + + // По этому индексу чистятся позиции при удалении шоу/коллекции: внешнего ключа на + // полиморфную ссылку нет, удаление идёт командой. + builder.HasIndex(x => new { x.ElementKind, x.ElementId }); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/JunctionTemplateConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/JunctionTemplateConfiguration.cs new file mode 100644 index 0000000..ec7cb16 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/JunctionTemplateConfiguration.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TeleWave.Domain.Programming; + +namespace TeleWave.Infrastructure.Persistence.Configurations; + +public class JunctionTemplateConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.HasIndex(x => x.ChannelId); + + builder + .HasMany(x => x.Elements) + .WithOne() + .HasForeignKey(e => e.JunctionTemplateId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(x => x.Elements).UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +public class JunctionElementConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(x => new { x.JunctionTemplateId, x.Position }); + builder.Property(x => x.ConditionsJson).HasColumnType("jsonb"); + + // Группа не удаляется, пока на неё ссылается врезка: иначе стык молча перестал бы работать. + builder + .HasOne() + .WithMany() + .HasForeignKey(x => x.GroupId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleEntryConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleEntryConfiguration.cs index 11aab4b..4413ff9 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleEntryConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleEntryConfiguration.cs @@ -11,6 +11,14 @@ public class ScheduleEntryConfiguration : IEntityTypeConfiguration new { x.ChannelId, x.StartsAtUtc }); builder.HasIndex(x => new { x.ChannelId, x.EndsAtUtc }); - builder.HasIndex(x => new { x.ChannelId, x.ShowId }); + // Остывание спрашивает «когда этот элемент играл в последний раз» — по этому индексу. + builder.HasIndex(x => new + { + x.ChannelId, + x.ShowId, + x.StartsAtUtc, + }); + + builder.Property(x => x.TraceJson).HasColumnType("jsonb"); } } diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleTemplateConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleTemplateConfiguration.cs new file mode 100644 index 0000000..73efe8c --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleTemplateConfiguration.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TeleWave.Domain.Programming; + +namespace TeleWave.Infrastructure.Persistence.Configurations; + +public class ScheduleTemplateConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(x => x.Name).IsRequired().HasMaxLength(256); + builder.HasIndex(x => x.ChannelId); + + builder + .HasMany(x => x.Layers) + .WithOne() + .HasForeignKey(l => l.TemplateId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(x => x.Layers).UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +public class GridLayerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.ApplicabilityJson).HasColumnType("jsonb"); + builder.HasIndex(x => new { x.TemplateId, x.Priority }); + + builder + .HasMany(x => x.Slots) + .WithOne() + .HasForeignKey(s => s.LayerId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(x => x.Slots).UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +public class SlotConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(x => x.Title).IsRequired().HasMaxLength(256); + builder.Property(x => x.StrategyJson).HasColumnType("jsonb"); + builder.Property(x => x.RepeatSourceJson).HasColumnType("jsonb"); + builder.HasIndex(x => new { x.LayerId, x.TargetStart }); + builder.HasIndex(x => x.GroupId); + + // Группа не удаляется, пока на неё ссылается слот: иначе слот молча превратился бы в дыру, + // и админ узнал бы об этом только из эфира. + builder + .HasOne() + .WithMany() + .HasForeignKey(x => x.GroupId) + .OnDelete(DeleteBehavior.Restrict); + } +} + +public class SlotStateConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.SlotId); + + // Состояние живёт ровно столько же, сколько слот. + builder + .HasOne() + .WithOne() + .HasForeignKey(x => x.SlotId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs index 2fc2901..2933b44 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs @@ -21,6 +21,13 @@ public class ShowConfiguration : IEntityTypeConfiguration .HasForeignKey(e => e.ShowId) .OnDelete(DeleteBehavior.Cascade); builder.Navigation(x => x.Episodes).UsePropertyAccessMode(PropertyAccessMode.Field); + + builder + .HasMany(x => x.Genres) + .WithOne() + .HasForeignKey(g => g.ShowId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(x => x.Genres).UsePropertyAccessMode(PropertyAccessMode.Field); } } diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelHandlersTests.cs index 263f876..5ce3634 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelHandlersTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelHandlersTests.cs @@ -1,324 +1,82 @@ -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Broadcast; -using TeleWave.Application.Broadcast.AddChannelAd; -using TeleWave.Application.Broadcast.AddChannelShow; -using TeleWave.Application.Broadcast.CreateChannel; -using TeleWave.Application.Broadcast.CreateOverride; -using TeleWave.Application.Broadcast.DeleteOverride; -using TeleWave.Application.Broadcast.RemoveChannelShow; -using TeleWave.Application.Broadcast.UpdateChannelSettings; -using TeleWave.Application.Broadcast.UpdateChannelShow; -using TeleWave.Application.Tests.Support; -using TeleWave.Domain.Broadcast; -using TeleWave.Domain.Library; -using TeleWave.Domain.Media; -using Xunit; - -namespace TeleWave.Application.Tests.Broadcast; - -public class ChannelHandlersTests -{ - private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); - - [Fact] - public async Task CreateChannel_Succeeds_AndRejectsDuplicateSlug() - { - var fixture = new TestDb(); - await using var db = fixture.New(); - - var ok = await new CreateChannelCommandHandler(db).Handle( - new CreateChannelCommand("News", "news"), - CancellationToken.None - ); - Assert.True(ok.IsSuccess); - await db.SaveChangesAsync(CancellationToken.None); - - var dup = await new CreateChannelCommandHandler(db).Handle( - new CreateChannelCommand("Other", "news"), - CancellationToken.None - ); - Assert.False(dup.IsSuccess); - Assert.Equal(ChannelErrors.DuplicateSlug, dup.Error); - } - - [Fact] - public async Task AddChannelShow_HappyPath_AndErrors() - { - var fixture = new TestDb(); - var channel = Channel.Create("c", "c", T0); - var show = Show.Create("S", ShowKind.Series); - await using (var seed = fixture.New()) - { - seed.Channels.Add(channel); - seed.Shows.Add(show); - await seed.SaveChangesAsync(CancellationToken.None); - } - - await using var db = fixture.New(); - var handler = new AddChannelShowCommandHandler(db); - - // неизвестный канал - var noChannel = await handler.Handle( - new AddChannelShowCommand(Guid.NewGuid(), show.Id, 1, BlockMode.Count, 1), - CancellationToken.None - ); - Assert.Equal(ChannelErrors.NotFound, noChannel.Error); - - // неизвестное шоу - var noShow = await handler.Handle( - new AddChannelShowCommand(channel.Id, Guid.NewGuid(), 1, BlockMode.Count, 1), - CancellationToken.None - ); - Assert.Equal(ChannelErrors.ShowNotFound, noShow.Error); - - // ок - var ok = await handler.Handle( - new AddChannelShowCommand(channel.Id, show.Id, 2, BlockMode.Count, 3), - CancellationToken.None - ); - Assert.True(ok.IsSuccess); - await db.SaveChangesAsync(CancellationToken.None); - - // повторно — уже добавлено - await using var db2 = fixture.New(); - var dup = await new AddChannelShowCommandHandler(db2).Handle( - new AddChannelShowCommand(channel.Id, show.Id, 1, BlockMode.Count, 1), - CancellationToken.None - ); - Assert.Equal(ChannelErrors.ShowAlreadyAdded, dup.Error); - } - - [Fact] - public async Task RemoveChannelShow_RemovesOrReportsNotFound() - { - var fixture = new TestDb(); - var channel = Channel.Create("c", "c", T0); - var cs = channel.AddShow(Guid.NewGuid(), 1, BlockMode.Count, 1); - await using (var seed = fixture.New()) - { - seed.Channels.Add(channel); - await seed.SaveChangesAsync(CancellationToken.None); - } - - await using var db = fixture.New(); - var handler = new RemoveChannelShowCommandHandler(db); - - var missing = await handler.Handle( - new RemoveChannelShowCommand(channel.Id, Guid.NewGuid()), - CancellationToken.None - ); - Assert.Equal(ChannelErrors.ChannelShowNotFound, missing.Error); - - var ok = await handler.Handle( - new RemoveChannelShowCommand(channel.Id, cs.Id), - CancellationToken.None - ); - Assert.True(ok.IsSuccess); - } - - [Fact] - public async Task AddChannelAd_HappyPath_AndAssetNotFound() - { - var fixture = new TestDb(); - var channel = Channel.Create("c", "c", T0); - var asset = MediaAsset.Register("clip.mp4", ".mp4", MediaSource.Upload); - await using (var seed = fixture.New()) - { - seed.Channels.Add(channel); - seed.MediaAssets.Add(asset); - await seed.SaveChangesAsync(CancellationToken.None); - } - - await using var db = fixture.New(); - var handler = new AddChannelAdCommandHandler(db); - - var noAsset = await handler.Handle( - new AddChannelAdCommand(channel.Id, Guid.NewGuid()), - CancellationToken.None - ); - Assert.Equal(ChannelErrors.AssetNotFound, noAsset.Error); - - var ok = await handler.Handle( - new AddChannelAdCommand(channel.Id, asset.Id), - CancellationToken.None - ); - Assert.True(ok.IsSuccess); - } - - [Fact] - public async Task UpdateChannelSettings_UpdatesBumperChances() - { - var fixture = new TestDb(); - var channel = Channel.Create("c", "c", T0); - await using (var seed = fixture.New()) - { - seed.Channels.Add(channel); - await seed.SaveChangesAsync(CancellationToken.None); - } - - await using var db = fixture.New(); - var result = await new UpdateChannelSettingsCommandHandler(db).Handle( - new UpdateChannelSettingsCommand( - channel.Id, - "c", - true, - AdInsertion.BetweenBlocks, - 1, - true, - new BumperSettingsInput( - BumperFont.Sans, - 5, - BumperSelection.WeightedRandom, - 0.5, - 0.2 - ), - null - ), - CancellationToken.None - ); - Assert.True(result.IsSuccess); - await db.SaveChangesAsync(CancellationToken.None); - - await using var verify = fixture.New(); - var stored = await verify.Channels.FindAsync(channel.Id); - Assert.Equal(0.5, stored!.BumperShowChangeChance); - Assert.Equal(0.2, stored.BumperEpisodeChangeChance); - Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection); - } - - [Fact] - public async Task UpdateChannelShow_SetsPreferredHours() - { - var fixture = new TestDb(); - var channel = Channel.Create("c", "c", T0); - var cs = channel.AddShow(Guid.NewGuid(), 1, BlockMode.Count, 1); - await using (var seed = fixture.New()) - { - seed.Channels.Add(channel); - await seed.SaveChangesAsync(CancellationToken.None); - } - - await using var db = fixture.New(); - var result = await new UpdateChannelShowCommandHandler(db).Handle( - new UpdateChannelShowCommand( - channel.Id, - cs.Id, - 3, - BlockMode.Count, - 2, - true, - 4, - [new HourWindowInput(18, 23)] - ), - CancellationToken.None - ); - Assert.True(result.IsSuccess); - await db.SaveChangesAsync(CancellationToken.None); - - await using var verify = fixture.New(); - var stored = await verify - .Channels.Include(c => c.Shows) - .ThenInclude(s => s.PreferredHours) - .FirstAsync(c => c.Id == channel.Id); - var show = Assert.Single(stored.Shows); - Assert.Equal(4, show.PreferredWeightMultiplier); - Assert.Single(show.PreferredHours); - } - - [Fact] - public async Task CreateOverride_OneTime_Weekly_AndValidation() - { - var fixture = new TestDb(); - var channel = Channel.Create("c", "c", T0); - var show = Show.Create("S", ShowKind.Series); - await using (var seed = fixture.New()) - { - seed.Channels.Add(channel); - seed.Shows.Add(show); - await seed.SaveChangesAsync(CancellationToken.None); - } - - await using var db = fixture.New(); - var handler = new CreateProgrammingOverrideCommandHandler(db); - OverrideShowInput[] shows = [new OverrideShowInput(show.Id, 1)]; - - // Разовое окно — ок. - var oneTime = await handler.Handle( - new CreateProgrammingOverrideCommand( - channel.Id, - OverrideMode.Exclusive, - OverrideRecurrence.OneTime, - T0, - T0.AddHours(2), - null, - null, - null, - shows - ), - CancellationToken.None - ); - Assert.True(oneTime.IsSuccess); - - // Еженедельное — ок. - var weekly = await handler.Handle( - new CreateProgrammingOverrideCommand( - channel.Id, - OverrideMode.Boost, - OverrideRecurrence.Weekly, - null, - null, - 6, - 600, - 720, - shows - ), - CancellationToken.None - ); - Assert.True(weekly.IsSuccess); - - // Невалидное окно (end <= start). - var bad = await handler.Handle( - new CreateProgrammingOverrideCommand( - channel.Id, - OverrideMode.Exclusive, - OverrideRecurrence.OneTime, - T0.AddHours(2), - T0, - null, - null, - null, - shows - ), - CancellationToken.None - ); - Assert.Equal(ChannelErrors.InvalidOverrideWindow, bad.Error); - } - - [Fact] - public async Task DeleteOverride_RemovesOrReportsNotFound() - { - var fixture = new TestDb(); - var channel = Channel.Create("c", "c", T0); - var ovr = channel.AddOverride(OverrideMode.Exclusive, T0, T0.AddHours(1)); - await using (var seed = fixture.New()) - { - seed.Channels.Add(channel); - await seed.SaveChangesAsync(CancellationToken.None); - } - - await using var db = fixture.New(); - var handler = new DeleteProgrammingOverrideCommandHandler(db); - - var missing = await handler.Handle( - new DeleteProgrammingOverrideCommand(channel.Id, Guid.NewGuid()), - CancellationToken.None - ); - Assert.Equal(ChannelErrors.OverrideNotFound, missing.Error); - - var ok = await handler.Handle( - new DeleteProgrammingOverrideCommand(channel.Id, ovr.Id), - CancellationToken.None - ); - Assert.True(ok.IsSuccess); - } -} +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Broadcast.CreateChannel; +using TeleWave.Application.Broadcast.UpdateChannelSettings; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using Xunit; + +namespace TeleWave.Application.Tests.Broadcast; + +public class ChannelHandlersTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task CreateChannel_Succeeds_AndRejectsDuplicateSlug() + { + var fixture = new TestDb(); + await using var db = fixture.New(); + + var ok = await new CreateChannelCommandHandler(db).Handle( + new CreateChannelCommand("News", "news"), + CancellationToken.None + ); + Assert.True(ok.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + + var dup = await new CreateChannelCommandHandler(db).Handle( + new CreateChannelCommand("Other", "news"), + CancellationToken.None + ); + Assert.False(dup.IsSuccess); + Assert.Equal(ChannelErrors.DuplicateSlug, dup.Error); + } + + + + + [Fact] + public async Task UpdateChannelSettings_UpdatesBumperChances() + { + var fixture = new TestDb(); + var channel = Channel.Create("c", "c", T0); + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new UpdateChannelSettingsCommandHandler(db).Handle( + new UpdateChannelSettingsCommand( + channel.Id, + "c", + true, + true, + new BumperSettingsInput( + BumperFont.Sans, + 5, + BumperSelection.WeightedRandom, + 0.5, + 0.2 + ), + null + ), + CancellationToken.None + ); + Assert.True(result.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + + await using var verify = fixture.New(); + var stored = await verify.Channels.FindAsync(channel.Id); + Assert.Equal(0.5, stored!.BumperShowChangeChance); + Assert.Equal(0.2, stored.BumperEpisodeChangeChance); + Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection); + } + + + +} diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs index 1106893..0b84724 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs @@ -1,260 +1,203 @@ -using Microsoft.EntityFrameworkCore; -using NSubstitute; -using TeleWave.Application.Broadcast; -using TeleWave.Application.Broadcast.Bumpers; -using TeleWave.Application.Broadcast.GetChannel; -using TeleWave.Application.Broadcast.GetSchedule; -using TeleWave.Application.Broadcast.ListChannels; -using TeleWave.Application.Broadcast.RemoveChannelAd; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Library.DeleteShow; -using TeleWave.Application.Library.GetShow; -using TeleWave.Application.Tests.Support; -using TeleWave.Domain.Broadcast; -using TeleWave.Domain.Library; -using TeleWave.Domain.Media; -using Xunit; - -namespace TeleWave.Application.Tests.Broadcast; - -public class QueryHandlersTests -{ - private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); - - [Fact] - public async Task GetChannel_ReturnsDto_WithBumperAndOverride() - { - var fixture = new TestDb(); - var channel = Channel.Create("News", "news", T0); - var show = Show.Create("S", ShowKind.Series); - channel.AddShow(show.Id, 1, BlockMode.Count, 1); - channel.AddWeeklyOverride(OverrideMode.Exclusive, 6, 600, 720).AddShow(show.Id, 1); - await using (var seed = fixture.New()) - { - seed.Shows.Add(show); - seed.Channels.Add(channel); - await seed.SaveChangesAsync(CancellationToken.None); - } - - await using var db = fixture.New(); - var result = await new GetChannelQueryHandler(db).Handle( - new GetChannelQuery(channel.Id), - CancellationToken.None - ); - - Assert.True(result.IsSuccess); - Assert.Equal("News", result.Value.Name); - Assert.Single(result.Value.BumperTemplates); - Assert.Single(result.Value.Overrides); - Assert.Equal(OverrideRecurrence.Weekly, result.Value.Overrides[0].Recurrence); - } - - [Fact] - public async Task GetChannel_UnknownId_ReturnsNotFound() - { - var fixture = new TestDb(); - await using var db = fixture.New(); - var result = await new GetChannelQueryHandler(db).Handle( - new GetChannelQuery(Guid.NewGuid()), - CancellationToken.None - ); - Assert.False(result.IsSuccess); - } - - [Fact] - public async Task ListChannels_ReturnsSummaries() - { - var fixture = new TestDb(); - await using (var seed = fixture.New()) - { - seed.Channels.Add(Channel.Create("A", "a", T0)); - seed.Channels.Add(Channel.Create("B", "b", T0)); - await seed.SaveChangesAsync(CancellationToken.None); - } - - await using var db = fixture.New(); - var result = await new ListChannelsQueryHandler(db).Handle( - new ListChannelsQuery(), - CancellationToken.None - ); - Assert.Equal(2, result.Count); - } - - [Fact] - public async Task GetChannelSchedule_ReturnsEntriesWithBumperLabel() - { - var fixture = new TestDb(); - var channel = Channel.Create("c", "c", T0); - var show = Show.Create("Show A", ShowKind.Series); - var variant = channel.BumperTemplates[0].Variants[0]; - var asset = MediaAsset.Register("Show.A.S01E01.mkv", ".mkv", MediaSource.Upload); - var program = ScheduleEntry.Program( - channel.Id, - asset.Id, - T0, - T0.AddMinutes(20), - show.Id, - 0 - ); - var bumper = ScheduleEntry.Bumper( - channel.Id, - Guid.NewGuid(), - T0.AddMinutes(20), - T0.AddMinutes(20).AddSeconds(8), - show.Id, - variant.Id - ); - await using (var seed = fixture.New()) - { - seed.Shows.Add(show); - seed.Channels.Add(channel); - seed.MediaAssets.Add(asset); - seed.ScheduleEntries.Add(program); - seed.ScheduleEntries.Add(bumper); - await seed.SaveChangesAsync(CancellationToken.None); - } - - await using var db = fixture.New(); - var result = await new GetChannelScheduleQueryHandler(db).Handle( - new GetChannelScheduleQuery(channel.Id, T0.AddMinutes(-5), T0.AddHours(1)), - CancellationToken.None - ); - - Assert.True(result.IsSuccess); - Assert.Equal(2, result.Value.Count); - var bumperDto = result.Value.Single(e => e.Kind == ScheduleEntryKind.Bumper); - Assert.NotNull(bumperDto.BumperName); - } - - [Fact] - public async Task GetShow_ReturnsDtoOrNotFound() - { - var fixture = new TestDb(); - var show = Show.Create("A", ShowKind.Series); - await using (var seed = fixture.New()) - { - seed.Shows.Add(show); - await seed.SaveChangesAsync(CancellationToken.None); - } - - await using var db = fixture.New(); - var ok = await new GetShowQueryHandler(db).Handle( - new GetShowQuery(show.Id), - CancellationToken.None - ); - Assert.True(ok.IsSuccess); - - var missing = await new GetShowQueryHandler(db).Handle( - new GetShowQuery(Guid.NewGuid()), - CancellationToken.None - ); - Assert.False(missing.IsSuccess); - } - - [Fact] - public async Task DeleteShow_RemovesOrNotFound() - { - var fixture = new TestDb(); - var show = Show.Create("A", ShowKind.Series); - await using (var seed = fixture.New()) - { - seed.Shows.Add(show); - await seed.SaveChangesAsync(CancellationToken.None); - } - - await using var db = fixture.New(); - var missing = await new DeleteShowCommandHandler(db).Handle( - new DeleteShowCommand(Guid.NewGuid()), - CancellationToken.None - ); - Assert.False(missing.IsSuccess); - - var ok = await new DeleteShowCommandHandler(db).Handle( - new DeleteShowCommand(show.Id), - CancellationToken.None - ); - Assert.True(ok.IsSuccess); - } - - [Fact] - public async Task RemoveChannelAd_RemovesOrNotFound() - { - var fixture = new TestDb(); - var channel = Channel.Create("c", "c", T0); - var ad = channel.AddAd(Guid.NewGuid()); - await using (var seed = fixture.New()) - { - seed.Channels.Add(channel); - await seed.SaveChangesAsync(CancellationToken.None); - } - - await using var db = fixture.New(); - var handler = new RemoveChannelAdCommandHandler(db); - - Assert.Equal( - ChannelErrors.AdNotFound, - ( - await handler.Handle( - new RemoveChannelAdCommand(channel.Id, Guid.NewGuid()), - CancellationToken.None - ) - ).Error - ); - Assert.True( - ( - await handler.Handle( - new RemoveChannelAdCommand(channel.Id, ad.Id), - CancellationToken.None - ) - ).IsSuccess - ); - } - - [Fact] - public async Task AddAndRemoveBumperTemplate_WorkThroughStorage() - { - var fixture = new TestDb(); - var channel = Channel.Create("c", "c", T0); - await using (var seed = fixture.New()) - { - seed.Channels.Add(channel); - await seed.SaveChangesAsync(CancellationToken.None); - } - - Guid templateId; - await using (var db = fixture.New()) - { - var added = await new AddBumperTemplateCommandHandler(db).Handle( - new AddBumperTemplateCommand(channel.Id, ""), - CancellationToken.None - ); - Assert.True(added.IsSuccess); - templateId = added.Value; - await db.SaveChangesAsync(CancellationToken.None); - } - - var storage = Substitute.For(); - await using (var db = fixture.New()) - { - var removed = await new RemoveBumperTemplateCommandHandler(db, storage).Handle( - new RemoveBumperTemplateCommand(channel.Id, templateId), - CancellationToken.None - ); - Assert.True(removed.IsSuccess); - } - await storage.Received(1).DeleteTemplateAsync(templateId, Arg.Any()); - - // дефолтный блок удалить нельзя - await using (var db = fixture.New()) - { - var def = await db - .Channels.Include(c => c.BumperTemplates) - .FirstAsync(c => c.Id == channel.Id); - var result = await new RemoveBumperTemplateCommandHandler(db, storage).Handle( - new RemoveBumperTemplateCommand(channel.Id, def.BumperTemplates[0].Id), - CancellationToken.None - ); - Assert.Equal(ChannelErrors.CannotRemoveDefaultBumperTemplate, result.Error); - } - } -} +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Broadcast.Bumpers; +using TeleWave.Application.Broadcast.GetChannel; +using TeleWave.Application.Broadcast.GetSchedule; +using TeleWave.Application.Broadcast.ListChannels; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Library.DeleteShow; +using TeleWave.Application.Library.GetShow; +using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using Xunit; + +namespace TeleWave.Application.Tests.Broadcast; + +public class QueryHandlersTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) => + new(db, new GroupStatsService(db, new GroupElementResolver(db))); + + + [Fact] + public async Task GetChannel_UnknownId_ReturnsNotFound() + { + var fixture = new TestDb(); + await using var db = fixture.New(); + var result = await new GetChannelQueryHandler(db).Handle( + new GetChannelQuery(Guid.NewGuid()), + CancellationToken.None + ); + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task ListChannels_ReturnsSummaries() + { + var fixture = new TestDb(); + await using (var seed = fixture.New()) + { + seed.Channels.Add(Channel.Create("A", "a", T0)); + seed.Channels.Add(Channel.Create("B", "b", T0)); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new ListChannelsQueryHandler(db).Handle( + new ListChannelsQuery(), + CancellationToken.None + ); + Assert.Equal(2, result.Count); + } + + [Fact] + public async Task GetChannelSchedule_ReturnsEntriesWithBumperLabel() + { + var fixture = new TestDb(); + var channel = Channel.Create("c", "c", T0); + var show = Show.Create("Show A", ShowKind.Series); + var variant = channel.BumperTemplates[0].Variants[0]; + var asset = MediaAsset.Register("Show.A.S01E01.mkv", ".mkv", MediaSource.Upload); + var program = ScheduleEntry.Program( + channel.Id, + asset.Id, + T0, + T0.AddMinutes(20), + show.Id, + 0 + ); + var bumper = ScheduleEntry.Bumper( + channel.Id, + Guid.NewGuid(), + T0.AddMinutes(20), + T0.AddMinutes(20).AddSeconds(8), + show.Id, + variant.Id + ); + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + seed.Channels.Add(channel); + seed.MediaAssets.Add(asset); + seed.ScheduleEntries.Add(program); + seed.ScheduleEntries.Add(bumper); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new GetChannelScheduleQueryHandler(db).Handle( + new GetChannelScheduleQuery(channel.Id, T0.AddMinutes(-5), T0.AddHours(1)), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(2, result.Value.Count); + var bumperDto = result.Value.Single(e => e.Kind == ScheduleEntryKind.Bumper); + Assert.NotNull(bumperDto.BumperName); + } + + [Fact] + public async Task GetShow_ReturnsDtoOrNotFound() + { + var fixture = new TestDb(); + var show = Show.Create("A", ShowKind.Series); + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var ok = await new GetShowQueryHandler(db).Handle( + new GetShowQuery(show.Id), + CancellationToken.None + ); + Assert.True(ok.IsSuccess); + + var missing = await new GetShowQueryHandler(db).Handle( + new GetShowQuery(Guid.NewGuid()), + CancellationToken.None + ); + Assert.False(missing.IsSuccess); + } + + [Fact] + public async Task DeleteShow_RemovesOrNotFound() + { + var fixture = new TestDb(); + var show = Show.Create("A", ShowKind.Series); + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var missing = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle( + new DeleteShowCommand(Guid.NewGuid()), + CancellationToken.None + ); + Assert.False(missing.IsSuccess); + + var ok = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle( + new DeleteShowCommand(show.Id), + CancellationToken.None + ); + Assert.True(ok.IsSuccess); + } + + + [Fact] + public async Task AddAndRemoveBumperTemplate_WorkThroughStorage() + { + var fixture = new TestDb(); + var channel = Channel.Create("c", "c", T0); + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + await seed.SaveChangesAsync(CancellationToken.None); + } + + Guid templateId; + await using (var db = fixture.New()) + { + var added = await new AddBumperTemplateCommandHandler(db).Handle( + new AddBumperTemplateCommand(channel.Id, ""), + CancellationToken.None + ); + Assert.True(added.IsSuccess); + templateId = added.Value; + await db.SaveChangesAsync(CancellationToken.None); + } + + var storage = Substitute.For(); + await using (var db = fixture.New()) + { + var removed = await new RemoveBumperTemplateCommandHandler(db, storage).Handle( + new RemoveBumperTemplateCommand(channel.Id, templateId), + CancellationToken.None + ); + Assert.True(removed.IsSuccess); + } + await storage.Received(1).DeleteTemplateAsync(templateId, Arg.Any()); + + // дефолтный блок удалить нельзя + await using (var db = fixture.New()) + { + var def = await db + .Channels.Include(c => c.BumperTemplates) + .FirstAsync(c => c.Id == channel.Id); + var result = await new RemoveBumperTemplateCommandHandler(db, storage).Handle( + new RemoveBumperTemplateCommand(channel.Id, def.BumperTemplates[0].Id), + CancellationToken.None + ); + Assert.Equal(ChannelErrors.CannotRemoveDefaultBumperTemplate, result.Error); + } + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Validators/ValidatorTests.cs b/backend/tests/TeleWave.Application.Tests/Validators/ValidatorTests.cs index 42a5b88..23de6fa 100644 --- a/backend/tests/TeleWave.Application.Tests/Validators/ValidatorTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Validators/ValidatorTests.cs @@ -1,142 +1,88 @@ -using TeleWave.Application.Admin.Users.CreateUser; -using TeleWave.Application.Admin.Users.ResetPassword; -using TeleWave.Application.Broadcast; -using TeleWave.Application.Broadcast.Bumpers; -using TeleWave.Application.Broadcast.CreateOverride; -using TeleWave.Application.Broadcast.UpdateChannelSettings; -using TeleWave.Application.Broadcast.UpdateChannelShow; -using TeleWave.Domain.Broadcast; -using Xunit; - -namespace TeleWave.Application.Tests.Validators; - -public class ValidatorTests -{ - [Fact] - public void UpdateChannelShow_RejectsBadWeightMultiplierAndWindows() - { - var v = new UpdateChannelShowCommandValidator(); - - var good = new UpdateChannelShowCommand( - Guid.NewGuid(), - Guid.NewGuid(), - 5, - BlockMode.Count, - 3, - true, - 4, - [new HourWindowInput(18, 23)] - ); - Assert.True(v.Validate(good).IsValid); - - var badMultiplier = good with { PreferredWeightMultiplier = 0 }; - Assert.False(v.Validate(badMultiplier).IsValid); - - var badWindow = good with { PreferredHours = [new HourWindowInput(23, 20)] }; - Assert.False(v.Validate(badWindow).IsValid); - - var badWeight = good with { Weight = 0 }; - Assert.False(v.Validate(badWeight).IsValid); - } - - [Fact] - public void UpdateBumperTextVariant_ChecksLengthsAndWeight() - { - var v = new UpdateBumperTextVariantCommandValidator(); - - var good = new UpdateBumperTextVariantCommand( - Guid.NewGuid(), - Guid.NewGuid(), - Guid.NewGuid(), - "Name", - BumperTextKind.NowNext, - "NOW", - "NEXT", - "l1", - "l2", - BumperTrigger.Both, - 3 - ); - Assert.True(v.Validate(good).IsValid); - - Assert.False(v.Validate(good with { Name = "" }).IsValid); - Assert.False(v.Validate(good with { Weight = -1 }).IsValid); - Assert.False(v.Validate(good with { Line1 = new string('x', 121) }).IsValid); - } - - [Fact] - public void UpdateChannelSettings_ChecksRanges() - { - var v = new UpdateChannelSettingsCommandValidator(); - var bumper = new BumperSettingsInput( - BumperFont.Sans, - 10, - BumperSelection.Rotation, - 0.5, - 0.5 - ); - - var good = new UpdateChannelSettingsCommand( - Guid.NewGuid(), - "Name", - true, - AdInsertion.BetweenBlocks, - 2, - true, - bumper, - null - ); - Assert.True(v.Validate(good).IsValid); - - Assert.False(v.Validate(good with { Name = "" }).IsValid); - Assert.False(v.Validate(good with { AdsPerBreak = 99 }).IsValid); - Assert.False( - v.Validate(good with { Bumper = bumper with { ShowChangeChance = 2 } }).IsValid - ); - } - - [Fact] - public void CreateOverride_RequiresShows_WithValidWeights() - { - var v = new CreateProgrammingOverrideCommandValidator(); - - var good = new CreateProgrammingOverrideCommand( - Guid.NewGuid(), - OverrideMode.Boost, - OverrideRecurrence.OneTime, - DateTimeOffset.UtcNow, - DateTimeOffset.UtcNow.AddHours(1), - null, - null, - null, - [new OverrideShowInput(Guid.NewGuid(), 3)] - ); - Assert.True(v.Validate(good).IsValid); - - Assert.False(v.Validate(good with { Shows = [] }).IsValid); - Assert.False( - v.Validate(good with { Shows = [new OverrideShowInput(Guid.NewGuid(), 0)] }).IsValid - ); - } - - [Fact] - public void ResetUserPassword_RequiresMinLength() - { - var v = new ResetUserPasswordCommandValidator(); - - Assert.True(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "Password1")).IsValid); - Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "short")).IsValid); - Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.Empty, "Password1")).IsValid); - } - - [Fact] - public void CreateUser_ChecksNameAndPassword() - { - var v = new CreateUserCommandValidator(); - - Assert.True(v.Validate(new CreateUserCommand("bob", "Password1", Guid.NewGuid())).IsValid); - Assert.False(v.Validate(new CreateUserCommand("ab", "Password1", Guid.NewGuid())).IsValid); - Assert.False(v.Validate(new CreateUserCommand("bob", "short", Guid.NewGuid())).IsValid); - Assert.False(v.Validate(new CreateUserCommand("bob", "Password1", Guid.Empty)).IsValid); - } -} +using TeleWave.Application.Admin.Users.CreateUser; +using TeleWave.Application.Admin.Users.ResetPassword; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Broadcast.Bumpers; +using TeleWave.Application.Broadcast.UpdateChannelSettings; +using TeleWave.Domain.Broadcast; +using Xunit; + +namespace TeleWave.Application.Tests.Validators; + +public class ValidatorTests +{ + + [Fact] + public void UpdateBumperTextVariant_ChecksLengthsAndWeight() + { + var v = new UpdateBumperTextVariantCommandValidator(); + + var good = new UpdateBumperTextVariantCommand( + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + "Name", + BumperTextKind.NowNext, + "NOW", + "NEXT", + "l1", + "l2", + BumperTrigger.Both, + 3 + ); + Assert.True(v.Validate(good).IsValid); + + Assert.False(v.Validate(good with { Name = "" }).IsValid); + Assert.False(v.Validate(good with { Weight = -1 }).IsValid); + Assert.False(v.Validate(good with { Line1 = new string('x', 121) }).IsValid); + } + + [Fact] + public void UpdateChannelSettings_ChecksRanges() + { + var v = new UpdateChannelSettingsCommandValidator(); + var bumper = new BumperSettingsInput( + BumperFont.Sans, + 10, + BumperSelection.Rotation, + 0.5, + 0.5 + ); + + var good = new UpdateChannelSettingsCommand( + Guid.NewGuid(), + "Name", + true, + true, + bumper, + null + ); + Assert.True(v.Validate(good).IsValid); + + Assert.False(v.Validate(good with { Name = "" }).IsValid); + Assert.False( + v.Validate(good with { Bumper = bumper with { ShowChangeChance = 2 } }).IsValid + ); + } + + + [Fact] + public void ResetUserPassword_RequiresMinLength() + { + var v = new ResetUserPasswordCommandValidator(); + + Assert.True(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "Password1")).IsValid); + Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "short")).IsValid); + Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.Empty, "Password1")).IsValid); + } + + [Fact] + public void CreateUser_ChecksNameAndPassword() + { + var v = new CreateUserCommandValidator(); + + Assert.True(v.Validate(new CreateUserCommand("bob", "Password1", Guid.NewGuid())).IsValid); + Assert.False(v.Validate(new CreateUserCommand("ab", "Password1", Guid.NewGuid())).IsValid); + Assert.False(v.Validate(new CreateUserCommand("bob", "short", Guid.NewGuid())).IsValid); + Assert.False(v.Validate(new CreateUserCommand("bob", "Password1", Guid.Empty)).IsValid); + } +} diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/ChannelShowTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/ChannelShowTests.cs deleted file mode 100644 index bc3da73..0000000 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/ChannelShowTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -using TeleWave.Domain.Broadcast; -using Xunit; - -namespace TeleWave.Domain.Tests.Broadcast; - -public class ChannelShowTests -{ - private static ChannelShow NewShow() => - Channel - .Create("c", "c", DateTimeOffset.UnixEpoch) - .AddShow(Guid.NewGuid(), 1, BlockMode.Count, 1); - - [Fact] - public void Create_HasDefaultPreferredMultiplier_AndNoWindows() - { - var cs = NewShow(); - - Assert.Equal(ChannelShow.DefaultPreferredWeightMultiplier, cs.PreferredWeightMultiplier); - Assert.Empty(cs.PreferredHours); - Assert.True(cs.IsEnabled); - Assert.Equal(0, cs.NextEpisodeIndex); - } - - [Fact] - public void Update_ChangesCoreFields() - { - var cs = NewShow(); - - cs.Update(9, BlockMode.Duration, 45, false); - - Assert.Equal(9, cs.Weight); - Assert.Equal(BlockMode.Duration, cs.BlockMode); - Assert.Equal(45, cs.BlockValue); - Assert.False(cs.IsEnabled); - } - - [Fact] - public void SetPreferredHours_ClampsMultiplier_AndNormalizesWindows() - { - var cs = NewShow(); - - cs.SetPreferredHours( - 0, // < 1 → должно стать 1 - [ - (18, 23), // валидное - (10, 10), // start == end → отбрасывается - (5, 25), // end > 24 → отбрасывается - (-1, 3), // start < 0 → отбрасывается - (18, 23), // дубликат → схлопывается - ] - ); - - Assert.Equal(1, cs.PreferredWeightMultiplier); - var window = Assert.Single(cs.PreferredHours); - Assert.Equal(18, window.StartHour); - Assert.Equal(23, window.EndHour); - } - - [Fact] - public void SetPreferredHours_ReplacesPreviousSet() - { - var cs = NewShow(); - cs.SetPreferredHours(3, [(1, 2)]); - cs.SetPreferredHours(2, [(8, 9), (20, 22)]); - - Assert.Equal(2, cs.PreferredWeightMultiplier); - Assert.Equal(2, cs.PreferredHours.Count); - } - - [Fact] - public void IsPreferredAt_ChecksWindows() - { - var cs = NewShow(); - cs.SetPreferredHours(3, [(18, 23)]); - - Assert.True(cs.IsPreferredAt(18)); - Assert.True(cs.IsPreferredAt(22)); - Assert.False(cs.IsPreferredAt(23)); // конец исключается - Assert.False(cs.IsPreferredAt(9)); - } - - [Fact] - public void SetNextEpisodeIndex_MovesCursor() - { - var cs = NewShow(); - cs.SetNextEpisodeIndex(5); - Assert.Equal(5, cs.NextEpisodeIndex); - } -} diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/ChannelTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/ChannelTests.cs index 05840d3..e841012 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/ChannelTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/ChannelTests.cs @@ -1,167 +1,131 @@ -using TeleWave.Domain.Broadcast; -using Xunit; - -namespace TeleWave.Domain.Tests.Broadcast; - -public class ChannelTests -{ - private static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); - - private static Channel NewChannel() => Channel.Create("News", "news", Epoch); - - [Fact] - public void Create_SetsDefaults_AndSeedsDefaultBumperTemplate() - { - var channel = NewChannel(); - - Assert.NotEqual(Guid.Empty, channel.Id); - Assert.Equal("News", channel.Name); - Assert.Equal("news", channel.Slug); - Assert.True(channel.IsEnabled); - Assert.Equal(Epoch, channel.EpochUtc); - Assert.Equal(AdInsertion.BetweenBlocks, channel.AdInsertion); - Assert.False(channel.BumpersEnabled); - Assert.Equal(BumperSelection.Rotation, channel.BumperSelection); - Assert.Equal(1.0, channel.BumperShowChangeChance); - Assert.Equal(1.0, channel.BumperEpisodeChangeChance); - - var template = Assert.Single(channel.BumperTemplates); - Assert.True(template.IsDefault); - Assert.Equal(0, template.Position); - Assert.Single(template.Variants); // дефолтный подблок - } - - [Fact] - public void UpdateSettings_ChangesFields() - { - var channel = NewChannel(); - var filler = Guid.NewGuid(); - - channel.UpdateSettings("N2", false, AdInsertion.BetweenEpisodes, 4, true, filler); - - Assert.Equal("N2", channel.Name); - Assert.False(channel.IsEnabled); - Assert.Equal(AdInsertion.BetweenEpisodes, channel.AdInsertion); - Assert.Equal(4, channel.AdsPerBreak); - Assert.True(channel.BumpersEnabled); - Assert.Equal(filler, channel.FillerAssetId); - } - - [Theory] - [InlineData(-1, 2.0, -5.0, 1.0, 0.0)] - [InlineData(30, 0.3, 0.9, 0.3, 0.9)] - public void UpdateBumperSettings_ClampsChancesAndInterval( - int interval, - double show, - double episode, - double expectedShow, - double expectedEpisode - ) - { - var channel = NewChannel(); - - channel.UpdateBumperSettings( - BumperFont.Serif, - interval, - BumperSelection.WeightedRandom, - show, - episode - ); - - Assert.Equal(BumperFont.Serif, channel.BumperFont); - Assert.Equal(Math.Max(0, interval), channel.BumperMinIntervalMinutes); - Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection); - Assert.Equal(expectedShow, channel.BumperShowChangeChance); - Assert.Equal(expectedEpisode, channel.BumperEpisodeChangeChance); - } - - [Fact] - public void AddBumperTemplate_AppendsWithNextPosition() - { - var channel = NewChannel(); - - var t1 = channel.AddBumperTemplate("Block 2"); - var t2 = channel.AddBumperTemplate("Block 3"); - - Assert.Equal(1, t1.Position); - Assert.Equal(2, t2.Position); - Assert.False(t1.IsDefault); - Assert.Equal(3, channel.BumperTemplates.Count); - Assert.Equal(t1, channel.FindBumperTemplate(t1.Id)); - Assert.Null(channel.FindBumperTemplate(Guid.NewGuid())); - } - - [Fact] - public void RemoveBumperTemplate_CannotRemoveDefault_CanRemoveOthers() - { - var channel = NewChannel(); - var def = channel.BumperTemplates[0]; - var extra = channel.AddBumperTemplate("Block 2"); - - Assert.False(channel.RemoveBumperTemplate(def.Id)); - Assert.True(channel.RemoveBumperTemplate(extra.Id)); - Assert.False(channel.RemoveBumperTemplate(Guid.NewGuid())); - Assert.Single(channel.BumperTemplates); - } - - [Fact] - public void Shows_AddFindRemoveHas() - { - var channel = NewChannel(); - var showId = Guid.NewGuid(); - - var cs = channel.AddShow(showId, 5, BlockMode.Count, 3); - - Assert.Equal(showId, cs.ShowId); - Assert.Equal(5, cs.Weight); - Assert.True(channel.HasShow(showId)); - Assert.Equal(cs, channel.FindShow(cs.Id)); - Assert.True(channel.RemoveShow(cs.Id)); - Assert.False(channel.RemoveShow(cs.Id)); - Assert.False(channel.HasShow(showId)); - } - - [Fact] - public void Ads_AppendWithPosition_AndRemove() - { - var channel = NewChannel(); - var a1 = channel.AddAd(Guid.NewGuid()); - var a2 = channel.AddAd(Guid.NewGuid()); - - Assert.Equal(0, a1.Position); - Assert.Equal(1, a2.Position); - Assert.True(channel.HasAd(a1.MediaAssetId)); - Assert.True(channel.RemoveAd(a1.Id)); - Assert.False(channel.RemoveAd(a1.Id)); - } - - [Fact] - public void AddOverride_OneTime_AndWeekly() - { - var channel = NewChannel(); - - var one = channel.AddOverride(OverrideMode.Exclusive, Epoch, Epoch.AddHours(2)); - Assert.Equal(OverrideRecurrence.OneTime, one.Recurrence); - Assert.Equal(Epoch, one.StartsAtUtc); - - var weekly = channel.AddWeeklyOverride(OverrideMode.Boost, 6, 600, 720); - Assert.Equal(OverrideRecurrence.Weekly, weekly.Recurrence); - Assert.Equal(6, weekly.DayOfWeek); - Assert.Equal(600, weekly.StartMinute); - - Assert.Equal(2, channel.Overrides.Count); - Assert.True(channel.RemoveOverride(one.Id)); - Assert.False(channel.RemoveOverride(Guid.NewGuid())); - } - - [Fact] - public void Cursors_AreMovedBySetters() - { - var channel = NewChannel(); - channel.SetNextAdIndex(7); - channel.SetNextBumperIndex(3); - - Assert.Equal(7, channel.NextAdIndex); - Assert.Equal(3, channel.NextBumperIndex); - } -} +using TeleWave.Domain.Broadcast; +using Xunit; + +namespace TeleWave.Domain.Tests.Broadcast; + +public class ChannelTests +{ + private static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static Channel NewChannel() => Channel.Create("News", "news", Epoch); + + [Fact] + public void Create_SetsDefaults_AndSeedsDefaultBumperTemplate() + { + var channel = NewChannel(); + + Assert.NotEqual(Guid.Empty, channel.Id); + Assert.Equal("News", channel.Name); + Assert.Equal("news", channel.Slug); + Assert.True(channel.IsEnabled); + Assert.Equal(Epoch, channel.EpochUtc); + Assert.False(channel.BumpersEnabled); + Assert.Equal(BumperSelection.Rotation, channel.BumperSelection); + Assert.Equal(1.0, channel.BumperShowChangeChance); + Assert.Equal(1.0, channel.BumperEpisodeChangeChance); + Assert.Equal(Channel.DefaultUtcOffsetMinutes, channel.UtcOffsetMinutes); + Assert.Equal(Channel.DefaultDayStartTime, channel.DayStartTime); + + var template = Assert.Single(channel.BumperTemplates); + Assert.True(template.IsDefault); + Assert.Equal(0, template.Position); + Assert.Single(template.Variants); // дефолтный подблок + } + + [Fact] + public void UpdateSettings_ChangesFields() + { + var channel = NewChannel(); + var filler = Guid.NewGuid(); + + channel.UpdateSettings("N2", false, true, filler); + + Assert.Equal("N2", channel.Name); + Assert.False(channel.IsEnabled); + Assert.True(channel.BumpersEnabled); + Assert.Equal(filler, channel.FillerAssetId); + } + + [Theory] + [InlineData(-1, 2.0, -5.0, 1.0, 0.0)] + [InlineData(30, 0.3, 0.9, 0.3, 0.9)] + public void UpdateBumperSettings_ClampsChancesAndInterval( + int interval, + double show, + double episode, + double expectedShow, + double expectedEpisode + ) + { + var channel = NewChannel(); + + channel.UpdateBumperSettings( + BumperFont.Serif, + interval, + BumperSelection.WeightedRandom, + show, + episode + ); + + Assert.Equal(BumperFont.Serif, channel.BumperFont); + Assert.Equal(Math.Max(0, interval), channel.BumperMinIntervalMinutes); + Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection); + Assert.Equal(expectedShow, channel.BumperShowChangeChance); + Assert.Equal(expectedEpisode, channel.BumperEpisodeChangeChance); + } + + [Fact] + public void AddBumperTemplate_AppendsWithNextPosition() + { + var channel = NewChannel(); + + var t1 = channel.AddBumperTemplate("Block 2"); + var t2 = channel.AddBumperTemplate("Block 3"); + + Assert.Equal(1, t1.Position); + Assert.Equal(2, t2.Position); + Assert.False(t1.IsDefault); + Assert.Equal(3, channel.BumperTemplates.Count); + Assert.Equal(t1, channel.FindBumperTemplate(t1.Id)); + Assert.Null(channel.FindBumperTemplate(Guid.NewGuid())); + } + + [Fact] + public void RemoveBumperTemplate_CannotRemoveDefault_CanRemoveOthers() + { + var channel = NewChannel(); + var def = channel.BumperTemplates[0]; + var extra = channel.AddBumperTemplate("Block 2"); + + Assert.False(channel.RemoveBumperTemplate(def.Id)); + Assert.True(channel.RemoveBumperTemplate(extra.Id)); + Assert.False(channel.RemoveBumperTemplate(Guid.NewGuid())); + Assert.Single(channel.BumperTemplates); + } + + [Fact] + public void UpdateTimeSettings_ClampsOffsetAndDropsNonPositiveNumber() + { + var channel = NewChannel(); + + channel.UpdateTimeSettings(0, 99 * 60, new TimeOnly(5, 0)); + + // Номер 0 — не номер; смещение за пределами суток оторвало бы сетку от календаря. + Assert.Null(channel.Number); + Assert.Equal(14 * 60, channel.UtcOffsetMinutes); + Assert.Equal(new TimeOnly(5, 0), channel.DayStartTime); + } + + [Fact] + public void SetTemplate_LinksAndUnlinks() + { + var channel = NewChannel(); + var templateId = Guid.NewGuid(); + + channel.SetTemplate(templateId); + Assert.Equal(templateId, channel.TemplateId); + + channel.SetTemplate(null); + Assert.Null(channel.TemplateId); + } +} diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/DomainRecordFactoryTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/DomainRecordFactoryTests.cs index 2e9f725..9650dd8 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/DomainRecordFactoryTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/DomainRecordFactoryTests.cs @@ -1,56 +1,41 @@ -using TeleWave.Domain.Broadcast; -using TeleWave.Domain.Settings; -using Xunit; - -namespace TeleWave.Domain.Tests.Broadcast; - -/// Мелкие фабрики/сеттеры простых сущностей, которые нигде не покрыты напрямую. -public class DomainRecordFactoryTests -{ - [Fact] - public void ChannelShowHour_Contains_HalfOpenWindow() - { - var cs = Channel - .Create("c", "c", DateTimeOffset.UnixEpoch) - .AddShow(Guid.NewGuid(), 1, BlockMode.Count, 1); - cs.SetPreferredHours(2, [(9, 12)]); - var hour = Assert.Single(cs.PreferredHours); - - Assert.False(hour.Contains(8)); - Assert.True(hour.Contains(9)); - Assert.True(hour.Contains(11)); - Assert.False(hour.Contains(12)); - } - - [Fact] - public void BumperAsset_Create_StoresPair() - { - var channel = Guid.NewGuid(); - var template = Guid.NewGuid(); - var variant = Guid.NewGuid(); - var from = Guid.NewGuid(); - var to = Guid.NewGuid(); - var asset = Guid.NewGuid(); - - var b = BumperAsset.Create(channel, template, variant, from, to, "sig", asset); - - Assert.Equal(channel, b.ChannelId); - Assert.Equal(template, b.TemplateId); - Assert.Equal(variant, b.VariantId); - Assert.Equal(from, b.FromShowId); - Assert.Equal(to, b.ToShowId); - Assert.Equal("sig", b.Signature); - Assert.Equal(asset, b.MediaAssetId); - } - - [Fact] - public void AppSetting_CreateAndSetValue() - { - var s = AppSetting.Create("site.title", "TeleWave"); - Assert.Equal("site.title", s.Key); - Assert.Equal("TeleWave", s.Value); - - s.SetValue("New"); - Assert.Equal("New", s.Value); - } -} +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Settings; +using Xunit; + +namespace TeleWave.Domain.Tests.Broadcast; + +/// Мелкие фабрики/сеттеры простых сущностей, которые нигде не покрыты напрямую. +public class DomainRecordFactoryTests +{ + [Fact] + public void BumperAsset_Create_StoresPair() + { + var channel = Guid.NewGuid(); + var template = Guid.NewGuid(); + var variant = Guid.NewGuid(); + var from = Guid.NewGuid(); + var to = Guid.NewGuid(); + var asset = Guid.NewGuid(); + + var b = BumperAsset.Create(channel, template, variant, from, to, "sig", asset); + + Assert.Equal(channel, b.ChannelId); + Assert.Equal(template, b.TemplateId); + Assert.Equal(variant, b.VariantId); + Assert.Equal(from, b.FromShowId); + Assert.Equal(to, b.ToShowId); + Assert.Equal("sig", b.Signature); + Assert.Equal(asset, b.MediaAssetId); + } + + [Fact] + public void AppSetting_CreateAndSetValue() + { + var s = AppSetting.Create("site.title", "TeleWave"); + Assert.Equal("site.title", s.Key); + Assert.Equal("TeleWave", s.Value); + + s.SetValue("New"); + Assert.Equal("New", s.Value); + } +} diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/ProgrammingOverrideTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/ProgrammingOverrideTests.cs deleted file mode 100644 index 4ba329d..0000000 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/ProgrammingOverrideTests.cs +++ /dev/null @@ -1,85 +0,0 @@ -using TeleWave.Domain.Broadcast; -using Xunit; - -namespace TeleWave.Domain.Tests.Broadcast; - -public class ProgrammingOverrideTests -{ - private static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); - - private static Channel NewChannel() => Channel.Create("c", "c", Epoch); - - [Fact] - public void OneTime_Covers_OnlyInsideWindow() - { - var ovr = NewChannel() - .AddOverride(OverrideMode.Exclusive, Epoch.AddHours(1), Epoch.AddHours(3)); - - Assert.False(ovr.Covers(Epoch.AddMinutes(59))); - Assert.True(ovr.Covers(Epoch.AddHours(1))); - Assert.True(ovr.Covers(Epoch.AddHours(2))); - Assert.False(ovr.Covers(Epoch.AddHours(3))); // конец исключается - } - - [Fact] - public void AddShow_AttachesWithWeight() - { - var ovr = NewChannel().AddOverride(OverrideMode.Boost, Epoch, Epoch.AddHours(1)); - var showId = Guid.NewGuid(); - - var os = ovr.AddShow(showId, 4); - - Assert.Equal(showId, os.ShowId); - Assert.Equal(4, os.Weight); - Assert.Single(ovr.Shows); - } - - [Fact] - public void Weekly_Covers_MatchingDayAndMinuteWindow() - { - // Epoch = 2026-01-01 — четверг (DayOfWeek=4). - var day = (int)Epoch.UtcDateTime.DayOfWeek; - var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 600, 720); // 10:00–12:00 - - Assert.True(ovr.Covers(Epoch.AddMinutes(600))); // 10:00 того же дня - Assert.True(ovr.Covers(Epoch.AddMinutes(719))); // 11:59 - Assert.False(ovr.Covers(Epoch.AddMinutes(720))); // 12:00 — конец исключается - Assert.False(ovr.Covers(Epoch.AddMinutes(599))); // 09:59 - } - - [Fact] - public void Weekly_Covers_NextWeekSameDay() - { - var day = (int)Epoch.UtcDateTime.DayOfWeek; - var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 0, 1440); - - Assert.True(ovr.Covers(Epoch.AddDays(7).AddHours(5))); - Assert.False(ovr.Covers(Epoch.AddDays(1).AddHours(5))); // другой день недели - } - - [Fact] - public void Weekly_Covers_WindowCrossingMidnight() - { - // Epoch = 2026-01-01 — четверг (DayOfWeek=4). Окно 23:00–01:00 пересекает полночь на пятницу. - var day = (int)Epoch.UtcDateTime.DayOfWeek; - var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 1380, 60); // 23:00–01:00 - - Assert.True(ovr.Covers(Epoch.AddMinutes(1380))); // чт 23:00 — начало - Assert.True(ovr.Covers(Epoch.AddMinutes(1439))); // чт 23:59 - Assert.True(ovr.Covers(Epoch.AddDays(1).AddMinutes(0))); // пт 00:00 (следующий день) - Assert.True(ovr.Covers(Epoch.AddDays(1).AddMinutes(59))); // пт 00:59 - Assert.False(ovr.Covers(Epoch.AddDays(1).AddMinutes(60))); // пт 01:00 — конец исключается - Assert.False(ovr.Covers(Epoch.AddMinutes(1379))); // чт 22:59 — до начала - Assert.False(ovr.Covers(Epoch.AddHours(12))); // чт полдень — вне окна - } - - [Fact] - public void Weekly_EmptyWindow_WhenStartEqualsEnd_CoversNothing() - { - var day = (int)Epoch.UtcDateTime.DayOfWeek; - var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 600, 600); - - Assert.False(ovr.Covers(Epoch.AddMinutes(600))); - Assert.False(ovr.Covers(Epoch.AddMinutes(700))); - } -} diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs deleted file mode 100644 index 148c60e..0000000 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs +++ /dev/null @@ -1,877 +0,0 @@ -using TeleWave.Domain.Broadcast; -using TeleWave.Domain.Broadcast.Scheduling; -using Xunit; - -namespace TeleWave.Domain.Tests.Broadcast; - -public class SchedulePlannerTests -{ - private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); - - /// Источник случайности с фиксированной (циклической) последовательностью значений. - private sealed class FixedRandom(params int[] values) : IRandomSource - { - private int _i; - - public int Next(int maxExclusive) => - values.Length == 0 ? 0 : values[_i++ % values.Length] % maxExclusive; - } - - private static Dictionary Durations(params (Guid Id, int Minutes)[] items) => - items.ToDictionary(x => x.Id, x => TimeSpan.FromMinutes(x.Minutes)); - - private static readonly Guid DefaultTemplate = Guid.NewGuid(); - private static readonly Guid DefaultVariant = Guid.NewGuid(); - - /// Конфиг заставок с одним дефолтным подблоком (8с) заданного триггера, если варианты не заданы. - private static PlannerBumperConfig Bumper( - bool enabled, - BumperTrigger trigger, - TimeSpan minInterval, - BumperSelection selection = BumperSelection.Rotation, - params PlannerBumperVariant[] variants - ) => - new( - enabled, - minInterval, - selection, - variants.Length == 0 - ? - [ - new PlannerBumperVariant( - DefaultVariant, - DefaultTemplate, - TimeSpan.FromSeconds(8), - trigger - ), - ] - : variants - ); - - [Fact] - public void Count_Block_ProducesConsecutiveEpisodes_BackToBack() - { - var cs = Guid.NewGuid(); - var show = Guid.NewGuid(); - Guid[] eps = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; - - var input = new PlannerInput( - ChannelId: Guid.NewGuid(), - AdInsertion: AdInsertion.BetweenBlocks, - AdsPerBreak: 1, - NextAdIndex: 0, - Shows: [new PlannerShow(cs, show, 1, BlockMode.Count, 3, eps, 0)], - AdPool: [], - Durations: Durations((eps[0], 2), (eps[1], 2), (eps[2], 2), (eps[3], 2)), - Overrides: [], - StartTime: Start, - HorizonEnd: Start.AddSeconds(1) - ); - - var result = SchedulePlanner.Plan(input, new FixedRandom(0)); - - Assert.Equal(3, result.Entries.Count); - Assert.All(result.Entries, e => Assert.Equal(ScheduleEntryKind.Program, e.Kind)); - Assert.Equal([0, 1, 2], result.Entries.Select(e => e.EpisodeIndex)); - Assert.Equal(Start, result.Entries[0].StartsAtUtc); - Assert.Equal(result.Entries[0].EndsAtUtc, result.Entries[1].StartsAtUtc); - Assert.Equal(result.Entries[1].EndsAtUtc, result.Entries[2].StartsAtUtc); - Assert.Equal(3, result.NextEpisodeIndexByChannelShow[cs]); - } - - [Fact] - public void Count_Block_WrapsAtEndOfSeries() - { - var cs = Guid.NewGuid(); - Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()]; - - var input = BaseInput( - [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 3, eps, 0)], - Durations((eps[0], 2), (eps[1], 2)), - horizonEnd: Start.AddSeconds(1) - ); - - var result = SchedulePlanner.Plan(input, new FixedRandom(0)); - - Assert.Equal([0, 1, 0], result.Entries.Select(e => e.EpisodeIndex)); - Assert.Equal(1, result.NextEpisodeIndexByChannelShow[cs]); - } - - [Fact] - public void Duration_Block_TakesEpisodesUntilBudgetReached() - { - var cs = Guid.NewGuid(); - Guid[] eps = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; - - var input = BaseInput( - [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Duration, 25, eps, 0)], - Durations((eps[0], 10), (eps[1], 10), (eps[2], 10), (eps[3], 10)), - horizonEnd: Start.AddMinutes(30) - ); - - var result = SchedulePlanner.Plan(input, new FixedRandom(0)); - - // 10+10+10 = 30 >= 25 → три серии. - Assert.Equal(3, result.Entries.Count); - Assert.Equal([0, 1, 2], result.Entries.Select(e => e.EpisodeIndex)); - Assert.Equal(3, result.NextEpisodeIndexByChannelShow[cs]); - } - - [Fact] - public void AdsBetweenBlocks_InsertsAdAfterBlock() - { - var cs = Guid.NewGuid(); - Guid ep = Guid.NewGuid(), - ad0 = Guid.NewGuid(), - ad1 = Guid.NewGuid(); - - var input = new PlannerInput( - Guid.NewGuid(), - AdInsertion.BetweenBlocks, - AdsPerBreak: 1, - NextAdIndex: 0, - Shows: [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, [ep], 0)], - AdPool: [ad0, ad1], - Durations: Durations((ep, 20), (ad0, 1), (ad1, 1)), - Overrides: [], - StartTime: Start, - HorizonEnd: Start.AddSeconds(1) - ); - - var result = SchedulePlanner.Plan(input, new FixedRandom(0)); - - Assert.Equal(2, result.Entries.Count); - Assert.Equal(ScheduleEntryKind.Program, result.Entries[0].Kind); - Assert.Equal(ScheduleEntryKind.Ad, result.Entries[1].Kind); - Assert.Equal(ad0, result.Entries[1].MediaAssetId); - Assert.Equal(1, result.NextAdIndex); - } - - [Fact] - public void AdsBetweenEpisodes_InsertsAdAfterEachEpisode() - { - var cs = Guid.NewGuid(); - Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()]; - Guid ad = Guid.NewGuid(); - - var input = new PlannerInput( - Guid.NewGuid(), - AdInsertion.BetweenEpisodes, - AdsPerBreak: 1, - NextAdIndex: 0, - Shows: [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 2, eps, 0)], - AdPool: [ad], - Durations: Durations((eps[0], 20), (eps[1], 20), (ad, 1)), - Overrides: [], - StartTime: Start, - HorizonEnd: Start.AddSeconds(1) - ); - - var result = SchedulePlanner.Plan(input, new FixedRandom(0)); - - Assert.Equal( - [ - ScheduleEntryKind.Program, - ScheduleEntryKind.Ad, - ScheduleEntryKind.Program, - ScheduleEntryKind.Ad, - ], - result.Entries.Select(e => e.Kind) - ); - Assert.Equal(2, result.NextAdIndex); - } - - [Fact] - public void WeightedPick_RespectsRoll() - { - var a = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 3, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var b = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); - - var pickA = SchedulePlanner.Plan( - BaseInput([a, b], durations, Start.AddSeconds(1)), - new FixedRandom(0) - ); - var pickB = SchedulePlanner.Plan( - BaseInput([a, b], durations, Start.AddSeconds(1)), - new FixedRandom(3) - ); - - Assert.Equal(a.ShowId, pickA.Entries[0].ShowId); - Assert.Equal(b.ShowId, pickB.Entries[0].ShowId); - } - - [Fact] - public void ExclusiveOverride_ForcesSingleShow() - { - var a = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 10, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var b = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); - - var input = new PlannerInput( - Guid.NewGuid(), - AdInsertion.BetweenBlocks, - 0, - 0, - [a, b], - [], - durations, - Overrides: - [ - new PlannerOverride( - OverrideMode.Exclusive, - [new PlannerOverrideShow(b.ShowId, 1)], - StartsAtUtc: Start, - EndsAtUtc: Start.AddHours(1) - ), - ], - StartTime: Start, - HorizonEnd: Start.AddSeconds(1) - ); - - var result = SchedulePlanner.Plan(input, new FixedRandom(0)); - - Assert.Equal(b.ShowId, result.Entries[0].ShowId); - } - - [Fact] - public void Bumpers_InsertedOnTransitionBetweenDifferentShows_BackToBack() - { - var a = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var b = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); - - var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with - { - Bumpers = Bumper(true, BumperTrigger.OnShowChange, TimeSpan.Zero), - }; - - // Чередуем выбор: roll 0 → a, roll 1 → b (веса 1/1, total 2). - var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); - - // Перед первым блоком заставки нет; далее по одной на каждый переход. - Assert.Equal(ScheduleEntryKind.Program, result.Entries[0].Kind); - var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList(); - Assert.NotEmpty(bumpers); - - var first = result.Entries[1]; - Assert.Equal(ScheduleEntryKind.Bumper, first.Kind); - Assert.Equal(Guid.Empty, first.MediaAssetId); // ассет подставит оркестратор - Assert.Equal(a.ShowId, first.FromShowId); - Assert.Equal(b.ShowId, first.ToShowId); - Assert.Equal(DefaultTemplate, first.BumperTemplateId); - Assert.Equal(TimeSpan.FromSeconds(8), first.EndsAtUtc - first.StartsAtUtc); - // Встык: программа → заставка → программа. - Assert.Equal(result.Entries[0].EndsAtUtc, first.StartsAtUtc); - Assert.Equal(first.EndsAtUtc, result.Entries[2].StartsAtUtc); - } - - [Fact] - public void Bumpers_SkippedForSameShow_WhenOnlyBetweenDifferentShows() - { - var cs = Guid.NewGuid(); - Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()]; - - var input = BaseInput( - [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, eps, 0)], - Durations((eps[0], 20), (eps[1], 20)), - horizonEnd: Start.AddMinutes(50) - ) with - { - Bumpers = Bumper(true, BumperTrigger.OnShowChange, TimeSpan.Zero), - }; - - var result = SchedulePlanner.Plan(input, new FixedRandom(0)); - - Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); - } - - [Fact] - public void Bumpers_InsertedForSameShow_WhenNotRestrictedToDifferent() - { - var cs = Guid.NewGuid(); - Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()]; - - var input = BaseInput( - [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, eps, 0)], - Durations((eps[0], 20), (eps[1], 20)), - horizonEnd: Start.AddMinutes(50) - ) with - { - Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero), - }; - - var result = SchedulePlanner.Plan(input, new FixedRandom(0)); - - Assert.Contains(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); - } - - [Fact] - public void Bumpers_MinInterval_SuppressesTooFrequentBumpers() - { - var a = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var b = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); - - // Два перехода в горизонте (~на 20-й и ~40-й минуте), но интервал 30 мин пропускает второй. - var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with - { - Bumpers = Bumper(true, BumperTrigger.OnShowChange, TimeSpan.FromMinutes(30)), - }; - - var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); - - Assert.Equal(1, result.Entries.Count(e => e.Kind == ScheduleEntryKind.Bumper)); - } - - [Fact] - public void Bumpers_Rotation_CyclesTemplatesAndAdvancesCursor() - { - var a = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var b = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); - Guid t0 = Guid.NewGuid(), - t1 = Guid.NewGuid(); - - var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with - { - Bumpers = Bumper( - true, - BumperTrigger.Both, - TimeSpan.Zero, - BumperSelection.Rotation, - new PlannerBumperVariant( - Guid.NewGuid(), - t0, - TimeSpan.FromSeconds(8), - BumperTrigger.Both - ), - new PlannerBumperVariant( - Guid.NewGuid(), - t1, - TimeSpan.FromSeconds(4), - BumperTrigger.Both - ) - ), - }; - - var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); - - var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList(); - Assert.True(bumpers.Count >= 2); - // Ротация: первый подблок → t0 (8с), второй → t1 (4с). Все — плейсхолдеры по паре шоу. - Assert.Equal(t0, bumpers[0].BumperTemplateId); - Assert.Equal(TimeSpan.FromSeconds(8), bumpers[0].EndsAtUtc - bumpers[0].StartsAtUtc); - Assert.Equal(t1, bumpers[1].BumperTemplateId); - Assert.Equal(TimeSpan.FromSeconds(4), bumpers[1].EndsAtUtc - bumpers[1].StartsAtUtc); - Assert.All(bumpers, e => Assert.Equal(Guid.Empty, e.MediaAssetId)); - Assert.Equal(bumpers.Count, result.NextBumperIndex); - } - - [Fact] - public void Bumpers_AlwaysFirst_AlwaysUsesFirstTemplate() - { - var a = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var b = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); - Guid t0 = Guid.NewGuid(), - t1 = Guid.NewGuid(); - - var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with - { - Bumpers = Bumper( - true, - BumperTrigger.Both, - TimeSpan.Zero, - BumperSelection.AlwaysFirst, - new PlannerBumperVariant( - Guid.NewGuid(), - t0, - TimeSpan.FromSeconds(8), - BumperTrigger.Both - ), - new PlannerBumperVariant( - Guid.NewGuid(), - t1, - TimeSpan.FromSeconds(4), - BumperTrigger.Both - ) - ), - }; - - var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); - - var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList(); - Assert.True(bumpers.Count >= 2); - Assert.All(bumpers, e => Assert.Equal(t0, e.BumperTemplateId)); - Assert.Equal(0, result.NextBumperIndex); // курсор ротации не двигается - } - - [Fact] - public void Bumpers_NoTemplates_NoBumpers() - { - var a = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var b = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); - - var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with - { - Bumpers = new PlannerBumperConfig( - true, - MinInterval: TimeSpan.Zero, - Selection: BumperSelection.Rotation, - Variants: [] - ), - }; - - var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); - - Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); - } - - [Fact] - public void Bumpers_Disabled_ProduceNoBumperEntries() - { - var a = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var b = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); - - var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with - { - Bumpers = Bumper(false, BumperTrigger.OnShowChange, TimeSpan.Zero), - }; - - var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); - - Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); - } - - [Fact] - public void Bumpers_ShowChangeChanceZero_SuppressesBumpersOnShowChange() - { - // Два шоу, ротация выбора подблока (без random в пути заставки) → чередование гарантировано, - // каждый переход — смена шоу. ShowChangeChance=0 глушит их, EpisodeChangeChance=1 не при чём. - var a = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var b = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); - - var input = BaseInput([a, b], durations, Start.AddMinutes(120)) with - { - Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero) with - { - ShowChangeChance = 0.0, - }, - }; - - var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); - - Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); - } - - [Fact] - public void Bumpers_EpisodeChangeChanceZero_SuppressesBumpersBetweenEpisodes() - { - // Одно шоу → каждый переход между блоками одного шоу. EpisodeChangeChance=0 глушит все заставки. - var a = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var durations = Durations((a.EpisodeAssetIds[0], 20)); - - var input = BaseInput([a], durations, Start.AddMinutes(120)) with - { - Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero) with - { - EpisodeChangeChance = 0.0, - }, - }; - - var result = SchedulePlanner.Plan(input, new FixedRandom(0)); - - Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); - } - - [Fact] - public void Bumpers_WeightedRandom_NeverPicksZeroWeightVariant() - { - // Взвешенный выбор: подблок с весом 0 не выбирается никогда, весь трафик уходит на t1 (вес 5). - var a = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var durations = Durations((a.EpisodeAssetIds[0], 20)); - Guid t0 = Guid.NewGuid(), - t1 = Guid.NewGuid(); - - var input = BaseInput([a], durations, Start.AddMinutes(120)) with - { - Bumpers = Bumper( - true, - BumperTrigger.Both, - TimeSpan.Zero, - BumperSelection.WeightedRandom, - new PlannerBumperVariant( - Guid.NewGuid(), - t0, - TimeSpan.FromSeconds(8), - BumperTrigger.Both, - 0 - ), - new PlannerBumperVariant( - Guid.NewGuid(), - t1, - TimeSpan.FromSeconds(8), - BumperTrigger.Both, - 5 - ) - ), - }; - - var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1, 2, 3, 4)); - - var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList(); - Assert.NotEmpty(bumpers); - Assert.All(bumpers, e => Assert.Equal(t1, e.BumperTemplateId)); - } - - [Fact] - public void WeeklyOverride_AppliesOnMatchingDayAndTime() - { - // Start = 2026-01-01 (четверг, DayOfWeek=4), полночь. Еженедельный override на четверг 00:00–01:00. - var a = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var b = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); - - var input = BaseInput([a, b], durations, Start.AddSeconds(1)) with - { - Overrides = - [ - new PlannerOverride( - OverrideMode.Exclusive, - [new PlannerOverrideShow(b.ShowId, 1)], - OverrideRecurrence.Weekly, - DayOfWeek: (int)Start.UtcDateTime.DayOfWeek, - StartMinute: 0, - EndMinute: 60 - ), - ], - }; - - var result = SchedulePlanner.Plan(input, new FixedRandom(0)); - - Assert.Equal(b.ShowId, result.Entries[0].ShowId); - } - - [Fact] - public void WeeklyOverride_IgnoredOnOtherDay() - { - // Override на другой день недели → базовая ротация (FixedRandom(0) берёт первое шоу — a). - var a = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var b = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); - var otherDay = ((int)Start.UtcDateTime.DayOfWeek + 1) % 7; - - var input = BaseInput([a, b], durations, Start.AddSeconds(1)) with - { - Overrides = - [ - new PlannerOverride( - OverrideMode.Exclusive, - [new PlannerOverrideShow(b.ShowId, 1)], - OverrideRecurrence.Weekly, - DayOfWeek: otherDay, - StartMinute: 0, - EndMinute: 1440 - ), - ], - }; - - var result = SchedulePlanner.Plan(input, new FixedRandom(0)); - - Assert.Equal(a.ShowId, result.Entries[0].ShowId); - } - - [Fact] - public void PreferredHours_BoostsWeight_InsideWindow() - { - // Час старта — 0 (UTC). Окно b покрывает его, множитель 100 → b доминирует, хотя базовые веса равны. - var a = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var b = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0, - PreferredHours: [new PlannerHourWindow(0, 24)], - PreferredWeightMultiplier: 100 - ); - var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); - - var input = BaseInput([a, b], durations, Start.AddMinutes(60)); - - var result = SchedulePlanner.Plan(input, new FixedRandom(50)); - - var programs = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList(); - Assert.NotEmpty(programs); - Assert.All(programs, e => Assert.Equal(b.ShowId, e.ShowId)); - } - - [Fact] - public void PreferredHours_OutsideWindow_KeepsBaseWeight() - { - // Час старта — 0, окно b — [10,12): не покрывает → буста нет, при FixedRandom(50) берётся первое шоу (a). - var a = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0 - ); - var b = new PlannerShow( - Guid.NewGuid(), - Guid.NewGuid(), - 1, - BlockMode.Count, - 1, - [Guid.NewGuid()], - 0, - PreferredHours: [new PlannerHourWindow(10, 12)], - PreferredWeightMultiplier: 100 - ); - var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); - - var input = BaseInput([a, b], durations, Start.AddMinutes(60)); - - var result = SchedulePlanner.Plan(input, new FixedRandom(50)); - - var programs = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList(); - Assert.NotEmpty(programs); - Assert.All(programs, e => Assert.Equal(a.ShowId, e.ShowId)); - } - - [Fact] - public void NoPlayableShows_ReturnsEmpty() - { - var input = BaseInput([], new Dictionary(), Start.AddHours(1)); - - var result = SchedulePlanner.Plan(input, new FixedRandom(0)); - - Assert.Empty(result.Entries); - } - - private static PlannerInput BaseInput( - IReadOnlyList shows, - IReadOnlyDictionary durations, - DateTimeOffset horizonEnd - ) => - new( - ChannelId: Guid.NewGuid(), - AdInsertion: AdInsertion.BetweenBlocks, - AdsPerBreak: 0, - NextAdIndex: 0, - Shows: shows, - AdPool: [], - Durations: durations, - Overrides: [], - StartTime: Start, - HorizonEnd: horizonEnd - ); -} diff --git a/backend/tests/TeleWave.Domain.Tests/Library/CollectionTests.cs b/backend/tests/TeleWave.Domain.Tests/Library/CollectionTests.cs new file mode 100644 index 0000000..195e47c --- /dev/null +++ b/backend/tests/TeleWave.Domain.Tests/Library/CollectionTests.cs @@ -0,0 +1,106 @@ +using TeleWave.Domain.Library; +using Xunit; + +namespace TeleWave.Domain.Tests.Library; + +public class CollectionTests +{ + private static IReadOnlyList Order(Collection collection) => + collection.Items.OrderBy(i => i.Position).Select(i => i.ShowId).ToList(); + + [Fact] + public void AddShow_AppendsToEnd() + { + var collection = Collection.Create("Терминатор"); + var first = Guid.NewGuid(); + var second = Guid.NewGuid(); + + collection.AddShow(first); + collection.AddShow(second); + + Assert.Equal([first, second], Order(collection)); + } + + [Fact] + public void AddShow_Duplicate_ReturnsNull() + { + var collection = Collection.Create("Терминатор"); + var showId = Guid.NewGuid(); + + collection.AddShow(showId); + + Assert.Null(collection.AddShow(showId)); + Assert.Single(collection.Items); + } + + [Fact] + public void RemoveShow_ReturnsFalse_WhenNotPresent() + { + var collection = Collection.Create("Терминатор"); + + Assert.False(collection.RemoveShow(Guid.NewGuid())); + } + + [Fact] + public void Reorder_AppliesRequestedOrder() + { + var collection = Collection.Create("Терминатор"); + var a = Guid.NewGuid(); + var b = Guid.NewGuid(); + var c = Guid.NewGuid(); + collection.AddShow(a); + collection.AddShow(b); + collection.AddShow(c); + + collection.Reorder([c, a, b]); + + Assert.Equal([c, a, b], Order(collection)); + Assert.Equal([0, 1, 2], collection.Items.OrderBy(i => i.Position).Select(i => i.Position)); + } + + [Fact] + public void Reorder_KeepsUnmentionedAfterRequested_InOriginalOrder() + { + var collection = Collection.Create("Терминатор"); + var a = Guid.NewGuid(); + var b = Guid.NewGuid(); + var c = Guid.NewGuid(); + collection.AddShow(a); + collection.AddShow(b); + collection.AddShow(c); + + // Перетащили только один элемент — остальные не должны потеряться или перемешаться. + collection.Reorder([c]); + + Assert.Equal([c, a, b], Order(collection)); + } + + [Fact] + public void Reorder_IgnoresUnknownShows() + { + var collection = Collection.Create("Терминатор"); + var a = Guid.NewGuid(); + collection.AddShow(a); + + collection.Reorder([Guid.NewGuid(), a]); + + Assert.Equal([a], Order(collection)); + } + + [Fact] + public void RemoveThenAdd_DoesNotReusePosition() + { + var collection = Collection.Create("Терминатор"); + var a = Guid.NewGuid(); + var b = Guid.NewGuid(); + collection.AddShow(a); + collection.AddShow(b); + + collection.RemoveShow(a); + var c = Guid.NewGuid(); + collection.AddShow(c); + + // Новая часть встаёт после существующих, а не занимает освободившийся номер. + Assert.Equal([b, c], Order(collection)); + } +} diff --git a/backend/tests/TeleWave.Domain.Tests/Library/ShowGenreTests.cs b/backend/tests/TeleWave.Domain.Tests/Library/ShowGenreTests.cs new file mode 100644 index 0000000..bf0aa8d --- /dev/null +++ b/backend/tests/TeleWave.Domain.Tests/Library/ShowGenreTests.cs @@ -0,0 +1,114 @@ +using TeleWave.Domain.Library; +using Xunit; + +namespace TeleWave.Domain.Tests.Library; + +public class ShowGenreTests +{ + private static Show NewShow() => Show.Create("A", ShowKind.Series); + + [Fact] + public void SetGenres_MarksRequestedPrimary() + { + var show = NewShow(); + var action = Guid.NewGuid(); + var comedy = Guid.NewGuid(); + + show.SetGenres([action, comedy], comedy); + + Assert.Equal(2, show.Genres.Count); + Assert.Equal(comedy, show.PrimaryGenreId); + } + + [Fact] + public void SetGenres_FallsBackToFirst_WhenPrimaryNotInSet() + { + var show = NewShow(); + var action = Guid.NewGuid(); + var comedy = Guid.NewGuid(); + + show.SetGenres([action, comedy], Guid.NewGuid()); + + // Шоу с жанрами не должно оставаться без основного — иначе список показывал бы прочерк. + Assert.Equal(action, show.PrimaryGenreId); + Assert.Single(show.Genres, g => g.IsPrimary); + } + + [Fact] + public void SetGenres_DropsDuplicatesAndEmpty() + { + var show = NewShow(); + var action = Guid.NewGuid(); + + show.SetGenres([action, action, Guid.Empty]); + + Assert.Single(show.Genres); + Assert.Equal(action, show.PrimaryGenreId); + } + + [Fact] + public void SetGenres_ReplacesPreviousSet() + { + var show = NewShow(); + var first = Guid.NewGuid(); + var second = Guid.NewGuid(); + + show.SetGenres([first]); + show.SetGenres([second]); + + Assert.Single(show.Genres); + Assert.Equal(second, show.PrimaryGenreId); + } + + [Fact] + public void SetGenres_Empty_LeavesNoPrimary() + { + var show = NewShow(); + show.SetGenres([Guid.NewGuid()]); + + show.SetGenres([]); + + Assert.Empty(show.Genres); + Assert.Null(show.PrimaryGenreId); + } +} + +public class GenreTests +{ + [Theory] + [InlineData(" Sci-Fi ", "sci-fi")] + [InlineData("Action & Adventure", "action & adventure")] + [InlineData("БОЕВИК", "боевик")] + [InlineData(" ", "")] + [InlineData(null, "")] + public void Normalize_CollapsesCaseAndSpacing(string? input, string expected) => + Assert.Equal(expected, GenreAlias.Normalize(input)); + + [Fact] + public void AddAlias_IgnoresDuplicatesRegardlessOfSpelling() + { + var genre = Genre.Create("Боевик", "action"); + + genre.AddAlias("Action"); + genre.AddAlias(" action "); + genre.AddAlias("боевик"); + + Assert.Equal(2, genre.Aliases.Count); + } + + [Fact] + public void ReplaceAliases_DropsOldOnes() + { + var genre = Genre.Create("Боевик", "action"); + genre.AddAlias("tmdb:28"); + + genre.ReplaceAliases(["tmdb:10759"]); + + Assert.Single(genre.Aliases); + Assert.Equal("tmdb:10759", genre.Aliases[0].Value); + } + + [Fact] + public void ProviderKey_BuildsNormalizedPrefixedAlias() => + Assert.Equal("tmdb:28", GenreAlias.ProviderKey("TMDB", "28")); +} diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/GoldenScheduleTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/GoldenScheduleTests.cs new file mode 100644 index 0000000..0b86817 --- /dev/null +++ b/backend/tests/TeleWave.Domain.Tests/Programming/GoldenScheduleTests.cs @@ -0,0 +1,198 @@ +using System.Globalization; +using System.Text; +using TeleWave.Domain.Broadcast.Scheduling; +using GridPlanner = TeleWave.Domain.Programming.Planning.SchedulePlanner; +using TeleWave.Domain.Programming; +using TeleWave.Domain.Programming.Planning; +using Xunit; + +namespace TeleWave.Domain.Tests.Programming; + +/// +/// Золотые тесты планировщика: для набора эталонных конфигураций фиксируется посуточная лента +/// целиком. Любая правка стратегий или подгонки сразу показывает построчный диф — что именно +/// поехало, — вместо «упало N тестов» через неделю после того, как это ушло в эфир. +/// +/// Эталон обновляется осознанно: переписыванием ожидаемой строки в тесте, а не автоматически при +/// расхождении. Автообновление превратило бы их в тесты, которые всегда зелёные. +/// +public class GoldenScheduleTests +{ + private static readonly DateTimeOffset Day = new(2026, 3, 2, 6, 0, 0, TimeSpan.Zero); + + /// Псевдослучайность с фиксированным зерном: жребий обязан быть воспроизводимым. + private sealed class SeededRandom(int seed) : IRandomSource + { + private int _state = seed; + + public int Next(int maxExclusive) + { + if (maxExclusive <= 0) + return 0; + _state = (int)((_state * 1103515245L + 12345) & 0x7FFFFFFF); + return _state % maxExclusive; + } + } + + /// Лента в компактном виде: время начала, тип, шоу и номер единицы — построчно. + private static List Render( + PlanningResult result, + IReadOnlyDictionary names + ) => + result + .Items.OrderBy(i => i.StartsAtUtc) + .Select(item => + { + var label = item.ShowId is { } showId && names.TryGetValue(showId, out var name) + ? $"{name}#{item.UnitIndex}" + : item.Kind.ToString(); + return string.Create( + CultureInfo.InvariantCulture, + $"{item.StartsAtUtc:HH:mm} {label}" + ); + }) + .ToList(); + + private static (PlanningElement Element, Guid ShowId) Series(int episodes, int minutes) + { + var showId = Guid.NewGuid(); + var units = Enumerable + .Range(0, episodes) + .Select(i => new PlanningUnit(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId, i)) + .ToList(); + return ( + new PlanningElement(GroupElementKind.Show, Guid.NewGuid(), 1, 0, units), + showId + ); + } + + private static PlanningSlot ContentSlot( + DateTimeOffset start, + int durationMinutes, + IReadOnlyList elements, + SlotBlockMode blockMode, + int blockValue, + bool isAnchor = false, + int? snap = null + ) => + new( + Guid.NewGuid(), + start, + durationMinutes, + SlotKind.Content, + isAnchor, + MaxDriftMinutes: 15, + snap, + blockMode, + blockValue, + OverflowPolicy.ContinueNext, + new PlanningStrategy(SlotStrategyKind.Sequential), + elements, + Cursor: null + ); + + private static PlanningUnit Filler() => + new(Guid.NewGuid(), TimeSpan.FromMinutes(1), Guid.Empty, 0); + + [Fact] + public void Golden_MorningCartoonsThenPrimeMovie() + { + var (cartoons, cartoonShow) = Series(6, 20); + var (movie, movieShow) = Series(1, 95); + + var slots = new[] + { + // Утренний блок: три мультфильма подряд. + ContentSlot(Day.AddHours(1), 60, [cartoons], SlotBlockMode.Count, 3), + // Прайм с якорем: старт жёсткий, предыдущий блок через него не перелезает. + ContentSlot(Day.AddHours(14), 120, [movie], SlotBlockMode.Count, 1, isAnchor: true), + }; + + var input = new PlanningInput( + Guid.NewGuid(), + Day, + Day.AddHours(17), + slots, + [Filler()], + SegmentSeconds: 2 + ); + + var result = GridPlanner.Plan(input, new SeededRandom(42)); + var names = new Dictionary + { + [cartoonShow] = "Мультфильмы", + [movieShow] = "Кино", + }; + + var tape = Render(result, names) + .Where(line => !line.Contains("Fallback", StringComparison.Ordinal)) + .ToList(); + + Assert.Equal( + [ + "07:00 Мультфильмы#0", + "07:20 Мультфильмы#1", + "07:40 Мультфильмы#2", + "20:00 Кино#0", + ], + tape + ); + } + + [Fact] + public void Golden_DriftIsCarriedForward() + { + var (series, showId) = Series(6, 25); + + // Три слота подряд по часу, блок — две серии по 25 минут: каждый слот отдаёт 50 минут, + // и разбег накапливается, пока его не подберёт следующий целевой старт. + var slots = Enumerable + .Range(0, 3) + .Select(i => + ContentSlot(Day.AddHours(i), 60, [series], SlotBlockMode.Count, 2) + ) + .ToArray(); + + var input = new PlanningInput( + Guid.NewGuid(), + Day, + Day.AddHours(4), + slots, + [Filler()], + SegmentSeconds: 2 + ); + + var result = GridPlanner.Plan(input, new SeededRandom(7)); + var programs = result + .Items.Where(i => i.Kind == PlannedItemKind.Program) + .OrderBy(i => i.StartsAtUtc) + .Select(i => i.StartsAtUtc.ToString("HH:mm", CultureInfo.InvariantCulture)) + .ToList(); + + // Слот не начинается раньше своего целевого времени: недобор закрывает фон, а не сдвиг вперёд. + Assert.Equal(["06:00", "06:25", "07:00", "07:25", "08:00", "08:25"], programs); + Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.Fallback); + Assert.Equal(showId, result.Items.First(i => i.Kind == PlannedItemKind.Program).ShowId); + } + + [Fact] + public void Golden_SameInputProducesSameTape() + { + var (series, _) = Series(10, 30); + var slots = new[] { ContentSlot(Day, 180, [series], SlotBlockMode.FillSlot, 1) }; + // Один и тот же филлер на оба прогона: разные ассеты фона сделали бы сравнение бессмысленным. + var filler = Filler(); + + PlanningInput MakeInput() => + new(Guid.NewGuid(), Day, Day.AddHours(4), slots, [filler], SegmentSeconds: 2); + + var first = GridPlanner.Plan(MakeInput(), new SeededRandom(1)); + var second = GridPlanner.Plan(MakeInput(), new SeededRandom(1)); + + // Один вход и одно зерно обязаны давать одну ленту: иначе предпросмотр разойдётся с эфиром. + Assert.Equal( + first.Items.Select(i => (i.StartsAtUtc, i.MediaAssetId)), + second.Items.Select(i => (i.StartsAtUtc, i.MediaAssetId)) + ); + } +} diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/GroupTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/GroupTests.cs new file mode 100644 index 0000000..6454fbd --- /dev/null +++ b/backend/tests/TeleWave.Domain.Tests/Programming/GroupTests.cs @@ -0,0 +1,104 @@ +using TeleWave.Domain.Programming; +using Xunit; + +namespace TeleWave.Domain.Tests.Programming; + +public class GroupTests +{ + private static IReadOnlyList Order(Group group) => + group.Items.OrderBy(i => i.Position).Select(i => i.Id).ToList(); + + [Fact] + public void AddElement_AppendsAndRejectsDuplicates() + { + var group = Group.Create("Боевики 90-х"); + var showId = Guid.NewGuid(); + + Assert.NotNull(group.AddElement(GroupElementKind.Show, showId)); + Assert.Null(group.AddElement(GroupElementKind.Show, showId)); + Assert.Single(group.Items); + } + + [Fact] + public void AddElement_SameIdDifferentKind_AreDistinct() + { + var group = Group.Create("Смешанная"); + var id = Guid.NewGuid(); + + group.AddElement(GroupElementKind.Show, id); + group.AddElement(GroupElementKind.Collection, id); + + Assert.Equal(2, group.Items.Count); + } + + [Fact] + public void RemoveElement_DropsAllMatchingPositions() + { + var group = Group.Create("Боевики"); + var showId = Guid.NewGuid(); + group.AddElement(GroupElementKind.Show, showId); + group.AddElement(GroupElementKind.Show, Guid.NewGuid()); + + var removed = group.RemoveElement(GroupElementKind.Show, showId); + + Assert.Equal(1, removed); + Assert.Single(group.Items); + } + + [Fact] + public void SetWeight_ClampsNegativeToZero() + { + var group = Group.Create("Боевики"); + var item = group.AddElement(GroupElementKind.Show, Guid.NewGuid())!; + + group.SetWeight(item.Id, -5); + + // Ноль — законный вес («исключить, не удаляя»), отрицательный сломал бы взвешенный выбор. + Assert.Equal(0, item.Weight); + } + + [Fact] + public void SetWeight_UnknownItem_ReturnsFalse() + { + var group = Group.Create("Боевики"); + + Assert.False(group.SetWeight(Guid.NewGuid(), 3)); + } + + [Fact] + public void Reorder_KeepsUnmentionedAfterRequested() + { + var group = Group.Create("Боевики"); + var a = group.AddElement(GroupElementKind.Show, Guid.NewGuid())!; + var b = group.AddElement(GroupElementKind.Show, Guid.NewGuid())!; + var c = group.AddElement(GroupElementKind.Show, Guid.NewGuid())!; + + group.Reorder([c.Id]); + + Assert.Equal([c.Id, a.Id, b.Id], Order(group)); + } + + [Fact] + public void SetFilter_TreatsBlankAsAbsent() + { + var group = Group.Create("Боевики"); + + group.SetFilter(" "); + + Assert.Null(group.FilterJson); + } + + [Fact] + public void UpdateStats_StoresSnapshot() + { + var group = Group.Create("Боевики"); + var at = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + group.UpdateStats(3, 42, TimeSpan.FromHours(18), at); + + Assert.Equal(3, group.ItemCount); + Assert.Equal(42, group.UnitCount); + Assert.Equal(TimeSpan.FromHours(18), group.TotalDuration); + Assert.Equal(at, group.StatsComputedAt); + } +} diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs new file mode 100644 index 0000000..10816c7 --- /dev/null +++ b/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs @@ -0,0 +1,252 @@ +using TeleWave.Domain.Broadcast.Scheduling; +using TeleWave.Domain.Programming; +using TeleWave.Domain.Programming.Planning; +using Xunit; +using GridPlanner = TeleWave.Domain.Programming.Planning.SchedulePlanner; + +namespace TeleWave.Domain.Tests.Programming; + +public class JunctionFillerTests +{ + private static readonly DateTimeOffset T0 = new(2026, 4, 6, 18, 0, 0, TimeSpan.Zero); + + private sealed class FirstAlways : IRandomSource + { + public int Next(int maxExclusive) => 0; + } + + private static PlanningUnit Unit(double minutes, Guid? showId = null, int index = 0) => + new(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId ?? Guid.NewGuid(), index); + + private static PlanningElement Series(int episodes, int minutes, out Guid showId) + { + showId = Guid.NewGuid(); + var id = showId; + var units = Enumerable + .Range(0, episodes) + .Select(i => Unit(minutes, id, i)) + .ToList(); + return new PlanningElement(GroupElementKind.Show, Guid.NewGuid(), 1, 0, units); + } + + private static PlanningJunctionElement Ads( + int count, + double minutesEach = 0.5, + bool required = false, + int minMinutesBetween = 0, + bool onlyOnElementChange = false + ) => + new( + JunctionElementKind.Ad, + Enumerable.Range(0, 8).Select(_ => Unit(minutesEach)).ToList(), + JunctionAmountMode.Count, + count, + required, + onlyOnElementChange, + minMinutesBetween + ); + + private static PlanningSlot Slot( + PlanningElement element, + int blockValue, + PlanningJunction? between = null, + PlanningJunction? after = null + ) => + new( + Guid.NewGuid(), + T0, + 240, + SlotKind.Content, + IsAnchor: false, + MaxDriftMinutes: 30, + SnapToMinutes: null, + SlotBlockMode.Count, + blockValue, + OverflowPolicy.ContinueNext, + new PlanningStrategy(SlotStrategyKind.Sequential), + [element], + Cursor: null, + RepeatUnits: null, + JunctionBetween: between, + JunctionAfter: after + ); + + private static PlanningResult Run(PlanningSlot slot) => + GridPlanner.Plan( + new PlanningInput( + Guid.NewGuid(), + T0, + T0.AddHours(5), + [slot], + [Unit(1)], + SegmentSeconds: 2 + ), + new FirstAlways() + ); + + [Fact] + public void BetweenJunction_PlacesAdsBetweenUnitsButNotBeforeFirst() + { + var element = Series(3, 20, out _); + var result = Run(Slot(element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(2)]))); + + var ordered = result.Items.OrderBy(i => i.StartsAtUtc).ToList(); + Assert.Equal(PlannedItemKind.Program, ordered[0].Kind); + // Между тремя сериями два стыка по две врезки. + Assert.Equal(4, result.Items.Count(i => i.Kind == PlannedItemKind.Ad)); + } + + [Fact] + public void AfterJunction_PlacesAdsAtBlockEnd() + { + var element = Series(2, 20, out _); + var result = Run(Slot(element, 2, after: new PlanningJunction(Guid.NewGuid(), [Ads(3)]))); + + var last = result + .Items.Where(i => i.Kind != PlannedItemKind.Fallback) + .OrderBy(i => i.StartsAtUtc) + .Last(); + Assert.Equal(PlannedItemKind.Ad, last.Kind); + Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Ad)); + } + + [Fact] + public void DurationMode_IncludesLastUnitWholly() + { + var element = Series(2, 20, out _); + var ads = new PlanningJunctionElement( + JunctionElementKind.Ad, + Enumerable.Range(0, 6).Select(_ => Unit(0.5)).ToList(), + JunctionAmountMode.Duration, + 1, + IsRequired: false + ); + + var result = Run(Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [ads]))); + + // Бюджет 1 минута, ролики по 30 секунд: две штуки добирают ровно минуту, третья не нужна. + Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Ad)); + } + + [Fact] + public void MinInterval_SkipsSecondJunction() + { + var element = Series(3, 20, out _); + var result = Run( + Slot( + element, + 3, + between: new PlanningJunction(Guid.NewGuid(), [Ads(1, minMinutesBetween: 60)]) + ) + ); + + // Серии по 20 минут: второй стык приходит через 20 минут после первого — раньше разрешённого. + Assert.Equal(1, result.Items.Count(i => i.Kind == PlannedItemKind.Ad)); + } + + [Fact] + public void OnlyOnElementChange_SkipsWithinSameShow() + { + var element = Series(3, 20, out _); + var result = Run( + Slot( + element, + 3, + between: new PlanningJunction(Guid.NewGuid(), [Ads(1, onlyOnElementChange: true)]) + ) + ); + + // Внутри одного сериала смены нет — врезка не ставится. + Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad); + } + + [Fact] + public void Bumper_ReservesDurationWithEmptyAssetAndShowPair() + { + var element = Series(2, 20, out var showId); + var bumper = new PlanningJunctionElement( + JunctionElementKind.Bumper, + [], + JunctionAmountMode.Count, + 1, + IsRequired: true, + BumperTemplateId: Guid.NewGuid(), + BumperDuration: TimeSpan.FromSeconds(10) + ); + + var result = Run(Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [bumper]))); + + var placed = Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Bumper); + // Ассет подставит рендер после сборки ленты — до неё пара соседей неизвестна. + Assert.Equal(Guid.Empty, placed.MediaAssetId); + Assert.Equal(showId, placed.ToShowId); + Assert.Equal(TimeSpan.FromSeconds(10), placed.EndsAtUtc - placed.StartsAtUtc); + } + + [Fact] + public void RequiredElement_GoesBeforeOptional() + { + var element = Series(2, 20, out _); + var optional = Ads(1, minutesEach: 1); + var required = new PlanningJunctionElement( + JunctionElementKind.Promo, + [Unit(0.5)], + JunctionAmountMode.Count, + 1, + IsRequired: true + ); + + var result = Run( + Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [optional, required])) + ); + + var junctionItems = result + .Items.Where(i => i.Kind is PlannedItemKind.Ad or PlannedItemKind.Promo) + .OrderBy(i => i.StartsAtUtc) + .ToList(); + Assert.Equal(PlannedItemKind.Promo, junctionItems[0].Kind); + } + + [Fact] + public void JunctionDoesNotCrossAnchor() + { + var element = Series(2, 20, out _); + var slot = Slot(element, 2, after: new PlanningJunction(Guid.NewGuid(), [Ads(4, 5)])) with + { + TargetDurationMinutes = 40, + }; + var anchor = new PlanningSlot( + Guid.NewGuid(), + T0.AddMinutes(45), + 60, + SlotKind.Content, + IsAnchor: true, + MaxDriftMinutes: 5, + SnapToMinutes: null, + SlotBlockMode.Count, + 1, + OverflowPolicy.ContinueNext, + new PlanningStrategy(SlotStrategyKind.Sequential), + [Series(1, 30, out _)], + Cursor: null + ); + + var result = GridPlanner.Plan( + new PlanningInput( + Guid.NewGuid(), + T0, + T0.AddHours(3), + [slot, anchor], + [Unit(1)], + SegmentSeconds: 2 + ), + new FirstAlways() + ); + + // До якоря остаётся пять минут: влезает одна пятиминутная врезка, остальные не начинают. + Assert.All( + result.Items.Where(i => i.Kind == PlannedItemKind.Ad), + item => Assert.True(item.EndsAtUtc <= anchor.TargetStartUtc) + ); + } +} diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs new file mode 100644 index 0000000..843b470 --- /dev/null +++ b/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs @@ -0,0 +1,377 @@ +using TeleWave.Domain.Broadcast.Scheduling; +using TeleWave.Domain.Programming; +using TeleWave.Domain.Programming.Planning; +using Xunit; + +namespace TeleWave.Domain.Tests.Programming; + +public class SchedulePlannerTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero); + + /// Всегда первый вариант — жребий в тестах не должен добавлять шума. + private sealed class FirstAlways : IRandomSource + { + public int Next(int maxExclusive) => 0; + } + + private static PlanningUnit Unit(int minutes, Guid showId, int index) => + new(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId, index); + + private static PlanningElement Element( + int unitCount, + int minutesEach, + int weight = 1, + int position = 0, + DateTimeOffset? lastPlayed = null + ) + { + var showId = Guid.NewGuid(); + var units = Enumerable + .Range(0, unitCount) + .Select(i => Unit(minutesEach, showId, i)) + .ToList(); + return new PlanningElement( + GroupElementKind.Show, + Guid.NewGuid(), + weight, + position, + units, + lastPlayed + ); + } + + private static PlanningSlot Slot( + DateTimeOffset startUtc, + int durationMinutes, + IReadOnlyList elements, + SlotBlockMode blockMode = SlotBlockMode.FillSlot, + int blockValue = 1, + OverflowPolicy overflow = OverflowPolicy.ContinueNext, + bool isAnchor = false, + int? snapToMinutes = null, + PlanningCursor? cursor = null, + SlotStrategyKind strategy = SlotStrategyKind.Sequential, + int cooldownDays = 0 + ) => + new( + Guid.NewGuid(), + startUtc, + durationMinutes, + SlotKind.Content, + isAnchor, + MaxDriftMinutes: 30, + snapToMinutes, + blockMode, + blockValue, + overflow, + new PlanningStrategy(strategy, CooldownDays: cooldownDays), + elements, + cursor + ); + + private static PlanningInput Input( + IReadOnlyList slots, + DateTimeOffset? start = null, + int horizonHours = 6, + IReadOnlyList? fallback = null + ) => + new( + Guid.NewGuid(), + start ?? T0, + (start ?? T0).AddHours(horizonHours), + slots, + fallback ?? [Unit(1, Guid.NewGuid(), 0)], + SegmentSeconds: 2 + ); + + private static PlanningResult Run(PlanningInput input) => + TeleWave.Domain.Programming.Planning.SchedulePlanner.Plan(input, new FirstAlways()); + + [Fact] + public void ContinuousTape_HasNoGapsBetweenItems() + { + var element = Element(4, 30); + var result = Run(Input([Slot(T0, 120, [element])])); + + var ordered = result.Items.OrderBy(i => i.StartsAtUtc).ToList(); + Assert.NotEmpty(ordered); + for (var i = 1; i < ordered.Count; i++) + Assert.Equal(ordered[i - 1].EndsAtUtc, ordered[i].StartsAtUtc); + } + + [Fact] + public void BlockModeCount_PlacesExactlyRequestedUnits() + { + var element = Element(10, 20); + var result = Run( + Input([Slot(T0, 300, [element], SlotBlockMode.Count, blockValue: 3)], horizonHours: 1) + ); + + Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program)); + } + + [Fact] + public void BlockModeDuration_IncludesLastUnitWholly() + { + var element = Element(10, 20); + var result = Run( + Input([Slot(T0, 300, [element], SlotBlockMode.Duration, blockValue: 50)], horizonHours: 2) + ); + + // 20+20 < 50, третья добирает до 60 — обрезать видеофайл нельзя. + Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program)); + } + + [Fact] + public void Anchor_DoesNotStartUnitThatCrossesIt() + { + var first = Element(10, 25); + var second = Element(1, 30); + + var slots = new[] + { + Slot(T0, 60, [first]), + Slot(T0.AddMinutes(60), 60, [second], isAnchor: true), + }; + var result = Run(Input(slots)); + + var anchored = result.Items.First(i => i.SlotId == slots[1].SlotId); + // Третья 25-минутная серия перелезла бы через якорь — её не начинают. + Assert.True(anchored.StartsAtUtc >= slots[1].TargetStartUtc); + } + + [Fact] + public void Snap_MovesStartToRoundTime() + { + // Первый слот кончается в 12:47 — второй с округлением до 15 минут стартует в 13:00. + var first = Element(1, 47); + var second = Element(1, 30); + + var slots = new[] + { + Slot(T0, 47, [first]), + Slot(T0.AddMinutes(40), 60, [second], snapToMinutes: 15), + }; + var result = Run(Input(slots)); + + var secondItem = result.Items.First(i => + i.SlotId == slots[1].SlotId && i.Kind == PlannedItemKind.Program + ); + Assert.Equal(0, secondItem.StartsAtUtc.Minute % 15); + } + + [Fact] + public void Snap_SkippedWhenDriftWouldExceedAllowance() + { + var element = Element(1, 30); + var slot = Slot(T0.AddMinutes(-1), 60, [element], snapToMinutes: 30) with + { + MaxDriftMinutes = 2, + }; + + var result = Run(Input([slot])); + + // Округление потребовало бы почти 30 минут фона — лучше начать не в круглое время. + var program = result.Items.First(i => i.Kind == PlannedItemKind.Program); + Assert.Equal(T0, program.StartsAtUtc); + } + + [Fact] + public void GapBeforeSlot_IsFilledWithFallback() + { + var element = Element(1, 30); + var result = Run(Input([Slot(T0.AddMinutes(10), 30, [element])])); + + Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.Fallback); + Assert.Equal(T0, result.Items.Min(i => i.StartsAtUtc)); + } + + [Fact] + public void EmptySlot_WarnsAndFallsBack() + { + var result = Run(Input([Slot(T0, 30, [])])); + + Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.SlotEmpty); + Assert.All(result.Items, i => Assert.NotEqual(PlannedItemKind.Program, i.Kind)); + } + + [Fact] + public void Cursor_AdvancesToNextUnit() + { + var element = Element(5, 20); + var slot = Slot(T0, 60, [element], SlotBlockMode.Count, blockValue: 2); + + var result = Run(Input([slot], horizonHours: 1)); + + var cursor = Assert.Single(result.Cursors); + Assert.Equal(element.ElementId, cursor.ElementId); + Assert.Equal(2, cursor.NextUnitIndex); + } + + [Fact] + public void Cursor_ResumesMidElement() + { + var element = Element(5, 20); + var slot = Slot( + T0, + 60, + [element], + SlotBlockMode.Count, + blockValue: 1, + cursor: new PlanningCursor(GroupElementKind.Show, element.ElementId, 3) + ); + + var result = Run(Input([slot], horizonHours: 1)); + + var program = result.Items.First(i => i.Kind == PlannedItemKind.Program); + Assert.Equal(3, program.UnitIndex); + } + + [Fact] + public void SkipIfNotFits_LeavesSlotToFallback() + { + // Элемент из трёх часовых частей в часовой слот целиком не влезает. + var element = Element(3, 60); + var slot = Slot(T0, 60, [element], overflow: OverflowPolicy.SkipIfNotFits); + + var result = Run(Input([slot], horizonHours: 2)); + + Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Program); + } + + [Fact] + public void ExtendSlot_PlaysElementBeyondBudget() + { + var element = Element(3, 60); + var slot = Slot(T0, 60, [element], overflow: OverflowPolicy.ExtendSlot); + + var result = Run(Input([slot], horizonHours: 5)); + + // Трилогия доигрывается целиком, слот залезает на соседей — так и задумано. + Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program)); + } + + [Fact] + public void Cooldown_PrefersElementOutsideWindow() + { + var hot = Element(1, 30, position: 0, lastPlayed: T0.AddDays(-1)); + var cold = Element(1, 30, position: 1, lastPlayed: T0.AddDays(-40)); + + var slot = Slot( + T0, + 30, + [hot, cold], + SlotBlockMode.Count, + blockValue: 1, + strategy: SlotStrategyKind.RandomWithCooldown, + cooldownDays: 28 + ); + + var result = Run(Input([slot], horizonHours: 1)); + + var program = result.Items.First(i => i.Kind == PlannedItemKind.Program); + Assert.Equal(cold.Units[0].ShowId, program.ShowId); + } + + [Fact] + public void Cooldown_Exhausted_WarnsAndTakesOldest() + { + var recentA = Element(1, 30, position: 0, lastPlayed: T0.AddDays(-2)); + var recentB = Element(1, 30, position: 1, lastPlayed: T0.AddDays(-5)); + + var slot = Slot( + T0, + 30, + [recentA, recentB], + SlotBlockMode.Count, + blockValue: 1, + strategy: SlotStrategyKind.RandomWithCooldown, + cooldownDays: 28 + ); + + var result = Run(Input([slot], horizonHours: 1)); + + Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.CooldownExhausted); + var program = result.Items.First(i => i.Kind == PlannedItemKind.Program); + Assert.Equal(recentB.Units[0].ShowId, program.ShowId); + } + + [Fact] + public void SignOffSlot_MarksItemsAccordingly() + { + var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.SignOff }; + + var result = Run(Input([slot], horizonHours: 2)); + + Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.SignOff); + } + + [Fact] + public void RepeatSlot_ReplaysSourceUnits() + { + var showId = Guid.NewGuid(); + var repeat = new[] { Unit(30, showId, 0), Unit(30, showId, 1) }; + var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.Repeat, RepeatUnits = repeat }; + + var result = Run(Input([slot], horizonHours: 2)); + + Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Program)); + } + + [Fact] + public void RepeatSlot_WithEmptySource_WarnsAndFallsBack() + { + var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.Repeat, RepeatUnits = [] }; + + var result = Run(Input([slot], horizonHours: 2)); + + Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty); + } + + [Fact] + public void Trace_IsWrittenWithSlotAndStrategy() + { + var element = Element(1, 30); + var slot = Slot(T0, 30, [element], SlotBlockMode.Count, blockValue: 1); + + var result = Run(Input([slot], horizonHours: 1)); + + var program = result.Items.First(i => i.Kind == PlannedItemKind.Program); + Assert.NotNull(program.Trace); + Assert.Equal(slot.SlotId, program.Trace!.SlotId); + Assert.Equal(SlotStrategyKind.Sequential, program.Trace.Strategy); + Assert.Equal(element.ElementId, program.Trace.ElementId); + } + + [Fact] + public void NoFallbackUnits_ProducesWarning() + { + var input = Input([], fallback: []); + + var result = Run(input); + + Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.FallbackEmpty); + } + + [Fact] + public void SequentialStrategy_MovesToNextElementWhenCurrentExhausted() + { + var first = Element(1, 30, position: 0); + var second = Element(1, 30, position: 1); + + var slot = Slot( + T0, + 30, + [first, second], + SlotBlockMode.Count, + blockValue: 1, + cursor: new PlanningCursor(GroupElementKind.Show, first.ElementId, 1) + ); + + var result = Run(Input([slot], horizonHours: 1)); + + var program = result.Items.First(i => i.Kind == PlannedItemKind.Program); + Assert.Equal(second.Units[0].ShowId, program.ShowId); + } +} diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/ScheduleTemplateTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/ScheduleTemplateTests.cs new file mode 100644 index 0000000..5e20ae9 --- /dev/null +++ b/backend/tests/TeleWave.Domain.Tests/Programming/ScheduleTemplateTests.cs @@ -0,0 +1,141 @@ +using TeleWave.Domain.Programming; +using Xunit; + +namespace TeleWave.Domain.Tests.Programming; + +public class ScheduleTemplateTests +{ + private static ScheduleTemplate NewTemplate() => + ScheduleTemplate.Create(Guid.NewGuid(), "Основная"); + + [Fact] + public void Create_AddsBackgroundLayer() + { + var template = NewTemplate(); + + var background = Assert.Single(template.Layers); + Assert.True(background.IsBackground); + Assert.Equal(GridLayer.BackgroundPriority, background.Priority); + } + + [Fact] + public void RemoveLayer_RefusesBackground() + { + var template = NewTemplate(); + var background = template.Background!; + + Assert.False(template.RemoveLayer(background.Id)); + Assert.Single(template.Layers); + } + + [Fact] + public void AddLayer_KeepsPriorityAboveBackground() + { + var template = NewTemplate(); + + var layer = template.AddLayer("Выходные", 0); + + // Ноль занят фоном: слой с таким же приоритетом сделал бы выбор неоднозначным. + Assert.True(layer.Priority > GridLayer.BackgroundPriority); + } + + [Fact] + public void BackgroundLayer_KeepsPriority_WhenUpdated() + { + var template = NewTemplate(); + var background = template.Background!; + + background.Update("Фон", 99, null, isEnabled: true); + + Assert.Equal(GridLayer.BackgroundPriority, background.Priority); + } + + [Fact] + public void MarkChanged_MakesPendingUntilApplied() + { + var template = NewTemplate(); + Assert.False(template.HasPendingChanges); + + template.MarkChanged(); + Assert.True(template.HasPendingChanges); + + template.MarkApplied(); + Assert.False(template.HasPendingChanges); + } +} + +public class GridLayerOverlapTests +{ + private static GridLayer NewLayer() => + ScheduleTemplate.Create(Guid.NewGuid(), "T").AddLayer("Базовая", 10); + + [Fact] + public void HasOverlap_DetectsIntersectionOnSameDay() + { + var layer = NewLayer(); + layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 90, weekday: 5); + + Assert.True(layer.HasOverlap(5, new TimeOnly(21, 0), 30, null)); + Assert.False(layer.HasOverlap(5, new TimeOnly(21, 30), 30, null)); + } + + [Fact] + public void HasOverlap_IgnoresOtherWeekdays() + { + var layer = NewLayer(); + layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 90, weekday: 5); + + Assert.False(layer.HasOverlap(3, new TimeOnly(20, 30), 30, null)); + } + + [Fact] + public void HasOverlap_EverydaySlotConflictsWithAnyDay() + { + var layer = NewLayer(); + layer.AddSlot("Новости", new TimeOnly(18, 0), 30); + + // Слот без дня недели идёт каждый день, поэтому конфликтует и с конкретным днём. + Assert.True(layer.HasOverlap(2, new TimeOnly(18, 15), 30, null)); + } + + [Fact] + public void HasOverlap_SkipsSlotBeingEdited() + { + var layer = NewLayer(); + var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 90, weekday: 5); + + Assert.False(layer.HasOverlap(5, new TimeOnly(20, 0), 120, slot.Id)); + } + + [Fact] + public void UpdateContent_ClearsFieldsForeignToSlotKind() + { + var layer = NewLayer(); + var slot = layer.AddSlot("Кино", new TimeOnly(20, 0), 90); + slot.UpdateContent( + "Кино", + SlotKind.Content, + Guid.NewGuid(), + "{\"type\":\"sequential\"}", + null, + SlotBlockMode.Count, + 1, + OverflowPolicy.ContinueNext + ); + + slot.UpdateContent( + "Конец вещания", + SlotKind.SignOff, + Guid.NewGuid(), + "{\"type\":\"sequential\"}", + null, + SlotBlockMode.FillSlot, + 1, + OverflowPolicy.ContinueNext + ); + + // Иначе генератор прочитал бы настройки, оставшиеся от прежнего типа слота. + Assert.Null(slot.GroupId); + Assert.Null(slot.StrategyJson); + } +} diff --git a/backend/tests/TeleWave.Integration.Tests/ScheduleGeneratorIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/ScheduleGeneratorIntegrationTests.cs deleted file mode 100644 index 364c266..0000000 --- a/backend/tests/TeleWave.Integration.Tests/ScheduleGeneratorIntegrationTests.cs +++ /dev/null @@ -1,207 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using TeleWave.Application.Broadcast.Bumpers; -using TeleWave.Application.Broadcast.Scheduling; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Streaming; -using TeleWave.Domain.Broadcast; -using TeleWave.Domain.Broadcast.Scheduling; -using TeleWave.Domain.Library; -using TeleWave.Domain.Media; -using TeleWave.Infrastructure.Persistence; -using Xunit; - -namespace TeleWave.Integration.Tests; - -[Collection("postgres")] -public sealed class ScheduleGeneratorIntegrationTests(PostgresFixture fixture) -{ - private static readonly DateTimeOffset Now = new(2026, 3, 1, 0, 0, 0, TimeSpan.Zero); - private const int Seg = 2; - - private static MediaAsset ReadyAsset(string name, int seconds) - { - var a = MediaAsset.Register(name, ".mkv", MediaSource.Upload); - a.MarkReady( - TimeSpan.FromSeconds(seconds), - Seg, - seconds / Seg, - 1920, - 1080, - "h264", - "aac", - $"assets/{a.Id}" - ); - return a; - } - - private static ScheduleGenerator NewGenerator( - AppDbContext db, - IRandomSource random, - IBumperRenderQueue queue - ) - { - var streaming = Options.Create(new StreamingOptions { SegmentSeconds = Seg }); - var resolver = new ScheduleBumperResolver( - db, - queue, - Options.Create(new BumperOptions()), - streaming - ); - return new ScheduleGenerator( - db, - random, - resolver, - Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionHours = 24 }), - streaming - ); - } - - private sealed class RecordingQueue : IBumperRenderQueue - { - public List Enqueued { get; } = []; - - public void Enqueue(Guid assetId) => Enqueued.Add(assetId); - - public ValueTask WaitAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask; - } - - [SkippableFact] - public async Task GenerateAsync_MaterializesProgramSchedule() - { - Skip.IfNot(fixture.Available, "Docker недоступен"); - var channel = Channel.Create("Prog", $"prog-{Guid.NewGuid():N}", Now); - var show = Show.Create("Show A", ShowKind.Series); - var a1 = ReadyAsset("a1.mkv", 1200); - var a2 = ReadyAsset("a2.mkv", 1200); - show.AddEpisode(a1.Id); - show.AddEpisode(a2.Id); - channel.AddShow(show.Id, 1, BlockMode.Count, 1); - - await using (var seed = fixture.CreateContext()) - { - seed.Shows.Add(show); - seed.MediaAssets.AddRange(a1, a2); - seed.Channels.Add(channel); - await seed.SaveChangesAsync(CancellationToken.None); - } - - int added; - await using (var db = fixture.CreateContext()) - { - added = await NewGenerator(db, new SequenceRandom(0), new RecordingQueue()) - .GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None); - } - - Assert.True(added > 0); - await using var verify = fixture.CreateContext(); - var entries = await verify - .ScheduleEntries.AsNoTracking() - .Where(e => e.ChannelId == channel.Id) - .ToListAsync(); - Assert.NotEmpty(entries); - Assert.Contains(entries, e => e.Kind == ScheduleEntryKind.Program); - Assert.All(entries, e => Assert.True(e.EndsAtUtc <= Now.AddDays(1).AddSeconds(Seg))); - } - - [SkippableFact] - public async Task GenerateAsync_CreatesPendingBumperAssets_ForShowTransitions() - { - Skip.IfNot(fixture.Available, "Docker недоступен"); - // Канал с двумя шоу и включёнными заставками — на смене шоу планировщик ставит заставку; - // её ассет должен создаться в Pending (Generated) и попасть в очередь фонового рендера. - var channel = Channel.Create("Bump", $"bump-{Guid.NewGuid():N}", Now); - channel.UpdateSettings(channel.Name, true, AdInsertion.BetweenBlocks, 0, true, null); - var showA = Show.Create("A", ShowKind.Series); - var showB = Show.Create("B", ShowKind.Series); - var a1 = ReadyAsset("a1.mkv", 1200); - var b1 = ReadyAsset("b1.mkv", 1200); - showA.AddEpisode(a1.Id); - showB.AddEpisode(b1.Id); - channel.AddShow(showA.Id, 1, BlockMode.Count, 1); - channel.AddShow(showB.Id, 1, BlockMode.Count, 1); - - await using (var seed = fixture.CreateContext()) - { - seed.Shows.AddRange(showA, showB); - seed.MediaAssets.AddRange(a1, b1); - seed.Channels.Add(channel); - await seed.SaveChangesAsync(CancellationToken.None); - } - - var queue = new RecordingQueue(); - await using (var db = fixture.CreateContext()) - { - // Чередуем выбор шоу (0,1,0,1…), чтобы гарантированно были переходы «A→B». - await NewGenerator(db, new SequenceRandom(0, 1), queue) - .GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None); - } - - await using var verify = fixture.CreateContext(); - var bumperCache = await verify - .BumperAssets.AsNoTracking() - .Where(b => b.ChannelId == channel.Id) - .ToListAsync(); - Assert.NotEmpty(bumperCache); - - var bumperAssetIds = bumperCache.Select(b => b.MediaAssetId).ToList(); - var bumperAssets = await verify - .MediaAssets.AsNoTracking() - .Where(a => bumperAssetIds.Contains(a.Id)) - .ToListAsync(); - Assert.All( - bumperAssets, - a => - { - Assert.Equal(MediaSource.Generated, a.Source); - Assert.Equal(MediaAssetStatus.Pending, a.Status); - } - ); - Assert.NotEmpty(queue.Enqueued); - } - - [SkippableFact] - public async Task ConcurrentGenerate_SerializedByAdvisoryLock_NoOverlappingEntries() - { - Skip.IfNot(fixture.Available, "Docker недоступен"); - var channel = Channel.Create("Race", $"race-{Guid.NewGuid():N}", Now); - var show = Show.Create("Show", ShowKind.Series); - var a1 = ReadyAsset("r1.mkv", 1200); - var a2 = ReadyAsset("r2.mkv", 1200); - show.AddEpisode(a1.Id); - show.AddEpisode(a2.Id); - channel.AddShow(show.Id, 1, BlockMode.Count, 1); - - await using (var seed = fixture.CreateContext()) - { - seed.Shows.Add(show); - seed.MediaAssets.AddRange(a1, a2); - seed.Channels.Add(channel); - await seed.SaveChangesAsync(CancellationToken.None); - } - - // Два параллельных прогона на РАЗНЫХ соединениях — advisory-lock должен их сериализовать, - // иначе оба дописали бы хвост от одной точки продолжения и записи бы перекрылись. - await using var db1 = fixture.CreateContext(); - await using var db2 = fixture.CreateContext(); - var g1 = NewGenerator(db1, new SequenceRandom(0), new RecordingQueue()) - .GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None); - var g2 = NewGenerator(db2, new SequenceRandom(0), new RecordingQueue()) - .GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None); - await Task.WhenAll(g1, g2); - - await using var verify = fixture.CreateContext(); - var entries = await verify - .ScheduleEntries.AsNoTracking() - .Where(e => e.ChannelId == channel.Id) - .OrderBy(e => e.StartsAtUtc) - .ToListAsync(); - - Assert.NotEmpty(entries); - for (var i = 1; i < entries.Count; i++) - Assert.True( - entries[i].StartsAtUtc >= entries[i - 1].EndsAtUtc, - $"Записи перекрываются: [{entries[i - 1].StartsAtUtc:o}..{entries[i - 1].EndsAtUtc:o}] и [{entries[i].StartsAtUtc:o}..{entries[i].EndsAtUtc:o}]" - ); - } -} diff --git a/docs/tv-scheduler-tasks.md b/docs/tv-scheduler-tasks.md index 8be020b..7734a77 100644 --- a/docs/tv-scheduler-tasks.md +++ b/docs/tv-scheduler-tasks.md @@ -1,411 +1,435 @@ -# Планировщик эфира: задачи - -Разбивка [tv-scheduler-architecture.md](tv-scheduler-architecture.md) на задачи для разработки. -Ссылки вида «см. 3.4» — на разделы спеки. - -Задачи внутри среза идут в порядке зависимостей: следующая опирается на предыдущие. Срезы — -последовательно, каждый заканчивается работающим каналом. - ---- - -## Общие требования к любой задаче - -Выполняется для каждой задачи, ниже в тексте не повторяется: - -- **Границы слоёв.** `Api → Infrastructure → Application → Domain`. В `Application` — только порты, - никаких `Npgsql`/`AspNetCore.Identity`. -- **Миграция.** Изменение модели → `dotnet ef migrations add --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api`. -- **Ошибки** — через `Result` и каталоги `XErrors`, не исключениями. Валидация — FluentValidation. -- **Тесты.** Доменная логика — `tests/TeleWave.Domain.Tests`, хендлеры — `tests/TeleWave.Application.Tests`, - эндпоинты — `tests/TeleWave.Integration.Tests`. Сборка не должна давать предупреждений - (`TreatWarningsAsErrors=true`). -- **Фронт.** Типы и клиент — в `api.ts` соответствующей фичи. Все строки — в `frontend/src/shared/lib/i18n.ts`, - **обе локали** (`ru` и `en`). Проверка: `pnpm lint && pnpm typecheck`. -- **Изображения** — только через реестр `Image` и контрол `ImageGallery`, своих файловых полей не заводить. - ---- - -## Срез 1 — сетка вещает - -Цель: канал вещает по слотам вместо взвешенной ротации. Реклама и заставки временно работают как -сейчас, стыков ещё нет. - -### 1A. Библиотека - -#### 1.1. Справочник жанров -`Domain` `Infrastructure` `Application` `Api` `Frontend` - -Сущность `Genre` (id, name, externalIds) и связь `ShowGenre` (showId, genreId, isPrimary). -Идемпотентный сид жанров TMDb в `DbInitializer`. CRUD жанров в админке (список, создание, -переименование, удаление с проверкой на использование). - -Готово: жанры есть в БД после старта, админ может править справочник. - -#### 1.2. Жанры на шоу -`Application` `Api` `Frontend` - -Проставление жанров шоу вручную (мультиселект + выбор основного). Автоматический маппинг жанров -провайдера при `ApplyShowMetadata` / `UpdateShowMetadata` по `Genre.externalIds`; нераспознанные -жанры логируются, шоу не ломают. Фильтр по жанру в `ListShows`. - -Готово: у шоу видны и правятся жанры, TMDb-метаданные проставляют их сами, список шоу фильтруется. - -#### 1.3. Расширение `ShowAudience` -`Domain` `Infrastructure` `Frontend` - -Пять значений по возрастанию строгости: `Kids → Family → Teen → General → Adult` (см. 3.1). -Текущий enum не отсортирован и переписывается; миграция переносит существующие значения -один-в-один. Обновить селекты и отображение в UI шоу. - -Готово: порядок значений в enum монотонен по строгости, старые данные не потеряны. - -#### 1.4. Коллекции -`Domain` `Infrastructure` `Application` `Api` `Frontend` - -`Collection` (id, name, posterImageId) + `CollectionItem` (collectionId, showId, position). -CRUD, изменение порядка перетаскиванием, постер через `ImageGallery`. На экране шоу — блок -«входит в коллекции». - -Готово: франшизу из трёх фильмов можно собрать, переупорядочить и открыть с экрана шоу. - -### 1B. Группы - -#### 1.5. Модель группы -`Domain` `Infrastructure` - -`Group` (id, name, description, filter jsonb, cachedStats jsonb) + `GroupItem` (groupId, -elementKind, elementId, weight, position). См. 3.3. Группы глобальные, без привязки к каналу. - -Готово: модель и миграция. - -#### 1.6. CRUD групп и состава -`Application` `Api` - -Создание/переименование/удаление группы (с проверкой, что она не используется слотами), добавление -и удаление позиций, изменение порядка и весов. Удаление шоу или коллекции из библиотеки чистит -позиции групп. - -Готово: состав группы правится через API, висячих ссылок не остаётся. - -#### 1.7. Фильтр набора и статистика -`Application` `Api` - -Выполнение `filter` (см. 3.3) как **разового набора**: возвращает подходящие позиции, отдельная -команда добавляет их в состав. Пересчёт `cachedStats` (число позиций, число единиц, суммарная -длительность) при изменении состава и фоново при изменении библиотеки. - -Готово: «добавить найденное по фильтру» работает, в API группы отдаётся актуальная статистика. - -#### 1.8. Редактор группы -`Frontend` - -Двухпанельный экран (см. 6.2): слева конструктор фильтра, справа состав с drag & drop порядка. -Перетаскивание шоу и коллекций из библиотеки. Вес — колонка за «дополнительно». Бейдж «доступно -N новых позиций по фильтру». - -Готово: группу можно собрать целиком мышкой, статистика видна на экране. - -### 1C. Шаблон, слои, слоты - -#### 1.9. Модель шаблона -`Domain` `Infrastructure` - -`ScheduleTemplate` (channelId, name, defaultJunctionId, fallbackGroupId, revision) → `GridLayer` -(templateId, name, priority, applicability jsonb, isEnabled) → `Slot` (все поля из 3.4, включая -`slotKind`, `snapToMinutes`, `overflowPolicy`, `isAnchor`). `SlotState` (см. 3.6). - -В этом срезе используются только базовый слой и фоновый; применимость слоёв — в срезе 3. - -Готово: модель и миграция; у канала при создании появляется шаблон с фоновым слоем. - -#### 1.10. Изменения в `Channel` -`Domain` `Infrastructure` `Application` `Api` - -Добавить `utcOffsetMinutes` (дефолт 180), `dayStartTime` (дефолт 06:00), `templateId`, `number`. -Обновить создание канала и команду настроек. - -Готово: канал создаётся с шаблоном, оффсетом и началом вещательных суток. - -#### 1.11. CRUD слоёв и слотов -`Application` `Api` - -Создание/правка/удаление слотов, проверка пересечений внутри слоя, валидация кратности времён, -проверка что группа существует и не пуста. Пометка шаблона изменённым (`revision`) при любой правке. - -Готово: сетку можно собрать через API, некорректный слот не сохраняется. - -### 1D. Генератор - -#### 1.12. Разрешение сетки -`Domain` - -Чистая функция: на момент времени вернуть активный слот — применимые слои по убыванию приоритета, -первый слот, накрывающий момент. Учёт вещательных суток (`dayStartTime`) при определении дня недели. -Юнит-тесты на границы суток и перекрытие слоёв. - -Готово: `tests/TeleWave.Domain.Tests` покрывает выбор слота, включая ночь после полуночи. - -#### 1.13. Стратегии выбора -`Domain` - -`sequential`, `randomWithCooldown`, `fixed` (см. 3.5). Внутри элемента единицы всегда по порядку -от курсора. Разворачивание элемента в последовательность единиц: сериал → серии, коллекция → -фильмы (сериал внутри коллекции разворачивается), одиночное шоу → одна единица (см. 3.2). - -Готово: юнит-тесты на каждую стратегию, включая заворот в конце и пустую группу. - -#### 1.14. Наполнение слота -`Domain` - -Бюджет по `blockMode` (`count` / `duration` / `fillSlot`), `overflowPolicy`, продвижение `SlotState`. -Пока без стыков и якорей — контент встык. - -Готово: юнит-тесты на все три режима блока и три политики переполнения. - -#### 1.15. Фоновый слой и филлер -`Domain` - -Незакрытые интервалы отдаются слоту фонового слоя; если пуст и он — `fallbackGroupId` шаблона; -если пуст и он — зацикленный аварийный ассет канала. Все длительности округляются вверх до сегмента -(см. 2.2). - -Готово: дыр в ленте не бывает; юнит-тест на канал с пустой группой. - -#### 1.16. Оркестратор генерации -`Application` - -Новый генератор по образцу `ScheduleGenerator`: загрузка конфигурации, вызов чистой части, -материализация с записью `trace` (см. 3.9), продвижение `SlotState`, advisory-lock канала, -транзакция. Горизонт из `SchedulerOptions.HorizonDays` (поднять дефолт до 7). - -Готово: команда генерации наполняет расписание канала на неделю вперёд. - -#### 1.17. Ретеншн и обслуживание -`Application` `Infrastructure` - -`SchedulerOptions.RetentionHours` → `RetentionDays` (дефолт 90). Фоновая задача обслуживания -(см. 3.6): чистка записей сверх ретеншна, осиротевших `BumperAsset`, временных файлов. -Валидация: правило требует истории глубже, чем хранится → предупреждение. - -Готово: старые записи и неиспользуемые заставки удаляются по расписанию. - -#### 1.18. Применение по кнопке -`Application` `Api` - -Правка правил не трогает эфир, только помечает шаблон изменённым. Отдельная команда «применить» -пересобирает хвост от `now` (см. 4.5). В ответ — сколько записей затронуто. - -Готово: сохранение слота эфир не двигает; применение пересобирает будущее и идемпотентно. - -#### 1.19. Золотые тесты генератора -`Domain.Tests` - -Набор эталонных конфигураций (канал с одним слоем, с фоном, с коллекцией, с пустой группой) и -зафиксированные сутки эфира. Сравнение посуточной ленты целиком, обновление эталона — явной -командой. - -Готово: правка генератора показывает построчный диф вместо «тесты упали». - -### 1E. Снос старого - -#### 1.20. Удаление ротации -`Domain` `Infrastructure` `Application` `Api` `Frontend` - -Удалить `ChannelShow`, `ChannelShowHour`, `ChannelAd`, `ProgrammingOverride`, `OverrideShow`, -`AdInsertion`, `OverrideMode`, `OverrideRecurrence`, `SchedulePlanner` и все `Planner*`-модели, -соответствующие команды и эндпоинты, компоненты `AddShowForm`, `AddAdForm`, `ChannelShowRow`, -`OverrideForm` (см. 9). - -Реклама и заставки на этом шаге временно генерируются старым способом — переезжают в стыки в срезе 2. - -Готово: старый планировщик удалён, сборка и тесты зелёные. - -### 1F. UI - -#### 1.21. Read-only сетка и инспектор слота -`Frontend` - -Недельная сетка (ось X — дни, ось Y — время от `dayStartTime`), пока без перетаскивания: добавление -слота кнопкой на пустом месте, правка в инспекторе (см. 6.1). Индикатор «правила изменены» с кнопкой -применения. Экран `ChannelDetail` переписывается. - -Готово: канал настраивается и вещает по сетке целиком через новый UI. - ---- - -## Срез 2 — врезки - -#### 2.1. Ролики как `Show(Kind = Interstitial)` -`Domain` `Infrastructure` `Application` - -Новое значение `ShowKind`. Исключение таких шоу из обычного списка библиотеки и из запросов -метаданных. - -#### 2.2. Раздел «Ролики» -`Api` `Frontend` - -Отдельный экран (см. 6.7): плоский список с длительностями и превью, массовая загрузка, сборка -блока перетаскиванием с показом суммарной длительности (сохраняется как коллекция), группы роликов. - -#### 2.3. Модель шаблона стыка -`Domain` `Infrastructure` - -`JunctionTemplate` + `JunctionElement` (см. 3.7), включая `amountMode` (`count` / `duration`) и -структурированные `conditions`. Ссылки `junctionBetweenId` / `junctionAfterId` на слоте. - -#### 2.4. Раскладка врезок -`Domain` - -Наполнение стыка: обязательные элементы, набор по бюджету, проверка условий, округление до сегмента. -Коллекция входит целиком и не разрезается. Юнит-тесты на смешанную группу блоков и одиночных роликов. - -#### 2.5. Переезд заставок в стык -`Application` - -`BumperTemplate` / `ScheduleBumperResolver` и рендер не меняются — меняется точка вызова: вместо -настроек канала (`BumperSelection`, `NextBumperIndex`, `BumperMinIntervalMinutes`, обе `*Chance`) -условия берутся из элемента стыка. Перечисленные поля канала удаляются, `BumperFont` остаётся. - -#### 2.6. Якоря и округление стартов -`Domain` - -Жёсткие старты якорных слотов (не начинать единицу, перелезающую через якорь), мягкое округление -`snapToMinutes` с отказом при превышении `maxDriftMinutes` (см. 4.3). Юнит-тесты на оба механизма -и на их сочетание. - -#### 2.7. Редактор стыка -`Frontend` - -Горизонтальная цепочка с перетаскиванием элементов, линейка суммарной длительности, попап параметров -(см. 6.3). - -#### 2.8. Предпросмотр -`Application` `Api` `Frontend` - -Прогон генератора на черновике правил **без записи и без продвижения курсоров**. Заставки — -плейсхолдерами известной длины, реальный рендер только при применении (см. 3.7). Вкладки «Программа» -и «Лента» с гистограммой нагрузки врезок по часам. - ---- - -## Срез 3 — гибкость и календарь - -#### 3.1. Применимость слоёв -`Domain` `Infrastructure` `Application` - -`applicability`: дни недели, разовые диапазоны дат, ежегодные диапазоны, конкретные даты (см. 3.4). -Приоритеты, разрешение конфликтов. Юнит-тесты на пересекающиеся слои и на границу года в ежегодном -диапазоне. - -#### 3.2. Фильтры кандидатов -`Domain` - -`maxAudienceByTime`, `cooldown`, `maxRepeatsInWindow` (см. 3.8) — применяются **до** взвешенного -выбора. История берётся из материализованной ленты. `fallback` стратегии, когда остывание отсекло -всех. - -#### 3.3. Слот `repeat` -`Domain` `Application` - -Чтение уже записанной ленты по `repeatSource` (daysAgo, time, durationMinutes) и перенос найденных -программ. Источник пуст → фон + предупреждение. - -#### 3.4. Слот `signOff` -`Domain` `Application` `Frontend` - -Конец вещания: заполнение зацикленным ассетом, пометка в EPG «эфир не ведётся», отображение -в программе. - -#### 3.5. Панель слоёв -`Frontend` - -Список слоёв с видимостью и приоритетом, drag для переупорядочивания, выбор редактируемого слоя, -штриховка перекрытых слотов, переключатель даты («показать сетку на 25 декабря»). - -#### 3.6. Drag & drop в календаре -`Frontend` - -Перетаскивание слотов и изменение длительности за края, копирование дня на другие дни, копирование -недели. Своя реализация — готовые календари не покрывают слои, вещательные сутки и якоря (см. 6.1). - ---- - -## Срез 4 — эксплуатация - -#### 4.1. Валидация до генерации -`Application` `Api` `Frontend` - -Проверки из 5.1: нехватка контента, пустая группа, дыра в сетке, пересечение слотов, недостижимый -кулдаун, возрастной конфликт. Отдаются вместе с шаблоном, показываются в редакторе. - -#### 4.2. Пост-проверки -`Application` - -Потолок врезок в час, доля жанра за сутки, превышение дрейфа, доля эфира у фона (см. 3.8, 5.2). -Предупреждения, не ошибки — ничего не переигрывается. - -#### 4.3. Вкладка «Проблемы» и тепловая карта -`Frontend` - -Сгруппированные предупреждения с переходом к источнику; матрица «элемент × день» с яркостью -по числу показов. - -#### 4.4. «Почему это здесь» -`Api` `Frontend` - -Отдача `trace` записи и экран цепочки происхождения (см. 6.5). - -#### 4.5. Диф перед применением -`Application` `Api` `Frontend` - -Сравнение текущего хвоста с пересчитанным, список изменений, отдельная подсветка ближайших суток -(см. 6.6). - -#### 4.6. Копирование шаблона -`Application` `Api` `Frontend` - -Глубокая копия шаблона, слоёв и слотов на другой канал; группы не копируются, они общие. - ---- - -## Смежное — зрительская часть - -От планировщика не зависит, делается параллельно в любой момент. Всё опционально и по умолчанию -выключено (см. 6.8). - -#### V.1. Номер канала -`Domain` `Application` `Api` `Frontend` - -`Channel.number`, уникальность, сортировка публичного списка по номеру. - -#### V.2. Переключение по номерам -`Frontend` - -Вверх-вниз по номерам, короткий чёрный кадр, номер в углу на секунду. Включается глобальным флагом -в `AppSetting` рядом с флагом регистрации. Сетка каналов остаётся вторым способом навигации. - -#### V.3. Логотип канала -`Domain` `Api` `Frontend` - -`logoImageId` (реестр изображений), угол и прозрачность. Оверлей поверх `