Compare commits

..
156 Commits
Author SHA1 Message Date
Leonid Pershin 0f609db4e9 Implement Discontinuity Index for Live Streaming Enhancements
ci / build-backend (push) Successful in 2m17s
ci / build-frontend (push) Successful in 53s
ci / tests (push) Successful in 1m39s
ci / sonar (push) Failing after 1m23s
Added a DiscontinuityIndex property to ScheduleEntry and related DTOs to track the cumulative number of discontinuities in live streaming. Updated the LiveWindowCalculator to utilize this index for generating accurate EXT-X-DISCONTINUITY-SEQUENCE values, ensuring compatibility with ffmpeg-based players. Enhanced the WriteEntriesAsync method to calculate and persist the discontinuity index during entry creation. Updated relevant tests and documentation to reflect these changes, improving the reliability of live streaming functionality.
2026-08-04 12:43:35 +03:00
Leonid Pershin 7a164dbd90 Enhance channel settings to include icon image support for IPTV
ci / build-frontend (push) Successful in 39s
ci / build-backend (push) Successful in 1m18s
ci / sonar (push) Successful in 3m41s
ci / tests (push) Successful in 1m22s
Updated the Channel and related DTOs to incorporate an optional IconImageId property, allowing for the specification of a channel icon for external IPTV players. Modified the UpdateChannelSettings command and its handler to accommodate this new property. Adjusted frontend components to manage the icon image, including selection and display functionality. Updated localization files to support new UI elements related to the IPTV icon. This enhancement improves the user experience by providing a visual representation of channels in IPTV applications.
2026-08-02 17:07:24 +03:00
Leonid Pershin bed6d4fcfc Add diagnostics endpoints and types for improved system monitoring
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Successful in 38s
ci / tests (push) Successful in 1m21s
ci / sonar (push) Successful in 3m46s
Implemented new diagnostics endpoints in the API and added corresponding route configurations in the frontend. Introduced types for diagnostics data, including host, forwarding, and system diagnostics, to enhance monitoring capabilities. Updated localization files to support diagnostics UI elements in both English and Russian, ensuring a comprehensive user experience.
2026-08-02 13:49:48 +03:00
Leonid Pershin 88cfe3716b Refactor ForwardedHeaders configuration to improve proxy and network handling
ci / build-backend (push) Successful in 1m33s
ci / build-frontend (push) Successful in 39s
ci / tests (push) Successful in 1m21s
ci / sonar (push) Successful in 3m42s
Updated the ForwardedHeadersOptions configuration in Program.cs to utilize a new ReadList method for better handling of known proxies and networks. This change allows for flexible input from environment variables and appsettings, ensuring proper parsing of both comma-separated values and array formats. Enhanced code readability and maintainability by consolidating the logic for reading configuration values.
2026-08-02 13:22:24 +03:00
Leonid Pershin 89b68a26e7 Refactor IptvEndpoints to improve XML generation and encoding handling
ci / sonar (push) Successful in 3m46s
ci / tests (push) Successful in 1m21s
ci / build-backend (push) Successful in 1m20s
ci / build-frontend (push) Successful in 39s
Updated the RenderGuide method to return a byte array instead of a string, enhancing performance and ensuring proper UTF-8 encoding without a BOM. Adjusted the XML writing process to use a MemoryStream for direct byte output, improving compatibility with XMLTV parsers. This change streamlines the generation of the EPG XML file and ensures accurate encoding in the response.
2026-08-02 13:18:55 +03:00
Leonid Pershin cbeee92d26 Enhance TelegramNotifier to include channel names in notifications
ci / build-backend (push) Successful in 2m3s
ci / sonar (push) Successful in 3m39s
ci / build-frontend (push) Successful in 42s
ci / tests (push) Successful in 1m26s
Updated the TelegramNotifier to fetch channel names in a single database query, improving efficiency. Modified notification messages to include channel names for better clarity, ensuring users know where to switch for live content. Added tests to verify the inclusion of channel names in notifications and updated documentation to reflect these changes.
2026-08-01 09:49:32 +03:00
Leonid Pershin 3dbe0b7698 Enhance TelegramNotifier to manage previous alerts and improve notification clarity
ci / build-backend (push) Successful in 1m21s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 1m33s
ci / sonar (push) Successful in 3m51s
Updated the TelegramNotifier to delete previous alert messages before sending new notifications, ensuring that only the latest alert is visible to subscribers. Introduced a new property in TelegramSubscriber to track the latest alert message ID. Adjusted related tests to verify the new alert management behavior and updated documentation to reflect these changes, enhancing the overall user experience in message handling.
2026-07-31 21:20:25 +03:00
Leonid Pershin 4a2e6ac7f1 Refactor LibraryDebugCollector and TelegramApi for improved data handling and clarity
ci / build-backend (push) Successful in 1m19s
ci / build-frontend (push) Successful in 42s
ci / tests (push) Successful in 1m30s
ci / sonar (push) Successful in 3m42s
Updated the LibraryDebugCollector to introduce a new CompositionEntry record for better organization of group composition data. Refactored the composition logic to utilize this new structure, enhancing readability and maintainability. In the TelegramApi, replaced inline allowed_updates array with a static readonly field to reduce redundancy and improve clarity in API call configurations. Adjusted related tests to ensure proper handling of message assertions, enhancing overall test reliability.
2026-07-31 10:07:28 +03:00
Leonid Pershin 08f56e14c2 Refactor TelegramBotService to streamline message sending process
ci / build-backend (push) Successful in 4m34s
ci / build-frontend (push) Successful in 1m7s
ci / tests (push) Successful in 4m22s
ci / sonar (push) Successful in 8m49s
Updated the TelegramBotService to replace the SendMessageAsync method with a new NoticeAsync method, improving clarity and functionality. Adjusted related tests to ensure proper handling of message IDs and maintain consistency in message management. This change enhances the overall user experience by simplifying the message notification process.
2026-07-31 08:28:42 +03:00
Leonid Pershin d6bc2b15ac Enhance Telegram bot functionality with message management and new API methods
ci / tests (push) Successful in 4m0s
ci / build-backend (push) Successful in 3m27s
ci / build-frontend (push) Successful in 1m5s
ci / sonar (push) Successful in 8m22s
Updated the ITelegramApi interface to include a method for deleting messages and modified SendMessageAsync to return the message ID. Enhanced the TelegramBotService to manage message visibility by replacing previous messages with new ones, ensuring a cleaner user experience. Introduced properties in TelegramSubscriber to track the latest menu and notice message IDs. Updated related tests to verify the new message management behavior and adjusted documentation to reflect these changes.
2026-07-31 08:24:42 +03:00
Leonid Pershin 8a439f1351 Enhance TelegramBotService with schedule handling and subscription toggling
ci / build-backend (push) Successful in 2m38s
ci / build-frontend (push) Successful in 58s
ci / tests (push) Successful in 4m27s
ci / sonar (push) Successful in 9m26s
Updated the TelegramBotService to support new actions for managing subscriptions and retrieving channel schedules. Introduced constants for action types and improved the callback data structure for better clarity. Added a method to send channel schedules, consolidating upcoming program information into a user-friendly format. Updated related tests to ensure proper functionality of the new features and adjusted documentation to reflect changes in button actions.
2026-07-31 08:16:37 +03:00
Leonid Pershin 99821d3c76 Add hidden username input to TelegramPanel for accessibility and password manager compatibility
ci / build-backend (push) Successful in 2m39s
ci / build-frontend (push) Successful in 59s
ci / sonar (push) Successful in 8m9s
ci / tests (push) Successful in 2m44s
Introduced a hidden input field for the bot's username in the TelegramPanel component. This change ensures compatibility with password managers and improves accessibility for screen readers by providing a meaningful value for the bot's credentials. The input is read-only and hidden from the user interface, maintaining a clean design while enhancing functionality.
2026-07-31 08:10:14 +03:00
Leonid Pershin 54cc4862b2 Refactor Telegram API and enhance TelegramPanel form handling
ci / build-backend (push) Successful in 1m56s
ci / build-frontend (push) Successful in 59s
ci / tests (push) Successful in 2m39s
ci / sonar (push) Successful in 8m58s
Updated the TelegramApi class to improve URL construction for API calls by introducing a new MethodUrl method, ensuring proper handling of bot tokens. Refactored the TelegramPanel component to use a form for input fields, allowing for better user experience and submission handling. Adjusted password input fields to use 'new-password' for improved security. These changes aim to enhance the functionality and usability of the Telegram integration.
2026-07-31 08:07:49 +03:00
Leonid Pershin ec957fe55c Update TelegramEndpoints to require admin role for access
ci / tests (push) Successful in 2m35s
ci / build-backend (push) Successful in 2m28s
ci / build-frontend (push) Successful in 58s
ci / sonar (push) Successful in 6m10s
Modified the TelegramEndpoints to enforce authorization by requiring the "admin" role for the admin group. This change enhances security by ensuring that only users with the appropriate role can access Telegram settings and related functionalities.
2026-07-31 08:03:23 +03:00
Leonid Pershin 2b03e43a83 Add Telegram bot integration and related features
ci / build-backend (push) Successful in 2m38s
ci / build-frontend (push) Successful in 1m16s
ci / tests (push) Successful in 2m33s
ci / sonar (push) Successful in 3m48s
Implemented Telegram bot functionality, including settings management, subscriber tracking, and link generation for user interaction. Updated the backend to support new Telegram-related services and database entities. Enhanced the frontend to display Telegram options and allow users to open a chat with the bot. Localization strings were added for both English and Russian to support the new features. This integration aims to improve user engagement through Telegram notifications and interactions.
2026-07-31 07:58:18 +03:00
Leonid Pershin bd3ced3637 Enhance ChannelDebugCollector and LibraryDebugCollector for improved data handling
ci / build-backend (push) Successful in 1m51s
ci / build-frontend (push) Successful in 50s
ci / tests (push) Successful in 1m33s
ci / sonar (push) Successful in 4m29s
Refactored the ChannelDebugCollector to introduce a new RunScope record, consolidating channel, template, and time horizon data for better readability and maintainability. Updated methods to utilize the new RunScope structure, enhancing the clarity of debug file generation. In the LibraryDebugCollector, added AsSplitQuery to optimize database queries and improve performance. Cleaned up unused parameters and ensured consistent handling of asset statuses. These changes aim to streamline debug data collection and improve overall efficiency.
2026-07-31 04:27:06 +03:00
Leonid Pershin 375e0a810b Refactor group mode switching logic to ensure proper list management
ci / build-backend (push) Successful in 1m36s
ci / build-frontend (push) Successful in 52s
ci / tests (push) Successful in 2m3s
ci / sonar (push) Successful in 6m21s
Updated the Group and UpdateGroupCommandHandler classes to clarify the behavior of group mode switching. The transition to dynamic mode now clears the existing item list to prevent old items from interfering with the new rule-based composition. Enhanced documentation and comments to explain the implications of mode changes. Updated tests to reflect the new behavior, ensuring that the group correctly handles item lists during mode transitions. Localization strings were also updated to inform users about the changes in item management during mode switching.
2026-07-31 04:22:36 +03:00
Leonid Pershin 7163a0b937 Implement media retry functionality and enhance error handling
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Failing after 15s
ci / tests (push) Skipped
ci / sonar (push) Skipped
Added endpoints for retrying failed media processing, allowing users to requeue media assets that encountered errors. Introduced error messages for scenarios where a media asset cannot be retried due to its status. Updated the MaintenanceBackgroundService to remove orphaned episodes and recompute group statistics, ensuring data integrity. Enhanced the frontend to support retry actions, including bulk retry options for failed media. Updated localization strings to reflect new features in both English and Russian.
2026-07-31 03:58:01 +03:00
Leonid Pershin 789357481d Add LibraryDebugCollector and enhance ChannelDebugCollector for drift analysis
ci / tests (push) Successful in 1m25s
ci / sonar (push) Successful in 3m47s
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Successful in 50s
Introduced the LibraryDebugCollector to gather library-related data for channel exports. Updated the ChannelDebugCollector to calculate and include drift information, comparing scheduled and actual slot times. Enhanced the ExportChannelDebugCommandHandler to integrate library data into the export process. Added tests to verify the correct functionality of the new drift analysis and library export features, ensuring comprehensive debug data for channel analysis.
2026-07-31 03:18:24 +03:00
Leonid Pershin 9cd364b174 Add debug export functionality for channel analysis
ci / build-backend (push) Successful in 1m16s
ci / build-frontend (push) Successful in 42s
ci / tests (push) Successful in 1m24s
ci / sonar (push) Successful in 5m9s
Implemented a new endpoint for exporting debug data related to channel scheduling, allowing users to download an archive containing channel settings, slot states, and trace information. Updated the frontend to include a button for triggering the export, along with necessary API adjustments for handling the download. Enhanced localization strings to support the new debug export feature in both English and Russian. Updated .gitignore to include debug export files while ensuring the directory structure is maintained for development.
2026-07-31 02:58:17 +03:00
Leonid Pershin 01ba48e163 Refactor GroupCatalog and enhance media preview functionality in ShowDetail and AddEpisodesDialog
ci / build-backend (push) Successful in 1m17s
ci / build-frontend (push) Successful in 39s
ci / tests (push) Successful in 3m41s
ci / sonar (push) Successful in 3m33s
Updated the GroupCatalog class to streamline the determination of the dominant show kind by introducing a new method, DominantKindOf, which improves code clarity. Enhanced the AddEpisodesDialog and ShowDetail components to integrate a media preview feature, allowing users to preview media assets directly within the dialog. This includes the addition of a preview state and corresponding UI elements for a better user experience. Updated localization strings to support the new preview functionality.
2026-07-30 23:30:34 +03:00
Leonid Pershin bee655ec1c Refactor media preview functionality and update localization strings
ci / tests (push) Successful in 1m22s
ci / sonar (push) Successful in 3m43s
ci / build-backend (push) Successful in 1m23s
ci / build-frontend (push) Successful in 39s
Moved the mediaPreviewUrl function to the media API for better organization and clarity. Updated the MediaPanel component to include a preview feature for media assets, allowing users to view processed clips. Enhanced the UI to include a dialog for media previews and added corresponding localization strings in both English and Russian for the new preview functionality.
2026-07-30 23:24:35 +03:00
Leonid Pershin b9d96c915c Enhance GridScheduleGenerator to prevent reopening aired slots during rebuilds
ci / build-backend (push) Successful in 1m31s
ci / build-frontend (push) Successful in 39s
ci / tests (push) Successful in 1m26s
ci / sonar (push) Successful in 3m57s
Updated the GridScheduleGenerator to include logic that checks if a slot has already aired before planning it again, preventing duplicate scheduling of programs. Introduced a new method, LoadAiredSlotWindowsAsync, to retrieve aired time windows and updated the planning logic accordingly. Added integration tests to verify that slots that have already aired are not reopened during rebuilds, ensuring accurate scheduling behavior. Updated documentation to reflect these changes.
2026-07-30 23:09:39 +03:00
Leonid Pershin e983374dec Implement hide breaks feature in AirSchedule component
ci / build-backend (push) Successful in 3m38s
ci / build-frontend (push) Successful in 38s
ci / tests (push) Successful in 1m27s
ci / sonar (push) Successful in 5m58s
Added a new state to manage the visibility of breaks in the AirSchedule component, allowing users to toggle the display of interstitials and advertisements. Updated the filtering logic to account for this new state, ensuring that the schedule reflects user preferences. Enhanced localization files to include new strings for the hide breaks functionality in both English and Russian.
2026-07-30 22:18:09 +03:00
Leonid Pershin de48317f7d Refactor JunctionFiller logic to improve eligibility and rolling mechanics
ci / sonar (push) Successful in 4m6s
ci / build-backend (push) Successful in 1m21s
ci / build-frontend (push) Successful in 40s
ci / tests (push) Successful in 1m27s
Updated the JunctionFiller class to adjust the eligibility criteria for junction elements, ensuring that the roll for chance is determined after the selection of a variant. Enhanced the Passes method to exclude the rolling logic, which now occurs in a separate Rolls method. This change clarifies the flow of decision-making in junctions and ensures that the chance of the selected variant is accurately represented. Additionally, introduced new tests to validate the updated rolling behavior and its impact on junction processing.
2026-07-30 22:12:46 +03:00
Leonid Pershin 926a20020f Add InterstitialGroups service and enhance SlotWriter and related components for interstitial handling
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Successful in 43s
ci / sonar (push) Successful in 3m38s
ci / tests (push) Successful in 3m38s
Introduced the InterstitialGroups service to manage interstitial group logic, preventing their inclusion in slots and fallback groups. Updated the SlotWriter class to utilize this service, ensuring proper validation during slot creation and updates. Enhanced error handling for interstitial groups in the ImportGridCommandHandler and UpdateTemplateCommandHandler, providing warnings instead of failures when interstitials are detected. Updated related tests to verify the correct behavior of these changes, ensuring robust handling of interstitials in the scheduling process.
2026-07-30 12:48:51 +03:00
Leonid Pershin 398b60facc Refactor GridScheduleGenerator and related components to improve cursor management and group handling
ci / build-backend (push) Successful in 1m17s
ci / build-frontend (push) Successful in 56s
ci / tests (push) Successful in 1m29s
ci / sonar (push) Successful in 3m34s
Updated the GridScheduleGenerator to reset cursors for slots with no aired positions, ensuring proper state management during rebuilds. Introduced a new method, ResetUnairedCursorsAsync, to handle cursor resets effectively. Enhanced the handling of interstitial groups in the scheduling process, preventing them from being included in slots or fallback groups. Updated related tests to verify the correct behavior of these changes, ensuring robust scheduling logic and accurate group categorization.
2026-07-30 09:55:14 +03:00
Leonid Pershin 5032614773 Enhance logging in RefreshShowEpisodesMetadataCommandHandler and TmdbMetadataProvider for image retrieval failures
ci / build-backend (push) Successful in 5m2s
ci / build-frontend (push) Successful in 55s
ci / tests (push) Successful in 1m44s
ci / sonar (push) Successful in 4m31s
Updated the RefreshShowEpisodesMetadataCommandHandler to include logging for scenarios where episode still images are not retrieved successfully. Introduced warning logs for missing still URLs and download failures, improving traceability. Additionally, modified the TmdbMetadataProvider to implement a fallback mechanism for episode still images, ensuring that appropriate imagery is displayed even when localized images are unavailable. Updated related tests to verify the correct behavior of these logging enhancements and fallback logic.
2026-07-30 02:08:50 +03:00
Leonid Pershin 3fecd81333 Enhance ShowMetadataApplier with logging for poster retrieval failures
ci / tests (push) Successful in 1m46s
ci / build-backend (push) Successful in 1m37s
ci / build-frontend (push) Successful in 53s
ci / sonar (push) Successful in 4m38s
Updated the ShowMetadataApplier class to include logging for scenarios where show posters are not retrieved successfully. Introduced logging statements to capture warnings when poster URLs are empty or when downloads fail, improving traceability and debugging capabilities. Additionally, updated related tests to ensure proper logging behavior during poster processing.
2026-07-30 02:00:07 +03:00
Leonid Pershin 1758a93011 Enhance TmdbMetadataProvider to support fallback for localized poster retrieval
ci / build-backend (push) Successful in 1m40s
ci / build-frontend (push) Successful in 54s
ci / tests (push) Successful in 1m44s
ci / sonar (push) Successful in 4m57s
Updated the TmdbMetadataProvider class to implement a fallback mechanism for obtaining show posters when localized images are unavailable. Introduced a new method, NeutralPosterPathAsync, which retrieves a language-neutral poster if the localized one is missing. Additionally, updated related tests to verify the correct behavior of this fallback logic, ensuring that shows without localized posters still display appropriate imagery.
2026-07-30 01:54:16 +03:00
Leonid Pershin 10e05b8689 Enhance ImageDownloader to improve error handling and logging
ci / build-backend (push) Successful in 1m33s
ci / build-frontend (push) Successful in 57s
ci / tests (push) Failing after 15s
ci / sonar (push) Skipped
Updated the ImageDownloader class to include detailed logging for various failure scenarios during image download attempts. Added checks for invalid URLs, unsuccessful HTTP responses, and empty content responses, ensuring that all failures are logged with appropriate warnings. This enhancement improves the robustness of the image downloading process and provides better insights into potential issues.
2026-07-30 01:48:07 +03:00
Leonid Pershin bd89abf24b Refactor IPTV endpoint logic for improved readability and maintainability
ci / sonar (push) Successful in 4m38s
ci / build-backend (push) Successful in 1m34s
ci / build-frontend (push) Successful in 55s
ci / tests (push) Successful in 1m43s
Consolidated channel and programme XML writing into dedicated methods, WriteChannel and WriteProgramme, enhancing code clarity and reducing duplication. Introduced a WriteIcon method to streamline icon handling for both channels and programmes. Updated GetChannelScheduleQueryHandler to utilize a new FranchisePosition record for better representation of franchise details in schedule entries, improving the overall structure and readability of the code.
2026-07-30 01:37:20 +03:00
Leonid Pershin 8119305c36 Enhance ScheduleEntryDto and related components to support franchise collection details
ci / tests (push) Successful in 1m46s
ci / build-backend (push) Successful in 3m19s
ci / build-frontend (push) Successful in 1m6s
ci / sonar (push) Successful in 4m50s
Updated ScheduleEntryDto to include properties for collection part, total parts, and collection name, allowing for better representation of franchise information. Modified GetChannelScheduleQueryHandler to populate these new fields based on collection data. Adjusted SchedulePreview component to prioritize collection part display over episode information. Enhanced localization strings to support new collection-related terms in both English and Russian.
2026-07-30 01:01:24 +03:00
Leonid Pershin 6be373476c Implement background clip functionality for bumpers
ci / build-frontend (push) Successful in 49s
ci / tests (push) Successful in 2m7s
ci / build-backend (push) Successful in 2m14s
ci / sonar (push) Successful in 7m39s
Enhanced the bumper system to support a new background type, 'Clip', allowing text to be overlaid on existing video clips. This update includes the addition of a BackgroundClipShowId property in various records and classes, ensuring that the clip's duration dictates the bumper length. Updated validation rules, mapping, and rendering logic to accommodate this new feature. Localization strings were also updated to reflect the new background option and its implications for audio handling.
2026-07-29 23:54:11 +03:00
Leonid Pershin 35cb4b09dc Enhance GroupDetail component to display show types on badges
ci / build-backend (push) Successful in 2m4s
ci / build-frontend (push) Successful in 42s
ci / tests (push) Successful in 2m23s
ci / sonar (push) Successful in 5m33s
Updated the GroupDetail component to include a new function, elementLabelKey, which determines the appropriate label for badges based on the element kind and show kind. This change improves the clarity of displayed information by showing specific show types (e.g., "Film", "Series") instead of generic labels. Adjusted badge rendering to utilize the new function, enhancing the user interface for better understanding of group elements.
2026-07-29 23:35:45 +03:00
Leonid Pershin 6f74e66f0c Enhance GroupFilterMatcher to handle interstitial shows and update related tests and documentation
ci / build-backend (push) Successful in 2m15s
ci / build-frontend (push) Successful in 54s
ci / tests (push) Successful in 2m7s
ci / sonar (push) Successful in 5m29s
Updated the GroupFilterMatcher to exclude interstitial shows by default unless explicitly specified in the filter. Added new tests to verify the behavior of the filter with and without interstitial shows. Updated localization strings to clarify the handling of show types in the user interface and documentation, ensuring users understand the distinction between films, series, and clips.
2026-07-29 23:32:30 +03:00
Leonid Pershin 281d081e6b Refactor admin panels to utilize CreateNameDialog for creating new entities
ci / tests (push) Successful in 2m18s
ci / sonar (push) Successful in 5m51s
ci / build-backend (push) Successful in 2m23s
ci / build-frontend (push) Successful in 54s
Updated the BumpersPanel, ChannelsPanel, CollectionsPanel, GroupsPanel, ClipGroupPanel, and ShowsPanel components to replace direct input fields with a CreateNameDialog for creating new items. This change enhances user experience by centralizing the creation process and improving UI consistency. Localization strings were also updated to reflect new dialog titles and labels in both English and Russian.
2026-07-29 23:26:32 +03:00
Leonid Pershin c4a94443f7 Add IPTV playlist functionality and enhance streaming endpoints
ci / sonar (push) Successful in 4m26s
ci / build-backend (push) Successful in 1m47s
ci / build-frontend (push) Successful in 52s
ci / tests (push) Successful in 1m36s
Implemented a new endpoint for downloading the IPTV playlist, allowing users to access a comprehensive M3U file containing all channels and program guides. Updated the StreamingEndpoints to support token validation for both cookie and query parameters, ensuring secure access for external IPTV players. Enhanced the AirPage component to include a download button for the IPTV playlist, with appropriate user feedback on success or failure. Updated localization strings to reflect new features and instructions for users.
2026-07-29 18:14:01 +03:00
Leonid Pershin 775f287c29 Refactor RunPlayHistory to optimize play retrieval logic and enhance StoragePanelSkeleton accessibility
ci / build-backend (push) Successful in 2m28s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 2m47s
ci / sonar (push) Successful in 5m40s
Updated the RunPlayHistory class to improve the logic for retrieving the latest play by using LINQ's Max method, ensuring that empty play lists are handled correctly. Additionally, modified the StoragePanelSkeleton component to replace the div with an output element for better accessibility, allowing screen readers to recognize the loading status more effectively.
2026-07-29 10:22:37 +03:00
Leonid Pershin 5952ba4536 Update localization strings to include new group-related terms in English and Russian
ci / sonar (push) Successful in 6m17s
ci / build-backend (push) Successful in 1m22s
ci / build-frontend (push) Successful in 39s
ci / tests (push) Successful in 1m22s
Added translations for 'Group' and 'Pick a group' in both the English and Russian locale files, enhancing the user interface for group selection features.
2026-07-29 09:07:38 +03:00
Leonid Pershin fbfc6e677e Enhance GridScheduleGenerator and related components for improved slot handling and cursor management
ci / tests (push) Successful in 1m40s
ci / sonar (push) Successful in 3m46s
ci / build-backend (push) Successful in 1m21s
ci / build-frontend (push) Successful in 51s
Updated the GridScheduleGenerator to support rebuilding future schedules while correctly managing aired positions. Introduced a new AiredPosition record to track the last aired state of slots, ensuring that the cursor rewinds to the correct position during rebuilds. Refactored the BuildInputAsync method to accept aired positions, and modified the LoadAiredPositionsAsync method for accurate retrieval of past entries. Enhanced the SchedulePlanner to utilize shared cursor states between main and background loops, preventing duplicate series plays. Updated documentation to reflect these changes and added integration tests to verify the correct behavior of the new functionality.
2026-07-29 08:57:46 +03:00
Leonid Pershin c67d1f7752 Refactor GridScheduleGenerator and related models for improved repeat slot handling
ci / build-backend (push) Successful in 1m17s
ci / build-frontend (push) Successful in 37s
ci / tests (push) Successful in 1m38s
ci / sonar (push) Successful in 3m44s
Updated the GridScheduleGenerator to utilize a new PlanningRepeatWindow structure for managing repeat slots, enhancing the logic for loading repeat units. The LoadRepeatUnitsAsync method was modified to accept a channel ID and repeat window, ensuring accurate retrieval of past entries. Additionally, the PlanningSlot record was updated to include the new RepeatWindow property, and the SchedulePlanner was adjusted to collect repeat units based on the updated logic. Enhanced unit tests to verify the correct behavior of repeat slot functionality, ensuring that overlaps and elastic start times are handled appropriately.
2026-07-29 08:34:03 +03:00
Leonid Pershin 46c0169dea Enhance channel management with apply report feature
ci / build-backend (push) Successful in 1m37s
ci / build-frontend (push) Successful in 57s
ci / tests (push) Successful in 1m59s
ci / sonar (push) Successful in 4m37s
Added functionality to display a consolidated apply report for warnings after applying channel templates, improving user experience by reducing clutter from individual warning notifications. Updated the ChannelDetail component to manage the new report state and integrated the ApplyReportDialog for displaying the report. Refactored the Problems component to utilize a new PlanningWarnings component for better organization of warning messages. Localization strings were updated to include new report-related texts in both English and Russian.
2026-07-29 08:15:45 +03:00
Leonid Pershin 3601404383 add skeleton
ci / build-backend (push) Successful in 1m32s
ci / build-frontend (push) Successful in 1m4s
ci / tests (push) Successful in 1m45s
ci / sonar (push) Successful in 4m48s
2026-07-29 07:45:10 +03:00
Leonid Pershin ac41111fa0 Implement functionality to add shows to existing collections from suggestions
ci / tests (push) Successful in 1m46s
ci / sonar (push) Successful in 6m47s
ci / build-backend (push) Successful in 1m40s
ci / build-frontend (push) Successful in 53s
Added a new endpoint for adding shows to already assembled franchises based on the same suggestion key. Updated the CollectionSuggestion and CollectionSuggestionDto records to include a CollectionId for tracking existing collections. Enhanced the CollectionSuggestions component in the frontend to support this new functionality, allowing users to add missing parts to existing collections. Updated localization strings to reflect the new addition feature in both English and Russian, ensuring clarity for users. Adjusted tests to verify the correct behavior of the addition process and its integration with existing collection logic.
2026-07-28 14:49:27 +03:00
Leonid Pershin 6c85db2d60 Add reset template functionality to channel management
ci / sonar (push) Successful in 4m21s
ci / build-backend (push) Successful in 1m35s
ci / build-frontend (push) Successful in 53s
ci / tests (push) Successful in 1m36s
Implemented a new endpoint for resetting the channel template, which clears the grid and removes future scheduled entries while preserving past and current recordings. Updated the frontend to include a reset button in the channel management interface, along with corresponding localization strings in English and Russian. Enhanced integration tests to verify the reset functionality, ensuring that the expected number of slots, layers, and entries are accurately reported after the reset operation.
2026-07-28 14:19:35 +03:00
Leonid Pershin 3908b6e722 Refactor group deletion logic to handle fallback group references
ci / sonar (push) Successful in 4m29s
ci / build-backend (push) Successful in 1m35s
ci / build-frontend (push) Successful in 1m6s
ci / tests (push) Successful in 2m2s
Updated the DeleteGroupCommandHandler to release fallback group references during group deletion, ensuring that groups can be removed even when used as fallback in templates. Adjusted related tests to verify the correct behavior of the deletion process, confirming that fallback references are cleared appropriately. Enhanced localization strings to provide clearer context for fallback group functionality in both English and Russian.
2026-07-28 14:01:05 +03:00
Leonid Pershin f924891f3b Refactor error handling in Bumper, Media, Group, and Junction components to include contextual information
ci / sonar (push) Successful in 4m43s
ci / build-backend (push) Successful in 1m35s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 1m46s
Updated error definitions in BumperErrors, MediaErrors, GroupErrors, and TemplateErrors to accept a parameter for contextual information, enhancing the clarity of error messages. Refactored related command handlers to utilize these updated error messages, ensuring that users receive specific details about the usage context when attempting to delete resources. Adjusted unit tests to verify the correctness of the new error handling logic.
2026-07-28 13:19:30 +03:00
Leonid Pershin f895e1ddbc Refactor GridScheduleGenerator for improved channel handling and entry writing
ci / build-backend (push) Successful in 1m32s
ci / build-frontend (push) Successful in 53s
ci / tests (push) Successful in 1m48s
ci / sonar (push) Successful in 5m17s
Simplified the logic for handling disabled channels by introducing the SilentChannelAsync method, which manages future entry deletions more efficiently. Refactored the entry writing process into a separate WriteEntries method to enhance code clarity and maintainability. These changes streamline the scheduling process and improve overall functionality in the GridScheduleGenerator class.
2026-07-28 12:57:14 +03:00
Leonid Pershin 30a38d163c Update TransactionIntegrationTests to use unique channel identifiers for deletion tests
ci / build-backend (push) Successful in 1m27s
ci / build-frontend (push) Successful in 54s
ci / tests (push) Successful in 1m39s
ci / sonar (push) Successful in 4m36s
Modified the channel creation in the DeleteShow tests to generate unique identifiers using GUIDs, ensuring test isolation and preventing potential conflicts. This change enhances the reliability of the integration tests related to show deletion functionality.
2026-07-28 12:26:12 +03:00
Leonid Pershin 4a500583e7 Add bulk poster filling endpoint and enhance show deletion options
ci / tests (push) Failing after 2m27s
ci / sonar (push) Skipped
ci / build-backend (push) Successful in 2m2s
ci / build-frontend (push) Successful in 57s
Introduced a new endpoint for bulk filling collection posters, allowing for automatic assignment of posters to collections without existing images. Updated the DeleteShowCommand to include an option for cutting shows from future airings, enhancing the deletion process. Refactored related components and API calls to support these features, ensuring a seamless user experience. Additionally, updated localization strings to reflect the new functionalities and adjusted tests to verify correct behavior.
2026-07-28 12:07:00 +03:00
Leonid Pershin 9db54a4625 Refactor media import components for improved user experience and layout consistency
ci / build-frontend (push) Successful in 57s
ci / build-backend (push) Successful in 2m30s
ci / sonar (push) Successful in 6m53s
ci / tests (push) Successful in 1m41s
Updated the ManualInboxPanel, MediaImportDialog, MovieImportPanel, and UploadToShowPanel to enhance their layout and responsiveness. Adjusted flex properties for consistent sizing and overflow handling. Simplified the MediaImportDialog by removing unnecessary file handling logic and improved localization strings for clarity. These changes aim to streamline user interactions and maintain a cohesive design across the media import interface.
2026-07-28 11:23:04 +03:00
Leonid Pershin 2e76fa1452 Enhance show deletion functionality to support media removal option
ci / build-backend (push) Successful in 1m32s
ci / build-frontend (push) Successful in 1m7s
ci / tests (push) Successful in 1m45s
ci / sonar (push) Failing after 31s
Updated the DeleteShowCommand and its handler to include an optional parameter for media deletion, allowing users to remove associated media files when deleting a show. Refactored related components and API calls to accommodate this new feature, ensuring a seamless user experience. Additionally, updated localization strings to reflect the new functionality and adjusted tests to verify the correct behavior of the deletion process with and without media.
2026-07-28 11:06:52 +03:00
Leonid Pershin 6ca629c13a Refactor media import handlers to utilize ManualInboxTaker for improved functionality
ci / tests (push) Successful in 1m36s
ci / sonar (push) Successful in 4m40s
ci / build-backend (push) Successful in 1m36s
ci / build-frontend (push) Successful in 57s
Updated the ImportManualInboxCommandHandler and ImportMoviesCommandHandler to replace direct storage interactions with the ManualInboxTaker, streamlining the import process. This change enhances code clarity and maintains consistency across media import functionalities. Additionally, refactored related tests to accommodate the new handler structure, ensuring robust testing of the import logic.
2026-07-28 10:46:21 +03:00
Leonid Pershin 287ed5aa12 Refactor ChannelDetail and MediaImportDialog components for improved functionality and clarity
ci / build-backend (push) Successful in 1m32s
ci / build-frontend (push) Successful in 1m6s
ci / tests (push) Successful in 1m42s
ci / sonar (push) Successful in 4m39s
Consolidated imports in ChannelDetail and replaced the SchedulePreview component with AirSchedule for enhanced scheduling display. Updated the MediaImportDialog to prioritize the manual tab and adjusted localization strings for clarity in both English and Russian. These changes aim to streamline user interactions and improve the overall user experience in managing channels and media imports.
2026-07-28 10:37:11 +03:00
Leonid Pershin ef5c56f04b Add kind filter to ShowsPanel and update localization strings
ci / tests (push) Successful in 1m51s
ci / sonar (push) Successful in 4m24s
ci / build-backend (push) Successful in 1m40s
ci / build-frontend (push) Successful in 52s
Introduced a new client-side filter for show types in the ShowsPanel, allowing users to filter shows by their kind. Updated the sorting logic to accommodate this new filter. Additionally, added corresponding localization strings for both English and Russian to support the new filter options. These changes enhance the user experience by providing more granular control over show listings.
2026-07-28 03:33:07 +03:00
Leonid Pershin 05e9919a7b Refactor media import components for improved layout and functionality
ci / build-backend (push) Successful in 1m31s
ci / build-frontend (push) Successful in 1m8s
ci / sonar (push) Successful in 4m34s
ci / tests (push) Successful in 1m40s
Updated the ManualInboxPanel, MediaImportDialog, MovieImportPanel, and UploadToShowPanel to enhance their layout and responsiveness. Adjusted flex properties to ensure consistent sizing and overflow handling across components. Simplified the MediaImportDialog by removing unnecessary file handling logic, and improved localization strings for clarity. These changes aim to enhance user experience and maintain a cohesive design across the media import interface.
2026-07-28 03:21:18 +03:00
Leonid Pershin f15b2fefcd Refactor movie matching logic to improve clarity and accuracy
ci / build-backend (push) Successful in 1m32s
ci / build-frontend (push) Successful in 56s
ci / tests (push) Successful in 1m40s
ci / sonar (push) Successful in 6m20s
Updated the MovieMatcher class to streamline the status determination for movie matches. Introduced a new method, StatusOf, to encapsulate the logic for assessing whether a movie is already in the library or if a confident match exists. This refactor enhances code readability and maintains the integrity of the movie import process. Adjusted related logic to ensure consistent handling of movie matching statuses.
2026-07-28 03:17:16 +03:00
Leonid Pershin d0bc0465e7 Enhance movie import process and metadata handling
ci / tests (push) Successful in 3m27s
ci / build-backend (push) Successful in 1m33s
ci / build-frontend (push) Successful in 55s
ci / sonar (push) Successful in 6m55s
Refactored the movie import functionality to ensure that the title from the source is used for the show name, while the file name is retained as the original title. Improved the handling of metadata during the import process, allowing for better integration of original titles from various sources. Updated related classes and methods to streamline the import workflow and enhance user experience. Added tests to verify the correct assignment of titles and original names during the import process. Updated documentation to reflect these changes.
2026-07-28 03:14:19 +03:00
Leonid Pershin d859a9894c Implement movie import functionality and enhance media processing
ci / build-backend (push) Successful in 1m34s
ci / build-frontend (push) Successful in 54s
ci / tests (push) Successful in 1m43s
ci / sonar (push) Successful in 6m45s
Added new endpoints for matching and importing movies, allowing for automated processing of film files from the inbox. Introduced logic to parse file names into titles and years, and integrated metadata matching to streamline the import process. Updated the MediaOptions to include a configuration for automatic movie creation from recognized files. Enhanced the InboxScanner to attempt movie attachment upon asset registration, improving user experience and efficiency. Updated documentation to reflect these new features and their usage.
2026-07-28 03:02:30 +03:00
Leonid Pershin e48838b69d Refactor CLAUDE.md for improved clarity and user guidance
ci / tests (push) Successful in 1m38s
ci / build-backend (push) Successful in 1m35s
ci / build-frontend (push) Successful in 56s
ci / sonar (push) Successful in 4m38s
Updated the CLAUDE.md file to enhance clarity in project principles and guidelines. Streamlined language for better readability, emphasizing the importance of user communication and file editing practices. Adjusted instructions regarding commit and push actions to ensure adherence to project protocols.
2026-07-28 02:35:03 +03:00
Leonid Pershin 0b9ea9f0f4 Refactor CLAUDE.md for clarity and updated architecture details
ci / build-backend (push) Successful in 3m56s
ci / tests (push) Successful in 4m8s
ci / build-frontend (push) Successful in 56s
ci / sonar (push) Successful in 4m36s
Revised the CLAUDE.md file to enhance clarity and accuracy in the project description. Updated terminology to reflect current functionality, including changes in user roles and media processing. Improved the architecture section by detailing the domain model and dependencies, ensuring a clearer understanding of the system's structure. Added references to documentation for better guidance on subsystem specifications.
2026-07-28 02:31:30 +03:00
Leonid Pershin fe11034b16 Enhance grid import/export functionality to support dynamic groups and collections
ci / tests (push) Successful in 4m14s
ci / build-backend (push) Successful in 1m43s
ci / build-frontend (push) Successful in 58s
ci / sonar (push) Successful in 4m38s
Updated the grid import/export process to allow for the inclusion of groups and collections directly from the configuration file. This enhancement ensures that missing groups and collections are created during import, improving the flexibility of the grid management system. Adjusted related classes and methods to accommodate these changes, ensuring a cohesive integration. Enhanced documentation and user prompts to clarify the new functionality and its usage.
2026-07-28 02:23:43 +03:00
Leonid Pershin f7e7b5f7c3 Implement template export/import endpoints and enhance grid generation logic
ci / build-frontend (push) Successful in 1m5s
ci / tests (push) Successful in 3m54s
ci / build-backend (push) Successful in 1m31s
ci / sonar (push) Successful in 4m40s
Added new endpoints for exporting and importing grid configurations in TemplateEndpoints, allowing for better management of template data. Enhanced the GenerateGridCommandHandler to support seasonal layers in grid generation, improving scheduling accuracy during holiday periods. Updated related classes and records to accommodate these changes, ensuring a cohesive integration of new features. Improved documentation for clarity and maintainability.
2026-07-28 02:15:04 +03:00
Leonid Pershin 7bee84c548 Enhance EffectiveGridBuilder to support background slots and improve scheduling logic
ci / build-backend (push) Successful in 2m37s
ci / build-frontend (push) Successful in 1m11s
ci / tests (push) Successful in 2m31s
ci / sonar (push) Successful in 6m52s
Updated the EffectiveGridBuilder to return an EffectiveGrid containing both scheduled and background slots, allowing for better management of overlapping content. Refactored the ResolveDay method to handle background slots separately, ensuring they fill gaps in the schedule appropriately. Adjusted related components, including GridScheduleGenerator and SchedulePlanner, to accommodate the new structure and improve overall scheduling accuracy. Enhanced documentation and comments for clarity.
2026-07-28 01:06:29 +03:00
Leonid Pershin e0b85807df Refactor dialog components and styles for improved UI consistency
ci / build-backend (push) Successful in 2m34s
ci / build-frontend (push) Successful in 1m5s
ci / tests (push) Successful in 2m25s
ci / sonar (push) Successful in 6m4s
Updated the dialog components to use a unified class name, replacing 'crt-panel' with 'crt-dialog' for better clarity and consistency across the application. Enhanced the dialog overlay with a denser background and blur effect to improve readability against underlying content. Additionally, modified the UploadSnackbar and ShowsPanel to reflect these changes, ensuring a cohesive user experience.
2026-07-28 00:39:36 +03:00
Leonid Pershin 9f86016a2c Enhance StoragePanel with improved volume color coding and metric display
ci / build-backend (push) Successful in 2m43s
ci / build-frontend (push) Successful in 1m5s
ci / tests (push) Successful in 2m42s
ci / sonar (push) Successful in 6m12s
Added a color coding system for volume segments in the StoragePanel to visually distinguish between owned, foreign, and free storage. Updated the Metric component to include a swatch for better visual association with the corresponding volume. Enhanced comments for clarity and improved the display logic for free storage, ensuring users can easily identify low space conditions.
2026-07-27 23:54:05 +03:00
Leonid Pershin 16b3c485d7 Refactor GridPlanner to improve slot fitting logic and enhance code clarity
ci / build-backend (push) Successful in 2m32s
ci / build-frontend (push) Successful in 1m9s
ci / tests (push) Successful in 2m35s
ci / sonar (push) Successful in 8m6s
Reintroduced the Fit method in the GridPlanner class with detailed documentation, clarifying the logic for determining slot duration based on available space. This change enhances the readability and maintainability of the code while ensuring accurate slot management during grid generation.
2026-07-27 23:48:44 +03:00
Leonid Pershin 1afe01aed8 Refactor BumperPlaceholders and BumperFacts for improved logic and readability
ci / build-backend (push) Successful in 2m15s
ci / build-frontend (push) Successful in 1m7s
ci / tests (push) Successful in 2m33s
ci / sonar (push) Successful in 10m26s
Updated the BumperPlaceholders class to streamline token extraction from texts, enhancing performance and clarity. Modified BumperFacts to initialize slot titles with an empty array instead of a dictionary for better consistency. Renamed methods in BumperResolver for clarity, and refactored GridScheduleGenerator to simplify return logic. Additionally, improved the BumperLinesEditor component by implementing a keyed list for better state management and user experience.
2026-07-27 23:04:15 +03:00
Leonid Pershin 3d2ae20368 Refactor CI workflow for improved test coverage reporting and enhance BumperPlaceholders logic
ci / build-frontend (push) Successful in 53s
ci / sonar (push) Successful in 6m18s
ci / build-backend (push) Successful in 1m27s
ci / tests (push) Successful in 10m43s
Updated the CI workflow to run tests for individual projects, ensuring accurate coverage reports are generated for each module. Enhanced the BumperPlaceholders class to handle dangling separators more effectively, improving text resolution logic and maintaining clarity in output.
2026-07-27 22:46:02 +03:00
Leonid Pershin 8ae8eebc12 Add storage management features and UI components
ci / build-backend (push) Successful in 1m32s
ci / build-frontend (push) Successful in 53s
ci / tests (push) Successful in 3m50s
ci / sonar (push) Successful in 4m7s
Implemented new storage endpoints in the API and updated the dependency injection to include storage-related services. Enhanced the frontend by adding storage routes and links in the admin layout, along with new types and localization for storage management. Updated documentation to reflect the new storage features and their usage in the admin interface.
2026-07-27 22:23:48 +03:00
Leonid Pershin 779f322f62 Refactor conditionsHint function in JunctionChain component for improved readability
ci / tests (push) Successful in 1m34s
ci / sonar (push) Successful in 4m6s
ci / build-backend (push) Successful in 1m36s
ci / build-frontend (push) Successful in 50s
Updated the conditionsHint function to enhance code clarity by adjusting the formatting of the conditional statement that processes dayparts. This change improves maintainability and aligns with best practices for code structure.
2026-07-27 22:11:06 +03:00
Leonid Pershin ba3721eb92 Implement BumperEndpoints and remove deprecated bumper-related functionality
ci / build-backend (push) Successful in 1m39s
ci / build-frontend (push) Failing after 26s
ci / tests (push) Skipped
ci / sonar (push) Skipped
Added new BumperEndpoints to the API for managing bumper templates and variants, enhancing the channel management capabilities. Removed outdated bumper-related commands and handlers from the application, streamlining the codebase and improving maintainability. Updated ChannelEndpoints to reflect these changes and ensure proper routing for the new endpoints.
2026-07-27 22:01:56 +03:00
Leonid Pershin ee0b4d2d01 Refactor ManualInboxDialog to improve show detection logic and user selection handling
ci / tests (push) Successful in 2m23s
ci / sonar (push) Successful in 6m14s
ci / build-backend (push) Successful in 2m51s
ci / build-frontend (push) Successful in 1m17s
Updated the logic for detecting shows based on user-selected files in the ManualInboxDialog component. The changes ensure that the detection process considers only the selected files, enhancing accuracy. Additionally, modified the useEffect to streamline the handling of show ID updates, improving the overall user experience when selecting shows.
2026-07-27 20:51:50 +03:00
Leonid Pershin e55161f8f9 Enhance GridPlanner with SlotFactory for improved slot composition and management
ci / build-backend (push) Successful in 1m35s
ci / tests (push) Successful in 3m54s
ci / build-frontend (push) Successful in 1m2s
ci / sonar (push) Successful in 4m22s
Refactored the GridPlanner class to introduce a SlotFactory for better organization and clarity in slot composition. Added detailed summaries for methods to improve code documentation and understanding. Enhanced the logic for handling content and repeat slots, ensuring more efficient management of grid slots during generation. These changes contribute to improved readability and maintainability of the code.
2026-07-27 19:53:01 +03:00
Leonid Pershin 986cf02b0d Update CollectionSuggestions and GroupSuggestions to default to collapsed state; enhance GenresPanel with pagination and sorting improvements
ci / build-backend (push) Failing after 33s
ci / build-frontend (push) Successful in 1m3s
ci / tests (push) Skipped
ci / sonar (push) Skipped
Modified CollectionSuggestions and GroupSuggestions components to start in a collapsed state for better UI experience. Enhanced GenresPanel by implementing pagination and updating sorting functionality to ensure a more manageable display of genre data, improving overall usability.
2026-07-27 12:16:42 +03:00
Leonid Pershin ef3328ec6f Refactor GalleryPanel to dynamically adjust height and improve layout behavior
ci / build-frontend (push) Successful in 1m6s
ci / build-backend (push) Successful in 2m25s
ci / sonar (push) Successful in 6m3s
ci / tests (push) Successful in 2m53s
Updated the GalleryPanel component to calculate its height based on the viewport and surrounding elements, ensuring it maintains a consistent layout without causing dual scrollbars. Introduced a ResizeObserver to handle window resizing and changes in the layout, enhancing the overall user experience in the image gallery interface.
2026-07-27 12:12:34 +03:00
Leonid Pershin a284066da5 Refactor GalleryPanel and GalleryBrowser for improved layout and scrolling behavior
ci / build-backend (push) Successful in 2m27s
ci / build-frontend (push) Failing after 15s
ci / tests (push) Skipped
ci / sonar (push) Skipped
Updated the GalleryPanel component to adjust its height calculation, ensuring it occupies the full screen without causing dual scrollbars. Modified the GalleryBrowser to implement a fixed-size grid layout, enhancing the visual consistency of image tiles across different screen sizes. These changes improve the overall user experience and usability of the image gallery interface.
2026-07-27 12:07:26 +03:00
Leonid Pershin 259f4041c3 Update Dockerfile to include pnpm-workspace.yaml for improved package management
ci / build-backend (push) Successful in 2m51s
ci / build-frontend (push) Successful in 1m5s
ci / sonar (push) Successful in 6m10s
ci / tests (push) Successful in 2m33s
Added the pnpm-workspace.yaml file to the Dockerfile to support overrides in pnpm 11, ensuring compatibility with the frozen lockfile during installation. This change enhances the package management process by addressing configuration mismatches and streamlining the build process.
2026-07-27 12:00:57 +03:00
Leonid Pershin 36c70a61df Update pnpm-lock.yaml for Radix UI package versions and enhance popover styling in CSS
ci / build-backend (push) Successful in 2m51s
ci / build-frontend (push) Successful in 1m4s
ci / tests (push) Successful in 2m41s
ci / sonar (push) Successful in 5m50s
Updated the pnpm-lock.yaml to override versions of '@radix-ui/react-dismissable-layer' and '@radix-ui/react-focus-scope' to their latest releases. Enhanced the CSS for popover components by introducing a new '.crt-popover' class for improved styling, ensuring a more distinct appearance over other content. Refactored related components to utilize the new class, improving visual consistency across the application.
2026-07-27 11:58:38 +03:00
Leonid Pershin 860cde19a3 Refactor GalleryPanel and GalleryBrowser for improved layout and functionality
ci / build-frontend (push) Failing after 24s
ci / tests (push) Skipped
ci / sonar (push) Skipped
ci / build-backend (push) Successful in 2m54s
Updated the GalleryPanel component to ensure it occupies the full screen height, enhancing the user experience by preventing dual scrollbars. Modified the GalleryBrowser to support a new 'fill' prop, allowing it to adapt its layout based on the context, and adjusted the internal scrolling behavior accordingly. These changes improve the overall usability and visual consistency of the image gallery interface.
2026-07-27 11:48:19 +03:00
Leonid Pershin 61f934d19f Update ListUsersTests to validate UserListFilter sorting and defaults
ci / build-backend (push) Successful in 2m47s
ci / build-frontend (push) Successful in 1m7s
ci / tests (push) Successful in 2m50s
ci / sonar (push) Successful in 6m45s
Enhanced the ListUsersTests by adding a comprehensive check for the UserListFilter, ensuring that sorting defaults are correctly applied when not specified in the request. This change improves the accuracy of the tests related to user listing functionality.
2026-07-27 11:39:49 +03:00
Leonid Pershin 96ff2cc7ab Implement interval intersection and subtraction methods in GridCoverage, update GridPlan and related DTOs for PlanNote integration
ci / build-backend (push) Successful in 2m45s
ci / sonar (push) Successful in 8m13s
ci / build-frontend (push) Successful in 1m12s
ci / tests (push) Successful in 2m52s
Added new methods for intersecting and subtracting time intervals in the GridCoverage class to enhance scheduling capabilities. Updated the GridPlan and GridPlanDto classes to replace string notes with a structured PlanNote type, improving note management. Enhanced the GridPlanner class to utilize these new features, ensuring better handling of scheduling conflicts and notes during grid generation. Adjusted related tests to validate the new functionality.
2026-07-27 11:36:06 +03:00
Leonid Pershin 062f4aa200 Enhance GridPlanner and GenerateGridDialog for improved slot management and user experience
ci / build-backend (push) Successful in 2m30s
ci / sonar (push) Successful in 6m28s
ci / build-frontend (push) Successful in 1m7s
ci / tests (push) Successful in 2m58s
Refactored the GridPlanner class to optimize slot management during grid generation, ensuring more efficient handling of slots. Updated the GenerateGridDialog component to improve its layout and usability, enhancing the overall user experience when interacting with slot lists. These changes contribute to better functionality and accessibility in grid management.
2026-07-27 10:35:52 +03:00
Leonid Pershin b8d6b6aad2 Refactor GenerateGridCommandHandler and GridPlanner for improved slot management and readability
ci / build-backend (push) Successful in 2m45s
ci / build-frontend (push) Successful in 1m11s
ci / tests (push) Successful in 2m45s
ci / sonar (push) Successful in 6m33s
Updated the GenerateGridCommandHandler to streamline slot removal during grid generation by introducing a new RemoveAllSlots method. Enhanced the GridPlanner class by refactoring the FillDay method to utilize a SlotTarget struct for better clarity and organization. These changes improve code maintainability and readability while ensuring efficient slot handling across grid generation processes.
2026-07-27 09:08:20 +03:00
Leonid Pershin f22aee4685 Enhance CleanupManualLeftoversAsync to remove subtitles from nested folders and empty directories
ci / build-backend (push) Successful in 2m50s
ci / tests (push) Successful in 3m1s
ci / sonar (push) Successful in 6m15s
ci / build-frontend (push) Successful in 1m7s
Updated the CleanupManualLeftoversAsync method to improve its functionality by allowing it to delete subtitle files located in nested directories and remove empty directories after the last episode is processed. Introduced a CompanionMatcher class to accurately identify companion files based on naming conventions. Added integration tests to verify the new cleanup behavior, ensuring that only relevant files are removed while preserving necessary content.
2026-07-27 08:57:11 +03:00
Leonid Pershin 0fd4e762f4 Add restore functionality for template management and enhance related components
ci / build-backend (push) Successful in 2m47s
ci / build-frontend (push) Successful in 1m8s
ci / tests (push) Successful in 2m28s
ci / sonar (push) Successful in 6m49s
Implemented a new endpoint for restoring templates to their last applied state, allowing users to revert changes made since the last application. Updated the ScheduleTemplate class to include a snapshot of the last applied state, enabling rollback capabilities. Enhanced the frontend with a restore button in the ChannelDetail component, providing users with a confirmation prompt before executing the restore action. Localization updates were made to support new UI strings in both English and Russian, improving the overall user experience in template management.
2026-07-27 08:43:53 +03:00
Leonid Pershin a302750de6 Update GenerateGridDialog layout for improved usability and responsiveness
ci / tests (push) Successful in 2m54s
ci / sonar (push) Successful in 6m18s
ci / build-backend (push) Successful in 2m35s
ci / build-frontend (push) Successful in 1m13s
Adjusted the GenerateGridDialog component to enhance its layout by increasing the maximum width and ensuring proper scrolling behavior. This change improves accessibility for users when interacting with long slot lists, maintaining a user-friendly interface.
2026-07-27 08:26:24 +03:00
Leonid Pershin 6306919ecd Refactor GridPlanner and GenerateGridDialog for improved functionality and UI
ci / tests (push) Successful in 2m48s
ci / sonar (push) Successful in 6m30s
ci / build-backend (push) Successful in 2m28s
ci / build-frontend (push) Successful in 1m8s
Updated the GridPlanner class to streamline fallback group handling by removing unnecessary notes and clarifying comments. Enhanced the GenerateGridDialog component to limit its height for better usability, ensuring that the dialog remains accessible even with long slot lists. Adjusted layout properties to improve scrolling behavior and overall user experience.
2026-07-27 08:21:33 +03:00
Leonid Pershin bef029b858 Add grid auto-build functionality with profile management and preview capabilities
ci / build-backend (push) Successful in 2m40s
ci / build-frontend (push) Successful in 1m14s
ci / tests (push) Successful in 2m45s
ci / sonar (push) Successful in 7m4s
Implemented new API endpoints and frontend components for grid auto-building based on predefined profiles. Added functionality to list grid profiles, preview generated grids, and generate grids with specified parameters. Enhanced the TemplateEndpoints and related services to support these features, improving the user experience for channel scheduling. Localization updates were made to accommodate new features in both English and Russian.
2026-07-27 08:08:56 +03:00
Leonid Pershin 6fe6404e61 Refactor GridTab and SlotInspector components for improved UI and functionality
ci / tests (push) Successful in 1m19s
ci / build-backend (push) Successful in 1m16s
ci / build-frontend (push) Successful in 42s
ci / sonar (push) Successful in 5m13s
Updated the GridTab component to reorganize the layout, integrating the TemplateIssues component into a more user-friendly format. Enhanced the SlotInspector component by transitioning it from a panel to a dialog for better visibility and usability, especially for lengthy forms. Added new button functionality for creating slots and improved the handling of template issues with collapsible sections. These changes enhance the overall user experience in managing channels and slots.
2026-07-27 06:57:27 +03:00
Leonid Pershin 212899bc2b Enhance collection and group suggestions to manage existing items visibility
ci / build-frontend (push) Failing after 31s
ci / tests (push) Skipped
ci / sonar (push) Skipped
ci / build-backend (push) Successful in 1m24s
Updated the CollectionSuggestions and GroupSuggestions components to include a toggle for displaying already created suggestions. Introduced new state management for handling visibility of existing items, along with localization updates for relevant UI strings in both English and Russian. This improves user experience by allowing admins to see both new and existing suggestions clearly.
2026-07-27 06:41:33 +03:00
Leonid Pershin f8923b5275 Add unit tests for dynamic group mode handling and exclusions
ci / build-backend (push) Successful in 1m36s
ci / build-frontend (push) Successful in 59s
ci / tests (push) Successful in 1m38s
ci / sonar (push) Successful in 4m5s
Implemented several unit tests to validate the behavior of dynamic group modes, including the preservation of pinned items when switching to dynamic mode, the dropping of exclusions when reverting to static mode, and the default pinning of new elements in dynamic groups. Additional tests ensure that exclusions are correctly applied and removed, and that static groups refuse exclusions. These tests enhance the coverage and reliability of the group management functionality.
2026-07-27 06:37:10 +03:00
Leonid Pershin 97fa77287f Refactor collection and group suggestion handling to improve name comparison logic
ci / build-backend (push) Successful in 1m25s
ci / build-frontend (push) Successful in 42s
ci / tests (push) Successful in 1m38s
ci / sonar (push) Successful in 6m30s
Updated the CreateCollectionFromSuggestionCommandHandler and CreateGroupFromSuggestionCommandHandler to compare names in memory rather than using SQL queries for case-insensitive checks. This change addresses potential discrepancies with SQL's case sensitivity, particularly with special characters. Additionally, modified unit tests to use array syntax for assertions, enhancing readability and consistency across the test suite.
2026-07-27 06:08:23 +03:00
Leonid Pershin 5ad28b2966 Implement collection suggestions and bulk show addition features
ci / build-backend (push) Successful in 2m41s
ci / build-frontend (push) Successful in 44s
ci / sonar (push) Successful in 5m12s
ci / tests (push) Successful in 2m33s
Added new API endpoints for suggesting collections based on existing shows and for bulk adding shows to collections. Enhanced the backend with necessary logic and DTOs to support these features. Updated the frontend to include new components for displaying collection suggestions and managing bulk additions, improving the user experience for collection management. Localization updates were made to support these new features in both English and Russian.
2026-07-27 05:39:23 +03:00
Leonid Pershin 1a7f73a5bd Implement bulk tagging and enriching of shows with new API endpoints and frontend integration
ci / build-frontend (push) Successful in 53s
ci / tests (push) Successful in 1m59s
ci / build-backend (push) Successful in 2m4s
ci / sonar (push) Successful in 6m6s
Added new API endpoints for bulk tagging and enriching shows, allowing for mass updates of genres and audience ratings. Implemented backend logic to handle bulk operations and updated the Dependency Injection configuration to include necessary services. Enhanced the frontend with new components for selecting shows and applying bulk actions, improving the user experience for managing multiple shows simultaneously. Localization updates were made to support these new features in both English and Russian.
2026-07-27 05:18:18 +03:00
Leonid Pershin 91af589547 Implement dynamic group management features with exclusions and mode handling
ci / build-backend (push) Successful in 2m30s
ci / build-frontend (push) Successful in 43s
ci / tests (push) Successful in 2m36s
ci / sonar (push) Failing after 33s
Enhanced the group management system by introducing dynamic group capabilities, allowing for the exclusion of elements from dynamic groups. Updated the Group and GroupItem models to support a new GroupMode, enabling the distinction between static and dynamic groups. Implemented API endpoints for excluding elements and updated related services to handle group composition based on the selected mode. Improved the frontend to accommodate these changes, including new API functions and UI components for managing exclusions and group modes, thereby enhancing the overall user experience in group management.
2026-07-27 05:03:09 +03:00
Leonid Pershin 24fdbdf680 Add group suggestions feature with API endpoints and frontend integration
ci / sonar (push) Successful in 4m37s
ci / build-backend (push) Successful in 1m55s
ci / build-frontend (push) Successful in 44s
ci / tests (push) Successful in 2m8s
Implemented new API endpoints for suggesting groups based on the current library, allowing admins to retrieve and create groups from suggestions. Updated the backend to include error handling for suggestions and added necessary DTOs. Enhanced the frontend with new API functions and UI components to display suggestions, improving the user experience for group management. Localization updates were made to support new features in both English and Russian.
2026-07-27 04:18:30 +03:00
Leonid Pershin 4774083dc9 Enhance episode parsing with season fallback and folder-based season extraction
ci / sonar (push) Successful in 8m32s
ci / build-backend (push) Successful in 1m56s
ci / build-frontend (push) Successful in 42s
ci / tests (push) Successful in 2m7s
Added a new option for seasonFallback in the episode parsing logic to allow for a default season when not specified in the file name. Implemented a function to extract season information from folder names, improving the accuracy of episode parsing when season data is not present in the file name. Updated the ManualInboxDialog to utilize this new functionality, ensuring a more robust handling of media files.
2026-07-27 03:51:52 +03:00
Leonid Pershin 67e8b941bb Enhance TMDb metadata handling with bearer token support and update .env.example documentation
ci / build-backend (push) Successful in 2m11s
ci / build-frontend (push) Failing after 16s
ci / tests (push) Skipped
ci / sonar (push) Skipped
Updated the TMDb metadata provider to support both API key and bearer token authentication methods, improving flexibility in API requests. Refactored the GetAsync and TryGetAsync methods to accept an optional bearer token parameter. Additionally, clarified the .env.example file to provide detailed instructions on using TMDb's authentication methods, ensuring users understand how to configure their API access correctly.
2026-07-27 03:43:21 +03:00
Leonid Pershin 8841059070 Update server-storage-setup.md to clarify file and directory permissions
ci / build-backend (push) Successful in 3m19s
ci / build-frontend (push) Successful in 40s
ci / tests (push) Successful in 1m53s
ci / sonar (push) Successful in 5m59s
Enhanced the documentation on file retrieval and deletion permissions within the application, emphasizing the importance of directory permissions over file permissions. Added instructions for setting default ACLs to ensure proper access for the `telewave` group, and included examples for verifying ACL settings. Updated the section on manual file handling to reflect the need for correct permissions in nested directories, ensuring users can manage files effectively without access issues.
2026-07-27 03:31:43 +03:00
Leonid Pershin be6947af06 Refactor ShowDetail component and add media management features
ci / build-frontend (push) Successful in 40s
ci / sonar (push) Failing after 32s
ci / build-backend (push) Successful in 1m35s
ci / tests (push) Successful in 2m10s
Updated the ShowDetail component to streamline episode management by removing unused state and logic related to candidate episodes. Introduced an AddEpisodesDialog for adding media assets to shows. Enhanced localization by adding new strings for media management in both English and Russian. This refactor improves code clarity and user experience when managing show episodes.
2026-07-27 03:27:52 +03:00
Leonid Pershin 790d01b587 Update README.md with additional SonarCloud badges and improve CI workflow for coverage reporting
ci / build-backend (push) Successful in 2m34s
ci / build-frontend (push) Successful in 41s
ci / tests (push) Successful in 2m55s
ci / sonar (push) Successful in 5m21s
Enhanced the README.md file by adding new SonarCloud badges for coverage, bugs, code smells, security rating, and maintainability rating. Updated the CI workflow to remove coverage collection from the test step, as it is now handled by SonarCloud, streamlining the process and ensuring accurate badge representation.
2026-07-27 03:19:18 +03:00
Leonid Pershin 464355434b Update README.md to change quality gate badge URL for SonarCloud
ci / build-backend (push) Successful in 2m11s
ci / build-frontend (push) Failing after 30s
ci / tests (push) Skipped
ci / sonar (push) Skipped
Modified the quality gate badge link in the README.md file to use the correct API endpoint for alert status measurement, ensuring accurate representation of the project's quality metrics.
2026-07-27 02:58:57 +03:00
Leonid Pershin a8b5a8f693 Add upload cleanup functionality and improve error handling for media uploads
ci / build-backend (push) Successful in 2m3s
ci / build-frontend (push) Successful in 44s
ci / tests (push) Successful in 2m39s
ci / sonar (push) Successful in 5m50s
Implemented a new background service, UploadsCleanupBackgroundService, to manage stale uploads in the uploads directory. Enhanced error handling in the media upload process to ensure incomplete uploads are deleted immediately upon failure, preventing unnecessary storage usage. Updated the .env.example and StorageOptions to include configuration for stale upload retention time. Improved documentation to reflect these changes and clarify the upload process.
2026-07-27 02:56:15 +03:00
Leonid Pershin 986f7ff52d Add missing seasons endpoint and update metadata handling
ci / build-backend (push) Successful in 4m7s
ci / build-frontend (push) Successful in 41s
ci / tests (push) Successful in 2m4s
ci / sonar (push) Successful in 4m41s
Implemented a new endpoint to retrieve missing seasons for shows, enhancing the MetadataEndpoints class. Updated the IMetadataProvider interface to include a method for fetching season numbers. Adjusted the ShowSummaryDto to include other genres and modified the ListShowsQueryHandler to handle multiple genres. Enhanced the Omdb and Tmdb metadata providers to support season number retrieval. Updated frontend components to integrate missing seasons functionality, including API calls and UI elements for displaying missing seasons reports.
2026-07-27 02:40:09 +03:00
Leonid Pershin 07c43a6875 Refactor episode regex handling for improved clarity and maintainability
ci / build-backend (push) Successful in 2m1s
ci / build-frontend (push) Successful in 52s
ci / tests (push) Successful in 2m16s
ci / sonar (push) Successful in 4m44s
Updated the separator handling in the buildEpisodeRegex function to enhance readability. The change replaces the inline conditional for the gap with a dedicated variable, making the code more understandable while maintaining functionality.
2026-07-27 02:28:29 +03:00
Leonid Pershin 2045b1a3f2 Add error handling for future schedule conflicts and media asset usage
ci / tests (push) Successful in 2m3s
ci / build-backend (push) Successful in 2m5s
ci / build-frontend (push) Successful in 40s
ci / sonar (push) Successful in 7m39s
Introduced new error types for handling conflicts when attempting to delete shows or media assets that are currently scheduled or in use. Updated the DeleteShowCommandHandler and DeleteMediaAssetCommandHandler to check for these conditions and return appropriate error responses. This enhances the robustness of the application by preventing unintended deletions and improving user feedback.
2026-07-27 02:18:25 +03:00
Leonid Pershin b80163b6b4 Refactor regex handling and improve null checks across various components
ci / build-backend (push) Successful in 1m45s
ci / build-frontend (push) Successful in 52s
ci / tests (push) Successful in 2m3s
ci / sonar (push) Successful in 4m41s
Updated regex patterns in episode parsing and metadata handling to use String.raw for better readability and maintainability. Enhanced null checks in BlockBuilder, MediaPanel, and ShowMetadataCard components to prevent potential runtime errors. Consolidated imports in UsersPanel for cleaner code structure.
2026-07-27 02:09:47 +03:00
Leonid PershinandClaude Opus 5 088af2318e Add the Prettier run to the blame ignore list
ci / build-backend (push) Successful in 2m1s
ci / build-frontend (push) Successful in 54s
ci / tests (push) Successful in 2m31s
ci / sonar (push) Successful in 5m16s
Первый прогон форматтера переписал 50 файлов; без этой записи `git blame` показывал бы его
вместо автора реальной правки. Включается локально: git config blame.ignoreRevsFile .git-blame-ignore-revs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:38:20 +03:00
Leonid PershinandClaude Opus 5 0606ea3e6e Add Prettier to the frontend and gate formatting in CI
Форматтера у фронтенда не было: стиль держался вручную и успел разъехаться в 50 файлах.
Ставим Prettier с настройками под уже сложившийся стиль (без точек с запятой, одинарные кавычки,
ширина 100 — подобрана замером: при 100 расходится меньше файлов, чем при 96 или 110) и
прогоняем его по коду. `src/routeTree.gen.ts` исключён — его переписывает плагин роутера.

Чтобы форматирование больше не расходилось незаметно, добавлены проверки в CI: `csharpier check`
для бэкенда (его отсутствие и позволило накопиться 79 неотформатированным файлам) и
`prettier --check` для фронтенда. Версии форматтеров прибиты точно, без кареток: минорка меняет
вывод и красит CI на файлах, которых никто не трогал.

`.editorconfig` задаёт редакторам те же отступы и LF ещё до форматтера; значения совпадают с
настройками csharpier и Prettier намеренно — оба его читают.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:38:12 +03:00
Leonid PershinandClaude Opus 5 0442056367 Normalize line endings to LF via .gitattributes
Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка
работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево,
чтобы форматтеры не переписывали файлы целиком на каждом прогоне.

Коммит чисто механический: изменений содержимого нет, только концы строк.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:37:33 +03:00
Leonid Pershin 9d1c6d2fc3 Enhance code readability and maintainability by refactoring various components. Update formatting commands in CLAUDE.md for csharpier integration. Simplify method signatures in SegmentFiles, UploadLimits, and multiple endpoint classes for improved clarity. Adjust query handlers and command handlers to streamline parameter handling and enhance consistency across the application.
ci / build-backend (push) Successful in 2m8s
ci / build-frontend (push) Failing after 32s
ci / tests (push) Skipped
ci / sonar (push) Skipped
2026-07-27 01:20:55 +03:00
Leonid Pershin 5449c05b5b Refactor components to enhance code clarity and maintainability by updating prop types to use Readonly for better immutability. Adjust Result class methods for consistency, and streamline query functions in ChannelDetail and other components to improve performance and readability.
ci / build-backend (push) Successful in 1m41s
ci / build-frontend (push) Successful in 47s
ci / sonar (push) Successful in 5m35s
ci / tests (push) Successful in 2m40s
2026-07-27 01:14:53 +03:00
Leonid Pershin ecb5417170 Update README.md to change quality gate badge URL for improved accuracy in project status reporting.
ci / build-backend (push) Successful in 2m6s
ci / build-frontend (push) Successful in 48s
ci / tests (push) Successful in 2m3s
ci / sonar (push) Successful in 5m41s
2026-07-27 01:08:20 +03:00
Leonid Pershin 19fa23b619 Refactor various components to improve code clarity and maintainability. Update ListUsersQueryHandler to utilize UserListFilter for parameter handling. Refactor BumperSpecFactory and related classes to encapsulate input parameters into dedicated records, enhancing readability. Adjust MediaAsset and Slot classes to streamline content updates with new content models. Improve BumperRenderBackgroundService and MediaProcessingBackgroundService to use MediaReadyInfo for asset readiness, ensuring consistent parameter management across the application.
ci / tests (push) Successful in 2m33s
ci / sonar (push) Successful in 6m28s
ci / build-backend (push) Successful in 1m55s
ci / build-frontend (push) Successful in 45s
2026-07-27 00:56:04 +03:00
Leonid Pershin 419aff54fa Enhance EntryTraceDialog component by refactoring data display logic into dedicated summary functions for improved readability and maintainability. Update GridTab to streamline checkbox state management with a new toggle function. Refactor RulesCard to simplify window removal logic. Adjust CollectionsPanel, GenresPanel, GroupsPanel, RolesPanel, ShowsPanel, and UsersPanel to import sorting utilities from a centralized location, enhancing code organization. Update ThemeProvider to utilize a shared theme context for better consistency across the application.
ci / sonar (push) Successful in 6m13s
ci / build-backend (push) Successful in 2m15s
ci / build-frontend (push) Successful in 50s
ci / tests (push) Successful in 2m34s
2026-07-27 00:25:11 +03:00
Leonid Pershin f4926848d7 Remove ToastProvider and related toast context functionality to streamline UI components and eliminate unused code. This change enhances overall code maintainability and reduces bundle size. 2026-07-27 00:25:03 +03:00
Leonid Pershin 38b6160bd2 Refactor JunctionFiller and SchedulePlanner to improve parameter handling by introducing JunctionPlacement record for cleaner code and better readability. Update Directory.Build.props to suppress additional warnings related to API boundaries and unused route parameters, ensuring a more focused analysis during builds.
ci / build-backend (push) Successful in 1m10s
ci / build-frontend (push) Successful in 37s
ci / tests (push) Successful in 1m31s
ci / sonar (push) Successful in 3m26s
2026-07-27 00:01:22 +03:00
Leonid Pershin 8699d8ee00 Refactor LayerApplicabilityDialog and RulesCard components to utilize useKeyedList for managing lists, improving state handling and performance. Update ScheduleGrid to enhance key assignment for mapped elements, ensuring better React reconciliation. Modify ManualInboxDialog to use unique keys for parts in sample name processing, and improve ToastProvider and ThemeProvider by memoizing context values to prevent unnecessary re-renders.
ci / build-backend (push) Successful in 1m15s
ci / build-frontend (push) Failing after 15s
ci / tests (push) Skipped
ci / sonar (push) Skipped
2026-07-26 23:54:30 +03:00
Leonid Pershin 940b622c03 Refactor various classes to initialize lists with empty array syntax for improved clarity and consistency. Update Program.cs to remove unnecessary partial class declaration due to changes in ASP.NET Core 10. Enhance MediaPathResolver by making AssetRelativePath static for better accessibility. Adjust MediaClaimingBackgroundService to centralize idle polling logic. Improve SignalQueue's WaitAsync method for clearer intent in signal draining.
ci / build-backend (push) Successful in 1m46s
ci / sonar (push) Successful in 5m1s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 1m38s
2026-07-26 23:36:55 +03:00
Leonid Pershin 59cc453026 Refactor RenderBumperPreviewCommandHandler to streamline background path resolution and rendering specification. Introduce helper methods for improved readability and maintainability. Update FindMissingEpisodesQueryHandler and RefreshShowEpisodesMetadataCommandHandler to encapsulate episode number resolution logic. Enhance CopyTemplateCommandHandler by modularizing junction copying and slot handling for better clarity. Improve FfmpegMediaProcessor by separating duration and stream parsing into dedicated methods. Refactor MediaClaimingBackgroundService to simplify idle waiting logic. Update GenreSeeder to modularize alias addition logic, enhancing code organization.
ci / build-backend (push) Successful in 1m29s
ci / build-frontend (push) Successful in 1m4s
ci / tests (push) Successful in 1m58s
ci / sonar (push) Successful in 3m34s
2026-07-26 23:31:56 +03:00
Leonid Pershin 69d1319564 Add UploadLimits service and refactor user and media listing endpoints to use filter records for improved parameter handling. Update audio upload logic to streamline parameter passing and enhance clarity. Adjust ImageEndpoints to use HashSet for allowed extensions, and refine StreamingEndpoints to clarify token handling.
ci / build-backend (push) Successful in 1m29s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 2m1s
ci / sonar (push) Successful in 4m22s
2026-07-26 23:05:37 +03:00
Leonid Pershin 0f0657e523 Refactor Program.cs to use async Run method for improved performance. Update index.html to specify language attribute for accessibility. Enhance BumperTemplateEditor and ScheduleGrid components by replacing divs with buttons for better keyboard accessibility. Improve TemplatePreview sorting logic and optimize episode regex handling. Add close button to ToastItem for better user interaction. Update localization files to include 'close' translations. Refactor CardTitle to ensure screen reader compatibility by explicitly rendering children.
ci / tests (push) Successful in 1m58s
ci / build-backend (push) Successful in 1m34s
ci / build-frontend (push) Successful in 1m0s
ci / sonar (push) Successful in 4m13s
2026-07-26 22:35:36 +03:00
Leonid Pershin b0740503bb Enhance metadata handling by introducing ShowKind parameter across various components. Update SearchShowMetadataQuery and related handlers to support querying by show type, ensuring accurate metadata retrieval for series and films. Implement validation to prevent episodes from being associated with non-series shows. Update frontend API to include ShowKind in metadata search requests, improving overall functionality and user experience.
ci / build-backend (push) Successful in 3m42s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 2m10s
ci / sonar (push) Successful in 6m40s
2026-07-26 22:29:16 +03:00
Leonid Pershin fdb0f321e4 Refactor ShowAudience handling across the application to align with MPAA rating system. Update ShowAudience enum to reflect new ratings (G, PG, PG-13, R, NC-17) and adjust related data models, API types, and frontend components to support nullable audience values. Enhance metadata handling to incorporate content ratings from external sources, ensuring proper audience filtering in scheduling logic. Update documentation to clarify changes in audience categorization and its implications for content management.
ci / sonar (push) Successful in 4m31s
ci / build-backend (push) Successful in 1m28s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 2m7s
2026-07-26 22:18:14 +03:00
Leonid Pershin c4c93cff02 Enhance query performance by applying AsSplitQuery to multiple handlers and loaders for Channels and ScheduleTemplates, improving efficiency in data retrieval and reducing unnecessary data duplication in queries.
ci / build-frontend (push) Successful in 49s
ci / build-backend (push) Successful in 1m26s
ci / sonar (push) Failing after 2m31s
ci / tests (push) Successful in 4m18s
2026-07-26 21:50:01 +03:00
Leonid Pershin b52080e026 Refactor Dockerfile and documentation for non-root user setup. Update CLAUDE.md and README.md to clarify media storage permissions and user requirements. Modify appsettings for local development database configuration. Enhance MediaEndpoints by removing unused regex and improving file resolution logic. Update BumperPreview to use SHA-256 for asset ID generation, ensuring better security practices.
ci / tests (push) Successful in 1m52s
ci / build-backend (push) Successful in 1m33s
ci / build-frontend (push) Successful in 1m2s
ci / sonar (push) Successful in 4m24s
2026-07-26 21:41:33 +03:00
Leonid Pershin e79769be81 Update .env.example to extend scheduler configuration: increase HorizonDays to 7 and change RetentionHours to RetentionDays with a default of 90. Revise documentation in media-storage-and-streaming.md to reflect these changes. Remove outdated tv-scheduler-tasks.md file.
ci / build-backend (push) Successful in 1m11s
ci / tests (push) Successful in 1m35s
ci / build-frontend (push) Successful in 36s
ci / sonar (push) Successful in 6m25s
2026-07-26 21:27:31 +03:00
Leonid Pershin 34701bd6b1 Update README.md to replace build and test badges with a consolidated CI badge. Remove obsolete workflow files for build, test, and Sonar analysis, streamlining the CI/CD process.
ci / tests (push) Successful in 1m32s
ci / build-backend (push) Successful in 1m10s
ci / build-frontend (push) Successful in 48s
ci / sonar (push) Successful in 3m17s
2026-07-26 20:52:37 +03:00
Leonid Pershin 205672b77d Refactor .gitignore to streamline ignored files and enhance clarity. Update CLAUDE.md to improve unit test instructions and add coverage reporting details. Revise README.md for better project overview and deployment instructions. Refactor ChannelEndpoints and StreamingEndpoints to utilize SegmentFiles for file resolution, improving code maintainability. Remove unused JunctionHandlers and update DependencyInjection for cleaner service registration. Enhance media processing services for better job handling and error management. Update frontend API types for consistency and clarity.
build / backend (push) Successful in 1m28s
build / frontend (push) Failing after 31s
sonar / analyze (push) Successful in 4m39s
tests / backend-tests (push) Canceled after 0s
2026-07-26 20:43:38 +03:00
Leonid Pershin f36dbfa9cb Refactor ChannelDetail component to improve template copying functionality and update translations. Change variable names for clarity, ensuring the source channel is correctly referenced when copying templates. Enhance user confirmation prompts and update related UI elements for better user experience.
build / backend (push) Successful in 1m10s
build / frontend (push) Successful in 34s
tests / backend-tests (push) Successful in 3m42s
2026-07-26 20:17:45 +03:00
Leonid Pershin 9c865e1faa Add CreateTemplate endpoint and related functionality for channels without grids
build / backend (push) Successful in 3m23s
build / frontend (push) Successful in 34s
tests / backend-tests (push) Successful in 1m38s
Implement a new API endpoint to create a template for channels lacking a grid, enhancing the template management capabilities. Update frontend components to support the creation of templates directly from the channel detail view, including user feedback and translations for improved clarity. Add integration tests to ensure the new functionality works as expected.
2026-07-26 20:10:47 +03:00
Leonid Pershin 69c236d8cc Refactor ChannelDetail component to implement tabbed navigation for channel settings, enhancing user experience by organizing content into distinct sections. Introduce a new TABS constant for better maintainability and update related components (BumperCard, RulesCard, ViewerCard, etc.) to support a 'bare' prop for streamlined rendering. Improve error handling for template loading and update translations for better clarity.
build / backend (push) Successful in 1m10s
build / frontend (push) Successful in 35s
tests / backend-tests (push) Successful in 1m31s
2026-07-26 20:03:14 +03:00
Leonid Pershin 2387223f0b Refactor AdminUserEndpoints and MediaEndpoints to include default parameter values for pagination and filtering, improving API usability. Update ManualInboxDialog to enhance show selection logic with auto-detection based on file names, and improve user feedback with new translations. Refactor PostgresFixture for better container management in integration tests.
build / backend (push) Successful in 2m26s
build / frontend (push) Successful in 1m5s
tests / backend-tests (push) Successful in 2m27s
2026-07-26 16:17:24 +03:00
Leonid Pershin 8fa4ea02fe Implement automatic media list refresh in MediaPanel: add activity tracking for queued and processing items, ensuring the media list updates when statuses change. Enhance query handling with refetch logic to improve user experience in media management.
build / backend (push) Successful in 3m38s
build / frontend (push) Successful in 1m6s
tests / backend-tests (push) Successful in 3m3s
2026-07-26 16:06:02 +03:00
Leonid Pershin b5a47c6d8a Enhance ManualInboxDialog: add show selection dropdown for improved media organization and update translations for better user guidance. Simplify layout by removing redundant elements while maintaining user hints.
tests / backend-tests (push) Successful in 1m57s
build / backend (push) Successful in 1m29s
build / frontend (push) Successful in 53s
2026-07-26 15:55:06 +03:00
Leonid Pershin 6bca42754c Update validation rules in ImportManualInboxCommand and ManualInboxDialog to allow season numbers starting from zero, reflecting the inclusion of special episodes and pilots. Adjust input constraints in the frontend to enhance user experience and ensure consistency with backend validation.
tests / backend-tests (push) Successful in 2m2s
build / backend (push) Successful in 1m48s
build / frontend (push) Successful in 51s
2026-07-26 15:53:09 +03:00
Leonid Pershin 7af6242003 Enhance manual inbox dialog: add regex presets and hints for episode number extraction, improve user interface for file selection, and update translations for better user guidance. Refactor state management and query handling to streamline the import process and enhance overall user experience in manual media management.
build / backend (push) Successful in 1m12s
build / frontend (push) Successful in 46s
tests / backend-tests (push) Successful in 4m19s
2026-07-26 15:48:41 +03:00
Leonid Pershin a95c0540d9 Refactor manual inbox handling: update ImportManualInboxCommand to accept ManualImportItem objects, enhancing file import functionality with explicit season and episode numbers. Implement CleanupManualLeftoversAsync in IMediaStorage to remove unnecessary files after import. Update frontend components to support new import structure and improve user experience in manual media management.
tests / backend-tests (push) Successful in 1m38s
build / backend (push) Successful in 1m11s
build / frontend (push) Successful in 34s
2026-07-26 15:41:31 +03:00
Leonid Pershin 74bca20a3c Refactor user settings access in navigation: replace direct settings link with username display for logged-in users, enhancing mobile and desktop menu experiences. Update logout button placement and styling for improved usability.
tests / backend-tests (push) Successful in 1m29s
build / backend (push) Successful in 1m14s
build / frontend (push) Successful in 45s
2026-07-26 15:25:18 +03:00
Leonid Pershin b602d099ca Refactor media storage and management functionalities: enhance IMediaStorage interface with manual inbox handling, update FileSystemMediaStorage to support manual file imports, and improve MediaPathResolver for better path management. Extend MediaEndpoints to include new manual inbox features and update frontend components for improved media management experience.
build / backend (push) Successful in 2m8s
build / frontend (push) Successful in 36s
tests / backend-tests (push) Successful in 1m35s
2026-07-26 15:16:55 +03:00
Leonid Pershin 2445ba56b5 Enhance scheduling and trace functionalities: add CollectionId to ScheduleEntry and PlannedItem models, update related query handlers and frontend components to support collection information in entry traces. Improve data handling in scheduling logic and enhance user experience in trace display.
build / backend (push) Successful in 2m4s
build / frontend (push) Successful in 39s
tests / backend-tests (push) Successful in 2m10s
2026-07-26 14:59:54 +03:00
Leonid Pershin f699576582 Enhance channel and settings functionalities: introduce viewer settings in ChannelEndpoints, update SiteSettings to include channel number toggling, and refactor related data structures. Implement new endpoints for validating templates and diffing scheduling changes, improving overall user experience and configuration management.
build / backend (push) Successful in 1m54s
build / frontend (push) Successful in 43s
tests / backend-tests (push) Successful in 2m4s
2026-07-26 14:50:32 +03:00
Leonid Pershin 8494eb5e6a Enhance template and scheduling functionalities: add PlanningRules to template endpoints and commands, implement repeat limits and audience filtering in scheduling logic, and update related data structures. Refactor frontend components to support new rules and improve user experience in channel management. 2026-07-26 14:25:32 +03:00
Leonid Pershin 7b12a06d1b Add interstitial endpoints and enhance media and template functionalities: implement MapInterstitialEndpoints in Program.cs, add preview functionality for media assets in MediaEndpoints, and introduce template preview capabilities in TemplateEndpoints. Remove obsolete bumper settings from Channel and related classes to streamline configuration.
build / frontend (push) Successful in 47s
tests / backend-tests (push) Successful in 5m57s
build / backend (push) Successful in 5m58s
2026-07-26 14:03:53 +03:00
Leonid Pershin 66040a8841 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.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s
2026-07-26 13:32:13 +03:00
Leonid Pershin c4ef954dea Update TV scheduler architecture documentation: expand on data model details, including channel, pool, grid layer, and slot structures. Enhance descriptions of scheduling strategies and constraints, and clarify the role of epoch and timezone in scheduling. Improve overall clarity and completeness of the specification.
build / backend (push) Successful in 1m11s
build / frontend (push) Successful in 31s
tests / backend-tests (push) Successful in 1m23s
2026-07-26 04:18:33 +03:00
Leonid Pershin a0533a8ed8 Refactor user and media endpoints: remove unused GetUser and GetMedia methods, streamline AdminUserEndpoints and MediaEndpoints, and enhance metadata handling in MetadataEndpoints. Update Program.cs to remove legacy image relocation logic and improve overall code clarity.
build / backend (push) Successful in 1m14s
build / frontend (push) Successful in 35s
tests / backend-tests (push) Successful in 3m16s
2026-07-26 02:58:17 +03:00
Leonid Pershin 44e954920c Add missing episodes feature: implement FindMissingEpisodes endpoint, update metadata providers to retrieve season episode counts, and enhance frontend components for displaying missing episodes report. Update translations for new UI elements.
build / frontend (push) Successful in 44s
tests / backend-tests (push) Successful in 2m38s
build / backend (push) Successful in 3m5s
2026-07-26 02:15:47 +03:00
Leonid Pershin 8962840654 Add audience categorization for shows: implement ShowAudience type, update related API endpoints, and enhance frontend components for audience selection and display. Modify data structures to support audience information in show creation and retrieval processes.
build / backend (push) Successful in 3m35s
build / frontend (push) Successful in 52s
tests / backend-tests (push) Successful in 3m27s
2026-07-26 02:06:08 +03:00
Leonid Pershin 525fd2ee01 Implement preferred audio languages feature: update site settings to include preferred audio languages, modify related data structures and API endpoints, and enhance media processing to select audio tracks based on user preferences. Update frontend components for managing and displaying these settings.
tests / backend-tests (push) Failing after 32s
build / backend (push) Successful in 2m17s
build / frontend (push) Successful in 58s
2026-07-26 01:51:55 +03:00
Leonid Pershin 64a2dade74 Enhance user and media management with sorting and statistics: add sorting options to user and media listing endpoints, implement media statistics retrieval, and update frontend components for sorting and displaying media processing times. Refactor related query handlers and API types to support new features.
build / backend (push) Successful in 1m35s
build / frontend (push) Successful in 31s
tests / backend-tests (push) Successful in 1m48s
2026-07-26 00:28:22 +03:00
Leonid Pershin d6ad8e11a4 Implement asynchronous bumper rendering and add integration tests: refactor BumperAsset to support async rendering, introduce BumperRenderBackgroundService for processing, and create TeleWave.Integration.Tests with Testcontainers-Postgres for comprehensive testing of scheduling and bumper generation scenarios. Update documentation and ensure tests skip if Docker is not available. 2026-07-25 23:42:40 +03:00
Leonid Pershin 6c18a9da79 Update configuration and enhance media processing: add stream token TTL and timeout settings in .env.example, improve error handling in media endpoints, and refactor command handlers for asynchronous operations. Update documentation to reflect current application state and features.
tests / backend-tests (push) Successful in 2m27s
build / backend (push) Successful in 2m19s
build / frontend (push) Successful in 53s
2026-07-25 23:14:55 +03:00
Leonid Pershin a261e261f0 Add RenderBumperPreviewCommandHandler: implement command handler for rendering bumper previews, including channel and template validation, audio duration handling, and rendering logic for bumper variants. 2026-07-25 23:14:46 +03:00
902 changed files with 113064 additions and 13206 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.3.0",
"commands": [
"csharpier"
],
"rollForward": false
}
}
}
+22
View File
@@ -0,0 +1,22 @@
# Базовые правила для редакторов — чтобы IDE не ставила свои отступы и концы строк до того, как
# файл дойдёт до форматтера. Итоговый стиль всё равно задают csharpier (.config/dotnet-tools.json)
# и Prettier (frontend/.prettierrc.json); значения здесь совпадают с их настройками намеренно —
# оба читают .editorconfig, и расхождение развернуло бы форматирование в другую сторону.
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
max_line_length = 100
[*.cs]
indent_size = 4
[*.md]
# В markdown два пробела в конце строки — это перенос, обрезать их нельзя.
trim_trailing_whitespace = false
+34 -4
View File
@@ -32,11 +32,22 @@ Storage__LiveWindowSegments=10
Storage__KeepOriginals=false Storage__KeepOriginals=false
# Порог свободного места, ниже которого загрузка отклоняется (10 ГБ). # Порог свободного места, ниже которого загрузка отклоняется (10 ГБ).
Storage__MinFreeSpaceBytes=10737418240 Storage__MinFreeSpaceBytes=10737418240
# Через сколько часов простоя убирать брошенное в uploads/ (след оборванной заливки или рестарта
# посреди неё). Идущую загрузку не задевает — отсчёт от времени последней записи. 0 — не убирать.
# Storage__StaleUploadHours=24
# TTL stream-токена (cookie tw_stream), минуты. Короткий срок ограничивает окно доступа после
# блокировки/логаута; фронт перевыпускает cookie, пока идёт просмотр. По умолчанию 30.
# Storage__StreamTokenMinutes=30
# ── Планировщик эфира ────────────────────────────────────────────────────── # ── Планировщик эфира ──────────────────────────────────────────────────────
# На сколько дней вперёд держать расписание; сколько часов прошлого хранить; период тика. # HorizonDays — на сколько суток вперёд держать материализованное расписание. Неделя — часть замысла:
Scheduler__HorizonDays=3 # «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
Scheduler__RetentionHours=24 # RetentionDays — сколько суток прошедшей ленты хранить. Окно обязано покрывать самое долгое остывание
# и самый глубокий повтор среди правил канала: история показов берётся из самой ленты, отдельного
# журнала нет — укоротишь окно, и остывание с потолком повторов начнут «забывать» показы.
# TickMinutes — период фонового тика, достраивающего горизонт.
Scheduler__HorizonDays=7
Scheduler__RetentionDays=90
Scheduler__TickMinutes=30 Scheduler__TickMinutes=30
# ── Обработка медиа (ffmpeg) ─────────────────────────────────────────────── # ── Обработка медиа (ffmpeg) ───────────────────────────────────────────────
@@ -55,10 +66,21 @@ Media__TranscodeThreads=3
Media__MaxParallelTranscodes=1 Media__MaxParallelTranscodes=1
# Период опроса каталога inbox/ сканером, секунды. # Период опроса каталога inbox/ сканером, секунды.
Media__InboxScanSeconds=15 Media__InboxScanSeconds=15
# Заводить фильм по файлу из inbox/, когда имя уверенно опознано в источнике метаданных.
# Неуверенное совпадение остаётся человеку: файл станет ассетом без шоу и попадёт в разбор.
Media__InboxAutoMovies=true
# Нормализация громкости при обработке (EBU R128 loudnorm) — все ролики одинаковой громкости. # Нормализация громкости при обработке (EBU R128 loudnorm) — все ролики одинаковой громкости.
Media__NormalizeLoudness=true Media__NormalizeLoudness=true
# Целевая громкость, LUFS (−16 типично для стриминга; тише — уменьшить). # Целевая громкость, LUFS (−16 типично для стриминга; тише — уменьшить).
Media__LoudnessTargetLufs=-16 Media__LoudnessTargetLufs=-16
# Таймауты вызовов ffprobe/ffmpeg, секунды (0 — без таймаута): не дают зависшему процессу вечно
# держать слот параллелизма/тик планировщика. Поднять TranscodeTimeoutSeconds для очень длинных файлов.
# Media__ProbeTimeoutSeconds=120
# Media__TranscodeTimeoutSeconds=1800
# Предпочитаемые языки аудиодорожек ПО УМОЛЧАНИЮ (через запятую, в порядке приоритета) — если файл
# многоозвучный и есть дорожка с таким языком, при обработке берётся она. Обычно задаётся в админке
# («Настройки»); этот конфиг — дефолт, если в настройках пусто. Пусто — выбор ffmpeg по умолчанию.
# Media__PreferredAudioLanguages=rus,eng
# ── ТВ-заставки «Сейчас/Далее» (bumpers) ─────────────────────────────────── # ── ТВ-заставки «Сейчас/Далее» (bumpers) ───────────────────────────────────
# Общие для всех каналов параметры рендера. Оформление и правила (цвета, подписи, длительность, # Общие для всех каналов параметры рендера. Оформление и правила (цвета, подписи, длительность,
@@ -71,11 +93,19 @@ Media__LoudnessTargetLufs=-16
# Bumpers__TemplateVersion=1 # Bumpers__TemplateVersion=1
# ── Метаданные шоу/серий (TMDb / OMDb) ───────────────────────────────────── # ── Метаданные шоу/серий (TMDb / OMDb) ─────────────────────────────────────
# Ключи бесплатные: TMDb — themoviedb.org (Settings → API, v3 key), OMDb — omdbapi.com. # Ключи бесплатные: TMDb — themoviedb.org (Settings → API), OMDb — omdbapi.com.
# Без ключа источник просто не показывается в админке (ручной режим работает всегда). # Без ключа источник просто не показывается в админке (ручной режим работает всегда).
# TMDb принимает оба вида учётных данных, различать их не нужно — подставляй что есть:
# «API Key (v3 auth)» — 32 hex-символа, уходит параметром api_key;
# «API Read Access Token (v4 auth)» — длинный JWT с точками, уходит заголовком Bearer.
Metadata__Language=ru-RU Metadata__Language=ru-RU
Metadata__Tmdb__ApiKey= Metadata__Tmdb__ApiKey=
Metadata__Omdb__ApiKey= Metadata__Omdb__ApiKey=
# Страны возрастной сертификации TMDb в порядке приоритета: берётся первая, у которой рейтинг есть.
# US впереди намеренно — его шкала (G/PG/PG-13/R/NC-17) совпадает с нашей и переводится точно;
# RU (0+…18+) приводится приблизительно и нужен ради покрытия там, где US-сертификации нет.
# Metadata__Tmdb__CertificationCountries__0=US
# Metadata__Tmdb__CertificationCountries__1=RU
# ── ASP.NET Core ────────────────────────────────────────────────────────── # ── ASP.NET Core ──────────────────────────────────────────────────────────
ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_ENVIRONMENT=Production
+15
View File
@@ -0,0 +1,15 @@
# Коммиты, которые правили только оформление: перевод концов строк в LF и первые прогоны
# csharpier/Prettier. Без этого списка `git blame` на половине файлов показывал бы их вместо
# автора реальной правки.
#
# Включить локально (в .git/config, а не в репозитории — потому команду надо выполнить у себя):
# git config blame.ignoreRevsFile .git-blame-ignore-revs
#
# Добавлять сюда только чисто механические коммиты, полным SHA, по одному на строку:
# git rev-parse HEAD >> .git-blame-ignore-revs
# Перевод дерева в LF.
0442056367cb6cd3b38964dacf75f44f123e11ac
# Первый прогон Prettier по фронтенду.
0606ea3e6ee4a4fd7faf8d1b054a316633442e98
+10
View File
@@ -0,0 +1,10 @@
# Концы строк в репозитории — всегда LF. Бэкенд и так лежал в LF, фронтенд — в CRLF; Prettier
# (endOfLine: lf по умолчанию) выровнял его, и этот файл не даёт разъехаться обратно: без него
# при core.autocrlf=true у другого разработчика CRLF вернулись бы в коммит.
* text=auto eol=lf
# Бинарники не трогаем.
*.png binary
*.jpg binary
*.ico binary
*.woff2 binary
-43
View File
@@ -1,43 +0,0 @@
name: build
on:
push:
branches: [main]
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore
working-directory: backend
run: dotnet restore TeleWave.slnx
- name: Build (Release)
working-directory: backend
run: dotnet build TeleWave.slnx -c Release --no-restore
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
run: corepack enable
- name: Install
working-directory: frontend
run: pnpm install --frozen-lockfile
- name: Lint
working-directory: frontend
run: pnpm lint
- name: Typecheck
working-directory: frontend
run: pnpm typecheck
- name: Build
working-directory: frontend
run: pnpm build
+170
View File
@@ -0,0 +1,170 @@
name: ci
# Один workflow вместо трёх: порядок build → tests → sonar задаётся через `needs`, а между
# отдельными файлами workflow'ов в Gitea связать этапы нечем. Побочный эффект — этапы идут
# последовательно, поэтому прогон дольше, зато тесты не стартуют на заведомо битой сборке,
# а анализ не уходит в SonarCloud с красными тестами.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
build-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore
working-directory: backend
run: dotnet restore TeleWave.slnx
# Форматирование проверяем до сборки: падает за секунду и сразу показывает, что чинить.
# Версия csharpier прибита в .config/dotnet-tools.json — глобальная копия разработчика на CI
# не влияет, иначе её минорка переформатировала бы репозиторий в свой стиль.
- name: Format check
working-directory: backend
run: |
dotnet tool restore
dotnet csharpier check .
# Строгая сборка: TreatWarningsAsErrors=true из Directory.Build.props не отключаем.
- name: Build (Release)
working-directory: backend
run: dotnet build TeleWave.slnx -c Release --no-restore
build-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
run: corepack enable
- name: Install
working-directory: frontend
run: pnpm install --frozen-lockfile
- name: Format check
working-directory: frontend
run: pnpm format:check
- name: Lint
working-directory: frontend
run: pnpm lint
- name: Typecheck
working-directory: frontend
run: pnpm typecheck
- name: Build
working-directory: frontend
run: pnpm build
tests:
runs-on: ubuntu-latest
needs: [build-backend, build-frontend]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore
working-directory: backend
run: dotnet restore TeleWave.slnx
# Покрытие здесь не собираем: его считает джоба sonar своим прогоном (в формате opencover),
# а бейдж покрытия в README берётся из SonarCloud.
- name: Test
working-directory: backend
run: >
dotnet test TeleWave.slnx
-c Release
--no-restore
--logger "console;verbosity=normal"
# Анализ SonarCloud вместе с покрытием: один прогон собирает решение под сканером, гоняет тесты
# и отправляет результат. Только push в main — декорация pull request'ов у SonarCloud завязана
# на GitHub/GitLab, из Gitea она не работает, и анализ PR только засорял бы ветки в проекте.
sonar:
runs-on: ubuntu-latest
needs: tests
if: github.event_name != 'pull_request'
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST: https://sonarcloud.io
SONAR_PROJECT_KEY: mrleo1nid_telewave
SONAR_ORGANIZATION: mrleo1nid
steps:
- uses: actions/checkout@v4
with:
# Полная история — иначе Sonar не сопоставит изменения с авторами и «новым кодом».
fetch-depth: 0
# Сканер — java-приложение, на runner-образе Java может не оказаться.
- uses: actions/setup-java@v4
if: env.SONAR_TOKEN != ''
with:
distribution: temurin
java-version: 21
- uses: actions/setup-dotnet@v4
if: env.SONAR_TOKEN != ''
with:
dotnet-version: 10.0.x
- name: Install scanner
if: env.SONAR_TOKEN != ''
run: dotnet tool install --global dotnet-sonarscanner
# Сканер запускается из корня репозитория, а не из backend/: так в анализ попадает и фронт.
- name: Begin analysis
if: env.SONAR_TOKEN != ''
run: |
export PATH="$PATH:$HOME/.dotnet/tools"
dotnet sonarscanner begin \
/k:"$SONAR_PROJECT_KEY" \
/o:"$SONAR_ORGANIZATION" \
/d:sonar.host.url="$SONAR_HOST" \
/d:sonar.token="$SONAR_TOKEN" \
/d:sonar.cs.opencover.reportsPaths="**/coverage/**/coverage.opencover.xml" \
/d:sonar.exclusions="frontend/node_modules/**,frontend/dist/**,frontend/src/routeTree.gen.ts,backend/src/TeleWave.Infrastructure/Migrations/**" \
/d:sonar.coverage.exclusions="backend/src/TeleWave.Infrastructure/Migrations/**,frontend/**" \
/d:sonar.cpd.exclusions="frontend/src/shared/lib/locales/**"
# Локали исключены из поиска дублей намеренно: ru.ts и en.ts обязаны совпадать ключ в ключ —
# это не копипаста, а единственный способ не потерять перевод, и «устранение» такого дубля
# означало бы отказ от второго языка.
# Сканер подмешивает в сборку свои анализаторы, а в проекте TreatWarningsAsErrors=true —
# любое замечание Sonar роняло бы сборку вместо того, чтобы приехать в отчёт. Строгая
# сборка живёт в джобе build-backend, здесь она нужна только как носитель анализа.
- name: Build
if: env.SONAR_TOKEN != ''
run: dotnet build backend/TeleWave.slnx -c Release /p:TreatWarningsAsErrors=false
# Формат opencover, а не cobertura по умолчанию: C#-анализатор Sonar читает именно его.
# Интеграционные тесты без Docker пропускаются сами (см. PostgresFixture).
#
# Проекты гоняются по одному, а не одним прогоном по решению: при общем прогоне сборщик
# покрытия теряет попадания — отчёт приезжает без модуля TeleWave.Application, и в Sonar
# покрытые тестами файлы выглядят нулевыми. Отчёты складываются в один каталог, сканер
# подбирает их маской.
- name: Test + coverage
if: env.SONAR_TOKEN != ''
run: |
for project in Domain Application Integration; do
dotnet test "backend/tests/TeleWave.$project.Tests" \
-c Release \
--no-build \
--collect:"XPlat Code Coverage;Format=opencover" \
--results-directory backend/coverage \
--logger "console;verbosity=normal"
done
- name: End analysis
if: env.SONAR_TOKEN != ''
run: |
export PATH="$PATH:$HOME/.dotnet/tools"
dotnet sonarscanner end /d:sonar.token="$SONAR_TOKEN"
- name: Skipped
if: env.SONAR_TOKEN == ''
run: echo "SONAR_TOKEN не задан — анализ пропущен."
-57
View File
@@ -1,57 +0,0 @@
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
backend-tests:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore
working-directory: backend
run: dotnet restore TeleWave.slnx
- name: Test + coverage
working-directory: backend
run: >
dotnet test TeleWave.slnx
-c Release
--no-restore
--collect:"XPlat Code Coverage"
--results-directory ./coverage
--logger "console;verbosity=normal"
# Бейдж покрытия обновляем только для main (не для PR/форков): генерируем SVG и
# force-пушим в отдельную orphan-ветку badges — README ссылается на её raw-URL.
- name: Coverage badge
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
working-directory: backend
env:
TOKEN: ${{ secrets.COVERAGE_TOKEN || secrets.GITEA_TOKEN }}
run: |
dotnet tool install --global dotnet-reportgenerator-globaltool
export PATH="$PATH:$HOME/.dotnet/tools"
reportgenerator \
"-reports:coverage/**/coverage.cobertura.xml" \
"-targetdir:coverage/badges" \
"-reporttypes:Badges" \
"-assemblyfilters:+TeleWave.Domain;+TeleWave.Application" \
"-classfilters:-System.*"
BADGE=coverage/badges/badge_linecoverage.svg
test -f "$BADGE" || { echo "badge not generated"; exit 1; }
WORK=$(mktemp -d)
cp "$BADGE" "$WORK/coverage.svg"
cd "$WORK"
git init -q -b badges
git add coverage.svg
git -c user.name=gitea-actions -c user.email=actions@local \
commit -q -m "coverage badge [skip ci]"
git push -f \
"https://gitea-actions:${TOKEN}@gitea.hsrv.site/mrleo1nid/TeleWave.git" badges
+90 -81
View File
@@ -1,82 +1,91 @@
# ---> VisualStudioCode # ---> VisualStudioCode
.vscode/* .vscode/*
!.vscode/settings.json !.vscode/settings.json
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json !.vscode/launch.json
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/*.code-snippets !.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# ---> VisualStudio
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
.vs/
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.VisualState.xml
TestResult.xml
nunit-*.xml
BenchmarkDotNet.Artifacts/
project.lock.json
project.fragment.lock.json
artifacts/
*.pdb
*.log
*.tlog
# NuGet Packages
*.nupkg
*.snupkg
**/[Pp]ackages/*
!**/[Pp]ackages/build/
# Others
*.pfx
*.publishsettings
# Node
node_modules/
dist/
dist-ssr/
# Local environment files (secrets) — keep .env.example, ignore real .env
.env
.env.local
.env.*.local
# Локальное медиахранилище для dev-запуска (Storage__RootPath в appsettings.Development.json)
.dev-media/
# Отчёты покрытия (артефакт dotnet test / CI)
coverage/
TestResults/
# Local History for Visual Studio Code # ---> TeleWave
.history/ # Отладочные дампы каналов: архивы кладёт сюда бэкенд (секция Debug:ExportDirectory).
debug-exports/*
# Built Visual Studio Code Extensions !debug-exports/.gitkeep
*.vsix
# ---> VisualStudio
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
.vs/
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.VisualState.xml
TestResult.xml
nunit-*.xml
BenchmarkDotNet.Artifacts/
project.lock.json
project.fragment.lock.json
artifacts/
*.pdb
*.log
*.tlog
# NuGet Packages
*.nupkg
*.snupkg
**/[Pp]ackages/*
!**/[Pp]ackages/build/
# Others
*.pfx
*.publishsettings
# Node
node_modules/
dist/
dist-ssr/
# Local environment files (secrets) — keep .env.example, ignore real .env
.env
.env.local
.env.*.local
# Локальное медиахранилище для dev-запуска (Storage__RootPath в appsettings.Development.json)
.dev-media/
+84 -101
View File
@@ -4,142 +4,125 @@
## Что это ## Что это
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из **TeleWave** — сервис онлайн-каналов: зритель смотрит сетку каналов, видео отдаётся из хранилища
хранилища на сервере. Админ управляет каналами и пользователями. на сервере. Админ ведёт библиотеку, каналы и эфир.
> Текущее состояние — **база**: вход/регистрация, роли, пользователи, минимальная админка, Работает: аутентификация, роли, пользователи; библиотека шоу/серий, коллекции, жанры, метаданные
> приветственная главная страница. Каталог каналов и сама раздача видео **не реализованы** — (TMDb/OMDb), обработка медиа (ffmpeg → HLS), изображения; группы, шаблон сетки, стыки и заставки,
> это следующий шаг. Telegram-бот сознательно не делаем. автосборка по профилям, обмен конфигурацией файлом и запросом к ИИ; генерация ленты, live-раздача,
публичный просмотр, статистика хранилища. Telegram-бот расписания (подписки на оповещения по каналам, прокси, опрос или вебхук). Конвенции — зеркало
[`PnvPanel`](../PnvPanel) (тот же автор).
Архитектура и код-конвенции — прямое зеркало [`D:\Github\PnvPanel`](../PnvPanel) (тот же автор, **Спеки в `docs/` — источник правды по домену**, читай до правок в подсистеме:
тот же стек), но домен урезан под текущий объём фичи. [`tv-scheduler-architecture.md`](docs/tv-scheduler-architecture.md) (группы, сетка, слоты, стыки,
заставки, планировщик, автосборка, обмен конфигурацией — главный документ),
[`media-storage-and-streaming.md`](docs/media-storage-and-streaming.md) и
[`server-storage-setup.md`](docs/server-storage-setup.md) (медиа и хранилище).
## Стек ## Стек
- **Backend**: C# / .NET 10, ASP.NET Core Web API (Minimal API), Clean Architecture, CQRS через - **Backend**: C# / .NET 10, ASP.NET Core Minimal API, Clean Architecture, CQRS через
[`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) (NuGet-пакет, лёгкая [`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) (лёгкая альтернатива MediatR с явным
CQRS-библиотека, альтернатива MediatR с явным разделением Command/Query — см. её README). разделением Command/Query — см. README). EF Core 10 + Npgsql, Identity + JWT, FluentValidation,
EF Core 10 + Npgsql, ASP.NET Core Identity + JWT (access + refresh), FluentValidation, Serilog. Serilog, маппинг DTO вручную, OpenAPI + Scalar UI, тесты на xUnit + NSubstitute.
Маппинг DTO вручную. OpenAPI — нативный `Microsoft.AspNetCore.OpenApi` + Scalar UI. - **Frontend**: React 19 + Vite + TS, TanStack Query/Router (файловые роуты, `routeTree.gen.ts`
- **Frontend**: React 19 + Vite + TS, TanStack Query/Router, shadcn-стиль поверх Radix + Tailwind генерируется сборкой), shadcn поверх Radix + Tailwind v4, Zustand (только auth+тема),
v4, Zustand (только auth+тема), react-hook-form + zod. pnpm, oxlint. react-hook-form + zod, i18n (ru/en), pnpm + oxlint + prettier.
- **Инфра**: единый Docker-образ (API + статика SPA); PostgreSQL — **внешний сервер**, не в
compose (в отличие от PnvPanel).
## Архитектура — жёсткие правила ## Архитектура — жёсткие правила
Слои и направление зависимостей: **Api → Infrastructure → Application → Domain** (внутрь). Зависимости строго внутрь: **Api → Infrastructure → Application → Domain**.
- **Domain** — без внешних зависимостей. Сейчас единственная сущность — `Auth/RefreshToken` - **Domain** — без внешних зависимостей: `Auth`, `Library` (шоу, серии, коллекции, жанры), `Media`,
(rich model: приватные сеттеры, фабричный `Issue(...)`, поведенческий `Revoke(...)`). `Images`, `Programming` (группы, сетка, слоты, стыки + **чистый планировщик** `Planning`),
- **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы: `Broadcast` (каналы, заставки, лента, live), `Settings`. Модель rich: приватные сеттеры,
`IAppDbContext`, `ICurrentUser`, `IIdentityService`, `IJwtTokenService`, `IRefreshTokenService`, фабрики `Create`, поведенческие методы.
`IRoleService`). Зависит только от Domain — никаких `Npgsql`/`AspNetCore.Identity`, только их - **Application** — CQRS-хендлеры, DTO, валидаторы и **порты** (`Common/Interfaces/I*.cs`:
абстракции. `IAppDbContext`, `ICurrentUser`, `IMediaProcessor`, `IMediaStorage`, `IBumperRenderer`,
`IMetadataProvider`, `IImageStore`, `IStorageInspector`, …). Зависит только от Domain: никаких
`Npgsql`/`AspNetCore.Identity`, лишь их абстракции.
- **Infrastructure** — реализации портов: EF Core (`AppDbContext : IdentityDbContext<AppUser, - **Infrastructure** — реализации портов: EF Core (`AppDbContext : IdentityDbContext<AppUser,
AppRole, Guid>`), `IdentityService`/`RoleService`/`JwtTokenService`/`RefreshTokenService`, AppRole, Guid>`, конфигурации в `Persistence/Configurations`), ffmpeg, хранилище, метаданные,
идемпотентный `DbInitializer` (сидинг ролей + админа). идемпотентный `DbInitializer` (роли + админ из env `AdminSeed__*`).
- **Api** — Minimal API эндпоинты (`Endpoints/*Endpoints.cs`), middleware, DI composition root - **Api** — Minimal API (`Endpoints/*Endpoints.cs`), middleware, DI composition root (`Program.cs`).
(`Program.cs`).
Обязательно: Обязательно:
- Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают. Диспетчер — - Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают. Диспетчер —
из `LiteCqrs.Net` (`ISender`/`ICommandHandler`/`IQueryHandler`), регистрация через `ISender`/`ICommandHandler`/`IQueryHandler`, регистрация в `Application/DependencyInjection.cs`.
`AddLiteCqrs(...)` в `TeleWave.Application/DependencyInjection.cs`. - Управляемые ошибки — через `Result<T>` (`Common/Models/Result.cs`), не исключениями. Валидация —
- Управляемые ошибки — через `Result<T>` (`Common/Models/Result.cs`), не исключениями. FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода.
- Валидация — FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода. - Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP; никаких `.Result`/`.Wait()`.
- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP. Никаких `.Result`/`.Wait()`. Nullable включён, `TreatWarningsAsErrors=true` — предупреждения анализаторов не игнорировать.
- Nullable reference types включены; `Directory.Build.props` включает - Планировщик (`Domain/Programming/Planning`) — **чистая функция**: вход `PlanningInput`, никаких
`TreatWarningsAsErrors=true` — предупреждения анализаторов не игнорировать. запросов и часов внутри. Данные под него собирает `Application/Programming/Planning`.
## Домен: роли, пользователи, аутентификация ## Инварианты, которые легко нарушить
Полноценной доменной модели (каналы/видео) пока нет — см. заметку в начале файла. Текущие - **Роли** динамические, у пользователя ровно одна; системные `admin`/`user` (`IsSystem=true`) не
инварианты: переименовать и не удалить, последнего админа не разжаловать, себя не заблокировать и не удалить.
- **Регистрация открытая** (создаётся `user`, токены сразу); понадобится модерация — заводи отдельный
- **Роли** (`AppRole : IdentityRole<Guid>`, `Infrastructure/Identity/`) — динамические, флаг, не переиспользуй `IsBlocked`. Вход по `UserName`, email и SMTP не используются; JWT access +
без квот (в отличие от PnvPanel: тут нет `MaxIpLimit`/`BillingEnabled` — при появлении refresh в httpOnly `tw_refresh_token` с обнаружением повторного использования отозванного.
доменных фич квоты/лимиты добавляются на `AppRole`/`AppUser` по мере необходимости, не заранее). - **Правка сетки эфир не двигает**: команда поднимает ревизию шаблона, ленту пересобирает применение.
У пользователя ровно одна роль. Системные роли `admin`/`user` (`IsSystem=true`) нельзя - **Вещательные сутки** начинаются с `Channel.DayStartTime` (06:00), сетка задаётся во времени
переименовать/удалить — проверяется в `RoleService`. Смену роли (`ChangeUserRoleCommand`) канала (`UtcOffsetMinutes`), в базе всё в UTC.
запрещено делать так, чтобы не осталось ни одного `admin` (`RoleErrors.CannotRemoveLastAdmin`).
- **Регистрация** — открытая, без гейта активации (в отличие от PnvPanel): `RegisterCommandHandler`
создаёт пользователя с ролью `user` и сразу выдаёт токены (auto-login). Если в будущем
понадобится модерация новых пользователей — добавлять отдельным полем/флагом по аналогии с
`IsActivated` в PnvPanel, не переиспользовать `IsBlocked`.
- **Блокировка** (`AppUser.IsBlocked`) — админ блокирует/разблокирует пользователя; вход
запрещён (`LoginCommandHandler`/`RefreshCommandHandler` проверяют флаг). Админ не может
заблокировать/удалить самого себя (`UserErrors.CannotBlockSelf`/`CannotDeleteSelf`).
- **Вход по `UserName`** (email не используется, SMTP не нужен). JWT — access (короткий TTL) +
refresh (httpOnly cookie `tw_refresh_token`, `SameSite=Strict`, ротация с обнаружением повторного
использования отозванного токена — см. `RefreshTokenService.RotateAsync`).
- **Сидинг**: идемпотентный `DbInitializer` создаёт системные роли + админа из env
(`AdminSeed__Username`/`Password`) при старте. Источник примера env — `.env.example`.
## Единый контейнер ## Единый контейнер
- Один образ: Api раздаёт REST (`/api`) и статику SPA из `wwwroot` (fallback на `index.html`). - Один образ: Api раздаёт REST (`/api`) и статику SPA из `wwwroot` (fallback на `index.html`), один
Один origin, база API — относительный `/api`. origin, база API — относительный `/api`. Multi-stage Dockerfile: node → dotnet sdk → aspnet.
- Multi-stage Dockerfile: node (фронт) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet Отдельный nginx для статики не вводить — ломает требование единого контейнера.
runtime. Никакого gosu/root-drop и Data Protection key-ring — секретов на диске пока нет - Работает под непривилегированным `USER $APP_UID` (uid/gid 1654) **статически**, без
(JWT-ключ — обычная конфигурация, не шифруемый секрет at-rest); если появятся зашифрованные gosu/entrypoint: права на bind-mount выставляет оператор (`docs/server-storage-setup.md` § 5).
данные (например API-ключи внешних сервисов) — добавлять Data Protection по образцу PnvPanel. Data Protection не заводим — шифруемых секретов на диске нет. **TLS внешний**: `app` отдаёт HTTP
- docker-compose: только `app` — PostgreSQL живёт вне compose (внешний сервер/хост, адрес и и доверяет `X-Forwarded-*`.
креды — в `ConnectionStrings__Default` из `.env`; база и пользователь на нём создаются - docker-compose: только `app`. PostgreSQL внешний (`ConnectionStrings__Default` из `.env`), база
заранее вручную, приложение их не сидит). Миграции применяются авто на старте и пользователь создаются заранее вручную; миграции применяются на старте (`ApplyMigrationsAsync`),
(`ApplyMigrationsAsync` в `Program.cs`) — прав `CREATEDB` не требуется, только DDL/DML в прав `CREATEDB` не нужно.
уже существующей базе.
- Не вводи отдельный nginx-контейнер для статики — ломает требование единого контейнера.
- **TLS — внешний**; `app` отдаёт HTTP + доверяет `X-Forwarded-*` (`ForwardedHeaders`).
## Соглашения по коду ## Соглашения по коду
- `<Verb><Noun>Command`/`<Get|List><Noun>Query` + `Handler`/`Validator` (валидатор — где есть что - `<Verb><Noun>Command`/`<Get|List><Noun>Query` + `Handler`/`Validator` (где есть что проверить),
проверить). Папки по фичам (`Auth/Login/`, `Admin/Roles/CreateRole/`, ...). папки по фичам (`Auth/Login/`, `Programming/Templates/Generate/`, …). Один публичный тип на файл =
- DTO — суффикс `Dto`; тела запросов Api-эндпоинтов, не совпадающие с командой 1:1, — `Body` имя файла, кроме `Body` — тела Api-эндпоинтов, не совпадающие с командой 1:1 (`sealed record`
(`sealed record` внизу файла эндпоинта). внизу файла эндпоинта). DTO — суффикс `Dto`.
- Один публичный тип на файл = имя файла (кроме `Body` в файле эндпоинтов). - Ошибки — каталоги `XErrors` (`Error.Validation/NotFound/Conflict/…`), наружу единым
- Ошибки — статические каталоги `XErrors` (`AuthErrors`, `RoleErrors`, `UserErrors`) с `application/problem+json` (`Api/Common/ResultExtensions.cs`). Секреты не логировать.
`Error.Validation/NotFound/Conflict/Unauthorized/Forbidden(...)`. - **Изображения — только через реестр `Image`** (`Domain/Images`, галерея `features/admin/images`):
- Секреты не логировать; логи — Serilog. картинка хранится как `ImageId` и выбирается контролом `ImageGallery`/`GalleryBrowser`, своих
- Ошибки API — единый `application/problem+json` (см. `Api/Common/ResultExtensions.cs`). файловых полей не заводить. Файлы — `images/{id}{ext}`, отдаются по `/api/images/{id}`.
- **Изображения — через общий реестр `Image`** (домен `Domain/Images`, галерея `features/admin/images`). - Фронт: фича = папка в `src/features/admin/<фича>`; тексты только через i18n, ключи в `ru.ts` и `en.ts`.
Любой новый функционал, где загружается или выбирается картинка, должен использовать контрол - Комментарии объясняют **почему**, а не что; по-русски, как в существующем коде.
`ImageGallery`/`GalleryBrowser` (пикер по категориям `ImageCategory`) и хранить ссылку на `ImageId`,
а не заводить своё файловое поле. Автоматически полученные картинки (например постер из метадаты)
тоже регистрируются как `Image`. Файлы лежат под `images/{id}{ext}`, отдаются по `/api/images/{id}`.
## Команды ## Команды
Backend (из `backend/`): Backend (из `backend/`):
```bash ```bash
dotnet build dotnet build
dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests dotnet csharpier format . # только через dotnet (версия из .config/); в CI — csharpier check
dotnet test tests/TeleWave.Domain.Tests # по одному проекту: MSBuild не берёт несколько
dotnet test tests/TeleWave.Application.Tests
dotnet test tests/TeleWave.Integration.Tests # Testcontainers; без Docker пропускаются
dotnet run --project src/TeleWave.Api dotnet run --project src/TeleWave.Api
dotnet ef migrations add <Name> --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api dotnet ef migrations add <Name> --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
dotnet ef database update --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
``` ```
Frontend (из `frontend/`): Frontend (из `frontend/`):
```bash ```bash
pnpm install pnpm install && pnpm dev
pnpm dev pnpm lint && pnpm typecheck && pnpm build
pnpm build pnpm format && pnpm format:check # CI падает на неотформатированном — прогоняй после правок
pnpm lint && pnpm typecheck
``` ```
Инфраструктура: Инфраструктура: `docker compose up -d --build` (только api; postgres внешний). Окружение — Windows,
```bash оболочка **PowerShell**; для POSIX-скриптов есть Bash-инструмент.
docker compose up -d --build # только api; postgres — внешний, см. ConnectionStrings__Default
```
> Окружение: Windows, основная оболочка — **PowerShell**. Для POSIX-скриптов есть Bash-инструмент.
## Рабочие принципы ## Рабочие принципы
- Не начинай крупную реализацию (каталог каналов, раздача видео, транскодинг и т.п.) без сверки - Базовый срез реализован — правь по месту. Новые крупные направления с непринятыми решениями
с пользователем — это следующий большой этап после базы, архитектурные решения там ещё не приняты. (многоэкземплярность, транскод-профили, подсистемы) — после сверки; неоднозначность — вопрос.
При неоднозначности — вопрос пользователю, не предположение. - Меняешь поведение подсистемы — обнови её спеку в `docs/` тем же заходом.
- Соблюдай границы слоёв — главный инвариант проекта, как и в PnvPanel. - Соблюдай границы слоёв — главный инвариант проекта.
- Не коммить и не пуши без явной просьбы. - Правь файлы через Edit/Write: пользователь видит дифф, а harness не даст перезаписать файл вслепую.
- Отвечай пользователю на русском. Скриптом на python — только при крайней необходимости (массовая механическая замена), и сказать явно.
- Не коммить и не пуши без явной просьбы; отвечай пользователю на русском.
+11 -2
View File
@@ -4,8 +4,12 @@
FROM node:22-alpine AS frontend FROM node:22-alpine AS frontend
WORKDIR /app/frontend WORKDIR /app/frontend
RUN corepack enable RUN corepack enable
COPY frontend/package.json frontend/pnpm-lock.yaml ./ # pnpm-workspace.yaml здесь не про воркспейсы: в pnpm 11 там живут overrides, и без него
RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile # --frozen-lockfile падает на несовпадении конфигурации с локом.
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
# --ignore-scripts: ни один пакет фронта не требует postinstall, поэтому lifecycle-скрипты зависимостей
# в сборке не выполняются — компрометация любого пакета в дереве не даёт исполнения кода на этапе install.
RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile --ignore-scripts
COPY frontend/ ./ COPY frontend/ ./
RUN pnpm build RUN pnpm build
@@ -34,6 +38,11 @@ ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_HTTP_PORTS=8080 ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080 EXPOSE 8080
COPY --from=backend /app/publish ./ COPY --from=backend /app/publish ./
# Работаем под непривилегированным пользователем образа aspnet (app, uid/gid 1654 — $APP_UID задан
# в самом образе). Порт 8080 непривилегированный, установка пакетов уже позади, приложению нужна
# только запись в /media. ВАЖНО: bind-mount пробрасывает права хоста как есть — каталог хранилища
# должен принадлежать uid 1654, иначе контейнер не сможет писать (см. docs/server-storage-setup.md § 5).
USER $APP_UID
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \ HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
CMD curl -f http://localhost:8080/health || exit 1 CMD curl -f http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "TeleWave.Api.dll"] ENTRYPOINT ["dotnet", "TeleWave.Api.dll"]
+86 -64
View File
@@ -1,64 +1,86 @@
# TeleWave # TeleWave
[![build](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions/workflows/build.yml/badge.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=build.yml) [![ci](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions/workflows/ci.yml/badge.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=ci.yml)
[![tests](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions/workflows/test.yml/badge.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=test.yml) [![quality gate](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=alert_status&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
[![coverage](https://gitea.hsrv.site/mrleo1nid/TeleWave/raw/branch/badges/coverage.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=test.yml) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=coverage&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=bugs&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=code_smells&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
хранилища на сервере, админ управляет каналами и пользователями. [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=security_rating&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=sqale_rating&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
> Текущее состояние — **база**: вход/регистрация, роли, пользователи, минимальная админка,
> приветственная главная страница. Каталог каналов и раздача видео — следующий этап, пока не **TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
> реализованы. Приложение (фронт + бек) поставляется **единым Docker-образом**; PostgreSQL — хранилища на сервере, админ управляет каналами и пользователями.
> **внешний**, не поднимается через compose.
> Текущее состояние — **рабочий вертикальный срез**: вход/регистрация, роли, пользователи, админка;
## Стек > библиотека шоу/серий, загрузка и обработка медиа (ffmpeg → HLS), метаданные (TMDb/OMDb), каталог
> каналов, планировщик эфира (реклама, ТВ-заставки, weekly-override'ы) и live-раздача HLS с публичным
| Слой | Технологии | > просмотром сетки. Приложение (фронт + бек) поставляется **единым Docker-образом**; PostgreSQL —
| -------- | ---------------------------------------------------------------------------------------- | > **внешний**, не поднимается через compose.
| Backend | C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS ([LiteCqrs.Net](https://github.com/mrleo1nid/LiteCqrs.Net)), EF Core |
| БД | PostgreSQL (Npgsql) | ## Стек
| Auth | ASP.NET Core Identity + JWT (access + refresh, ротация) |
| Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind v4 (ретро-CRT тема) | | Слой | Технологии |
| Упаковка | Единый Docker-образ (API + статика SPA); PostgreSQL — внешний сервер | | -------- | ---------------------------------------------------------------------------------------- |
| Backend | C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS ([LiteCqrs.Net](https://github.com/mrleo1nid/LiteCqrs.Net)), EF Core |
## Развёртывание | БД | PostgreSQL (Npgsql) |
| Auth | ASP.NET Core Identity + JWT (access + refresh, ротация) |
Postgres не входит в compose — база и пользователь создаются заранее на внешнем сервере БД: | Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind v4 (ретро-CRT тема) |
| Упаковка | Единый Docker-образ (API + статика SPA); PostgreSQL — внешний сервер |
```sql
-- на сервере БД (psql -h <хост БД> -U postgres) ## Развёртывание
CREATE USER telewave WITH PASSWORD 'change-me-strong-db-password';
CREATE DATABASE telewave OWNER telewave; Postgres не входит в compose — база и пользователь создаются заранее на внешнем сервере БД:
```
```sql
Затем на хосте с приложением: -- на сервере БД (psql -h <хост БД> -U postgres)
CREATE USER telewave WITH PASSWORD 'change-me-strong-db-password';
```bash CREATE DATABASE telewave OWNER telewave;
cp .env.example .env ```
# заполнить ConnectionStrings__Default (хост/база/юзер/пароль БД), Jwt__SigningKey, AdminSeed__*
docker compose up -d --build Затем на хосте с приложением:
# → http://localhost:8085 (админ — логин/пароль из .env, AdminSeed__Username/Password)
``` ```bash
cp .env.example .env
## Локальная разработка # заполнить ConnectionStrings__Default (хост/база/юзер/пароль БД), Jwt__SigningKey, AdminSeed__*
docker compose up -d --build
```bash # → http://localhost:8085 (админ — логин/пароль из .env, AdminSeed__Username/Password)
# backend, из backend/ ```
dotnet build && dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests
dotnet run --project src/TeleWave.Api **Права на медиахранилище.** Контейнер работает под непривилегированным пользователем (uid/gid
`1654`), а bind-mount пробрасывает права хоста как есть — каталог хранилища должен принадлежать
# frontend, из frontend/ этому uid, иначе приложение стартует, но любая запись в `/media` упадёт с `Permission denied`.
pnpm install Полная процедура (включая группу, через которую вы сами кладёте файлы в `inbox/` и `manual/`) —
pnpm dev # проксирует /api на localhost:8080 [`docs/server-storage-setup.md`](docs/server-storage-setup.md), шаг 5:
```
```bash
## Документация sudo groupadd -g 1654 telewave
sudo usermod -aG telewave "$USER" # затем перелогиниться
Пример переменных окружения (сид админа, БД, JWT) — [`.env.example`](.env.example). sudo chown -R 1654:1654 /srv/telewave/media
Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md) (стек, архитектурные sudo chmod -R 750 /srv/telewave/media
правила, доменные инварианты, соглашения по коду). sudo chmod 2775 /srv/telewave/media/inbox /srv/telewave/media/manual
```
## Лицензия
> Обновляетесь с версии, где контейнер работал под root? Это и есть вся миграция: остановите
Не определена. > контейнер (`docker compose down`), выполните команды выше, поднимайте новый образ.
## Локальная разработка
```bash
# backend, из backend/
dotnet build && dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests
dotnet run --project src/TeleWave.Api
# frontend, из frontend/
pnpm install
pnpm dev # проксирует /api на localhost:8080
```
## Документация
Пример переменных окружения (сид админа, БД, JWT) — [`.env.example`](.env.example).
Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md) (стек, архитектурные
правила, доменные инварианты, соглашения по коду).
## Лицензия
Не определена.
-128
View File
@@ -1,128 +0,0 @@
# Ревью TeleWave — план исправлений
Статус чекбоксов: `[ ]` не сделано · `[x]` исправлено · `[~]` в работе.
Дата ревью: 2026-07-25. Область: backend (C#/.NET 10), frontend (React 19), инфра.
Сборка чистая, 144 теста зелёные, typecheck чистый.
---
## 🔴 Критичное
- [x] **C1. Утечка слота семафора → медиа-обработка навсегда встаёт**
`MediaProcessingBackgroundService.cs:52` — слот берётся до `ClaimNextAsync`; бросок из claim
уводит во внешний `catch` без `Release()`. После N ошибок диспетчер зависает навсегда.
_Fix: try/finally вокруг claim либо Release() в ветке ошибки._
- [x] **C2. Слабый JWT-ключ по умолчанию без fail-fast**
`appsettings.json:8` + `DependencyInjection.cs:65` — placeholder-ключ, нет проверки на
переопределение и длину; тот же ключ у stream-токенов. Забыли → обход авторизации.
_Fix: на старте кидать, если ключ = placeholder или < 32 байт._
- [x] **C3. Инъекция/поломка ffmpeg-фильтра из пользовательских подписей**
`FfmpegBumperRenderer.cs:306``EscapeText` не экранирует `,` `;` `[` `]` `%` перевод строки.
Подпись с запятой рвёт filtergraph; `[`/`]` — инъекция звеньев. Валидатор проверяет только длину.
_Fix: экранировать полный набор метасимволов filtergraph._
---
## 🟠 Среднее
### Безопасность
- [x] **M1. Cookie без `Secure` за TLS-прокси**`AuthEndpoints.cs:207`, `StreamingEndpoints.cs:63`.
- [x] **M2. Ключи TMDb/OMDb в логах**`TmdbMetadataProvider.cs:27`, `OmdbMetadataProvider.cs:25` (`System.Net.Http` не приглушён в Serilog).
- [ ] **M3. Stream-токен неотзывной, TTL 6ч, игнорирует userId/блокировку**`StreamTokenService.cs:15`, `StreamingEndpoints.cs:98,123`. _(дизайн-решение — отложено)_
- [x] **M4. Rate-limiter глобальный (не партиционирован), только на `/api/auth`**`Program.cs:66`.
### Архитектура / транзакции
- [ ] **M5. `ExecuteDeleteAsync` ломает границу UnitOfWork + файлы удаляются до коммита**`DeleteShowMediaCommandHandler.cs:36`, `ClearAllMediaCommandHandler.cs`, `DeleteAllShowsCommandHandler.cs`. _(дизайн-решение — отложено)_
- [ ] **M6. Query с побочным эффектом на ФС**`RenderBumperPreviewQueryHandler.cs:18` (рендер файлов под видом запроса). _(дизайн-решение — отложено)_
- [ ] **M7. Файловый/HTTP I/O до коммита** — Delete/Register/RefreshEpisodes хендлеры (orphan-файлы при откате). _(дизайн-решение — отложено)_
### Планировщик / медиа
- [ ] **M8. Нет таймаута на ffmpeg/ffprobe**`ProcessRunner.cs:63` (зависший процесс держит слот). _(нужен разумный таймаут — отложено на согласование)_
- [ ] **M9. Гонка при конкурентной генерации расписания канала**`ScheduleGenerator.cs:62` (тик + regenerate → дубли записей). _(дизайн-решение — отложено)_
- [ ] **M10. Рендер заставок синхронно внутри тика планировщика**`ScheduleBumperResolver.cs:174`. _(дизайн-решение — отложено)_
- [x] **M11. Override через полночь не работает**`SchedulePlannerModels.cs:40` (`EndMinute <= StartMinute` → пустое окно).
### Фронтенд
- [x] **M12. Молчаливое проглатывание ошибки → вечный скелетон**`AirPage.tsx:43`.
- [x] **M13. Повторный 401 после refresh не разлогинивает**`client.ts:80`.
- [ ] **M14. Клиентская пагинация поверх усечённого ответа**`ShowDetail.tsx:42` (`pageSize:500`), `ChannelDetail.tsx:32` (`pageSize:100`). _(нужна серверная пагинация — отложено)_
- [ ] **M15. Дедуп загрузок только по имени файла**`upload-store.ts:143`. _(зависит от контракта серверной уникальности — отложено)_
---
## 🟡 Низкое / прочее
- [ ] **L1. Синхронный I/O без CancellationToken в портах удаления** (`IMediaStorage`/`IImageStore`/`IBumperTemplateStorage`). _(меняет интерфейсы портов — отложено)_
- [x] **L2. `ValidationBehavior` вызывает `Validate` вместо `ValidateAsync`**`ValidationBehavior.cs:24`.
- [ ] **L3. Инвариант «Single = 1 серия» проверяется в хендлере, а не в агрегате**`Show.AddEpisode`. _(рефактор агрегата — отложено)_
- [ ] **L4. Двойной `SaveChanges` в командном пути ScheduleGenerator**`ScheduleGenerator.cs:79,132`. _(связано с M9 — отложено)_
- [x] **L5. HLS-плеер не восстанавливается после fatal network/media error**`ChannelPlayer.tsx:106` (+ симметричная очистка слушателей нативного HLS).
- [x] **L6. Бесконечная повторная регистрация «падающего» файла из inbox**`InboxScannerBackgroundService.cs:92`.
- [x] **L7. Возможное переполнение int в весах шоу**`SchedulePlanner.cs:256,265`.
- [ ] **L8. OpenAPI/Scalar мапятся всегда, без гейта по окружению**`Program.cs:108`. _(возможно намеренно для внутреннего инструмента — решение за владельцем)_
- [ ] **L9. `AllowedHosts: "*"` и dev-креды БД в appsettings.json**. _(dev-дефолты; решение за владельцем)_
- [x] **L10. SSRF-поверхность в ImageDownloader** (без allowlist схемы/хоста) — `ImageDownloader.cs:16` (allowlist http/https).
- [ ] **L11. Документация (CLAUDE.md/README) отстала**: заявлено «каталог каналов и раздача видео не реализованы», хотя реализованы. _(нужна сверка с владельцем по объёму)_
- [ ] **L12. Пробелы в тестах**: фоновые сервисы, ScheduleGenerator-оркестрация, media-конвейер, эндпоинты без тестов. _(частично: добавлены тесты на M11)_
---
## Журнал исправлений
### 2026-07-25 — критические (C1–C3)
- **C1** — `MediaProcessingBackgroundService.cs`: `ClaimNextAsync` обёрнут в `try/catch`, слот
освобождается (`slots.Release()`) при любом сбое захвата перед `throw`. Теперь транзиентная
ошибка БД не «съедает» слот семафора — диспетчер не зависает.
- **C2** — `Infrastructure/DependencyInjection.cs`: после загрузки `JwtOptions` добавлен fail-fast:
старт падает с понятным сообщением, если `Jwt:SigningKey` короче 32 байт или содержит `change-me`
(значение-заглушка). Тот же ключ подписывает stream-токены, поэтому это закрывает и их.
- **C3** — `FfmpegBumperRenderer.cs`: подписи «Сейчас/Далее» (`NowLabel`/`NextLabel`) переведены с
инлайнового `text=` на `textfile=` (`nowlabel.txt`/`nextlabel.txt`, `expansion=none`), как уже
сделано для названий шоу. Метасимволы filtergraph в подписи больше не ломают/не инъектируют
цепочку. Неиспользуемый `EscapeText` удалён. Файлы чистятся в `finally`.
Проверка: `dotnet build` — 0 warnings/0 errors (при `TreatWarningsAsErrors`); тесты 95 + 49 зелёные.
### 2026-07-25 — механические средние/низкие (M1, M2, M4, M11, M12, M13, L2, L5, L6, L7, L10)
Backend:
- **M1** — `AuthEndpoints.cs`/`StreamingEndpoints.cs`: cookie-флаг `Secure` вычисляется через
`UseSecureCookie` (`!env.IsDevelopment() || request.IsHttps`) вместо голого `request.IsHttps`.
Вне Development cookie всегда `Secure` — прод за внешним TLS-прокси больше не отдаёт refresh/stream
cookie в открытую при неполной настройке ForwardedHeaders. В Development HTTP-разработка сохранена.
- **M2** — `appsettings.json`: добавлен override `System.Net.Http.HttpClient: Warning` — URI запросов
к TMDb/OMDb (с `api_key` в query) больше не пишутся в лог на уровне Information.
- **M4** — `Program.cs`: rate-limiter `auth` переведён на партиционирование по IP клиента
(`RateLimitPartition.GetFixedWindowLimiter`) — один клиент больше не исчерпывает окно логина для всех.
- **M11** — `SchedulePlannerModels.cs` + `ProgrammingOverride.cs`: `Covers` для еженедельного override
поддерживает окно через полночь (`EndMinute <= StartMinute``[start,24:00)` в day + `[0,end)` на
следующий день); `end == start` — пустое окно; добавлены null-guards. Регресс-тесты в
`ProgrammingOverrideTests` (+2, всего 97 domain).
- **L2** — `ValidationBehavior.cs`: `ValidateAsync(context, cancellationToken)` вместо синхронного
`Validate` — async-правила FluentValidation и отмена работают.
- **L6** — `InboxScannerBackgroundService.cs`: файлы, стабильно отклоняемые командой регистрации,
помечаются (`_failed`) и не регистрируются повторно каждые два тика; метка снимается при исчезновении файла.
- **L7** — `SchedulePlanner.cs`: веса шоу/вариантов суммируются в `long` с clamp до `int.MaxValue`
экстремальные вес/множитель не переполняют int (иначе взвешенный выбор молча вырождался в первого).
- **L10** — `ImageDownloader.cs`: allowlist схемы (только абсолютные http/https) — подменённый ответ
провайдера не заставит сервер дёрнуть `file://`/`ftp://` и т.п.
Frontend:
- **M12** — `AirPage.tsx`: провал `watchChannel` показывает offline-панель с кнопкой ретрая (а не
бесконечный скелетон); ретрай (`attempt`) заново дёргает `watchChannel`.
- **M13** — `client.ts`: повторный 401 уже после успешного refresh вызывает `onUnauthorized()`
мёртвая сессия чистит авторизацию, а не остаётся «залогиненной». Плюс guard на тело refresh-ответа
(`accessToken` должен быть строкой).
- **L5** — `ChannelPlayer.tsx`: fatal network/media HLS-ошибка сперва пробует восстановиться
(`startLoad`/`recoverMediaError`, до 3 попыток) и лишь затем уходит в offline; слушатели нативной
HLS-ветки снимаются в cleanup.
Отложено (требует решения владельца/дизайна): M3, M5M10, M14, M15, L1, L3, L4, L8, L9, L11, L12 —
причины помечены у пунктов.
Проверка: `dotnet build` 0/0; тесты 97 + 49 зелёные; frontend `tsc` чистый; `oxlint` без новых
предупреждений.
+13 -1
View File
@@ -9,6 +9,18 @@
<AnalysisLevel>latest</AnalysisLevel> <AnalysisLevel>latest</AnalysisLevel>
<GenerateDocumentationFile>false</GenerateDocumentationFile> <GenerateDocumentationFile>false</GenerateDocumentationFile>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873</NoWarn> <!--
CA1859 (сменить интерфейс коллекции на конкретный тип ради девиртуализации) — осознанно
выключено: помечаются им границы API (доменные модели планировщика, порты, хелперы тестов),
и «ускорение» там означает изменяемую коллекцию, протекающую через границу. Пути не горячие
(генерация расписания идёт раз в тик, а не в цикле по кадрам). Понадобится — включать точечно
по месту, а не глобально.
ASP0018 (параметр маршрута не используется хендлером) — тоже осознанно: часть шаблонов
объявляет сегменты ради формы URL, а не ради хендлера. Так, превью заставки адресуется
подблоком, но канал и блок остаются в пути, потому что по ним строит ссылки плейлист;
у /{slug}/watch токен подтверждает зрителя, а не подписку на канал.
-->
<NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873;CA1859;ASP0018</NoWarn>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+2
View File
@@ -43,5 +43,7 @@
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="NSubstitute" Version="6.0.0" /> <PackageVersion Include="NSubstitute" Version="6.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+1
View File
@@ -8,5 +8,6 @@
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/TeleWave.Domain.Tests/TeleWave.Domain.Tests.csproj" /> <Project Path="tests/TeleWave.Domain.Tests/TeleWave.Domain.Tests.csproj" />
<Project Path="tests/TeleWave.Application.Tests/TeleWave.Application.Tests.csproj" /> <Project Path="tests/TeleWave.Application.Tests/TeleWave.Application.Tests.csproj" />
<Project Path="tests/TeleWave.Integration.Tests/TeleWave.Integration.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>
@@ -0,0 +1,34 @@
using System.Text.RegularExpressions;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Common;
/// <summary>
/// Общая проверка файлов нарезки для всех эндпоинтов, отдающих HLS: эфир, превью заставок.
/// Имя сегмента сверяется с allowlist, а путь резолвится через <see cref="MediaPathResolver"/>,
/// который бросает <see cref="UnauthorizedAccessException"/> на попытку выйти за пределы каталога —
/// наружу это должно выглядеть как обычный 404, а не как ошибка сервера.
/// </summary>
internal static partial class SegmentFiles
{
[GeneratedRegex(@"^seg\d{1,6}\.ts$")]
private static partial Regex SegmentName();
public static bool IsSegmentName(string file) => SegmentName().IsMatch(file);
/// <summary>Путь к существующему файлу нарезки, либо null — если имя опасно или файла нет.</summary>
public static string? TryResolveExisting(MediaPathResolver paths, Guid assetId, string fileName)
{
string path;
try
{
path = paths.SegmentPath(assetId, fileName);
}
catch (UnauthorizedAccessException)
{
return null;
}
return File.Exists(path) ? path : null;
}
}
@@ -0,0 +1,18 @@
using Microsoft.Extensions.Options;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Common;
/// <summary>
/// Лимиты приёма файла, собранные в одно место. Они живут в двух разных секциях конфигурации
/// (<c>Media</c> и <c>Storage</c>), но проверяются всегда вместе и только на входе загрузки —
/// хендлеру незачем знать про обе секции и тащить два <see cref="IOptions{T}"/> в сигнатуре.
/// </summary>
public sealed class UploadLimits(IOptions<MediaOptions> media, IOptions<StorageOptions> storage)
{
/// <summary>Потолок размера загружаемого файла.</summary>
public long MaxUploadBytes { get; } = media.Value.MaxUploadBytes;
/// <summary>Сколько места должно остаться свободным после приёма файла.</summary>
public long MinFreeSpaceBytes { get; } = storage.Value.MinFreeSpaceBytes;
}
@@ -3,7 +3,6 @@ using TeleWave.Api.Common;
using TeleWave.Application.Admin.Users.BlockUser; using TeleWave.Application.Admin.Users.BlockUser;
using TeleWave.Application.Admin.Users.CreateUser; using TeleWave.Application.Admin.Users.CreateUser;
using TeleWave.Application.Admin.Users.DeleteUser; using TeleWave.Application.Admin.Users.DeleteUser;
using TeleWave.Application.Admin.Users.GetUser;
using TeleWave.Application.Admin.Users.ListUsers; using TeleWave.Application.Admin.Users.ListUsers;
using TeleWave.Application.Admin.Users.ResetPassword; using TeleWave.Application.Admin.Users.ResetPassword;
using TeleWave.Application.Admin.Users.UnblockUser; using TeleWave.Application.Admin.Users.UnblockUser;
@@ -23,7 +22,6 @@ public static class AdminUserEndpoints
admin.MapGet("", ListUsers).Produces<PagedList<UserSummaryDto>>(); admin.MapGet("", ListUsers).Produces<PagedList<UserSummaryDto>>();
admin.MapPost("", CreateUser).Produces<CreatedIdResponse>(StatusCodes.Status201Created); admin.MapPost("", CreateUser).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("/{id:guid}", GetUser).Produces<UserSummaryDto>();
admin.MapPost("/{id:guid}/block", BlockUser).Produces(StatusCodes.Status204NoContent); admin.MapPost("/{id:guid}/block", BlockUser).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/{id:guid}/unblock", UnblockUser).Produces(StatusCodes.Status204NoContent); admin.MapPost("/{id:guid}/unblock", UnblockUser).Produces(StatusCodes.Status204NoContent);
admin admin
@@ -35,22 +33,20 @@ public static class AdminUserEndpoints
} }
private static async Task<IResult> ListUsers( private static async Task<IResult> ListUsers(
int page, [AsParameters] ListUsersFilter filter,
int pageSize,
string? search,
Guid? roleId,
bool? isBlocked,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new ListUsersQuery( new ListUsersQuery(
page <= 0 ? 1 : page, filter.Page is > 0 ? filter.Page.Value : 1,
pageSize <= 0 ? 20 : pageSize, filter.PageSize is > 0 ? filter.PageSize.Value : 20,
search, filter.Search,
roleId, filter.RoleId,
isBlocked filter.IsBlocked,
filter.Sort,
filter.Desc ?? false
), ),
cancellationToken cancellationToken
); );
@@ -75,16 +71,6 @@ public static class AdminUserEndpoints
: result.ToHttpResult(); : result.ToHttpResult();
} }
private static async Task<IResult> GetUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetUserQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> BlockUser( private static async Task<IResult> BlockUser(
Guid id, Guid id,
ISender sender, ISender sender,
@@ -130,6 +116,22 @@ public static class AdminUserEndpoints
} }
} }
/// <summary>
/// Фильтр и страница списка пользователей. Все поля nullable намеренно: обязательный параметр в query
/// превращает запрос без него в 400 ещё до хендлера — ошибку в таком виде отладить тяжело. Умолчания
/// подставляет сам хендлер, а не объявление: при <c>AsParameters</c> обязательность определяется
/// nullable-типом, а не наличием значения по умолчанию в конструкторе.
/// </summary>
public sealed record ListUsersFilter(
int? Page,
int? PageSize,
string? Search,
Guid? RoleId,
bool? IsBlocked,
string? Sort,
bool? Desc
);
public sealed record CreateUserBody(string UserName, string Password, Guid RoleId); public sealed record CreateUserBody(string UserName, string Password, Guid RoleId);
public sealed record ResetPasswordBody(string NewPassword); public sealed record ResetPasswordBody(string NewPassword);
@@ -1,41 +1,88 @@
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using LiteCqrs; using LiteCqrs;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media; using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
/// <summary>Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью.</summary> /// <summary>
public static partial class ChannelEndpoints /// Блоки ТВ-заставок: оформление, звук, подблоки с текстом и рендер превью. Блоки общие для всех
/// каналов, поэтому и раздел свой, не канальный — канал только ссылается на них врезками стыков.
/// </summary>
public static class BumperEndpoints
{ {
private static readonly Regex BumperSegmentFileName = new( public static IEndpointRouteBuilder MapBumperEndpoints(this IEndpointRouteBuilder app)
@"^seg\d{1,6}\.ts$", {
RegexOptions.Compiled var admin = app.MapGroup("/api/admin/bumpers")
); .WithTags("Admin.Bumpers")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
private static async Task<IResult> AddBumperTemplate( admin.MapGet("", List).Produces<IReadOnlyList<BumperTemplateDto>>();
Guid id, admin.MapPost("", Create).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
AddBumperTemplateBody body, admin.MapPut("/{templateId:guid}", Update).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{templateId:guid}", Delete).Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{templateId:guid}/audio", UploadAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{templateId:guid}/audio", ClearAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{templateId:guid}/background", SetBackground)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{templateId:guid}/background", ClearBackground)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{templateId:guid}/variants", AddVariant)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/{templateId:guid}/variants/{variantId:guid}", UpdateVariant)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{templateId:guid}/variants/{variantId:guid}", RemoveVariant)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{templateId:guid}/preview", RenderPreview)
.Produces(StatusCodes.Status204NoContent);
admin.MapGet("/{templateId:guid}/preview/{variantId:guid}/index.m3u8", PreviewPlaylist);
admin.MapGet("/{templateId:guid}/preview/{variantId:guid}/{file}", PreviewSegment);
return app;
}
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListBumperTemplatesQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Create(
BumperNameBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new AddBumperTemplateCommand(id, body.Name), new CreateBumperTemplateCommand(body.Name),
cancellationToken cancellationToken
); );
return result.IsSuccess return result.IsSuccess
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value)) ? Results.Created(
$"/api/admin/bumpers/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult(); : result.ToHttpResult();
} }
private static async Task<IResult> UpdateBumperTemplate( private static async Task<IResult> Update(
Guid id,
Guid templateId, Guid templateId,
UpdateBumperTemplateBody body, UpdateBumperTemplateBody body,
ISender sender, ISender sender,
@@ -44,35 +91,35 @@ public static partial class ChannelEndpoints
{ {
var result = await sender.Send( var result = await sender.Send(
new UpdateBumperTemplateCommand( new UpdateBumperTemplateCommand(
id,
templateId, templateId,
body.Name, new BumperStyle(
body.BackgroundColor, body.Name,
body.BackgroundColor2, body.Font,
body.AccentColor, body.BackgroundColor,
body.TextColor body.BackgroundColor2,
body.AccentColor,
body.TextColor
)
), ),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> RemoveBumperTemplate( private static async Task<IResult> Delete(
Guid id,
Guid templateId, Guid templateId,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new RemoveBumperTemplateCommand(id, templateId), new DeleteBumperTemplateCommand(templateId),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> UploadTemplateAudio( private static async Task<IResult> UploadAudio(
Guid id,
Guid templateId, Guid templateId,
string fileName, string fileName,
HttpRequest request, HttpRequest request,
@@ -82,8 +129,8 @@ public static partial class ChannelEndpoints
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
if (ResolveBumperExtension(fileName, request, BumperFiles.AudioExtensions) is not { } ext) if (ResolveExtension(fileName, request) is not { } ext)
return ChannelErrors.InvalidBumperFile.ToProblem(); return BumperErrors.InvalidFile.ToProblem();
await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken); await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken);
@@ -94,104 +141,89 @@ public static partial class ChannelEndpoints
: await probe.TryGetDurationAsync(path, cancellationToken); : await probe.TryGetDurationAsync(path, cancellationToken);
var result = await sender.Send( var result = await sender.Send(
new SetBumperTemplateAudioCommand(id, templateId, ext, duration?.TotalSeconds ?? 0), new SetBumperTemplateAudioCommand(templateId, ext, duration?.TotalSeconds ?? 0),
cancellationToken cancellationToken
); );
if (!result.IsSuccess) if (!result.IsSuccess)
storage.DeleteAudio(templateId); await storage.DeleteAudioAsync(templateId, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ClearTemplateAudio( private static async Task<IResult> ClearAudio(
Guid id,
Guid templateId, Guid templateId,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new ClearBumperTemplateAudioCommand(id, templateId), new ClearBumperTemplateAudioCommand(templateId),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> SetTemplateBackground( private static async Task<IResult> SetBackground(
Guid id,
Guid templateId, Guid templateId,
SetBumperTemplateBackgroundBody body, SetBumperBackgroundBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new SetBumperTemplateBackgroundCommand(id, templateId, body.ImageId), new SetBumperTemplateBackgroundCommand(templateId, body.ImageId),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ClearTemplateBackground( private static async Task<IResult> ClearBackground(
Guid id,
Guid templateId, Guid templateId,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new ClearBumperTemplateBackgroundCommand(id, templateId), new ClearBumperTemplateBackgroundCommand(templateId),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> AddBumperVariant( private static async Task<IResult> AddVariant(
Guid id,
Guid templateId, Guid templateId,
AddBumperVariantBody body, BumperNameBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new AddBumperTextVariantCommand(id, templateId, body.Name), new AddBumperVariantCommand(templateId, body.Name),
cancellationToken cancellationToken
); );
return result.IsSuccess return result.IsSuccess
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value)) ? Results.Created(
$"/api/admin/bumpers/{templateId}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult(); : result.ToHttpResult();
} }
private static async Task<IResult> UpdateBumperVariant( private static async Task<IResult> UpdateVariant(
Guid id,
Guid templateId, Guid templateId,
Guid variantId, Guid variantId,
UpdateBumperVariantBody body, BumperVariantInput input,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new UpdateBumperTextVariantCommand( new UpdateBumperVariantCommand(templateId, variantId, input),
id,
templateId,
variantId,
body.Name,
body.Kind,
body.NowLabel,
body.NextLabel,
body.Line1,
body.Line2,
body.Trigger,
body.Weight
),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> RemoveBumperVariant( private static async Task<IResult> RemoveVariant(
Guid id,
Guid templateId, Guid templateId,
Guid variantId, Guid variantId,
ISender sender, ISender sender,
@@ -199,7 +231,7 @@ public static partial class ChannelEndpoints
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new RemoveBumperTextVariantCommand(id, templateId, variantId), new RemoveBumperVariantCommand(templateId, variantId),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
@@ -207,42 +239,27 @@ public static partial class ChannelEndpoints
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary> /// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
private static async Task<IResult> RenderPreview( private static async Task<IResult> RenderPreview(
Guid id,
Guid templateId, Guid templateId,
Guid? channelId,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new RenderBumperPreviewQuery(id, templateId), new RenderBumperPreviewCommand(templateId, channelId),
cancellationToken cancellationToken
); );
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult(); return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
} }
/// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary> /// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist( private static IResult PreviewPlaylist(Guid templateId, Guid variantId, MediaPathResolver paths)
Guid id,
Guid templateId,
Guid variantId,
MediaPathResolver paths
)
{ {
var previewId = BumperPreview.AssetId(variantId); var previewId = BumperPreview.AssetId(variantId);
string indexPath; if (SegmentFiles.TryResolveExisting(paths, previewId, "index.m3u8") is not { } indexPath)
try
{
indexPath = paths.SegmentPath(previewId, "index.m3u8");
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(indexPath))
return Results.NotFound(); return Results.NotFound();
var baseUrl = var baseUrl = $"/api/admin/bumpers/{templateId}/preview/{variantId}/";
$"/api/admin/channels/{id}/bumper/templates/{templateId}/preview/{variantId}/";
var sb = new StringBuilder(); var sb = new StringBuilder();
foreach (var line in File.ReadLines(indexPath)) foreach (var line in File.ReadLines(indexPath))
{ {
@@ -257,28 +274,17 @@ public static partial class ChannelEndpoints
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl"); return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
} }
private static IResult PreviewSegment( /// <summary>
Guid id, /// Сегмент превью. Блок в маршруте есть, но хендлеру не нужен: каталог превью адресуется
Guid templateId, /// подблоком (см. <see cref="BumperPreview.AssetId"/>) — незаявленные параметры не связываются.
Guid variantId, /// </summary>
string file, private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths)
MediaPathResolver paths
)
{ {
if (!BumperSegmentFileName.IsMatch(file)) if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound(); return Results.NotFound();
var previewId = BumperPreview.AssetId(variantId); var previewId = BumperPreview.AssetId(variantId);
string path; if (SegmentFiles.TryResolveExisting(paths, previewId, file) is not { } path)
try
{
path = paths.SegmentPath(previewId, file);
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(path))
return Results.NotFound(); return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true); return Results.File(path, "video/mp2t", enableRangeProcessing: true);
@@ -286,43 +292,27 @@ public static partial class ChannelEndpoints
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает /// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
/// нормализованное расширение (с точкой, нижний регистр) или null при отказе.</summary> /// нормализованное расширение (с точкой, нижний регистр) или null при отказе.</summary>
private static string? ResolveBumperExtension( private static string? ResolveExtension(string fileName, HttpRequest request)
string fileName,
HttpRequest request,
IReadOnlySet<string> allowedExtensions
)
{ {
if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null) if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null)
return null; return null;
var ext = Path.GetExtension(fileName).ToLowerInvariant(); var ext = Path.GetExtension(fileName).ToLowerInvariant();
return allowedExtensions.Contains(ext) ? ext : null; return BumperFiles.AudioExtensions.Contains(ext) ? ext : null;
} }
} }
public sealed record AddBumperTemplateBody(string Name); public sealed record BumperNameBody(string Name);
public sealed record UpdateBumperTemplateBody( public sealed record UpdateBumperTemplateBody(
string Name, string Name,
BumperFont Font,
string BackgroundColor, string BackgroundColor,
string BackgroundColor2, string BackgroundColor2,
string AccentColor, string AccentColor,
string TextColor string TextColor
); );
public sealed record SetBumperTemplateBackgroundBody(Guid ImageId); public sealed record SetBumperBackgroundBody(Guid ImageId);
public sealed record AddBumperVariantBody(string Name);
public sealed record UpdateBumperVariantBody(
string Name,
BumperTextKind Kind,
string NowLabel,
string NextLabel,
string Line1,
string Line2,
BumperTrigger Trigger,
int Weight
);
/// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary> /// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary>
internal static class BumperFiles internal static class BumperFiles
@@ -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;
/// <summary>Эндпоинты временных override'ов / марафонов канала (разовые и еженедельные).</summary>
public static partial class ChannelEndpoints
{
private static async Task<IResult> 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<IResult> 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<OverrideShowInput> Shows
);
@@ -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;
/// <summary>Эндпоинты канала: шоу в ротации и рекламный пул.</summary>
public static partial class ChannelEndpoints
{
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<HourWindowInput> PreferredHours
);
public sealed record AddChannelAdBody(Guid MediaAssetId);
@@ -5,20 +5,22 @@ using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.GetChannel; using TeleWave.Application.Broadcast.GetChannel;
using TeleWave.Application.Broadcast.GetSchedule; using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels; using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Broadcast.RegenerateSchedule;
using TeleWave.Application.Broadcast.UpdateChannelSettings; using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Broadcast.UpdateChannelTime;
using TeleWave.Application.Broadcast.UpdateViewerSettings;
using TeleWave.Application.Programming.Planning.DebugExport;
using TeleWave.Application.Programming.Planning.Trace;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
/// <summary> /// <summary>
/// Админ-эндпоинты канала. Реализация разнесена по partial-файлам под-ресурсов: /// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки
/// <c>ChannelEndpoints.Shows.cs</c> (шоу+реклама), <c>ChannelEndpoints.Bumpers.cs</c> /// и стыки общие для всех каналов и живут своими разделами (<c>BumperEndpoints</c>,
/// (блоки/подблоки/файлы/preview), <c>ChannelEndpoints.Overrides.cs</c> (override'ы). Здесь — /// <c>JunctionEndpoints</c>); что и когда идёт в эфире, задаёт шаблон сетки (<c>TemplateEndpoints</c>).
/// регистрация всех маршрутов и хендлеры уровня канала (создание/список/настройки/расписание).
/// </summary> /// </summary>
public static partial class ChannelEndpoints public static class ChannelEndpoints
{ {
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
{ {
@@ -32,95 +34,72 @@ public static partial class ChannelEndpoints
admin admin
.MapPut("/{id:guid}/settings", UpdateSettings) .MapPut("/{id:guid}/settings", UpdateSettings)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/shows", AddShow)
.Produces<CreatedIdResponse>(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<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/ads/{channelAdId:guid}", RemoveAd)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
.Produces<CreatedIdResponse>(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<CreatedIdResponse>(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<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/overrides/{overrideId:guid}", DeleteOverride)
.Produces(StatusCodes.Status204NoContent);
admin.MapPost("/{id:guid}/regenerate", Regenerate).Produces(StatusCodes.Status204NoContent);
admin admin
.MapGet("/{id:guid}/schedule", GetSchedule) .MapGet("/{id:guid}/schedule", GetSchedule)
.Produces<IReadOnlyList<ScheduleEntryDto>>(); .Produces<IReadOnlyList<ScheduleEntryDto>>();
admin
.MapPut("/{id:guid}/viewer", UpdateViewerSettings)
.Produces(StatusCodes.Status204NoContent);
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>();
// Отладочный дамп: вход планировщика и результат одним архивом.
admin.MapPost("/{id:guid}/debug-export", ExportDebug).Produces(StatusCodes.Status200OK);
return app; return app;
} }
/// <summary>
/// Отдаёт архив со снимком канала. POST, а не GET: команда оставляет копию дампа на сервере,
/// и кэшировать такой ответ никому не следует.
/// </summary>
private static async Task<IResult> ExportDebug(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ExportChannelDebugCommand(id), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
return Results.File(result.Value.Content, "application/zip", result.Value.FileName);
}
private static async Task<IResult> UpdateViewerSettings(
Guid id,
UpdateViewerSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateViewerSettingsCommand(
id,
body.LogoImageId,
body.LogoCorner,
body.LogoOpacity,
body.ShowClock,
body.AnalogFilterStrength
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GetEntryTrace(
Guid entryId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateChannel( private static async Task<IResult> CreateChannel(
CreateChannelCommand command, CreateChannelCommand command,
ISender sender, ISender sender,
@@ -155,6 +134,20 @@ public static partial class ChannelEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> 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<IResult> UpdateSettings( private static async Task<IResult> UpdateSettings(
Guid id, Guid id,
UpdateChannelSettingsBody body, UpdateChannelSettingsBody body,
@@ -167,27 +160,14 @@ public static partial class ChannelEndpoints
id, id,
body.Name, body.Name,
body.IsEnabled, body.IsEnabled,
body.AdInsertion, body.FillerAssetId,
body.AdsPerBreak, body.IconImageId
body.BumpersEnabled,
body.Bumper,
body.FillerAssetId
), ),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> Regenerate(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RegenerateChannelScheduleCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetSchedule( private static async Task<IResult> GetSchedule(
Guid id, Guid id,
DateTimeOffset? from, DateTimeOffset? from,
@@ -206,12 +186,25 @@ public static partial class ChannelEndpoints
} }
} }
/// <summary>Номер канала и его время: смещение от UTC и начало вещательных суток.</summary>
public sealed record UpdateChannelTimeBody(
int? Number,
int UtcOffsetMinutes,
TimeOnly DayStartTime
);
public sealed record UpdateChannelSettingsBody( public sealed record UpdateChannelSettingsBody(
string Name, string Name,
bool IsEnabled, bool IsEnabled,
AdInsertion AdInsertion, Guid? FillerAssetId,
int AdsPerBreak, Guid? IconImageId
bool BumpersEnabled, );
BumperSettingsInput Bumper,
Guid? FillerAssetId /// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
public sealed record UpdateViewerSettingsBody(
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
); );
@@ -0,0 +1,257 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library.Collections;
using TeleWave.Application.Library.Collections.AddCollectionShow;
using TeleWave.Application.Library.Collections.AddShowsToCollection;
using TeleWave.Application.Library.Collections.CreateCollection;
using TeleWave.Application.Library.Collections.DeleteCollection;
using TeleWave.Application.Library.Collections.FillCollectionPosters;
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.Suggest;
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<IReadOnlyList<CollectionSummaryDto>>();
admin.MapGet("/{id:guid}", GetCollection).Produces<CollectionDto>();
admin
.MapPost("", CreateCollection)
.Produces<CreatedIdResponse>(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);
// Сборка франшиз: массовое добавление отмеченных шоу и предложения по библиотеке.
admin.MapPost("/bulk/shows", AddShows).Produces<AddShowsToCollectionResultDto>();
// Постеры по частям: без списка — всем коллекциям, у которых постера ещё нет.
admin.MapPost("/bulk/posters", FillPosters).Produces<int>();
admin
.MapGet("/suggestions", Suggestions)
.Produces<IReadOnlyList<CollectionSuggestionDto>>();
admin
.MapPost("/suggestions", CreateFromSuggestion)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
// Дополнение уже собранной франшизы: коллекция определяется тем же ключом предложения.
admin.MapPost("/suggestions/add", AddFromSuggestion).Produces<int>();
return app;
}
private static async Task<IResult> FillPosters(
FillCollectionPostersBody? body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new FillCollectionPostersCommand(body?.CollectionIds),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> AddShows(
AddShowsToCollectionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddShowsToCollectionCommand(
body.ShowIds,
body.CollectionId,
body.NewCollectionName
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Suggestions(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new SuggestCollectionsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> CreateFromSuggestion(
CreateCollectionFromSuggestionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateCollectionFromSuggestionCommand(body.Key),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/collections/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> AddFromSuggestion(
CreateCollectionFromSuggestionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new AddSuggestedShowsCommand(body.Key), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListCollections(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListCollectionsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetCollection(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetCollectionQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> DeleteCollection(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteCollectionCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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<Guid> ShowIdsInOrder);
public sealed record CollectionPosterBody(Guid? ImageId);
/// <summary>Куда складывать: существующая коллекция либо новая по имени.</summary>
public sealed record AddShowsToCollectionBody(
IReadOnlyList<Guid> ShowIds,
Guid? CollectionId = null,
string? NewCollectionName = null
);
public sealed record CreateCollectionFromSuggestionBody(string Key);
public sealed record FillCollectionPostersBody(IReadOnlyList<Guid>? CollectionIds);
@@ -0,0 +1,162 @@
using System.Reflection;
using System.Runtime.InteropServices;
using LiteCqrs;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Options;
using TeleWave.Application.Diagnostics;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Админская диагностика. Главное здесь — картина прокси-форвардинга: заголовки, которым доверяет
/// приложение, и итоговая схема/хост, из которых строятся абсолютные ссылки (M3U/EPG). Именно тут
/// видно, почему ссылки уходят по http вместо https: X-Forwarded-Proto пришёл, но сосед не доверен.
/// Плюс живое состояние инфраструктуры (БД, том хранилища, ffmpeg) — через системный запрос.
/// </summary>
public static class DiagnosticsEndpoints
{
public static IEndpointRouteBuilder MapDiagnosticsEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/admin/diagnostics", Get)
.WithTags("Admin.Diagnostics")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))
.Produces<DiagnosticsResponse>();
return app;
}
private static async Task<IResult> Get(
HttpContext http,
IOptions<ForwardedHeadersOptions> forwardedOptions,
IHostEnvironment env,
ISender sender,
CancellationToken cancellationToken
)
{
var system = await sender.Send(new GetSystemDiagnosticsQuery(), cancellationToken);
var host = BuildHost(env);
var forwarding = BuildForwarding(http, forwardedOptions.Value);
return Results.Ok(new DiagnosticsResponse(host, forwarding, system));
}
private static HostDiagnosticsDto BuildHost(IHostEnvironment env)
{
var assembly = Assembly.GetEntryAssembly();
var version =
assembly
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion
?? assembly?.GetName().Version?.ToString();
return new HostDiagnosticsDto(
version,
env.EnvironmentName,
RuntimeInformation.FrameworkDescription,
RuntimeInformation.OSDescription,
DateTimeOffset.UtcNow
);
}
private static ForwardingDiagnosticsDto BuildForwarding(
HttpContext http,
ForwardedHeadersOptions options
)
{
var request = http.Request;
var scheme = request.Scheme;
var host = request.Host.Value ?? string.Empty;
var isHttps = string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase);
var remoteIp = http.Connection.RemoteIpAddress?.ToString();
var forwardedProto = Header(request, "X-Forwarded-Proto");
// X-Original-* заголовки ставит сам ForwardedHeaders-middleware, когда доверяет соседу и
// применяет форвардинг — по их наличию отличаем «применено» от «отброшено как недоверенное».
var originalProto = Header(request, "X-Original-Proto");
var originalRemoteIp = Header(request, "X-Original-For");
var applied = originalProto is not null || originalRemoteIp is not null;
// Что прокси реально прислал (до возможного применения), и вывод с готовой подсказкой.
var sentProto = originalProto ?? forwardedProto;
string verdict;
string? suggestedTrustedIp = null;
if (isHttps)
verdict = "ok";
else if (string.Equals(sentProto, "https", StringComparison.OrdinalIgnoreCase))
{
// Заголовок пришёл, но схема осталась http → непосредственный сосед не в доверенных.
verdict = "proxyNotTrusted";
suggestedTrustedIp = remoteIp;
}
else
verdict = "protoHeaderMissing";
return new ForwardingDiagnosticsDto(
scheme,
host,
$"{scheme}://{host}",
isHttps,
remoteIp,
forwardedProto,
Header(request, "X-Forwarded-For"),
Header(request, "X-Forwarded-Host"),
Header(request, "X-Real-IP"),
originalProto,
originalRemoteIp,
applied,
options.ForwardLimit,
options.ForwardedHeaders.ToString(),
[.. options.KnownProxies.Select(ip => ip.ToString())],
[.. options.KnownIPNetworks.Select(net => net.ToString())],
verdict,
suggestedTrustedIp
);
}
private static string? Header(HttpRequest request, string name) =>
request.Headers.TryGetValue(name, out var value) && !string.IsNullOrEmpty(value)
? value.ToString()
: null;
}
/// <summary>Ответ диагностики: процесс/рантайм, картина форвардинга и живое состояние инфраструктуры.</summary>
public sealed record DiagnosticsResponse(
HostDiagnosticsDto Host,
ForwardingDiagnosticsDto Forwarding,
SystemDiagnosticsDto System
);
public sealed record HostDiagnosticsDto(
string? AppVersion,
string Environment,
string Framework,
string Os,
DateTimeOffset ServerTimeUtc
);
/// <summary>
/// Картина X-Forwarded-*. <c>Verdict</c>: <c>ok</c> — схема https; <c>proxyNotTrusted</c> — прокси
/// прислал https, но <c>Applied=false</c>, и <c>SuggestedTrustedIp</c> нужно внести в KnownProxies;
/// <c>protoHeaderMissing</c> — прокси вообще не шлёт X-Forwarded-Proto.
/// </summary>
public sealed record ForwardingDiagnosticsDto(
string Scheme,
string Host,
string Origin,
bool IsHttps,
string? RemoteIp,
string? XForwardedProto,
string? XForwardedFor,
string? XForwardedHost,
string? XRealIp,
string? OriginalProto,
string? OriginalRemoteIp,
bool Applied,
int? ForwardLimit,
string ForwardedHeaders,
IReadOnlyList<string> KnownProxies,
IReadOnlyList<string> KnownNetworks,
string Verdict,
string? SuggestedTrustedIp
);
@@ -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<IReadOnlyList<GenreDto>>();
admin.MapPost("", CreateGenre).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/{id:guid}", UpdateGenre).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteGenre).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListGenres(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListGenresQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<string>? Aliases);
@@ -0,0 +1,256 @@
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.ExcludeGroupElement;
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.Suggest;
using TeleWave.Application.Programming.Groups.UpdateGroup;
using TeleWave.Domain.Programming;
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<IReadOnlyList<GroupSummaryDto>>();
admin.MapGet("/{id:guid}", GetGroup).Produces<GroupDto>();
admin.MapPost("", CreateGroup).Produces<CreatedIdResponse>(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<IReadOnlyList<GroupCandidateDto>>();
// Предложения: что имеет смысл завести группой при нынешней библиотеке.
admin.MapGet("/suggestions", Suggestions).Produces<IReadOnlyList<GroupSuggestionDto>>();
admin
.MapPost("/suggestions", CreateFromSuggestion)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPost("/{id:guid}/items", AddElements).Produces<AddedCountResponse>();
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);
// Поправки к вычисленному составу динамической группы.
admin.MapPost("/{id:guid}/exclusions", Exclude).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListGroups(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListGroupsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetGroup(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetGroupQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> Suggestions(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new SuggestGroupsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> CreateFromSuggestion(
CreateGroupFromSuggestionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateGroupFromSuggestionCommand(body.Key),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/groups/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateGroup(
Guid id,
UpdateGroupBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateGroupCommand(id, body.Name, body.Description, body.Filter, body.Mode),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteGroup(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteGroupCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> Exclude(
Guid id,
ExcludeElementBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ExcludeGroupElementCommand(id, body.ElementKind, body.ElementId, body.Excluded),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> 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,
GroupMode? Mode = null
);
/// <summary>Исключить элемент из динамической группы либо вернуть его правилу.</summary>
public sealed record ExcludeElementBody(
GroupElementKind ElementKind,
Guid ElementId,
bool Excluded
);
public sealed record FindCandidatesBody(GroupFilter? Filter);
public sealed record CreateGroupFromSuggestionBody(string Key);
public sealed record AddGroupElementsBody(IReadOnlyList<GroupElementRef> Elements);
public sealed record GroupItemWeightBody(int Weight);
public sealed record ReorderGroupBody(IReadOnlyList<Guid> ItemIdsInOrder);
/// <summary>Сколько позиций реально добавлено (уже входящие в группу пропускаются).</summary>
public sealed record AddedCountResponse(int Added);
@@ -15,7 +15,9 @@ public static class ImageEndpoints
{ {
private const long MaxBytes = 50L * 1024 * 1024; // 50 МБ private const long MaxBytes = 50L * 1024 * 1024; // 50 МБ
private static readonly IReadOnlySet<string> AllowedExtensions = new HashSet<string>( // Тип поля — HashSet, а не IReadOnlySet: поле приватное, и через интерфейс Contains уходит
// в виртуальный вызов вместо прямого.
private static readonly HashSet<string> AllowedExtensions = new(
StringComparer.OrdinalIgnoreCase StringComparer.OrdinalIgnoreCase
) )
{ {
@@ -0,0 +1,64 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library.Interstitials;
using TeleWave.Application.Library.Interstitials.ImportInterstitials;
using TeleWave.Application.Library.Interstitials.ListInterstitialBlocks;
using TeleWave.Application.Library.Interstitials.ListInterstitials;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Ролики-врезки: тот же <c>Show(Kind = Interstitial)</c>, но со своим экраном (см. 6.7). Правка
/// названия и удаление идут через обычные эндпоинты шоу — здесь только то, чего у библиотеки нет:
/// список с длительностями, блоки и импорт загруженных файлов.
/// </summary>
public static class InterstitialEndpoints
{
public static IEndpointRouteBuilder MapInterstitialEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/interstitials")
.WithTags("Admin.Interstitials")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", List).Produces<IReadOnlyList<InterstitialDto>>();
admin.MapGet("/blocks", ListBlocks).Produces<IReadOnlyList<InterstitialBlockDto>>();
admin.MapPost("/import", Import).Produces<ImportInterstitialsResponse>();
return app;
}
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListInterstitialsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> ListBlocks(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListInterstitialBlocksQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Import(
ImportInterstitialsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportInterstitialsCommand(body.MediaAssetIds),
cancellationToken
);
return result.IsSuccess
? Results.Ok(new ImportInterstitialsResponse(result.Value))
: result.ToHttpResult();
}
}
public sealed record ImportInterstitialsBody(IReadOnlyList<Guid> MediaAssetIds);
public sealed record ImportInterstitialsResponse(int Imported);
@@ -0,0 +1,241 @@
using System.Globalization;
using System.Text;
using System.Xml;
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming.GetIptvGuide;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Выгрузка для внешних IPTV-плееров: M3U со списком каналов и XMLTV с программой передач.
///
/// Плеер не умеет ни Bearer, ни httpOnly-cookie — он умеет только открыть URL. Поэтому пропуск
/// зашит в сами ссылки файла долгим stream-токеном: **файл и есть доступ к эфиру**, и делиться им
/// нельзя. Срок — <c>Storage:IptvTokenDays</c>; блокировка зрителя обрывает доступ на ближайшей
/// перезагрузке плейлиста канала, а смена <c>Jwt:SigningKey</c> обнуляет все выданные разом.
/// </summary>
public static class IptvEndpoints
{
/// <summary>Сколько суток программы класть в XMLTV: горизонт ленты и так неделя.</summary>
private static readonly TimeSpan GuideWindow = TimeSpan.FromDays(7);
/// <summary>Заглушка иконки канала, когда своя не задана: фавикон сайта из wwwroot.</summary>
private const string FaviconPath = "/favicon.svg";
public static IEndpointRouteBuilder MapIptvEndpoints(this IEndpointRouteBuilder app)
{
var iptv = app.MapGroup("/api/iptv").WithTags("IPTV");
// Файл забирает браузер под обычной авторизацией — токен внутрь кладёт уже сервер.
iptv.MapGet("/playlist.m3u", Playlist).RequireAuthorization();
// XMLTV тянет сам плеер, у него из пропусков только токен из ссылки.
iptv.MapGet("/epg.xml", Guide);
return app;
}
private static async Task<IResult> Playlist(
ICurrentUser currentUser,
StreamTokenService tokens,
HttpRequest request,
ISender sender,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Results.Unauthorized();
// Окно нулевое: плейлисту нужен только список каналов, программу плеер возьмёт из XMLTV.
var now = DateTimeOffset.UtcNow;
var channels = await sender.Send(new GetIptvGuideQuery(now, now), cancellationToken);
var (token, _) = tokens.IssueForIptv(userId);
var origin = Origin(request);
var body = RenderPlaylist(channels, origin, token);
return Results.File(
Encoding.UTF8.GetBytes(body),
"audio/x-mpegurl",
fileDownloadName: "telewave.m3u"
);
}
private static async Task<IResult> Guide(
HttpRequest request,
StreamTokenService tokens,
IIdentityService identity,
ISender sender,
CancellationToken cancellationToken
)
{
if (tokens.Validate(request.Query["token"]) is not { } userId)
return Results.Unauthorized();
// Та же сверка, что и у плейлиста канала: заблокированный зритель не должен продолжать
// тянуть гид по выданному раньше файлу.
var profile = await identity.GetProfileAsync(userId, cancellationToken);
if (profile is null || profile.IsBlocked)
return Results.Unauthorized();
var now = DateTimeOffset.UtcNow;
var channels = await sender.Send(
new GetIptvGuideQuery(now, now.Add(GuideWindow)),
cancellationToken
);
return Results.File(
RenderGuide(channels, Origin(request)),
"application/xml; charset=utf-8",
fileDownloadName: "telewave-epg.xml"
);
}
/// <summary>
/// Абсолютный адрес сервиса. Плееру нужны именно абсолютные ссылки — относительные ему не от
/// чего считать. Схема и хост берутся из запроса: TLS внешний, и их подставляет
/// <c>ForwardedHeaders</c>.
/// </summary>
private static string Origin(HttpRequest request) => $"{request.Scheme}://{request.Host}";
private static string RenderPlaylist(
IReadOnlyList<IptvChannelDto> channels,
string origin,
string token
)
{
var sb = new StringBuilder();
sb.Append(
CultureInfo.InvariantCulture,
$"#EXTM3U x-tvg-url=\"{origin}/api/iptv/epg.xml?token={token}\"\n"
);
foreach (var channel in channels)
{
// tvg-logo ставим всегда: своя иконка либо фавикон-заглушка — иначе в плеере пустой квадрат.
var logo = $" tvg-logo=\"{ChannelIcon(origin, channel.IconImageId)}\"";
var number = channel.Number is { } value
? $" tvg-chno=\"{value.ToString(CultureInfo.InvariantCulture)}\""
: string.Empty;
var name = Attribute(channel.Name);
sb.Append(
CultureInfo.InvariantCulture,
$"#EXTINF:-1 tvg-id=\"{Attribute(channel.Slug)}\" tvg-name=\"{name}\"{number}{logo} group-title=\"TeleWave\",{name}\n"
);
sb.Append(
CultureInfo.InvariantCulture,
$"{origin}/api/channels/{channel.Slug}/live.m3u8?token={token}\n"
);
}
return sb.ToString();
}
/// <summary>
/// Значение атрибута M3U. Экранирования в формате нет вовсе, поэтому кавычки и переводы строк
/// не заменяются, а вырезаются: иначе название канала рвало бы строку файла.
/// </summary>
private static string Attribute(string value) =>
value.Replace("\"", string.Empty).ReplaceLineEndings(" ").Trim();
private static byte[] RenderGuide(IReadOnlyList<IptvChannelDto> channels, string origin)
{
using var output = new MemoryStream();
// Пишем сразу в поток в UTF-8, а не в StringBuilder: XmlWriter поверх строки объявил бы
// encoding="utf-16" (строки .NET — UTF-16), тогда как байты мы всё равно отдаём в UTF-8.
// Часть плееров (Go encoding/xml) отвергает такое расхождение декларации и реальных байт.
// BOM не пишем: XMLTV его не ждёт, и он мешает строгим парсерам.
// Через XmlWriter, а не склейкой строк: в названия и описания приходит текст из библиотеки,
// и экранировать его руками — верный способ однажды отдать битый XML.
using (
var writer = XmlWriter.Create(
output,
new XmlWriterSettings
{
Indent = true,
Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
}
)
)
{
writer.WriteStartDocument();
writer.WriteStartElement("tv");
writer.WriteAttributeString("generator-info-name", "TeleWave");
// Сначала все каналы, потом все передачи: XMLTV требует именно такого порядка секций.
foreach (var channel in channels)
WriteChannel(writer, channel, origin);
foreach (var channel in channels)
{
foreach (var programme in channel.Programmes)
WriteProgramme(writer, channel.Slug, programme, origin);
}
writer.WriteEndElement();
writer.WriteEndDocument();
}
return output.ToArray();
}
private static void WriteChannel(XmlWriter writer, IptvChannelDto channel, string origin)
{
writer.WriteStartElement("channel");
writer.WriteAttributeString("id", channel.Slug);
writer.WriteElementString("display-name", channel.Name);
// Номер вторым display-name: так плееры с нумерацией подхватывают его без своих настроек.
if (channel.Number is { } number)
writer.WriteElementString(
"display-name",
number.ToString(CultureInfo.InvariantCulture)
);
// Иконку канала пишем всегда: своя либо фавикон-заглушка (в отличие от иконок передач ниже).
writer.WriteStartElement("icon");
writer.WriteAttributeString("src", ChannelIcon(origin, channel.IconImageId));
writer.WriteEndElement();
writer.WriteEndElement();
}
private static void WriteProgramme(
XmlWriter writer,
string slug,
IptvProgrammeDto programme,
string origin
)
{
writer.WriteStartElement("programme");
writer.WriteAttributeString("start", XmltvTime(programme.StartsAtUtc));
writer.WriteAttributeString("stop", XmltvTime(programme.EndsAtUtc));
writer.WriteAttributeString("channel", slug);
writer.WriteElementString("title", programme.Title);
if (!string.IsNullOrWhiteSpace(programme.SubTitle))
writer.WriteElementString("sub-title", programme.SubTitle);
if (!string.IsNullOrWhiteSpace(programme.Description))
writer.WriteElementString("desc", programme.Description);
WriteIcon(writer, programme.ImageId, origin);
writer.WriteEndElement();
}
private static void WriteIcon(XmlWriter writer, Guid? imageId, string origin)
{
if (imageId is not { } id)
return;
writer.WriteStartElement("icon");
writer.WriteAttributeString("src", $"{origin}/api/images/{id}");
writer.WriteEndElement();
}
/// <summary>URL иконки канала: своя из реестра либо фавикон-заглушка сайта (абсолютный адрес).</summary>
private static string ChannelIcon(string origin, Guid? iconImageId) =>
iconImageId is { } id ? $"{origin}/api/images/{id}" : $"{origin}{FaviconPath}";
/// <summary>Время XMLTV: <c>YYYYMMDDHHMMSS +0000</c>. Отдаём в UTC — плеер сдвинет сам.</summary>
private static string XmltvTime(DateTimeOffset moment) =>
moment.ToUniversalTime().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)
+ " +0000";
}
@@ -0,0 +1,156 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Programming.Templates.Junctions;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Шаблоны стыков: что играет между программами. Стыки общие для всех каналов, канал только
/// ссылается на них слотами. Как и правка сетки, эфира не двигают — помечают шаблоны каналов,
/// которые их используют, изменёнными, а хвост пересобирается применением.
/// </summary>
public static class JunctionEndpoints
{
public static IEndpointRouteBuilder MapJunctionEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/junctions")
.WithTags("Admin.Junctions")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", List).Produces<IReadOnlyList<JunctionTemplateDto>>();
admin.MapPost("", Create).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/{junctionId:guid}", Update).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{junctionId:guid}", Delete).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{junctionId:guid}/elements", AddElement)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/{junctionId:guid}/elements/{elementId:guid}", UpdateElement)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{junctionId:guid}/elements/{elementId:guid}", RemoveElement)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{junctionId:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListJunctionsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Create(
JunctionNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new CreateJunctionCommand(body.Name), cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/junctions/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> Update(
Guid junctionId,
UpdateJunctionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateJunctionCommand(junctionId, body.Name, body.MaxTotalSeconds),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Delete(
Guid junctionId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteJunctionCommand(junctionId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> Reorder(
Guid junctionId,
ReorderJunctionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ReorderJunctionCommand(junctionId, body.Order),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record JunctionNameBody(string Name);
public sealed record UpdateJunctionBody(string Name, int? MaxTotalSeconds);
public sealed record JunctionElementKindBody(JunctionElementKind Kind);
/// <summary>Порядок врезок вместе с их развилками — перетаскивание меняет и то, и другое разом.</summary>
public sealed record ReorderJunctionBody(IReadOnlyList<JunctionElementOrder> Order);
@@ -1,3 +1,4 @@
using System.Text;
using LiteCqrs; using LiteCqrs;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TeleWave.Api.Common; using TeleWave.Api.Common;
@@ -5,9 +6,12 @@ using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Application.Media; using TeleWave.Application.Media;
using TeleWave.Application.Media.Delete; using TeleWave.Application.Media.Delete;
using TeleWave.Application.Media.GetMedia;
using TeleWave.Application.Media.ListMedia; using TeleWave.Application.Media.ListMedia;
using TeleWave.Application.Media.ManualInbox;
using TeleWave.Application.Media.MovieImport;
using TeleWave.Application.Media.Register; using TeleWave.Application.Media.Register;
using TeleWave.Application.Media.Retry;
using TeleWave.Application.Media.Stats;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media; using TeleWave.Infrastructure.Media;
@@ -24,12 +28,50 @@ public static class MediaEndpoints
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created); admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>(); admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
admin.MapGet("/{id:guid}", Get).Produces<MediaAssetDto>(); admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
// Перезапуск упавшей обработки: файл на месте, повторить нарезку — обычное дело.
admin.MapPost("/{id:guid}/retry", Retry).Produces<RetryMediaResultDto>();
admin.MapPost("/retry-failed", RetryFailed).Produces<RetryMediaResultDto>();
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
// Разбор фильмов: имена приходят с клиента (каталог manual/ либо выбранные в браузере
// файлы), сервер разбирает их и ищет в источнике. Импорт заводит шоу на файл.
admin.MapPost("/movies/match", MatchMovies).Produces<IReadOnlyList<MovieMatchDto>>();
admin.MapPost("/movies/match-one", MatchMovie).Produces<MovieMatchDto>();
admin.MapPost("/movies/import", ImportMovies).Produces<ImportMoviesResultDto>();
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
return app; return app;
} }
private static async Task<IResult> Retry(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RetryMediaCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RetryFailed(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RetryMediaCommand(), cancellationToken);
return result.ToHttpResult();
}
/// <summary> /// <summary>
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName». /// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку. /// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
@@ -40,8 +82,7 @@ public static class MediaEndpoints
IMediaStorage storage, IMediaStorage storage,
IMediaProcessingQueue queue, IMediaProcessingQueue queue,
ISender sender, ISender sender,
IOptions<MediaOptions> mediaOptions, UploadLimits limits,
IOptions<StorageOptions> storageOptions,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
@@ -60,7 +101,7 @@ public static class MediaEndpoints
); );
var contentLength = request.ContentLength ?? 0; var contentLength = request.ContentLength ?? 0;
if (contentLength > mediaOptions.Value.MaxUploadBytes) if (contentLength > limits.MaxUploadBytes)
return Results.Problem( return Results.Problem(
title: MediaErrors.FileTooLarge.Code, title: MediaErrors.FileTooLarge.Code,
detail: MediaErrors.FileTooLarge.Message, detail: MediaErrors.FileTooLarge.Message,
@@ -68,7 +109,7 @@ public static class MediaEndpoints
); );
var free = storage.GetAvailableFreeSpaceBytes(); var free = storage.GetAvailableFreeSpaceBytes();
if (free - contentLength < storageOptions.Value.MinFreeSpaceBytes) if (free - contentLength < limits.MinFreeSpaceBytes)
return Results.Problem( return Results.Problem(
title: MediaErrors.InsufficientStorage.Code, title: MediaErrors.InsufficientStorage.Code,
detail: MediaErrors.InsufficientStorage.Message, detail: MediaErrors.InsufficientStorage.Message,
@@ -78,13 +119,27 @@ public static class MediaEndpoints
var extension = Path.GetExtension(fileName); var extension = Path.GetExtension(fileName);
var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken); var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
var result = await sender.Send( // Файл уже на диске в uploads/, но в БД его ещё нет и ссылок на токен нигде не остаётся:
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName), // любой исход, кроме успешной регистрации, обязан за собой убрать. Удаляем и при исключении
cancellationToken // (оборванный запрос, сбой БД) — иначе гигабайты остаются в uploads/ до уборщика.
); // Токен отмены для уборки не пробрасываем: при обрыве запроса он уже сработал.
Result<Guid> result;
try
{
result = await sender.Send(
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
cancellationToken
);
}
catch
{
await storage.DeleteUploadAsync(token, CancellationToken.None);
throw;
}
if (!result.IsSuccess) if (!result.IsSuccess)
{ {
storage.DeleteUpload(token); await storage.DeleteUploadAsync(token, CancellationToken.None);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -96,34 +151,29 @@ public static class MediaEndpoints
} }
private static async Task<IResult> List( private static async Task<IResult> List(
int page, [AsParameters] ListMediaFilter filter,
int pageSize,
MediaAssetStatus[]? status,
string? search,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new ListMediaAssetsQuery( new ListMediaAssetsQuery(
page <= 0 ? 1 : page, filter.Page is > 0 ? filter.Page.Value : 1,
pageSize <= 0 ? 20 : pageSize, filter.PageSize is > 0 ? filter.PageSize.Value : 20,
status ?? [], filter.Status ?? [],
search filter.Search,
filter.Sort,
filter.Desc ?? false
), ),
cancellationToken cancellationToken
); );
return Results.Ok(result); return Results.Ok(result);
} }
private static async Task<IResult> Get( private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new GetMediaAssetQuery(id), cancellationToken); var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
return result.ToHttpResult(); return Results.Ok(result);
} }
private static async Task<IResult> Delete( private static async Task<IResult> Delete(
@@ -135,6 +185,122 @@ public static class MediaEndpoints
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken); var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ListManual(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> MatchMovies(
MatchMoviesBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new MatchMoviesQuery(body.Names, body.Provider),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> MatchMovie(
MatchMovieBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new MatchMovieQuery(body.Name, body.Title, body.Year, body.Provider),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> ImportMovies(
ImportMoviesBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportMoviesCommand(body.Items, body.Provider),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ImportManual(
ImportManualInboxBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportManualInboxCommand(body.Items, body.ShowId),
cancellationToken
);
return result.ToHttpResult();
}
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
{
if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath)
return Results.NotFound();
var baseUrl = $"/api/admin/media/{id}/preview/";
var sb = new StringBuilder();
foreach (var line in File.ReadLines(indexPath))
{
var trimmed = line.Trim();
if (trimmed.Length == 0)
continue;
// Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
.Append('\n');
}
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
}
private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths)
{
if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path)
return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
} }
/// <summary>
/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
/// <c>AsParameters</c> обязательность определяется nullable-типом, а не значением по умолчанию.
/// </summary>
public sealed record ListMediaFilter(
int? Page,
int? PageSize,
MediaAssetStatus[]? Status,
string? Search,
string? Sort,
bool? Desc
);
public sealed record UploadMediaResponse(Guid Id); public sealed record UploadMediaResponse(Guid Id);
public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId);
public sealed record MatchMoviesBody(IReadOnlyList<string> Names, string Provider);
public sealed record MatchMovieBody(string Name, string Title, int? Year, string Provider);
public sealed record ImportMoviesBody(IReadOnlyList<MovieImportItem> Items, string Provider);
@@ -1,35 +1,22 @@
using LiteCqrs; using LiteCqrs;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Images.DeleteImage;
using TeleWave.Application.Images.UploadImage;
using TeleWave.Application.Metadata; using TeleWave.Application.Metadata;
using TeleWave.Application.Metadata.ApplyShowMetadata; using TeleWave.Application.Metadata.ApplyShowMetadata;
using TeleWave.Application.Metadata.ClearShowMetadata; using TeleWave.Application.Metadata.ClearShowMetadata;
using TeleWave.Application.Metadata.FindMissingEpisodes;
using TeleWave.Application.Metadata.FindMissingSeasons;
using TeleWave.Application.Metadata.GetProviders; using TeleWave.Application.Metadata.GetProviders;
using TeleWave.Application.Metadata.RefreshEpisodes; using TeleWave.Application.Metadata.RefreshEpisodes;
using TeleWave.Application.Metadata.SearchShows; using TeleWave.Application.Metadata.SearchShows;
using TeleWave.Application.Metadata.SetShowPoster; using TeleWave.Application.Metadata.SetShowPoster;
using TeleWave.Application.Metadata.UpdateShowMetadata; using TeleWave.Application.Metadata.UpdateShowMetadata;
using TeleWave.Domain.Images; using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
public static class MetadataEndpoints public static class MetadataEndpoints
{ {
private const long MaxPosterBytes = 10L * 1024 * 1024; // 10 МБ
private static readonly IReadOnlySet<string> PosterExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
".jpg",
".jpeg",
".png",
".webp",
};
public static IEndpointRouteBuilder MapMetadataEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapMetadataEndpoints(this IEndpointRouteBuilder app)
{ {
var admin = app.MapGroup("/api/admin/metadata") var admin = app.MapGroup("/api/admin/metadata")
@@ -41,13 +28,16 @@ public static class MetadataEndpoints
admin.MapPost("/shows/{showId:guid}/apply", Apply).Produces(StatusCodes.Status204NoContent); admin.MapPost("/shows/{showId:guid}/apply", Apply).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/shows/{showId:guid}", Update).Produces(StatusCodes.Status204NoContent); admin.MapPut("/shows/{showId:guid}", Update).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/shows/{showId:guid}", Clear).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/shows/{showId:guid}", Clear).Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/shows/{showId:guid}/poster", UploadPoster)
.Produces(StatusCodes.Status204NoContent);
admin admin
.MapPut("/shows/{showId:guid}/poster-image", SetPosterImage) .MapPut("/shows/{showId:guid}/poster-image", SetPosterImage)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin.MapPost("/shows/{showId:guid}/refresh-episodes", RefreshEpisodes).Produces<int>(); admin.MapPost("/shows/{showId:guid}/refresh-episodes", RefreshEpisodes).Produces<int>();
admin
.MapGet("/shows/{showId:guid}/missing-episodes", FindMissing)
.Produces<MissingEpisodesReport>();
admin
.MapGet("/shows/{showId:guid}/missing-seasons", FindMissingSeasons)
.Produces<MissingSeasonsReport>();
// Постеры шоу и кадры серий теперь в общем реестре и отдаются по /api/images/{id}. // Постеры шоу и кадры серий теперь в общем реестре и отдаются по /api/images/{id}.
return app; return app;
@@ -65,12 +55,13 @@ public static class MetadataEndpoints
private static async Task<IResult> Search( private static async Task<IResult> Search(
string provider, string provider,
string query, string query,
ShowKind kind,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new SearchShowMetadataQuery(provider, query), new SearchShowMetadataQuery(provider, query, kind),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
@@ -114,49 +105,6 @@ public static class MetadataEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> UploadPoster(
Guid showId,
string fileName,
HttpRequest request,
IImageStore imageStore,
ISender sender,
CancellationToken cancellationToken
)
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();
if (
request.ContentLength is > MaxPosterBytes or 0 or null
|| !PosterExtensions.Contains(ext)
)
return MetadataErrors.InvalidPoster.ToProblem();
// Регистрируем постер в общем реестре (категория ShowPoster) и привязываем к шоу.
var created = await sender.Send(
new UploadImageCommand(ImageCategory.ShowPoster, ext, fileName),
cancellationToken
);
if (!created.IsSuccess)
return created.ToHttpResult();
try
{
await imageStore.SaveAsync(created.Value, ext, request.Body, cancellationToken);
}
catch
{
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
throw;
}
var result = await sender.Send(
new SetShowPosterCommand(showId, created.Value),
cancellationToken
);
if (!result.IsSuccess)
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetPosterImage( private static async Task<IResult> SetPosterImage(
Guid showId, Guid showId,
SetPosterImageBody body, SetPosterImageBody body,
@@ -183,6 +131,26 @@ public static class MetadataEndpoints
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> FindMissing(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new FindMissingEpisodesQuery(showId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> FindMissingSeasons(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new FindMissingSeasonsQuery(showId), cancellationToken);
return result.ToHttpResult();
}
} }
public sealed record ApplyMetadataBody(string Provider, string ExternalId); public sealed record ApplyMetadataBody(string Provider, string ExternalId);
@@ -37,11 +37,19 @@ public static class SettingsEndpoints
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new UpdateSiteSettingsCommand(body.RegistrationEnabled), new UpdateSiteSettingsCommand(
body.RegistrationEnabled,
body.PreferredAudioLanguages ?? "",
body.ChannelNumbersEnabled
),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
} }
public sealed record UpdateSiteSettingsBody(bool RegistrationEnabled); public sealed record UpdateSiteSettingsBody(
bool RegistrationEnabled,
string? PreferredAudioLanguages,
bool ChannelNumbersEnabled
);
@@ -2,13 +2,19 @@ using LiteCqrs;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Application.Library; using TeleWave.Application.Library;
using TeleWave.Application.Library.AddEpisode; using TeleWave.Application.Library.AddEpisode;
using TeleWave.Application.Library.BulkTagShows;
using TeleWave.Application.Library.CreateShow; using TeleWave.Application.Library.CreateShow;
using TeleWave.Application.Library.DeleteShow; using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow; using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Library.GetShowUsage;
using TeleWave.Application.Library.ListShows; using TeleWave.Application.Library.ListShows;
using TeleWave.Application.Library.RemoveEpisode; using TeleWave.Application.Library.RemoveEpisode;
using TeleWave.Application.Library.RenameShow; using TeleWave.Application.Library.RenameShow;
using TeleWave.Application.Library.SetShowAudience;
using TeleWave.Application.Library.SetShowGenres;
using TeleWave.Application.Library.SetShowOriginalName; using TeleWave.Application.Library.SetShowOriginalName;
using TeleWave.Application.Metadata.EnrichShows;
using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
@@ -28,7 +34,14 @@ public static class ShowEndpoints
admin admin
.MapPut("/{id:guid}/original-name", SetOriginalName) .MapPut("/{id:guid}/original-name", SetOriginalName)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
admin.MapGet("/{id:guid}/usage", GetUsage).Produces<ShowUsageDto>();
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
// Массовые операции над отмеченными шоу: разметка руками и обогащение из источника.
admin.MapPost("/bulk/tag", BulkTag).Produces<BulkTaggedResponse>();
admin.MapPost("/bulk/enrich", BulkEnrich).Produces<EnrichShowsResultDto>();
admin admin
.MapPost("/{id:guid}/episodes", AddEpisode) .MapPost("/{id:guid}/episodes", AddEpisode)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created); .Produces<CreatedIdResponse>(StatusCodes.Status201Created);
@@ -56,10 +69,15 @@ public static class ShowEndpoints
private static async Task<IResult> ListShows( private static async Task<IResult> ListShows(
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken,
Guid? genreId = null,
bool interstitials = false
) )
{ {
var result = await sender.Send(new ListShowsQuery(), cancellationToken); var result = await sender.Send(
new ListShowsQuery(genreId, interstitials),
cancellationToken
);
return Results.Ok(result); return Results.Ok(result);
} }
@@ -98,13 +116,48 @@ public static class ShowEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> DeleteShow( private static async Task<IResult> SetAudience(
Guid id, Guid id,
SetShowAudienceBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new DeleteShowCommand(id), cancellationToken); var result = await sender.Send(
new SetShowAudienceCommand(id, body.Audience),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> 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();
}
/// <param name="withMedia">Снести заодно файлы шоу — иначе они останутся осиротевшими.</param>
/// <param name="withSchedule">Вырезать шоу из будущего эфира вместо отказа.</param>
private static async Task<IResult> DeleteShow(
Guid id,
ISender sender,
CancellationToken cancellationToken,
bool withMedia = false,
bool withSchedule = false
)
{
var result = await sender.Send(
new DeleteShowCommand(id, withMedia, withSchedule),
cancellationToken
);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -134,10 +187,73 @@ public static class ShowEndpoints
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken); var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetUsage(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetShowUsageQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> BulkTag(
BulkTagShowsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new BulkTagShowsCommand(
body.ShowIds,
body.GenreIds,
body.ReplaceGenres,
body.Audience,
body.SetAudience
),
cancellationToken
);
return result.IsSuccess
? Results.Ok(new BulkTaggedResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> BulkEnrich(
BulkEnrichBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new EnrichShowsMetadataCommand(body.ShowIds, body.Provider),
cancellationToken
);
return result.ToHttpResult();
}
} }
public sealed record AddEpisodeBody(Guid MediaAssetId); public sealed record AddEpisodeBody(Guid MediaAssetId);
/// <summary>Массовая разметка: жанры и рейтинг применяются независимо друг от друга.</summary>
public sealed record BulkTagShowsBody(
IReadOnlyList<Guid> ShowIds,
IReadOnlyList<Guid>? GenreIds = null,
bool ReplaceGenres = false,
ShowAudience? Audience = null,
bool SetAudience = false
);
public sealed record BulkEnrichBody(IReadOnlyList<Guid> ShowIds, string Provider);
/// <summary>Скольких шоу коснулась массовая правка.</summary>
public sealed record BulkTaggedResponse(int Updated);
public sealed record RenameShowBody(string Name); public sealed record RenameShowBody(string Name);
public sealed record SetShowOriginalNameBody(string? OriginalName); public sealed record SetShowOriginalNameBody(string? OriginalName);
/// <summary>Рейтинг шоу; null — снять проставленный.</summary>
public sealed record SetShowAudienceBody(ShowAudience? Audience);
public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId);
@@ -0,0 +1,33 @@
using LiteCqrs;
using TeleWave.Application.Storage;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>Чем занят диск: объём по областям хранилища и заполненность тома.</summary>
public static class StorageEndpoints
{
public static IEndpointRouteBuilder MapStorageEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/admin/storage", Stats)
.WithTags("Admin.Storage")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))
.Produces<StorageStatsDto>();
return app;
}
/// <param name="refresh">Пересчитать, не дожидаясь истечения кэша: обход дерева долгий.</param>
private static async Task<IResult> Stats(
bool? refresh,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new GetStorageStatsQuery(refresh ?? false),
cancellationToken
);
return Results.Ok(result);
}
}
@@ -1,6 +1,5 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using LiteCqrs; using LiteCqrs;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common; using TeleWave.Api.Common;
@@ -17,13 +16,14 @@ namespace TeleWave.Api.Endpoints;
public static class StreamingEndpoints public static class StreamingEndpoints
{ {
private const string StreamCookieName = "tw_stream"; private const string StreamCookieName = "tw_stream";
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
{ {
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie. // Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization(); var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>(); channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent); channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
channels.MapGet("/{slug}/epg", Epg); channels.MapGet("/{slug}/epg", Epg);
@@ -43,8 +43,22 @@ public static class StreamingEndpoints
return Results.Ok(result); return Results.Ok(result);
} }
private static async Task<IResult> ViewerFeatures(
ISiteSettings siteSettings,
CancellationToken cancellationToken
) =>
Results.Ok(
new ViewerFeaturesDto(
await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken)
)
);
/// <summary>
/// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными
/// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на
/// конкретную ленту, поэтому в сигнатуре slug не нужен.
/// </summary>
private static IResult Watch( private static IResult Watch(
string slug,
ICurrentUser currentUser, ICurrentUser currentUser,
StreamTokenService tokens, StreamTokenService tokens,
HttpRequest request, HttpRequest request,
@@ -95,11 +109,20 @@ public static class StreamingEndpoints
HttpRequest request, HttpRequest request,
HttpResponse response, HttpResponse response,
StreamTokenService tokens, StreamTokenService tokens,
IIdentityService identity,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
if (!tokens.Validate(request.Cookies[StreamCookieName])) // Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
// Для IPTV это единственная точка, где блокировка вообще может сработать: у внешнего плеера
// пропуск долгий и перевыпуску не подлежит.
if (Viewer(request, tokens) is not { } userId)
return Results.Unauthorized();
var profile = await identity.GetProfileAsync(userId, cancellationToken);
if (profile is null || profile.IsBlocked)
return Results.Unauthorized(); return Results.Unauthorized();
var result = await sender.Send( var result = await sender.Send(
@@ -111,8 +134,15 @@ public static class StreamingEndpoints
if (result.Value.Segments.Count == 0) if (result.Value.Segments.Count == 0)
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
// Токен из ссылки тянется дальше в адреса сегментов: плеер разрешает их относительно
// плейлиста и query при этом теряет, а cookie у него нет — без этого сегменты дадут 401.
var token = request.Query["token"].ToString();
var suffix = string.IsNullOrEmpty(token)
? string.Empty
: $"?token={Uri.EscapeDataString(token)}";
response.Headers.CacheControl = "no-cache"; response.Headers.CacheControl = "no-cache";
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl"); return Results.Text(Render(result.Value, suffix), "application/vnd.apple.mpegurl");
} }
private static IResult Segment( private static IResult Segment(
@@ -124,29 +154,27 @@ public static class StreamingEndpoints
MediaPathResolver paths MediaPathResolver paths
) )
{ {
if (!tokens.Validate(request.Cookies[StreamCookieName])) if (Viewer(request, tokens) is null)
return Results.Unauthorized(); return Results.Unauthorized();
if (!SegmentFileName.IsMatch(file)) if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound(); return Results.NotFound();
string path; if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
try
{
path = paths.SegmentPath(assetId, file);
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(path))
return Results.NotFound(); return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable"; response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.File(path, "video/mp2t", enableRangeProcessing: true); return Results.File(path, "video/mp2t", enableRangeProcessing: true);
} }
private static string Render(LivePlaylistDto playlist) /// <summary>
/// Кто смотрит: cookie — для браузера, токен в ссылке — для внешнего IPTV-плеера, который
/// cookie не носит. Токен один и тот же по формату, различается только сроком выдачи.
/// </summary>
private static Guid? Viewer(HttpRequest request, StreamTokenService tokens) =>
tokens.Validate(request.Cookies[StreamCookieName])
?? tokens.Validate(request.Query["token"]);
private static string Render(LivePlaylistDto playlist, string segmentSuffix)
{ {
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture); var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
var sb = new StringBuilder(); var sb = new StringBuilder();
@@ -160,6 +188,12 @@ public static class StreamingEndpoints
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n" $"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
); );
// Без DISCONTINUITY-SEQUENCE ffmpeg-плееры (Jellyfin) теряют счёт склеек, когда тег
// EXT-X-DISCONTINUITY уезжает за верхний край окна, и встают на стыке серий.
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-DISCONTINUITY-SEQUENCE:{playlist.DiscontinuitySequence}\n"
);
foreach (var segment in playlist.Segments) foreach (var segment in playlist.Segments)
{ {
@@ -168,10 +202,13 @@ public static class StreamingEndpoints
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n"); sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
sb.Append( sb.Append(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n" $"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts{segmentSuffix}\n"
); );
} }
return sb.ToString(); return sb.ToString();
} }
} }
/// <summary>Опции зрительской части, включённые глобально.</summary>
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);
@@ -0,0 +1,175 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Notifications;
using TeleWave.Application.Notifications.Link;
using TeleWave.Application.Notifications.Settings;
using TeleWave.Application.Notifications.Subscribers;
using TeleWave.Domain.Notifications;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Бот расписания: настройка в админке, привязка чата зрителем и приём обновлений вебхуком.
/// </summary>
public static class TelegramEndpoints
{
/// <summary>Заголовок, которым Telegram подтверждает, что вызов вебхука — его.</summary>
private const string SecretHeader = "X-Telegram-Bot-Api-Secret-Token";
public static IEndpointRouteBuilder MapTelegramEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/telegram")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/settings", GetSettings).Produces<TelegramSettingsDto>();
admin.MapPut("/settings", UpdateSettings).Produces<TelegramSettingsDto>();
admin
.MapGet("/subscribers", ListSubscribers)
.Produces<IReadOnlyList<TelegramSubscriberDto>>();
// Настроен ли бот — публично: по этому флагу зрительская страница решает, показывать ли
// кнопку. Ничего, кроме флага и имени бота, здесь не отдаётся.
app.MapGet("/api/telegram/status", Status).AllowAnonymous().Produces<TelegramStatusDto>();
// Ссылка на чат — зрителю, а не админу: код привязывает его собственную учётку.
app.MapPost("/api/telegram/link", IssueLink)
.RequireAuthorization()
.Produces<TelegramLinkDto>();
// Вебхук открыт наружу по построению: его вызывает Telegram, а не наш фронт. Пускаем
// только по секрету из настроек — адрес рано или поздно окажется в чужих логах.
app.MapPost("/api/telegram/webhook", Webhook).AllowAnonymous();
return app;
}
private static async Task<IResult> Status(
IAppDbContext dbContext,
CancellationToken cancellationToken
)
{
var settings = await dbContext
.TelegramSettings.AsNoTracking()
.FirstOrDefaultAsync(cancellationToken);
var link = settings is null ? null : TelegramSettingsService.BotLink(settings);
return Results.Ok(new TelegramStatusDto(link is not null, settings?.BotUsername));
}
private static async Task<IResult> GetSettings(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetTelegramSettingsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateSettings(
TelegramSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateTelegramSettingsCommand(
body.IsEnabled,
body.Transport,
body.BotToken,
body.WebhookUrl,
body.ProxyKind,
body.ProxyHost,
body.ProxyPort,
body.ProxyUsername,
body.ProxyPassword
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ListSubscribers(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListTelegramSubscribersQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> IssueLink(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new IssueTelegramLinkCommand(), cancellationToken);
return result.ToHttpResult();
}
/// <summary>
/// Приём обновления от Telegram. Разбираем тем же сервисом, что и опрос: транспорт разный,
/// разговор один.
///
/// Отвечаем 200 всегда, когда секрет сошёлся: на ошибку Telegram повторяет доставку, и одно
/// кривое обновление зациклило бы вебхук.
/// </summary>
private static async Task<IResult> Webhook(
HttpRequest request,
IAppDbContext dbContext,
TelegramBotService bot,
ITelegramApi api,
ILoggerFactory loggers,
CancellationToken cancellationToken
)
{
var settings = await dbContext.TelegramSettings.FirstOrDefaultAsync(cancellationToken);
if (
settings is null
|| !settings.IsOperational
|| settings.Transport != TelegramTransport.Webhook
)
return Results.NotFound();
var secret = request.Headers[SecretHeader].ToString();
if (string.IsNullOrWhiteSpace(settings.WebhookSecret) || secret != settings.WebhookSecret)
return Results.Unauthorized();
var update = await api.ReadUpdateAsync(request.Body, cancellationToken);
if (update is null)
return Results.Ok();
var logger = loggers.CreateLogger("Telegram.Webhook");
try
{
await bot.HandleAsync(settings, update, DateTimeOffset.UtcNow, cancellationToken);
settings.MarkContact(DateTimeOffset.UtcNow);
settings.AdvanceUpdateId(update.UpdateId);
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogError(exception, "Не разобрано обновление {UpdateId}", update.UpdateId);
}
return Results.Ok();
}
}
/// <summary>Что зрительской части нужно знать о боте: настроен ли и как он называется.</summary>
public sealed record TelegramStatusDto(bool IsConfigured, string? BotUsername);
/// <summary>Тело правки настроек: секреты необязательны — пусто значит «не менять».</summary>
public sealed record TelegramSettingsBody(
bool IsEnabled,
TelegramTransport Transport,
string? BotToken,
string? WebhookUrl,
TelegramProxyKind ProxyKind,
string? ProxyHost,
int? ProxyPort,
string? ProxyUsername,
string? ProxyPassword
);
@@ -0,0 +1,434 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Programming.Planning.ApplyTemplate;
using TeleWave.Application.Programming.Planning.Diff;
using TeleWave.Application.Programming.Planning.Preview;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CopyTemplate;
using TeleWave.Application.Programming.Templates.CreateSlot;
using TeleWave.Application.Programming.Templates.CreateTemplate;
using TeleWave.Application.Programming.Templates.DeleteSlot;
using TeleWave.Application.Programming.Templates.Generate;
using TeleWave.Application.Programming.Templates.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers;
using TeleWave.Application.Programming.Templates.Reset;
using TeleWave.Application.Programming.Templates.Restore;
using TeleWave.Application.Programming.Templates.Transfer;
using TeleWave.Application.Programming.Templates.UpdateSlot;
using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон
/// изменённым, а хвост пересобирается отдельной командой применения.
/// </summary>
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<ScheduleTemplateDto>();
// Завести сетку каналу, у которого её нет (напр. пережившему снос старой ротации).
admin
.MapPost("/channels/{channelId:guid}/template", CreateTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/templates/{templateId:guid}", UpdateTemplate)
.Produces(StatusCodes.Status204NoContent);
// Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает.
admin
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
.Produces<ApplyResultDto>();
// Откат правок к состоянию, по которому идёт эфир. Ленту не трогает — она и так по нему.
admin
.MapPost("/channels/{channelId:guid}/template/restore", RestoreTemplate)
.Produces<RestoreTemplateResultDto>();
// Сброс сетки вместе с собранным по ней будущим эфиром. Прошлое и идущая запись остаются.
admin
.MapPost("/channels/{channelId:guid}/template/reset", ResetTemplate)
.Produces<ResetTemplateResultDto>();
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
admin
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
.Produces<SchedulePreviewDto>();
// Проверки по правилам — только по шаблону, без прогона генератора.
admin
.MapGet("/channels/{channelId:guid}/template/issues", ValidateTemplate)
.Produces<IReadOnlyList<TemplateIssueDto>>();
// Что изменится в эфире, если применить прямо сейчас.
admin
.MapGet("/channels/{channelId:guid}/template/diff", DiffTemplate)
.Produces<ScheduleDiffDto>();
admin
.MapPost(
"/channels/{channelId:guid}/template/copy-to/{targetChannelId:guid}",
CopyTemplate
)
.Produces<CopyTemplateResultDto>();
// Автосборка сетки: каталог профилей, план по выбранному и его создание. План считается
// одним и тем же кодом, поэтому предпросмотр показывает ровно то, что создаст кнопка.
admin
.MapGet("/grid-profiles", ListGridProfiles)
.Produces<IReadOnlyList<GridProfileDto>>();
admin
.MapGet("/channels/{channelId:guid}/template/grid-plan", PreviewGrid)
.Produces<GridPlanDto>();
admin
.MapPost("/channels/{channelId:guid}/template/generate", GenerateGrid)
.Produces<GenerateGridResultDto>();
// Обмен конфигурацией: выгрузка файлом, загрузка чужого файла и сборка запроса к ИИ.
// Запрос никуда не отправляется — админ копирует его в свою модель и приносит ответ назад.
admin
.MapGet("/channels/{channelId:guid}/template/export", ExportGrid)
.Produces<GridConfig>();
admin
.MapPost("/channels/{channelId:guid}/template/import", ImportGrid)
.Produces<GridImportResultDto>();
admin
.MapPost("/channels/{channelId:guid}/template/ai-prompt", BuildPrompt)
.Produces<GridPromptDto>();
admin
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
.Produces<CreatedIdResponse>(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<CreatedIdResponse>(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<IResult> GetTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateChannelTemplateCommand(channelId),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{channelId}/template",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ApplyTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ApplyChannelTemplateCommand(channelId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RestoreTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RestoreTemplateCommand(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ResetTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ResetTemplateCommand(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> PreviewTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken,
int days = 1
)
{
var result = await sender.Send(
new PreviewScheduleQuery(channelId, days),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ValidateTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ValidateTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DiffTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new PreviewApplyDiffQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListGridProfiles(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListGridProfilesQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> PreviewGrid(
Guid channelId,
GridProfileKind profile,
GridGenerationMode mode,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new PreviewGeneratedGridQuery(channelId, profile, mode),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GenerateGrid(
Guid channelId,
GenerateGridBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new GenerateGridCommand(channelId, body.Profile, body.Mode),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ExportGrid(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ExportGridQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ImportGrid(
Guid channelId,
ImportGridBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportGridCommand(channelId, body.Config, body.Replace),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> BuildPrompt(
Guid channelId,
GridPromptBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new BuildGridPromptQuery(channelId, body.References ?? [], body.Notes),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> CopyTemplate(
Guid channelId,
Guid targetChannelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CopyTemplateCommand(channelId, targetChannelId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateTemplate(
Guid templateId,
UpdateTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateTemplateCommand(
templateId,
body.Name,
body.FallbackGroupId,
body.DefaultJunctionId,
body.Rules
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> DeleteLayer(
Guid layerId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> 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,
PlanningRules? Rules
);
public sealed record GenerateGridBody(GridProfileKind Profile, GridGenerationMode Mode);
public sealed record ImportGridBody(GridConfig Config, bool Replace);
public sealed record GridPromptBody(IReadOnlyList<string>? References, string? Notes);
public sealed record CreateLayerBody(string Name, int Priority);
public sealed record UpdateLayerBody(
string Name,
int Priority,
LayerApplicability? Applicability,
bool IsEnabled
);
+45 -21
View File
@@ -32,35 +32,45 @@ builder.Services.AddSerilog(
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно // За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя // перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback; // прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback,
// для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example). // а для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
builder.Services.Configure<ForwardedHeadersOptions>(options => builder.Services.Configure<ForwardedHeadersOptions>(options =>
{ {
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach ( foreach (var proxy in ReadList(builder.Configuration, "ForwardedHeaders:KnownProxies"))
var proxy in builder
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
.Get<string[]>()
?? []
)
options.KnownProxies.Add(IPAddress.Parse(proxy)); options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach ( foreach (var network in ReadList(builder.Configuration, "ForwardedHeaders:KnownNetworks"))
var network in builder
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
.Get<string[]>()
?? []
)
{ {
var parts = network.Split('/'); var parts = network.Split('/');
options.KnownIPNetworks.Add( options.KnownIPNetworks.Add(
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1])) new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
); );
} }
// Список доверенных прокси/сетей приходит из .env одной строкой (env-переменная по одному ключу
// в string[] не биндится) — принимаем и запятые, и массив appsettings/индексы __0. Иначе
// задокументированная в .env.example запись через запятую молча игнорировалась бы, оставляя
// доверие только loopback и обрывая подхват X-Forwarded-Proto.
static string[] ReadList(IConfiguration config, string key)
{
var array = config.GetSection(key).Get<string[]>();
if (array is { Length: > 0 })
return array;
var scalar = config[key];
return string.IsNullOrWhiteSpace(scalar)
? []
: scalar.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
);
}
}); });
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<UploadLimits>();
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddInfrastructure(builder.Configuration);
@@ -98,7 +108,6 @@ var app = builder.Build();
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте. // Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync(); await app.Services.ApplyMigrationsAsync();
await app.Services.RelocateLegacyImagesAsync();
await app.Services.SeedDataAsync(); await app.Services.SeedDataAsync();
app.UseForwardedHeaders(); app.UseForwardedHeaders();
@@ -110,8 +119,13 @@ app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapOpenApi(); // Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
app.MapScalarApiReference(); // не раскрывать полную карту эндпоинтов без необходимости.
if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.MapHealthChecks("/health"); app.MapHealthChecks("/health");
@@ -120,19 +134,29 @@ app.MapRoleEndpoints();
app.MapAdminUserEndpoints(); app.MapAdminUserEndpoints();
app.MapMediaEndpoints(); app.MapMediaEndpoints();
app.MapShowEndpoints(); app.MapShowEndpoints();
app.MapGenreEndpoints();
app.MapInterstitialEndpoints();
app.MapCollectionEndpoints();
app.MapGroupEndpoints();
app.MapTemplateEndpoints();
app.MapBumperEndpoints();
app.MapJunctionEndpoints();
app.MapChannelEndpoints(); app.MapChannelEndpoints();
app.MapTelegramEndpoints();
app.MapStreamingEndpoints(); app.MapStreamingEndpoints();
app.MapIptvEndpoints();
app.MapMaintenanceEndpoints(); app.MapMaintenanceEndpoints();
app.MapStorageEndpoints();
app.MapSettingsEndpoints(); app.MapSettingsEndpoints();
app.MapMetadataEndpoints(); app.MapMetadataEndpoints();
app.MapImageEndpoints(); app.MapImageEndpoints();
app.MapDiagnosticsEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. // Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles(); app.UseDefaultFiles();
app.UseStaticFiles(); app.UseStaticFiles();
app.MapFallbackToFile("index.html"); app.MapFallbackToFile("index.html");
app.Run(); // Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела
// сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним.
/// <summary>Делает неявный класс Program доступным для WebApplicationFactory&lt;Program&gt; в интеграционных тестах.</summary> await app.RunAsync();
public partial class Program;
@@ -1,15 +1,29 @@
{ {
// Локальная БД разработчика. В appsettings.json строки подключения нет намеренно: этот файл
// едет в образ, и креды в нём (даже заведомо игрушечные) — это и находка сканера, и приглашение
// однажды поправить их «на месте» вместо ConnectionStrings__Default из окружения.
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave"
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"Jwt": {
"SigningKey": "telewave-development-only-signing-key-not-for-production-0001"
},
"AdminSeed": { "AdminSeed": {
"Username": "admin", "Username": "admin",
"Password": "Passw0rd!Dev" "Password": "Passw0rd!Dev"
}, },
"Storage": { "Storage": {
"RootPath": ".dev-media" "RootPath": ".dev-media"
},
// Отладочные дампы каналов ложатся в папку репозитория: разбирать «почему сетка построилась
// так» приходится рядом с кодом. Путь относительный — от рабочего каталога Api (backend/src/TeleWave.Api).
"Debug": {
"ExportDirectory": "../../../debug-exports"
} }
} }
+2 -5
View File
@@ -1,7 +1,4 @@
{ {
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave"
},
"Jwt": { "Jwt": {
"Issuer": "TeleWave", "Issuer": "TeleWave",
"Audience": "TeleWave", "Audience": "TeleWave",
@@ -21,8 +18,8 @@
"MinFreeSpaceBytes": 10737418240 "MinFreeSpaceBytes": 10737418240
}, },
"Scheduler": { "Scheduler": {
"HorizonDays": 3, "HorizonDays": 7,
"RetentionHours": 24, "RetentionDays": 90,
"TickMinutes": 30 "TickMinutes": 30
}, },
"Media": { "Media": {
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html> <html lang="en">
<head><meta charset="utf-8" /><title>TeleWave</title></head> <head><meta charset="utf-8" /><title>TeleWave</title></head>
<body> <body>
<p>Frontend build not present yet — run the Vite build to populate wwwroot.</p> <p>Frontend build not present yet — run the Vite build to populate wwwroot.</p>
@@ -1,7 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.GetUser;
public sealed record GetUserQuery(Guid Id) : IQuery<Result<UserSummaryDto>>;
@@ -1,20 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.GetUser;
public sealed class GetUserQueryHandler(IIdentityService identityService)
: IQueryHandler<GetUserQuery, Result<UserSummaryDto>>
{
public async Task<Result<UserSummaryDto>> Handle(
GetUserQuery query,
CancellationToken cancellationToken
)
{
var user = await identityService.GetUserAsync(query.Id, cancellationToken);
return user is null
? Result.Failure<UserSummaryDto>(UserErrors.NotFound)
: Result.Success(user);
}
}
@@ -9,5 +9,7 @@ public sealed record ListUsersQuery(
int PageSize, int PageSize,
string? Search, string? Search,
Guid? RoleId, Guid? RoleId,
bool? IsBlocked bool? IsBlocked,
string? Sort = null,
bool Desc = false
) : IQuery<PagedList<UserSummaryDto>>; ) : IQuery<PagedList<UserSummaryDto>>;
@@ -12,11 +12,15 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService)
CancellationToken cancellationToken CancellationToken cancellationToken
) => ) =>
identityService.ListUsersAsync( identityService.ListUsersAsync(
query.Page, new UserListFilter(
query.PageSize, query.Page,
query.Search, query.PageSize,
query.RoleId, query.Search,
query.IsBlocked, query.RoleId,
query.IsBlocked,
query.Sort,
query.Desc
),
cancellationToken cancellationToken
); );
} }
@@ -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<Result<Guid>>;
@@ -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<AddChannelAdCommand, Result<Guid>>
{
public async Task<Result<Guid>> 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<Guid>(ChannelErrors.NotFound);
var assetExists = await dbContext.MediaAssets.AnyAsync(
a => a.Id == command.MediaAssetId,
cancellationToken
);
if (!assetExists)
return Result.Failure<Guid>(ChannelErrors.AssetNotFound);
if (channel.HasAd(command.MediaAssetId))
return Result.Failure<Guid>(ChannelErrors.AdAlreadyAdded);
var ad = channel.AddAd(command.MediaAssetId);
return Result.Success(ad.Id);
}
}
@@ -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<Result<Guid>>;
@@ -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<AddChannelShowCommand, Result<Guid>>
{
public async Task<Result<Guid>> 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<Guid>(ChannelErrors.NotFound);
var showExists = await dbContext.Shows.AnyAsync(
s => s.Id == command.ShowId,
cancellationToken
);
if (!showExists)
return Result.Failure<Guid>(ChannelErrors.ShowNotFound);
if (channel.HasShow(command.ShowId))
return Result.Failure<Guid>(ChannelErrors.ShowAlreadyAdded);
var channelShow = channel.AddShow(
command.ShowId,
command.Weight,
command.BlockMode,
command.BlockValue
);
return Result.Success(channelShow.Id);
}
}
@@ -1,12 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.AddChannelShow;
public sealed class AddChannelShowCommandValidator : AbstractValidator<AddChannelShowCommand>
{
public AddChannelShowCommandValidator()
{
RuleFor(x => x.Weight).InclusiveBetween(1, 1000);
RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000);
}
}
@@ -1,7 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Добавить новый блок заставки на канал (звук/фон загружаются отдельно).</summary>
public sealed record AddBumperTemplateCommand(Guid ChannelId, string Name) : ICommand<Result<Guid>>;
@@ -1,28 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class AddBumperTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddBumperTemplateCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddBumperTemplateCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var name = string.IsNullOrWhiteSpace(command.Name)
? $"Заставка {channel.BumperTemplates.Count + 1}"
: command.Name.Trim();
var template = channel.AddBumperTemplate(name);
return Result.Success(template.Id);
}
}
@@ -1,8 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Добавить подблок (текст-вариант) в блок заставки.</summary>
public sealed record AddBumperTextVariantCommand(Guid ChannelId, Guid TemplateId, string Name)
: ICommand<Result<Guid>>;
@@ -1,33 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class AddBumperTextVariantCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddBumperTextVariantCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddBumperTextVariantCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null)
return Result.Failure<Guid>(ChannelErrors.BumperTemplateNotFound);
var name = string.IsNullOrWhiteSpace(command.Name)
? $"Текст {template.Variants.Count + 1}"
: command.Name.Trim();
var variant = template.AddVariant(name);
return Result.Success(variant.Id);
}
}
@@ -0,0 +1,27 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class AddBumperVariantCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddBumperVariantCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddBumperVariantCommand command,
CancellationToken cancellationToken
)
{
var template = await BumperTemplateLoader.LoadAsync(
dbContext,
command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure<Guid>(BumperErrors.TemplateNotFound);
var variant = template.AddVariant(command.Name);
variant.SetLines(BumperTemplateLoader.DefaultLines());
return Result.Success(variant.Id);
}
}
@@ -0,0 +1,104 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed record ListBumperTemplatesQuery : IQuery<IReadOnlyList<BumperTemplateDto>>;
public sealed record CreateBumperTemplateCommand(string Name) : ICommand<Result<Guid>>;
/// <summary>Оформление блока: имя, шрифт и палитра. Меняет ревизию — заставки пересобираются.</summary>
public sealed record UpdateBumperTemplateCommand(Guid TemplateId, BumperStyle Style)
: ICommand<Result>;
public sealed record DeleteBumperTemplateCommand(Guid TemplateId) : ICommand<Result>;
public sealed record SetBumperTemplateAudioCommand(
Guid TemplateId,
string Extension,
double DurationSeconds
) : ICommand<Result>;
public sealed record ClearBumperTemplateAudioCommand(Guid TemplateId) : ICommand<Result>;
public sealed record SetBumperTemplateBackgroundCommand(Guid TemplateId, Guid ImageId)
: ICommand<Result>;
public sealed record ClearBumperTemplateBackgroundCommand(Guid TemplateId) : ICommand<Result>;
public sealed record AddBumperVariantCommand(Guid TemplateId, string Name) : ICommand<Result<Guid>>;
/// <summary>Полное содержимое подблока — строки редактор всегда присылает списком целиком.</summary>
public sealed record BumperVariantInput(
string Name,
BumperTrigger Trigger,
BumperBackground Background,
int Weight,
IReadOnlyList<BumperLineDto> Lines,
/// <summary>Ролик-подложка для фона <see cref="BumperBackground.Clip"/>.</summary>
Guid? BackgroundClipShowId = null
);
public sealed record UpdateBumperVariantCommand(
Guid TemplateId,
Guid VariantId,
BumperVariantInput Input
) : ICommand<Result>;
public sealed record RemoveBumperVariantCommand(Guid TemplateId, Guid VariantId) : ICommand<Result>;
/// <summary>
/// Рендер примера заставки. Канал нужен только для образцов подстановки: блок общий, но посмотреть
/// его надо глазами конкретного канала — иначе <c>{channel}</c> не на что заменить.
/// </summary>
public sealed record RenderBumperPreviewCommand(Guid TemplateId, Guid? ChannelId)
: ICommand<Result>;
public sealed class CreateBumperTemplateCommandValidator
: AbstractValidator<CreateBumperTemplateCommand>
{
public CreateBumperTemplateCommandValidator() =>
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
}
public sealed class UpdateBumperTemplateCommandValidator
: AbstractValidator<UpdateBumperTemplateCommand>
{
public UpdateBumperTemplateCommandValidator()
{
RuleFor(x => x.Style.Name).NotEmpty().MaximumLength(64);
RuleFor(x => x.Style.BackgroundColor).NotEmpty().MaximumLength(32);
RuleFor(x => x.Style.BackgroundColor2).NotEmpty().MaximumLength(32);
RuleFor(x => x.Style.AccentColor).NotEmpty().MaximumLength(32);
RuleFor(x => x.Style.TextColor).NotEmpty().MaximumLength(32);
}
}
public sealed class AddBumperVariantCommandValidator : AbstractValidator<AddBumperVariantCommand>
{
public AddBumperVariantCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
}
public sealed class UpdateBumperVariantCommandValidator
: AbstractValidator<UpdateBumperVariantCommand>
{
/// <summary>Больше шести строк в кадр не помещается ни при каком размере шрифта.</summary>
private const int MaxLines = 6;
public UpdateBumperVariantCommandValidator()
{
RuleFor(x => x.Input.Name).NotEmpty().MaximumLength(64);
RuleFor(x => x.Input.Weight).InclusiveBetween(0, 1000);
RuleFor(x => x.Input.Lines).NotNull().Must(l => l.Count <= MaxLines);
RuleForEach(x => x.Input.Lines)
.ChildRules(line => line.RuleFor(l => l.Text).NotEmpty().MaximumLength(120));
// Фон из ролика без самого ролика — это не «пока не выбрал», а заставка, которая молча
// отрендерится градиентом. Пусть выбор будет обязательным сразу.
RuleFor(x => x.Input.BackgroundClipShowId)
.NotNull()
.When(x => x.Input.Background == BumperBackground.Clip);
}
}
@@ -0,0 +1,36 @@
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Строка заставки: роль, цвет из палитры блока и текст с плейсхолдерами.</summary>
public sealed record BumperLineDto(BumperLineStyle Style, BumperLineColor Color, string Text);
/// <summary>Подблок (текст-вариант): свои строки, фон и правило показа поверх оформления блока.</summary>
public sealed record BumperTextVariantDto(
Guid Id,
int Position,
string Name,
BumperTrigger Trigger,
BumperBackground Background,
int Weight,
IReadOnlyList<BumperLineDto> Lines,
/// <summary>Ролик-подложка для фона <see cref="BumperBackground.Clip"/>.</summary>
Guid? BackgroundClipShowId
);
/// <summary>Блок заставки: оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
public sealed record BumperTemplateDto(
Guid Id,
string Name,
BumperFont Font,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor,
Guid? BackgroundImageId,
bool HasAudio,
double? AudioDurationSeconds,
/// <summary>Во скольких врезках стыков используется блок — блоки общие, и это надо видеть до правки.</summary>
int UsageCount,
IReadOnlyList<BumperTextVariantDto> Variants
);
@@ -0,0 +1,48 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Ошибки блоков заставок. Блоки общие для всех каналов, поэтому и каталог свой, не канальный.</summary>
public static class BumperErrors
{
public static readonly Error TemplateNotFound = Error.NotFound(
"Bumpers.TemplateNotFound",
"Блок заставки не найден."
);
public static readonly Error VariantNotFound = Error.NotFound(
"Bumpers.VariantNotFound",
"Подблок заставки не найден."
);
public static readonly Error CannotRemoveLastVariant = Error.Validation(
"Bumpers.CannotRemoveLastVariant",
"Нельзя удалить последний подблок — нужен хотя бы один."
);
/// <summary>Блок общий: держат его врезки конкретных стыков, их и перечисляем.</summary>
public static Error TemplateInUse(string where) =>
Error.Conflict(
"Bumpers.TemplateInUse",
where.Length > 0
? $"Блок заставки используется во врезках стыков ({where}) — уберите его оттуда."
: "Блок заставки используется во врезках стыков."
);
public static readonly Error VariantInUse = Error.Conflict(
"Bumpers.VariantInUse",
"Подблок заставки выбран во врезке стыка."
);
public static readonly Error InvalidFile = Error.Validation(
"Bumpers.InvalidFile",
"Недопустимый файл заставки (формат или размер)."
);
/// <summary>Неизвестный плейсхолдер — ошибка ввода: в эфире его бы уже никто не заметил.</summary>
public static Error UnknownPlaceholders(IEnumerable<string> tokens) =>
Error.Validation(
"Bumpers.UnknownPlaceholders",
$"Неизвестные плейсхолдеры: {string.Join(", ", tokens.Select(t => $"{{{t}}}"))}."
);
}
@@ -0,0 +1,37 @@
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Ручной маппинг блока заставки в DTO — одна точка на список и на карточку.</summary>
public static class BumperMapper
{
public static BumperTemplateDto ToDto(BumperTemplate template, int usageCount) =>
new(
template.Id,
template.Name,
template.Font,
template.BackgroundColor,
template.BackgroundColor2,
template.AccentColor,
template.TextColor,
template.BackgroundImageId,
template.AudioExtension is not null,
template.AudioDurationSeconds,
usageCount,
template
.Variants.OrderBy(v => v.Position)
.Select(v => new BumperTextVariantDto(
v.Id,
v.Position,
v.Name,
v.Trigger,
v.Background,
v.Weight,
v.Lines.OrderBy(l => l.Position)
.Select(l => new BumperLineDto(l.Style, l.Color, l.Text))
.ToList(),
v.BackgroundClipShowId
))
.ToList()
);
}
@@ -0,0 +1,201 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Чем подставляются плейсхолдеры одной заставки. Собирает контекст планировщик — только он знает
/// и пару соседей, и точное время показа; редактор подставляет те же поля образцами.
/// </summary>
public sealed record BumperContext(
string ChannelName,
int? ChannelNumber,
/// <summary>Момент показа заставки во времени канала.</summary>
DateTimeOffset LocalMoment,
string? NowTitle = null,
string? NextTitle = null,
string? NowEpisode = null,
string? NextEpisode = null,
int? NextYear = null,
string? NextGenre = null,
/// <summary>Во сколько начнётся следующая программа (время канала).</summary>
TimeOnly? NextTime = null,
string? SlotTitle = null,
/// <summary>Что и когда идёт вечером этих вещательных суток — для анонсов.</summary>
BumperAnnouncement? Tonight = null,
BumperAnnouncement? Tomorrow = null
);
/// <summary>Анонсируемая программа: что и во сколько. Null — анонсировать нечего, строка схлопнется.</summary>
public sealed record BumperAnnouncement(string Title, TimeOnly Time);
/// <summary>
/// Плейсхолдеры текста заставки: «ДАЛЕЕ В {next.time}» → «ДАЛЕЕ В 21:30».
///
/// Список закрытый и проверяется при сохранении: незнакомый плейсхолдер — ошибка ввода, а не
/// сюрприз в эфире, где его уже не увидит никто, кроме зрителя.
/// </summary>
public static partial class BumperPlaceholders
{
/// <summary>Все допустимые имена. Описания и образцы живут в локалях редактора, не здесь.</summary>
public static readonly IReadOnlySet<string> Tokens = new HashSet<string>(StringComparer.Ordinal)
{
"channel",
"channel.number",
"now.title",
"next.title",
"now.episode",
"next.episode",
"next.year",
"next.genre",
"next.time",
"tonight.title",
"tonight.time",
"tomorrow.title",
"tomorrow.time",
"time",
"date",
"weekday",
"slot",
};
/// <summary>
/// Плейсхолдеры, привязанные к моменту показа. Каждое их значение уникально, поэтому кэш
/// отрендеренных заставок с ними перестаёт работать — редактор обязан об этом предупредить.
/// </summary>
public static readonly IReadOnlySet<string> VolatileTokens = new HashSet<string>(
StringComparer.Ordinal
)
{
"time",
"date",
"weekday",
};
// Русская культура фиксирована: заставка рендерится один раз в видео, локали зрителя у неё нет.
private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU");
/// <summary>Время в кадре — только часы и минуты: секунды в заставке не читаются.</summary>
private const string TimeFormat = "HH:mm";
[GeneratedRegex(@"\{([a-zA-Z][a-zA-Z.]*)\}", RegexOptions.CultureInvariant)]
private static partial Regex TokenPattern();
[GeneratedRegex(@"[ \t]{2,}", RegexOptions.CultureInvariant)]
private static partial Regex ExtraSpaces();
/// <summary>Разделители, которые остаются висеть, когда подставлять оказалось нечего.</summary>
private static readonly char[] DanglingSeparators = ['—', '', '-', ':', '·', ',', ';', '/'];
/// <summary>
/// Подставляет значения. Неизвестное значение даёт пустую строку, а осиротевшие разделители
/// по краям убираются: «СЕЙЧАС — {now.title}» без текущей программы должно стать «СЕЙЧАС»,
/// а не «СЕЙЧАС —».
///
/// Строка, в которой не подставился ни один плейсхолдер, исчезает целиком: она и писалась ради
/// данных, а «ДАЛЕЕ В» без времени — это не подпись, а мусор в кадре. Строки без плейсхолдеров
/// это не касается: они и есть постоянный текст.
/// </summary>
public static string Resolve(string text, BumperContext context)
{
if (string.IsNullOrWhiteSpace(text))
return string.Empty;
var tokens = TokenPattern().Matches(text);
if (tokens.Count == 0)
return Clean(text);
// Собираем строку сами, а не через Replace с лямбдой: подстановку надо не только сделать,
// но и сосчитать, а счётчик в замыкании читается хуже и не виден анализаторам.
var builder = new StringBuilder();
var cursor = 0;
var filled = 0;
foreach (Match token in tokens)
{
builder.Append(text, cursor, token.Index - cursor);
cursor = token.Index + token.Length;
var value = Value(token.Groups[1].Value, context);
if (string.IsNullOrWhiteSpace(value))
continue;
builder.Append(value);
filled++;
}
if (filled == 0)
return string.Empty;
builder.Append(text, cursor, text.Length - cursor);
return Clean(builder.ToString());
}
/// <summary>Схлопывает лишние пробелы и снимает осиротевшие разделители по краям.</summary>
private static string Clean(string text) =>
ExtraSpaces().Replace(text, " ").Trim().Trim(DanglingSeparators).Trim();
/// <summary>Какие плейсхолдеры встречаются в текстах — по ним планировщик решает, что грузить.</summary>
public static IReadOnlySet<string> TokensIn(IEnumerable<string> texts)
{
var matches = texts
.Where(t => !string.IsNullOrWhiteSpace(t))
.SelectMany(text => TokenPattern().Matches(text))
.Select(match => match.Groups[1].Value);
return new HashSet<string>(matches, StringComparer.Ordinal);
}
/// <summary>Плейсхолдеры текста, которых нет в списке допустимых.</summary>
public static IReadOnlyList<string> UnknownTokens(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return [];
return TokenPattern()
.Matches(text)
.Select(m => m.Groups[1].Value)
.Where(token => !Tokens.Contains(token))
.Distinct(StringComparer.Ordinal)
.ToList();
}
/// <summary>Есть ли в тексте плейсхолдер, привязанный к моменту показа (ломает кэш рендера).</summary>
public static bool IsVolatile(string? text) =>
!string.IsNullOrWhiteSpace(text)
&& TokenPattern().Matches(text).Any(m => VolatileTokens.Contains(m.Groups[1].Value));
private static string? Value(string token, BumperContext context) =>
token switch
{
"channel" => context.ChannelName,
"channel.number" => context.ChannelNumber?.ToString(Culture),
"now.title" => context.NowTitle,
"next.title" => context.NextTitle,
"now.episode" => context.NowEpisode,
"next.episode" => context.NextEpisode,
"next.year" => context.NextYear?.ToString(Culture),
"next.genre" => context.NextGenre,
"next.time" => context.NextTime?.ToString(TimeFormat, Culture),
"tonight.title" => context.Tonight?.Title,
"tonight.time" => context.Tonight?.Time.ToString(TimeFormat, Culture),
"tomorrow.title" => context.Tomorrow?.Title,
"tomorrow.time" => context.Tomorrow?.Time.ToString(TimeFormat, Culture),
"time" => TimeOnly
.FromDateTime(context.LocalMoment.DateTime)
.ToString(TimeFormat, Culture),
"date" => context.LocalMoment.ToString("d MMMM", Culture),
"weekday" => context.LocalMoment.ToString("dddd", Culture),
"slot" => context.SlotTitle,
_ => null,
};
/// <summary>Подпись серии в эфирном виде: «с5э12», либо просто номер, если сезон не распознан.</summary>
public static string? Episode(int? season, int? episode)
{
if (season is { } s and > 0 && episode is { } e and > 0)
return string.Create(Culture, $"с{s}э{e}");
return episode is { } only and > 0 ? string.Create(Culture, $"э{only}") : null;
}
}
@@ -3,10 +3,16 @@ using System.Security.Cryptography;
namespace TeleWave.Application.Broadcast.Bumpers; namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary> /// <summary>
/// Детерминированный id ассета-превью для блока заставки: один и тот же на каждый повторный рендер, /// Детерминированный id ассета-превью для подблока заставки: один и тот же на каждый повторный рендер,
/// поэтому предпросмотр перезаписывает единственный каталог assets/{id}, а не плодит новые. /// поэтому предпросмотр перезаписывает единственный каталог assets/{id}, а не плодит новые.
/// </summary> /// </summary>
public static class BumperPreview public static class BumperPreview
{ {
public static Guid AssetId(Guid templateId) => new(MD5.HashData(templateId.ToByteArray())); /// <summary>
/// Свёртка id подблока в стабильный id превью. Не защита — просто способ получить из одного GUID
/// другой, воспроизводимо; берём SHA-256 и первые 16 байт, чтобы в коде не оставалось вызовов
/// сломанных хеш-функций, которые потом приходится каждый раз объяснять сканерам.
/// </summary>
public static Guid AssetId(Guid variantId) =>
new(SHA256.HashData(variantId.ToByteArray()).AsSpan(0, 16));
} }
@@ -0,0 +1,37 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Сериализация готовых строк заставки в кэш-запись. Хранить подставленный текст обязательно:
/// время показа и пара соседей из ссылок задним числом не восстанавливаются, а ffmpeg запускается
/// фоновым сервисом уже после того, как лента записана.
/// </summary>
public static class BumperRenderedText
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
public static string ToJson(IReadOnlyList<BumperRenderLine> lines) =>
JsonSerializer.Serialize(lines, Options);
public static IReadOnlyList<BumperRenderLine> FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return JsonSerializer.Deserialize<List<BumperRenderLine>>(json, Options) ?? [];
}
catch (JsonException)
{
return [];
}
}
}
@@ -0,0 +1,36 @@
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Чистая сборка <see cref="BumperRenderSpec"/> из уже разрешённых входов (готовые строки, пути к
/// постеру/фону/звуку). Общая точка для фонового рендерера заставок расписания и превью в админке.
/// </summary>
public static class BumperSpecFactory
{
public static BumperRenderSpec Build(
BumperOptions bumper,
BumperTemplate template,
int alignedDurationSeconds,
BumperSpecInputs inputs
) =>
new(
alignedDurationSeconds,
bumper.Width,
bumper.Height,
template.BackgroundColor,
template.BackgroundColor2,
template.AccentColor,
template.TextColor,
template.Font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans,
inputs.Lines,
inputs.BackgroundAbsolutePath,
inputs.AudioPath,
inputs.PosterAbsolutePath,
inputs.ClipAbsolutePath,
// Джингл назначен — он и звучит, подрезанный под ролик; не назначен — играет
// оригинальная дорожка ролика.
UseClipAudio: inputs.ClipAbsolutePath is not null && inputs.AudioPath is null
);
}
@@ -0,0 +1,18 @@
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Уже разрешённые входы рендера заставки: готовые строки (плейсхолдеры подставлены) и пути к
/// файлам. Разрешает их вызывающий — генератор эфира по реальной паре соседей, редактор по
/// образцам, — а <see cref="BumperSpecFactory"/> только раскладывает их по спецификации.
/// </summary>
public sealed record BumperSpecInputs(
IReadOnlyList<BumperRenderLine> Lines,
string? AudioPath = null,
/// <summary>Постер шоу как фон; подставляется, только если подблок его запросил.</summary>
string? PosterAbsolutePath = null,
string? BackgroundAbsolutePath = null,
/// <summary>Плейлист ролика-подложки, если текст пишется поверх готового видео.</summary>
string? ClipAbsolutePath = null
);
@@ -0,0 +1,127 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Media;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки: планировщик сохранил
/// готовые строки и ссылку на блок, а рендеру нужны ещё абсолютные пути к звуку, постеру и фону.
/// Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения, воркер лишь крутит ffmpeg.
/// </summary>
public sealed class BumperSpecLoader(
IAppDbContext dbContext,
IBumperTemplateStorage bumperStorage,
IImageStore imageStore,
IMediaStorage mediaStorage,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions
)
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Спецификация заставки для ассета, либо null если восстановить её уже нельзя.</summary>
public async Task<BumperRenderSpec?> LoadAsync(
Guid assetId,
CancellationToken cancellationToken
)
{
var cache = await dbContext
.BumperAssets.AsNoTracking()
.Where(b => b.MediaAssetId == assetId)
.OrderByDescending(b => b.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (cache is null)
return null;
var template = await dbContext
.BumperTemplates.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == cache.TemplateId, cancellationToken);
if (template is null)
return null;
var posterPath = cache.PosterShowId is { } showId
? await ResolveShowPosterAsync(showId, cancellationToken)
: null;
var bgPath = await ResolveImagePathAsync(template.BackgroundImageId, cancellationToken);
var (clipPath, clipSeconds) = await ResolveClipAsync(
cache.BackgroundAssetId,
cancellationToken
);
// Длину задаёт ролик, если он есть: подрезать чужую заставку под джингл значило бы обрывать
// её на полуслове. Выравнивание по сегменту остаётся — лента этого требует от любой записи.
var aligned = BumperDuration.Aligned(
clipSeconds ?? BumperDuration.TemplateSeconds(template),
_segmentSeconds
);
return BumperSpecFactory.Build(
_bumper,
template,
aligned,
new BumperSpecInputs(
BumperRenderedText.FromJson(cache.RenderedLinesJson),
bumperStorage.AudioPath(template.Id, template.AudioExtension),
posterPath,
bgPath,
clipPath
)
);
}
/// <summary>Плейлист ролика-подложки и его длительность; null — ролика уже нет или он не готов.</summary>
private async Task<(string? Path, int? Seconds)> ResolveClipAsync(
Guid? assetId,
CancellationToken cancellationToken
)
{
if (assetId is not { } id)
return (null, null);
var duration = await dbContext
.MediaAssets.AsNoTracking()
.Where(a => a.Id == id && a.Status == MediaAssetStatus.Ready)
.Select(a => a.Duration)
.FirstOrDefaultAsync(cancellationToken);
if (duration is not { } value || value <= TimeSpan.Zero)
return (null, null);
var path = mediaStorage.ResolvePlaylistPath(id);
return path is null ? (null, null) : (path, (int)Math.Ceiling(value.TotalSeconds));
}
private async Task<string?> ResolveShowPosterAsync(
Guid showId,
CancellationToken cancellationToken
)
{
var posterImageId = await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId && s.PosterImageId != null)
.Select(s => s.PosterImageId)
.FirstOrDefaultAsync(cancellationToken);
return await ResolveImagePathAsync(posterImageId, cancellationToken);
}
private async Task<string?> ResolveImagePathAsync(
Guid? imageId,
CancellationToken cancellationToken
)
{
if (imageId is not { } id)
return null;
var ext = await dbContext
.Images.AsNoTracking()
.Where(i => i.Id == id)
.Select(i => i.FileExtension)
.FirstOrDefaultAsync(cancellationToken);
return ext is null ? null : imageStore.ResolvePath(id, ext);
}
}
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Общее для команд блока заставки: загрузка вместе с подблоками и строками.</summary>
internal static class BumperTemplateLoader
{
public static Task<BumperTemplate?> LoadAsync(
IAppDbContext dbContext,
Guid templateId,
CancellationToken cancellationToken
) =>
dbContext
.BumperTemplates.Include(t => t.Variants)
.FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
/// <summary>Строки подблока «Сейчас / Далее» — с них начинается новый блок.</summary>
public static IReadOnlyList<BumperLine> DefaultLines() =>
[
BumperLine.Create(0, BumperLineStyle.Label, BumperLineColor.Accent, "СЕЙЧАС"),
BumperLine.Create(1, BumperLineStyle.Title, BumperLineColor.Text, "{now.title}"),
BumperLine.Create(2, BumperLineStyle.Label, BumperLineColor.Accent, "ДАЛЕЕ"),
BumperLine.Create(3, BumperLineStyle.Title, BumperLineColor.Text, "{next.title}"),
];
}
@@ -1,8 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Удалить загруженный звук блока (вернуться к синтезированному джинглу).</summary>
public sealed record ClearBumperTemplateAudioCommand(Guid ChannelId, Guid TemplateId)
: ICommand<Result>;
@@ -1,5 +1,4 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
@@ -15,18 +14,16 @@ public sealed class ClearBumperTemplateAudioCommandHandler(
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var channel = await dbContext var template = await BumperTemplateLoader.LoadAsync(
.Channels.Include(c => c.BumperTemplates) dbContext,
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); command.TemplateId,
if (channel is null) cancellationToken
return Result.Failure(ChannelErrors.NotFound); );
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null) if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound); return Result.Failure(BumperErrors.TemplateNotFound);
template.ClearAudio(); template.ClearAudio();
storage.DeleteAudio(command.TemplateId); await storage.DeleteAudioAsync(template.Id, cancellationToken);
return Result.Success(); return Result.Success();
} }
} }
@@ -1,8 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Удалить загруженную фон-картинку блока (вернуться к градиенту/постеру).</summary>
public sealed record ClearBumperTemplateBackgroundCommand(Guid ChannelId, Guid TemplateId)
: ICommand<Result>;
@@ -1,5 +1,4 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
@@ -13,17 +12,14 @@ public sealed class ClearBumperTemplateBackgroundCommandHandler(IAppDbContext db
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var channel = await dbContext var template = await BumperTemplateLoader.LoadAsync(
.Channels.Include(c => c.BumperTemplates) dbContext,
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); command.TemplateId,
if (channel is null) cancellationToken
return Result.Failure(ChannelErrors.NotFound); );
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null) if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound); return Result.Failure(BumperErrors.TemplateNotFound);
// Отвязываем фон; сама картинка остаётся в галерее.
template.ClearBackgroundImage(); template.ClearBackgroundImage();
return Result.Success(); return Result.Success();
} }
@@ -0,0 +1,24 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class CreateBumperTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateBumperTemplateCommand, Result<Guid>>
{
private const string DefaultVariantName = "Текст 1";
public Task<Result<Guid>> Handle(
CreateBumperTemplateCommand command,
CancellationToken cancellationToken
)
{
var template = BumperTemplate.Create(command.Name, DefaultVariantName);
// Пустой блок в редакторе выглядит поломанным — новый начинается с «Сейчас / Далее».
template.Variants[0].SetLines(BumperTemplateLoader.DefaultLines());
dbContext.BumperTemplates.Add(template);
return Task.FromResult(Result.Success(template.Id));
}
}
@@ -0,0 +1,53 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class DeleteBumperTemplateCommandHandler(
IAppDbContext dbContext,
IBumperTemplateStorage storage
) : ICommandHandler<DeleteBumperTemplateCommand, Result>
{
public async Task<Result> Handle(
DeleteBumperTemplateCommand command,
CancellationToken cancellationToken
)
{
var template = await BumperTemplateLoader.LoadAsync(
dbContext,
command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure(BumperErrors.TemplateNotFound);
// Блок общий: удалив используемый, мы бы молча выключили заставки в чужих каналах.
var used = await dbContext.JunctionElements.AnyAsync(
e => e.BumperTemplateId == template.Id,
cancellationToken
);
if (used)
{
// Имя стыка — по связи врезки с ним; факт использования проверен отдельно и раньше.
var junctions = await (
from element in dbContext.JunctionElements
join junction in dbContext.JunctionTemplates
on element.JunctionTemplateId equals junction.Id
where element.BumperTemplateId == template.Id
select junction.Name
)
.Distinct()
.ToListAsync(cancellationToken);
return Result.Failure(
BumperErrors.TemplateInUse(UsageText.Part("стыки", junctions) ?? string.Empty)
);
}
dbContext.BumperTemplates.Remove(template);
await storage.DeleteAudioAsync(template.Id, cancellationToken);
return Result.Success();
}
}
@@ -0,0 +1,32 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class ListBumperTemplatesQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListBumperTemplatesQuery, IReadOnlyList<BumperTemplateDto>>
{
public async Task<IReadOnlyList<BumperTemplateDto>> Handle(
ListBumperTemplatesQuery query,
CancellationToken cancellationToken
)
{
var templates = await dbContext
.BumperTemplates.AsNoTracking()
.Include(t => t.Variants)
.OrderBy(t => t.Name)
.ToListAsync(cancellationToken);
// Блоки общие, поэтому «сколько врезок на меня ссылается» — не справка, а условие правки.
var usage = await dbContext
.JunctionElements.AsNoTracking()
.Where(e => e.Kind == JunctionElementKind.Bumper && e.BumperTemplateId != null)
.GroupBy(e => e.BumperTemplateId!.Value)
.Select(g => new { TemplateId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.TemplateId, x => x.Count, cancellationToken);
return templates.Select(t => BumperMapper.ToDto(t, usage.GetValueOrDefault(t.Id))).ToList();
}
}
@@ -1,8 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Удалить блок заставки (кроме дефолтного) и его файлы.</summary>
public sealed record RemoveBumperTemplateCommand(Guid ChannelId, Guid TemplateId)
: ICommand<Result>;
@@ -1,34 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class RemoveBumperTemplateCommandHandler(
IAppDbContext dbContext,
IBumperTemplateStorage storage
) : ICommandHandler<RemoveBumperTemplateCommand, Result>
{
public async Task<Result> Handle(
RemoveBumperTemplateCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
if (template.IsDefault)
return Result.Failure(ChannelErrors.CannotRemoveDefaultBumperTemplate);
channel.RemoveBumperTemplate(command.TemplateId);
storage.DeleteTemplate(command.TemplateId);
return Result.Success();
}
}
@@ -1,8 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Удалить подблок (кроме последнего) из блока заставки.</summary>
public sealed record RemoveBumperTextVariantCommand(Guid ChannelId, Guid TemplateId, Guid VariantId)
: ICommand<Result>;
@@ -1,33 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class RemoveBumperTextVariantCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RemoveBumperTextVariantCommand, Result>
{
public async Task<Result> Handle(
RemoveBumperTextVariantCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
if (template.FindVariant(command.VariantId) is null)
return Result.Failure(ChannelErrors.BumperTextVariantNotFound);
return template.RemoveVariant(command.VariantId)
? Result.Success()
: Result.Failure(ChannelErrors.CannotRemoveLastBumperTextVariant);
}
}
@@ -0,0 +1,39 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class RemoveBumperVariantCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RemoveBumperVariantCommand, Result>
{
public async Task<Result> Handle(
RemoveBumperVariantCommand command,
CancellationToken cancellationToken
)
{
var template = await BumperTemplateLoader.LoadAsync(
dbContext,
command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure(BumperErrors.TemplateNotFound);
if (template.FindVariant(command.VariantId) is null)
return Result.Failure(BumperErrors.VariantNotFound);
// Врезка могла выбрать этот подблок жёстко — тогда удаление оставило бы её без текста.
var used = await dbContext.JunctionElements.AnyAsync(
e => e.BumperVariantId == command.VariantId,
cancellationToken
);
if (used)
return Result.Failure(BumperErrors.VariantInUse);
return template.RemoveVariant(command.VariantId)
? Result.Success()
: Result.Failure(BumperErrors.CannotRemoveLastVariant);
}
}
@@ -0,0 +1,252 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Рендерит пример каждого подблока. Блок общий, поэтому образцы подстановки берутся глазами
/// выбранного канала: без него <c>{channel}</c> не на что заменить, а названия шоу были бы
/// случайными из библиотеки.
/// </summary>
public sealed class RenderBumperPreviewCommandHandler(
IAppDbContext dbContext,
IBumperRenderer renderer,
IBumperTemplateStorage storage,
IImageStore imageStore,
IMediaStorage mediaStorage,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions
) : ICommandHandler<RenderBumperPreviewCommand, Result>
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
public async Task<Result> Handle(
RenderBumperPreviewCommand command,
CancellationToken cancellationToken
)
{
var template = await dbContext
.BumperTemplates.AsNoTracking()
.Include(t => t.Variants)
.FirstOrDefaultAsync(t => t.Id == command.TemplateId, cancellationToken);
if (template is null)
return Result.Failure(BumperErrors.TemplateNotFound);
var channel = command.ChannelId is { } channelId
? await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken)
: null;
var samples = await SampleShowsAsync(channel, cancellationToken);
var context = BuildContext(channel, samples);
var backgroundPath = await ResolveImagePathAsync(
template.BackgroundImageId,
cancellationToken
);
var posterPath = await ResolveImagePathAsync(samples.NextPosterId, cancellationToken);
var aligned = BumperDuration.Aligned(
BumperDuration.TemplateSeconds(template),
_segmentSeconds
);
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
var clips = await ResolveClipsAsync(template, cancellationToken);
foreach (var variant in template.Variants.OrderBy(v => v.Position))
{
var lines = variant
.Lines.OrderBy(l => l.Position)
.Select(l => new BumperRenderLine(
l.Style,
l.Color,
BumperPlaceholders.Resolve(l.Text, context)
))
.Where(l => !string.IsNullOrWhiteSpace(l.Text))
.ToList();
// У ролика своя длина — в превью она тоже должна быть настоящей, иначе админ увидит
// не ту заставку, которая пойдёт в эфир.
var clip = variant.BackgroundClipShowId is { } clipShowId
? clips.GetValueOrDefault(clipShowId)
: null;
var spec = BumperSpecFactory.Build(
_bumper,
template,
clip is null ? aligned : BumperDuration.Aligned(clip.Seconds, _segmentSeconds),
new BumperSpecInputs(
lines,
audioPath,
variant.Background == BumperBackground.Template ? null : posterPath,
backgroundPath,
clip?.Path
)
);
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
}
return Result.Success();
}
/// <summary>Ролик-подложка: плейлист и длительность.</summary>
private sealed record ClipSource(string Path, int Seconds);
/// <summary>Ролики-подложки подблоков блока, по шоу. Неготовые молча выпадают — превью не эфир.</summary>
private async Task<Dictionary<Guid, ClipSource>> ResolveClipsAsync(
BumperTemplate template,
CancellationToken cancellationToken
)
{
var showIds = template
.Variants.Where(v => v.Background == BumperBackground.Clip)
.Select(v => v.BackgroundClipShowId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
if (showIds.Count == 0)
return [];
var episodes = await dbContext
.Shows.AsNoTracking()
.SelectMany(s => s.Episodes)
.Where(e => showIds.Contains(e.ShowId))
.OrderBy(e => e.Position)
.Select(e => new { e.ShowId, e.MediaAssetId })
.ToListAsync(cancellationToken);
var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
var ready = 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 result = new Dictionary<Guid, ClipSource>();
foreach (var episode in episodes)
{
if (result.ContainsKey(episode.ShowId))
continue;
if (!ready.TryGetValue(episode.MediaAssetId, out var duration))
continue;
if (mediaStorage.ResolvePlaylistPath(episode.MediaAssetId) is not { } path)
continue;
result[episode.ShowId] = new ClipSource(path, (int)Math.Ceiling(duration.TotalSeconds));
}
return result;
}
private static BumperContext BuildContext(Channel? channel, SampleShows samples)
{
var offset = TimeSpan.FromMinutes(
channel?.UtcOffsetMinutes ?? Channel.DefaultUtcOffsetMinutes
);
var moment = DateTimeOffset.UtcNow.ToOffset(offset);
return new BumperContext(
channel?.Name ?? "Канал",
channel?.Number,
moment,
samples.NowTitle,
samples.NextTitle,
"с1э5",
"с2э3",
samples.NextYear,
samples.NextGenre,
TimeOnly.FromDateTime(moment.AddMinutes(30).DateTime),
samples.SlotTitle,
// Анонсы в превью показываются образцом: настоящий вечер известен только ленте.
new BumperAnnouncement(samples.NextTitle, new TimeOnly(20, 0)),
new BumperAnnouncement(samples.NowTitle, new TimeOnly(20, 0))
);
}
/// <summary>
/// Пара шоу для образца. Берём те, что реально ходят в этом канале (через группы его слотов), —
/// иначе предпросмотр показывает библиотеку, а не канал.
/// </summary>
private async Task<SampleShows> SampleShowsAsync(
Channel? channel,
CancellationToken cancellationToken
)
{
var query = dbContext.Shows.AsNoTracking().AsQueryable();
if (channel?.TemplateId is { } templateId)
query =
from show in query
join item in dbContext.GroupItems.AsNoTracking() on show.Id equals item.ElementId
join slot in dbContext.Slots.AsNoTracking() on item.GroupId equals slot.GroupId
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
where layer.TemplateId == templateId && item.ElementKind == GroupElementKind.Show
select show;
var shows = await query
.Select(s => new
{
s.Name,
s.Year,
s.PosterImageId,
})
.Distinct()
.Take(2)
.ToListAsync(cancellationToken);
var slotTitle = channel?.TemplateId is { } id
? await (
from slot in dbContext.Slots.AsNoTracking()
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
where layer.TemplateId == id
select slot.Title
).FirstOrDefaultAsync(cancellationToken)
: null;
var next = shows.ElementAtOrDefault(1) ?? shows.ElementAtOrDefault(0);
return new SampleShows(
shows.ElementAtOrDefault(0)?.Name ?? "Первое шоу",
next?.Name ?? "Второе шоу",
next?.Year,
null,
next?.PosterImageId,
slotTitle ?? "Вечернее кино"
);
}
private async Task<string?> ResolveImagePathAsync(
Guid? imageId,
CancellationToken cancellationToken
)
{
if (imageId is not { } id)
return null;
var extension = await dbContext
.Images.AsNoTracking()
.Where(i => i.Id == id)
.Select(i => i.FileExtension)
.FirstOrDefaultAsync(cancellationToken);
return extension is null ? null : imageStore.ResolvePath(id, extension);
}
private sealed record SampleShows(
string NowTitle,
string NextTitle,
int? NextYear,
string? NextGenre,
Guid? NextPosterId,
string SlotTitle
);
}
@@ -1,10 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Синхронно рендерит примеры всех подблоков блока (с примерными названиями шоу). Каждый подблок —
/// в свой ассет-превью (id детерминирован по подблоку). БД не меняет — read-side генерация артефактов.
/// </summary>
public sealed record RenderBumperPreviewQuery(Guid ChannelId, Guid TemplateId) : IQuery<Result>;
@@ -1,123 +0,0 @@
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 RenderBumperPreviewQueryHandler(
IAppDbContext dbContext,
IBumperRenderer renderer,
IBumperTemplateStorage storage,
IImageStore imageStore,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions
) : IQueryHandler<RenderBumperPreviewQuery, Result>
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
private const int DefaultBumperDurationSeconds = 8;
public async Task<Result> Handle(
RenderBumperPreviewQuery 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();
}
/// <summary>Примерные названия «из/в» — берём первые два шоу канала, иначе заглушки.</summary>
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) ?? "Второе шоу"
);
}
}
@@ -1,12 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Отметить загруженный звук блока: расширение (с точкой) и длину в секундах (замер ffprobe).</summary>
public sealed record SetBumperTemplateAudioCommand(
Guid ChannelId,
Guid TemplateId,
string Extension,
double DurationSeconds
) : ICommand<Result>;
@@ -1,5 +1,4 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
@@ -13,15 +12,13 @@ public sealed class SetBumperTemplateAudioCommandHandler(IAppDbContext dbContext
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var channel = await dbContext var template = await BumperTemplateLoader.LoadAsync(
.Channels.Include(c => c.BumperTemplates) dbContext,
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); command.TemplateId,
if (channel is null) cancellationToken
return Result.Failure(ChannelErrors.NotFound); );
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null) if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound); return Result.Failure(BumperErrors.TemplateNotFound);
template.SetAudio(command.Extension, command.DurationSeconds); template.SetAudio(command.Extension, command.DurationSeconds);
return Result.Success(); return Result.Success();
@@ -1,11 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Привязать фон-картинку блока по ссылке на изображение из реестра (галерея).</summary>
public sealed record SetBumperTemplateBackgroundCommand(
Guid ChannelId,
Guid TemplateId,
Guid ImageId
) : ICommand<Result>;
@@ -2,6 +2,7 @@ using LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Application.Images;
namespace TeleWave.Application.Broadcast.Bumpers; namespace TeleWave.Application.Broadcast.Bumpers;
@@ -13,15 +14,16 @@ public sealed class SetBumperTemplateBackgroundCommandHandler(IAppDbContext dbCo
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var channel = await dbContext var template = await BumperTemplateLoader.LoadAsync(
.Channels.Include(c => c.BumperTemplates) dbContext,
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); command.TemplateId,
if (channel is null) cancellationToken
return Result.Failure(ChannelErrors.NotFound); );
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null) if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound); return Result.Failure(BumperErrors.TemplateNotFound);
if (!await dbContext.Images.AnyAsync(i => i.Id == command.ImageId, cancellationToken))
return Result.Failure(ImageErrors.NotFound);
template.SetBackgroundImage(command.ImageId); template.SetBackgroundImage(command.ImageId);
return Result.Success(); return Result.Success();
@@ -1,15 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Обновить оформление блока заставки: имя и цвета (в нотации ffmpeg).</summary>
public sealed record UpdateBumperTemplateCommand(
Guid ChannelId,
Guid TemplateId,
string Name,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor
) : ICommand<Result>;
@@ -1,5 +1,4 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
@@ -13,23 +12,15 @@ public sealed class UpdateBumperTemplateCommandHandler(IAppDbContext dbContext)
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var channel = await dbContext var template = await BumperTemplateLoader.LoadAsync(
.Channels.Include(c => c.BumperTemplates) dbContext,
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); command.TemplateId,
if (channel is null) cancellationToken
return Result.Failure(ChannelErrors.NotFound);
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
template.UpdateStyle(
command.Name.Trim(),
command.BackgroundColor,
command.BackgroundColor2,
command.AccentColor,
command.TextColor
); );
if (template is null)
return Result.Failure(BumperErrors.TemplateNotFound);
template.UpdateStyle(command.Style);
return Result.Success(); return Result.Success();
} }
} }
@@ -1,31 +0,0 @@
using System.Text.RegularExpressions;
using FluentValidation;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed partial class UpdateBumperTemplateCommandValidator
: AbstractValidator<UpdateBumperTemplateCommand>
{
public UpdateBumperTemplateCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
// Цвета уходят в строку ffmpeg-фильтра без экранирования — допускаем только безопасный формат
// (0xRRGGBB[AA], #RRGGBB[AA] или имя цвета), чтобы исключить инъекцию синтаксиса фильтра.
RuleFor(x => x.BackgroundColor).Must(BeSafeColor).WithMessage(ColorMessage);
RuleFor(x => x.BackgroundColor2).Must(BeSafeColor).WithMessage(ColorMessage);
RuleFor(x => x.AccentColor).Must(BeSafeColor).WithMessage(ColorMessage);
RuleFor(x => x.TextColor).Must(BeSafeColor).WithMessage(ColorMessage);
}
private const string ColorMessage =
"Цвет должен быть в формате 0xRRGGBB, #RRGGBB или именем (например white).";
private static bool BeSafeColor(string? value) =>
!string.IsNullOrWhiteSpace(value) && ColorRegex().IsMatch(value);
[GeneratedRegex(
@"^((0x|#)?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?|[A-Za-z]{2,20}(@[0-9]?\.?[0-9]+)?)$"
)]
private static partial Regex ColorRegex();
}
@@ -1,20 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Обновить подблок: имя, режим текста, текст, правило показа и вес.</summary>
public sealed record UpdateBumperTextVariantCommand(
Guid ChannelId,
Guid TemplateId,
Guid VariantId,
string Name,
BumperTextKind Kind,
string NowLabel,
string NextLabel,
string Line1,
string Line2,
BumperTrigger Trigger,
int Weight
) : ICommand<Result>;
@@ -1,43 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class UpdateBumperTextVariantCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateBumperTextVariantCommand, Result>
{
public async Task<Result> Handle(
UpdateBumperTextVariantCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
var variant = template.FindVariant(command.VariantId);
if (variant is null)
return Result.Failure(ChannelErrors.BumperTextVariantNotFound);
variant.Update(
command.Name.Trim(),
command.Kind,
command.NowLabel,
command.NextLabel,
command.Line1,
command.Line2,
command.Trigger,
command.Weight
);
return Result.Success();
}
}
@@ -1,17 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class UpdateBumperTextVariantCommandValidator
: AbstractValidator<UpdateBumperTextVariantCommand>
{
public UpdateBumperTextVariantCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
RuleFor(x => x.NowLabel).MaximumLength(64);
RuleFor(x => x.NextLabel).MaximumLength(64);
RuleFor(x => x.Line1).MaximumLength(120);
RuleFor(x => x.Line2).MaximumLength(120);
RuleFor(x => x.Weight).InclusiveBetween(0, 1000);
}
}
@@ -0,0 +1,52 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class UpdateBumperVariantCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateBumperVariantCommand, Result>
{
public async Task<Result> Handle(
UpdateBumperVariantCommand command,
CancellationToken cancellationToken
)
{
var template = await BumperTemplateLoader.LoadAsync(
dbContext,
command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure(BumperErrors.TemplateNotFound);
var variant = template.FindVariant(command.VariantId);
if (variant is null)
return Result.Failure(BumperErrors.VariantNotFound);
// Незнакомый плейсхолдер ловим здесь: в эфире он превратился бы в пустоту, и заметить это
// было бы уже некому.
var unknown = command
.Input.Lines.SelectMany(l => BumperPlaceholders.UnknownTokens(l.Text))
.Distinct(StringComparer.Ordinal)
.ToList();
if (unknown.Count > 0)
return Result.Failure(BumperErrors.UnknownPlaceholders(unknown));
variant.Update(
command.Input.Name,
command.Input.Trigger,
command.Input.Background,
command.Input.Weight,
command.Input.BackgroundClipShowId
);
variant.SetLines(
command.Input.Lines.Select(
(line, index) => BumperLine.Create(index, line.Style, line.Color, line.Text)
)
);
return Result.Success();
}
}
@@ -4,89 +4,27 @@ namespace TeleWave.Application.Broadcast;
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled); 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<HourWindowDto> PreferredHours
);
/// <summary>Окно предпочтительных часов [StartHour, EndHour) суток (UTC).</summary>
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<OverrideShowDto> Shows
);
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
public sealed record BumperSettingsDto(
BumperFont Font,
int MinIntervalMinutes,
BumperSelection Selection,
double ShowChangeChance,
double EpisodeChangeChance
);
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
public sealed record BumperTextVariantDto(
Guid Id,
int Position,
string Name,
BumperTextKind Kind,
string NowLabel,
string NextLabel,
string Line1,
string Line2,
BumperTrigger Trigger,
int Weight
);
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
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<BumperTextVariantDto> Variants
);
public sealed record ChannelDto( public sealed record ChannelDto(
Guid Id, Guid Id,
string Name, string Name,
string Slug, string Slug,
bool IsEnabled, bool IsEnabled,
AdInsertion AdInsertion, int? Number,
int AdsPerBreak, int UtcOffsetMinutes,
bool BumpersEnabled, TimeOnly DayStartTime,
BumperSettingsDto Bumper, Guid? TemplateId,
IReadOnlyList<BumperTemplateDto> BumperTemplates,
Guid? FillerAssetId, Guid? FillerAssetId,
IReadOnlyList<ChannelShowDto> Shows, /// <summary>Иконка канала для внешних IPTV-плееров (M3U/XMLTV); null — отдаётся заглушка.</summary>
IReadOnlyList<ChannelAdDto> Ads, Guid? IconImageId,
IReadOnlyList<ProgrammingOverrideDto> Overrides /// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
ViewerSettingsDto Viewer
);
/// <summary>Как канал выглядит у зрителя: логотип-оверлей, часы, аналоговый фильтр.</summary>
public sealed record ViewerSettingsDto(
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
); );
@@ -6,36 +6,21 @@ public static class ChannelErrors
{ {
public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден."); 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( public static readonly Error DuplicateSlug = Error.Conflict(
"Channels.DuplicateSlug", "Channels.DuplicateSlug",
"Канал с таким slug уже существует." "Канал с таким 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( public static readonly Error BumperTemplateNotFound = Error.NotFound(
"Channels.BumperTemplateNotFound", "Channels.BumperTemplateNotFound",
"Блок заставки не найден." "Блок заставки не найден."
@@ -65,19 +50,4 @@ public static class ChannelErrors
"Channels.InvalidBumperFile", "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 должен ссылаться хотя бы на одно шоу."
);
} }
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Broadcast.CreateChannel; namespace TeleWave.Application.Broadcast.CreateChannel;
@@ -22,7 +23,13 @@ public sealed class CreateChannelCommandHandler(IAppDbContext dbContext)
return Result.Failure<Guid>(ChannelErrors.DuplicateSlug); return Result.Failure<Guid>(ChannelErrors.DuplicateSlug);
var channel = Channel.Create(command.Name, command.Slug, DateTimeOffset.UtcNow); 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.Channels.Add(channel);
dbContext.ScheduleTemplates.Add(template);
return Result.Success(channel.Id); return Result.Success(channel.Id);
} }
} }
@@ -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<OverrideShowInput> Shows
) : ICommand<Result<Guid>>;
@@ -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<CreateProgrammingOverrideCommand, Result<Guid>>
{
public async Task<Result<Guid>> 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<Guid>(ChannelErrors.InvalidOverrideWindow);
}
else if (
command.StartsAtUtc is not { } start
|| command.EndsAtUtc is not { } end
|| end <= start
)
{
return Result.Failure<Guid>(ChannelErrors.InvalidOverrideWindow);
}
if (command.Shows.Count == 0)
return Result.Failure<Guid>(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<Guid>(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<Guid>(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);
}
}
@@ -1,14 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.CreateOverride;
public sealed class CreateProgrammingOverrideCommandValidator
: AbstractValidator<CreateProgrammingOverrideCommand>
{
public CreateProgrammingOverrideCommandValidator()
{
RuleFor(x => x.Shows).NotEmpty();
RuleForEach(x => x.Shows)
.ChildRules(s => s.RuleFor(i => i.Weight).InclusiveBetween(1, 1000));
}
}

Some files were not shown because too many files have changed in this diff Show More