Compare commits

..
112 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
615 changed files with 79068 additions and 21592 deletions
+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
+10 -1
View File
@@ -32,6 +32,9 @@ 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), минуты. Короткий срок ограничивает окно доступа после # TTL stream-токена (cookie tw_stream), минуты. Короткий срок ограничивает окно доступа после
# блокировки/логаута; фронт перевыпускает cookie, пока идёт просмотр. По умолчанию 30. # блокировки/логаута; фронт перевыпускает cookie, пока идёт просмотр. По умолчанию 30.
# Storage__StreamTokenMinutes=30 # Storage__StreamTokenMinutes=30
@@ -63,6 +66,9 @@ 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 типично для стриминга; тише — уменьшить).
@@ -87,8 +93,11 @@ 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=
+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
+33 -40
View File
@@ -21,6 +21,14 @@ jobs:
- name: Restore - name: Restore
working-directory: backend working-directory: backend
run: dotnet restore TeleWave.slnx 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 не отключаем. # Строгая сборка: TreatWarningsAsErrors=true из Directory.Build.props не отключаем.
- name: Build (Release) - name: Build (Release)
working-directory: backend working-directory: backend
@@ -38,6 +46,9 @@ jobs:
- name: Install - name: Install
working-directory: frontend working-directory: frontend
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Format check
working-directory: frontend
run: pnpm format:check
- name: Lint - name: Lint
working-directory: frontend working-directory: frontend
run: pnpm lint run: pnpm lint
@@ -51,8 +62,6 @@ jobs:
tests: tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build-backend, build-frontend] needs: [build-backend, build-frontend]
permissions:
contents: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4 - uses: actions/setup-dotnet@v4
@@ -61,44 +70,16 @@ jobs:
- name: Restore - name: Restore
working-directory: backend working-directory: backend
run: dotnet restore TeleWave.slnx run: dotnet restore TeleWave.slnx
- name: Test + coverage # Покрытие здесь не собираем: его считает джоба sonar своим прогоном (в формате opencover),
# а бейдж покрытия в README берётся из SonarCloud.
- name: Test
working-directory: backend working-directory: backend
run: > run: >
dotnet test TeleWave.slnx dotnet test TeleWave.slnx
-c Release -c Release
--no-restore --no-restore
--collect:"XPlat Code Coverage"
--results-directory ./coverage
--logger "console;verbosity=normal" --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
# Анализ SonarCloud вместе с покрытием: один прогон собирает решение под сканером, гоняет тесты # Анализ SonarCloud вместе с покрытием: один прогон собирает решение под сканером, гоняет тесты
# и отправляет результат. Только push в main — декорация pull request'ов у SonarCloud завязана # и отправляет результат. Только push в main — декорация pull request'ов у SonarCloud завязана
# на GitHub/GitLab, из Gitea она не работает, и анализ PR только засорял бы ветки в проекте. # на GitHub/GitLab, из Gitea она не работает, и анализ PR только засорял бы ветки в проекте.
@@ -145,7 +126,12 @@ jobs:
/d:sonar.token="$SONAR_TOKEN" \ /d:sonar.token="$SONAR_TOKEN" \
/d:sonar.cs.opencover.reportsPaths="**/coverage/**/coverage.opencover.xml" \ /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.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.coverage.exclusions="backend/src/TeleWave.Infrastructure/Migrations/**,frontend/**" \
/d:sonar.cpd.exclusions="frontend/src/shared/lib/locales/**"
# Локали исключены из поиска дублей намеренно: ru.ts и en.ts обязаны совпадать ключ в ключ —
# это не копипаста, а единственный способ не потерять перевод, и «устранение» такого дубля
# означало бы отказ от второго языка.
# Сканер подмешивает в сборку свои анализаторы, а в проекте TreatWarningsAsErrors=true — # Сканер подмешивает в сборку свои анализаторы, а в проекте TreatWarningsAsErrors=true —
# любое замечание Sonar роняло бы сборку вместо того, чтобы приехать в отчёт. Строгая # любое замечание Sonar роняло бы сборку вместо того, чтобы приехать в отчёт. Строгая
@@ -156,15 +142,22 @@ jobs:
# Формат opencover, а не cobertura по умолчанию: C#-анализатор Sonar читает именно его. # Формат opencover, а не cobertura по умолчанию: C#-анализатор Sonar читает именно его.
# Интеграционные тесты без Docker пропускаются сами (см. PostgresFixture). # Интеграционные тесты без Docker пропускаются сами (см. PostgresFixture).
#
# Проекты гоняются по одному, а не одним прогоном по решению: при общем прогоне сборщик
# покрытия теряет попадания — отчёт приезжает без модуля TeleWave.Application, и в Sonar
# покрытые тестами файлы выглядят нулевыми. Отчёты складываются в один каталог, сканер
# подбирает их маской.
- name: Test + coverage - name: Test + coverage
if: env.SONAR_TOKEN != '' if: env.SONAR_TOKEN != ''
run: > run: |
dotnet test backend/TeleWave.slnx for project in Domain Application Integration; do
-c Release dotnet test "backend/tests/TeleWave.$project.Tests" \
--no-build -c Release \
--collect:"XPlat Code Coverage;Format=opencover" --no-build \
--results-directory backend/coverage --collect:"XPlat Code Coverage;Format=opencover" \
--results-directory backend/coverage \
--logger "console;verbosity=normal" --logger "console;verbosity=normal"
done
- name: End analysis - name: End analysis
if: env.SONAR_TOKEN != '' if: env.SONAR_TOKEN != ''
+5
View File
@@ -84,3 +84,8 @@ dist-ssr/
# Отчёты покрытия (артефакт dotnet test / CI) # Отчёты покрытия (артефакт dotnet test / CI)
coverage/ coverage/
TestResults/ TestResults/
# ---> TeleWave
# Отладочные дампы каналов: архивы кладёт сюда бэкенд (секция Debug:ExportDirectory).
debug-exports/*
!debug-exports/.gitkeep
+83 -115
View File
@@ -4,157 +4,125 @@
## Что это ## Что это
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из **TeleWave** — сервис онлайн-каналов: зритель смотрит сетку каналов, видео отдаётся из хранилища
хранилища на сервере. Админ управляет каналами и пользователями. на сервере. Админ ведёт библиотеку, каналы и эфир.
> Текущее состояние — **рабочий вертикальный срез**: вход/регистрация, роли, пользователи, админка; Работает: аутентификация, роли, пользователи; библиотека шоу/серий, коллекции, жанры, метаданные
> библиотека шоу/серий, загрузка и обработка медиа (ffmpeg → HLS-сегменты), реестр изображений, (TMDb/OMDb), обработка медиа (ffmpeg → HLS), изображения; группы, шаблон сетки, стыки и заставки,
> метаданные (TMDb/OMDb), каталог каналов, планировщик эфира (реклама, ТВ-заставки-переходы, автосборка по профилям, обмен конфигурацией файлом и запросом к ИИ; генерация ленты, live-раздача,
> weekly-override'ы) и live-раздача HLS с публичным просмотром сетки. Telegram-бот сознательно публичный просмотр, статистика хранилища. 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. Контейнер работает под непривилегированным пользователем образа (`USER $APP_UID`, - Работает под непривилегированным `USER $APP_UID` (uid/gid 1654) **статически**, без
uid/gid 1654) — но **статически**, без gosu/entrypoint-скриптов и подгонки uid на старте: права gosu/entrypoint: права на bind-mount выставляет оператор (`docs/server-storage-setup.md` § 5).
на bind-mount хранилища выставляет оператор на хосте (см. `docs/server-storage-setup.md` § 5). Data Protection не заводим — шифруемых секретов на диске нет. **TLS внешний**: `app` отдаёт HTTP
Data Protection key-ring не заводим — секретов на диске пока нет (JWT-ключ — обычная конфигурация, и доверяет `X-Forwarded-*`.
не шифруемый секрет at-rest); появятся зашифрованные данные (например API-ключи внешних - docker-compose: только `app`. PostgreSQL внешний (`ConnectionStrings__Default` из `.env`), база
сервисов) — добавлять Data Protection по образцу PnvPanel. и пользователь создаются заранее вручную; миграции применяются на старте (`ApplyMigrationsAsync`),
- docker-compose: только `app` — PostgreSQL живёт вне compose (внешний сервер/хост, адрес и прав `CREATEDB` не нужно.
креды — в `ConnectionStrings__Default` из `.env`; база и пользователь на нём создаются
заранее вручную, приложение их не сидит). Миграции применяются авто на старте
(`ApplyMigrationsAsync` в `Program.cs`) — прав `CREATEDB` не требуется, только DDL/DML в
уже существующей базе.
- Не вводи отдельный 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
# Форматирование — csharpier, версия закреплена в .config/dotnet-tools.json (один раз: dotnet tool restore). dotnet csharpier format . # только через dotnet (версия из .config/); в CI — csharpier check
# Глобально установленный csharpier может быть другой версии — вызывать только через dotnet: dotnet test tests/TeleWave.Domain.Tests # по одному проекту: MSBuild не берёт несколько
dotnet csharpier format .
dotnet csharpier check .
# Юнит-тесты (по одному проекту за вызов — MSBuild не принимает несколько):
dotnet test tests/TeleWave.Domain.Tests
dotnet test tests/TeleWave.Application.Tests dotnet test tests/TeleWave.Application.Tests
# Интеграционные тесты (Testcontainers-Postgres) — нужен запущенный Docker; без него пропускаются: dotnet test tests/TeleWave.Integration.Tests # Testcontainers; без Docker пропускаются
dotnet test tests/TeleWave.Integration.Tests
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-инструмент.
## Рабочие принципы ## Рабочие принципы
- Базовый вертикальный срез (каналы, планировщик, раздача HLS, обработка медиа) уже реализован — - Базовый срез реализован — правь по месту. Новые крупные направления с непринятыми решениями
правь его по месту. Но новые крупные направления с непринятыми архитектурными решениями (напр. (многоэкземплярность, транскод-профили, подсистемы) — после сверки; неоднозначность — вопрос.
многоэкземплярное развёртывание, транскод-профили, новые доменные подсистемы) начинай только после - Меняешь поведение подсистемы — обнови её спеку в `docs/` тем же заходом.
сверки с пользователем. При неоднозначности — вопрос пользователю, не предположение. - Соблюдай границы слоёв — главный инвариант проекта.
- Соблюдай границы слоёв — главный инвариант проекта, как и в PnvPanel. - Правь файлы через Edit/Write: пользователь видит дифф, а harness не даст перезаписать файл вслепую.
- Не коммить и не пуши без явной просьбы. Скриптом на python — только при крайней необходимости (массовая механическая замена), и сказать явно.
- Отвечай пользователю на русском. - Не коммить и не пуши без явной просьбы; отвечай пользователю на русском.
+3 -1
View File
@@ -4,7 +4,9 @@
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, и без него
# --frozen-lockfile падает на несовпадении конфигурации с локом.
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
# --ignore-scripts: ни один пакет фронта не требует postinstall, поэтому lifecycle-скрипты зависимостей # --ignore-scripts: ни один пакет фронта не требует postinstall, поэтому lifecycle-скрипты зависимостей
# в сборке не выполняются — компрометация любого пакета в дереве не даёт исполнения кода на этапе install. # в сборке не выполняются — компрометация любого пакета в дереве не даёт исполнения кода на этапе install.
RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile --ignore-scripts RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile --ignore-scripts
+6 -2
View File
@@ -1,8 +1,12 @@
# TeleWave # TeleWave
[![ci](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions/workflows/ci.yml/badge.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=ci.yml) [![ci](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions/workflows/ci.yml/badge.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=ci.yml)
[![coverage](https://gitea.hsrv.site/mrleo1nid/TeleWave/raw/branch/badges/coverage.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=ci.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)
[![quality gate](https://sonarcloud.io/api/project_badges/quality_gate?project=mrleo1nid_telewave&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave) [![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)
[![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** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из **TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
хранилища на сервере, админ управляет каналами и пользователями. хранилища на сервере, админ управляет каналами и пользователями.
@@ -1,35 +1,88 @@
using System.Text; using System.Text;
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 async Task<IResult> AddBumperTemplate( public static IEndpointRouteBuilder MapBumperEndpoints(this IEndpointRouteBuilder app)
Guid id, {
AddBumperTemplateBody body, var admin = app.MapGroup("/api/admin/bumpers")
.WithTags("Admin.Bumpers")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", List).Produces<IReadOnlyList<BumperTemplateDto>>();
admin.MapPost("", Create).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
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,
@@ -38,35 +91,37 @@ public static partial class ChannelEndpoints
{ {
var result = await sender.Send( var result = await sender.Send(
new UpdateBumperTemplateCommand( new UpdateBumperTemplateCommand(
id,
templateId, templateId,
new BumperStyle(
body.Name, body.Name,
body.Font,
body.BackgroundColor, body.BackgroundColor,
body.BackgroundColor2, body.BackgroundColor2,
body.AccentColor, body.AccentColor,
body.TextColor 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(
[AsParameters] BumperAudioUpload upload, Guid templateId,
string fileName,
HttpRequest request, HttpRequest request,
IBumperTemplateStorage storage, IBumperTemplateStorage storage,
IAudioProbe probe, IAudioProbe probe,
@@ -74,124 +129,101 @@ public static partial class ChannelEndpoints
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
if ( if (ResolveExtension(fileName, request) is not { } ext)
ResolveBumperExtension(upload.FileName, request, BumperFiles.AudioExtensions) return BumperErrors.InvalidFile.ToProblem();
is not { } ext
)
return ChannelErrors.InvalidBumperFile.ToProblem();
await storage.SaveAudioAsync(upload.TemplateId, ext, request.Body, cancellationToken); await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken);
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина). // Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
var path = storage.AudioPath(upload.TemplateId, ext); var path = storage.AudioPath(templateId, ext);
var duration = path is null var duration = path is null
? null ? null
: await probe.TryGetDurationAsync(path, cancellationToken); : await probe.TryGetDurationAsync(path, cancellationToken);
var result = await sender.Send( var result = await sender.Send(
new SetBumperTemplateAudioCommand( new SetBumperTemplateAudioCommand(templateId, ext, duration?.TotalSeconds ?? 0),
upload.Id,
upload.TemplateId,
ext,
duration?.TotalSeconds ?? 0
),
cancellationToken cancellationToken
); );
if (!result.IsSuccess) if (!result.IsSuccess)
await storage.DeleteAudioAsync(upload.TemplateId, cancellationToken); 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,33 +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 RenderBumperPreviewCommand(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);
if (SegmentFiles.TryResolveExisting(paths, previewId, "index.m3u8") is not { } indexPath) if (SegmentFiles.TryResolveExisting(paths, previewId, "index.m3u8") is not { } 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))
{ {
@@ -249,9 +275,8 @@ public static partial class ChannelEndpoints
} }
/// <summary> /// <summary>
/// Сегмент превью. Канал и блок в маршруте есть, но хендлеру не нужны: каталог превью /// Сегмент превью. Блок в маршруте есть, но хендлеру не нужен: каталог превью адресуется
/// адресуется подблоком (см. BumperPreview.AssetId), поэтому в сигнатуре их нет — незаявленные /// подблоком (см. <see cref="BumperPreview.AssetId"/>) — незаявленные параметры не связываются.
/// параметры маршрута просто не связываются.
/// </summary> /// </summary>
private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths) private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths)
{ {
@@ -267,50 +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>
/// Адрес загружаемого звука: канал и блок из маршрута плюс имя исходного файла из query (по нему
/// проверяется расширение). Свёрнуто в один параметр — кроме него хендлеру нужны ещё запрос, два
/// сервиса, диспетчер и токен отмены, и плоским списком сигнатура перестаёт читаться.
/// </summary>
public sealed record BumperAudioUpload(Guid Id, Guid TemplateId, string FileName);
/// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary> /// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary>
internal static class BumperFiles internal static class BumperFiles
@@ -8,6 +8,7 @@ using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Broadcast.UpdateChannelSettings; using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Broadcast.UpdateChannelTime; using TeleWave.Application.Broadcast.UpdateChannelTime;
using TeleWave.Application.Broadcast.UpdateViewerSettings; using TeleWave.Application.Broadcast.UpdateViewerSettings;
using TeleWave.Application.Programming.Planning.DebugExport;
using TeleWave.Application.Programming.Planning.Trace; using TeleWave.Application.Programming.Planning.Trace;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
@@ -15,11 +16,11 @@ using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
/// <summary> /// <summary>
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки /// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки
/// в <c>ChannelEndpoints.Bumpers.cs</c>. Что и когда идёт в эфире, задаёт шаблон сетки /// и стыки общие для всех каналов и живут своими разделами (<c>BumperEndpoints</c>,
/// (<c>TemplateEndpoints</c>). /// <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)
{ {
@@ -35,62 +36,6 @@ public static partial class ChannelEndpoints
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/time", UpdateTime).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 admin
.MapGet("/{id:guid}/schedule", GetSchedule) .MapGet("/{id:guid}/schedule", GetSchedule)
.Produces<IReadOnlyList<ScheduleEntryDto>>(); .Produces<IReadOnlyList<ScheduleEntryDto>>();
@@ -101,9 +46,29 @@ public static partial class ChannelEndpoints
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации. // «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>(); 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( private static async Task<IResult> UpdateViewerSettings(
Guid id, Guid id,
UpdateViewerSettingsBody body, UpdateViewerSettingsBody body,
@@ -195,9 +160,8 @@ public static partial class ChannelEndpoints
id, id,
body.Name, body.Name,
body.IsEnabled, body.IsEnabled,
body.BumpersEnabled, body.FillerAssetId,
body.Bumper, body.IconImageId
body.FillerAssetId
), ),
cancellationToken cancellationToken
); );
@@ -232,9 +196,8 @@ public sealed record UpdateChannelTimeBody(
public sealed record UpdateChannelSettingsBody( public sealed record UpdateChannelSettingsBody(
string Name, string Name,
bool IsEnabled, bool IsEnabled,
bool BumpersEnabled, Guid? FillerAssetId,
BumperSettingsInput Bumper, Guid? IconImageId
Guid? FillerAssetId
); );
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary> /// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
@@ -2,13 +2,16 @@ using LiteCqrs;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Application.Library.Collections; using TeleWave.Application.Library.Collections;
using TeleWave.Application.Library.Collections.AddCollectionShow; using TeleWave.Application.Library.Collections.AddCollectionShow;
using TeleWave.Application.Library.Collections.AddShowsToCollection;
using TeleWave.Application.Library.Collections.CreateCollection; using TeleWave.Application.Library.Collections.CreateCollection;
using TeleWave.Application.Library.Collections.DeleteCollection; using TeleWave.Application.Library.Collections.DeleteCollection;
using TeleWave.Application.Library.Collections.FillCollectionPosters;
using TeleWave.Application.Library.Collections.GetCollection; using TeleWave.Application.Library.Collections.GetCollection;
using TeleWave.Application.Library.Collections.ListCollections; using TeleWave.Application.Library.Collections.ListCollections;
using TeleWave.Application.Library.Collections.RemoveCollectionShow; using TeleWave.Application.Library.Collections.RemoveCollectionShow;
using TeleWave.Application.Library.Collections.ReorderCollection; using TeleWave.Application.Library.Collections.ReorderCollection;
using TeleWave.Application.Library.Collections.SetCollectionPoster; using TeleWave.Application.Library.Collections.SetCollectionPoster;
using TeleWave.Application.Library.Collections.Suggest;
using TeleWave.Application.Library.Collections.UpdateCollection; using TeleWave.Application.Library.Collections.UpdateCollection;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
@@ -36,9 +39,89 @@ public static class CollectionEndpoints
admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/poster-image", SetPoster).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; 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( private static async Task<IResult> ListCollections(
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
@@ -161,3 +244,14 @@ public sealed record CollectionShowBody(Guid ShowId);
public sealed record ReorderCollectionBody(IReadOnlyList<Guid> ShowIdsInOrder); public sealed record ReorderCollectionBody(IReadOnlyList<Guid> ShowIdsInOrder);
public sealed record CollectionPosterBody(Guid? ImageId); 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
);
@@ -4,13 +4,16 @@ using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Groups.AddGroupElements; using TeleWave.Application.Programming.Groups.AddGroupElements;
using TeleWave.Application.Programming.Groups.CreateGroup; using TeleWave.Application.Programming.Groups.CreateGroup;
using TeleWave.Application.Programming.Groups.DeleteGroup; using TeleWave.Application.Programming.Groups.DeleteGroup;
using TeleWave.Application.Programming.Groups.ExcludeGroupElement;
using TeleWave.Application.Programming.Groups.FindGroupCandidates; using TeleWave.Application.Programming.Groups.FindGroupCandidates;
using TeleWave.Application.Programming.Groups.GetGroup; using TeleWave.Application.Programming.Groups.GetGroup;
using TeleWave.Application.Programming.Groups.ListGroups; using TeleWave.Application.Programming.Groups.ListGroups;
using TeleWave.Application.Programming.Groups.RemoveGroupItem; using TeleWave.Application.Programming.Groups.RemoveGroupItem;
using TeleWave.Application.Programming.Groups.ReorderGroup; using TeleWave.Application.Programming.Groups.ReorderGroup;
using TeleWave.Application.Programming.Groups.SetGroupItemWeight; using TeleWave.Application.Programming.Groups.SetGroupItemWeight;
using TeleWave.Application.Programming.Groups.Suggest;
using TeleWave.Application.Programming.Groups.UpdateGroup; using TeleWave.Application.Programming.Groups.UpdateGroup;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
@@ -34,6 +37,12 @@ public static class GroupEndpoints
.MapPost("/{id:guid}/candidates", FindCandidates) .MapPost("/{id:guid}/candidates", FindCandidates)
.Produces<IReadOnlyList<GroupCandidateDto>>(); .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.MapPost("/{id:guid}/items", AddElements).Produces<AddedCountResponse>();
admin admin
.MapDelete("/{id:guid}/items/{itemId:guid}", RemoveItem) .MapDelete("/{id:guid}/items/{itemId:guid}", RemoveItem)
@@ -43,6 +52,9 @@ public static class GroupEndpoints
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
// Поправки к вычисленному составу динамической группы.
admin.MapPost("/{id:guid}/exclusions", Exclude).Produces(StatusCodes.Status204NoContent);
return app; return app;
} }
@@ -80,6 +92,33 @@ public static class GroupEndpoints
: result.ToHttpResult(); : 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( private static async Task<IResult> UpdateGroup(
Guid id, Guid id,
UpdateGroupBody body, UpdateGroupBody body,
@@ -88,7 +127,7 @@ public static class GroupEndpoints
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new UpdateGroupCommand(id, body.Name, body.Description, body.Filter), new UpdateGroupCommand(id, body.Name, body.Description, body.Filter, body.Mode),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
@@ -160,6 +199,20 @@ public static class GroupEndpoints
return result.ToHttpResult(); 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( private static async Task<IResult> Reorder(
Guid id, Guid id,
ReorderGroupBody body, ReorderGroupBody body,
@@ -175,10 +228,24 @@ public static class GroupEndpoints
} }
} }
public sealed record UpdateGroupBody(string Name, string? Description, GroupFilter? Filter); 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 FindCandidatesBody(GroupFilter? Filter);
public sealed record CreateGroupFromSuggestionBody(string Key);
public sealed record AddGroupElementsBody(IReadOnlyList<GroupElementRef> Elements); public sealed record AddGroupElementsBody(IReadOnlyList<GroupElementRef> Elements);
public sealed record GroupItemWeightBody(int Weight); public sealed record GroupItemWeightBody(int Weight);
@@ -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";
}
@@ -7,67 +7,50 @@ using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
/// <summary> /// <summary>
/// Шаблоны стыков канала: что играет между программами. Как и правка сетки, эфира не двигают — /// Шаблоны стыков: что играет между программами. Стыки общие для всех каналов, канал только
/// помечают шаблон канала изменённым, а хвост пересобирается применением. /// ссылается на них слотами. Как и правка сетки, эфира не двигают — помечают шаблоны каналов,
/// которые их используют, изменёнными, а хвост пересобирается применением.
/// </summary> /// </summary>
public static class JunctionEndpoints public static class JunctionEndpoints
{ {
public static IEndpointRouteBuilder MapJunctionEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapJunctionEndpoints(this IEndpointRouteBuilder app)
{ {
var admin = app.MapGroup("/api/admin") var admin = app.MapGroup("/api/admin/junctions")
.WithTags("Admin.Junctions") .WithTags("Admin.Junctions")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin admin.MapGet("", List).Produces<IReadOnlyList<JunctionTemplateDto>>();
.MapGet("/channels/{channelId:guid}/junctions", List) admin.MapPost("", Create).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
.Produces<IReadOnlyList<JunctionTemplateDto>>(); admin.MapPut("/{junctionId:guid}", Update).Produces(StatusCodes.Status204NoContent);
admin admin.MapDelete("/{junctionId:guid}", Delete).Produces(StatusCodes.Status204NoContent);
.MapPost("/channels/{channelId:guid}/junctions", Create)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/junctions/{junctionId:guid}", Rename)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/junctions/{junctionId:guid}", Delete)
.Produces(StatusCodes.Status204NoContent);
admin admin
.MapPost("/junctions/{junctionId:guid}/elements", AddElement) .MapPost("/{junctionId:guid}/elements", AddElement)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created); .Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin admin
.MapPut("/junctions/{junctionId:guid}/elements/{elementId:guid}", UpdateElement) .MapPut("/{junctionId:guid}/elements/{elementId:guid}", UpdateElement)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin admin
.MapDelete("/junctions/{junctionId:guid}/elements/{elementId:guid}", RemoveElement) .MapDelete("/{junctionId:guid}/elements/{elementId:guid}", RemoveElement)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/junctions/{junctionId:guid}/order", Reorder)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{junctionId:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
return app; return app;
} }
private static async Task<IResult> List( private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ListJunctionsQuery(channelId), cancellationToken); var result = await sender.Send(new ListJunctionsQuery(), cancellationToken);
return Results.Ok(result); return Results.Ok(result);
} }
private static async Task<IResult> Create( private static async Task<IResult> Create(
Guid channelId,
JunctionNameBody body, JunctionNameBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(new CreateJunctionCommand(body.Name), cancellationToken);
new CreateJunctionCommand(channelId, body.Name),
cancellationToken
);
return result.IsSuccess return result.IsSuccess
? Results.Created( ? Results.Created(
$"/api/admin/junctions/{result.Value}", $"/api/admin/junctions/{result.Value}",
@@ -76,15 +59,15 @@ public static class JunctionEndpoints
: result.ToHttpResult(); : result.ToHttpResult();
} }
private static async Task<IResult> Rename( private static async Task<IResult> Update(
Guid junctionId, Guid junctionId,
JunctionNameBody body, UpdateJunctionBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new RenameJunctionCommand(junctionId, body.Name), new UpdateJunctionCommand(junctionId, body.Name, body.MaxTotalSeconds),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
@@ -156,7 +139,7 @@ public static class JunctionEndpoints
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new ReorderJunctionCommand(junctionId, body.ElementIdsInOrder), new ReorderJunctionCommand(junctionId, body.Order),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
@@ -165,6 +148,9 @@ public static class JunctionEndpoints
public sealed record JunctionNameBody(string Name); public sealed record JunctionNameBody(string Name);
public sealed record UpdateJunctionBody(string Name, int? MaxTotalSeconds);
public sealed record JunctionElementKindBody(JunctionElementKind Kind); public sealed record JunctionElementKindBody(JunctionElementKind Kind);
public sealed record ReorderJunctionBody(IReadOnlyList<Guid> ElementIdsInOrder); /// <summary>Порядок врезок вместе с их развилками — перетаскивание меняет и то, и другое разом.</summary>
public sealed record ReorderJunctionBody(IReadOnlyList<JunctionElementOrder> Order);
@@ -8,7 +8,9 @@ using TeleWave.Application.Media;
using TeleWave.Application.Media.Delete; using TeleWave.Application.Media.Delete;
using TeleWave.Application.Media.ListMedia; using TeleWave.Application.Media.ListMedia;
using TeleWave.Application.Media.ManualInbox; 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.Application.Media.Stats;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
@@ -29,10 +31,20 @@ public static class MediaEndpoints
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>(); 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: сканером не разбирается — файлы выбирает админ и сразу указывает шоу. // Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>(); admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>(); 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. // по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist); admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
@@ -41,6 +53,25 @@ public static class MediaEndpoints
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».
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку. /// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
@@ -88,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/, но в БД его ещё нет и ссылок на токен нигде не остаётся:
// любой исход, кроме успешной регистрации, обязан за собой убрать. Удаляем и при исключении
// (оборванный запрос, сбой БД) — иначе гигабайты остаются в uploads/ до уборщика.
// Токен отмены для уборки не пробрасываем: при обрыве запроса он уже сработал.
Result<Guid> result;
try
{
result = await sender.Send(
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName), new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
cancellationToken cancellationToken
); );
}
catch
{
await storage.DeleteUploadAsync(token, CancellationToken.None);
throw;
}
if (!result.IsSuccess) if (!result.IsSuccess)
{ {
await storage.DeleteUploadAsync(token, cancellationToken); await storage.DeleteUploadAsync(token, CancellationToken.None);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -150,6 +195,45 @@ public static class MediaEndpoints
return Results.Ok(result); 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( private static async Task<IResult> ImportManual(
ImportManualInboxBody body, ImportManualInboxBody body,
ISender sender, ISender sender,
@@ -214,3 +298,9 @@ public sealed record ListMediaFilter(
public sealed record UploadMediaResponse(Guid Id); public sealed record UploadMediaResponse(Guid Id);
public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId); 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);
@@ -4,6 +4,7 @@ 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.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;
@@ -34,6 +35,9 @@ public static class MetadataEndpoints
admin admin
.MapGet("/shows/{showId:guid}/missing-episodes", FindMissing) .MapGet("/shows/{showId:guid}/missing-episodes", FindMissing)
.Produces<MissingEpisodesReport>(); .Produces<MissingEpisodesReport>();
admin
.MapGet("/shows/{showId:guid}/missing-seasons", FindMissingSeasons)
.Produces<MissingSeasonsReport>();
// Постеры шоу и кадры серий теперь в общем реестре и отдаются по /api/images/{id}. // Постеры шоу и кадры серий теперь в общем реестре и отдаются по /api/images/{id}.
return app; return app;
@@ -137,6 +141,16 @@ public static class MetadataEndpoints
var result = await sender.Send(new FindMissingEpisodesQuery(showId), cancellationToken); var result = await sender.Send(new FindMissingEpisodesQuery(showId), cancellationToken);
return result.ToHttpResult(); 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);
@@ -2,15 +2,18 @@ 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.SetShowAudience;
using TeleWave.Application.Library.SetShowGenres; 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.Domain.Library;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
@@ -33,7 +36,12 @@ public static class ShowEndpoints
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/genres", SetGenres).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);
@@ -136,13 +144,20 @@ public static class ShowEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
/// <param name="withMedia">Снести заодно файлы шоу — иначе они останутся осиротевшими.</param>
/// <param name="withSchedule">Вырезать шоу из будущего эфира вместо отказа.</param>
private static async Task<IResult> DeleteShow( private static async Task<IResult> DeleteShow(
Guid id, Guid id,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken,
bool withMedia = false,
bool withSchedule = false
) )
{ {
var result = await sender.Send(new DeleteShowCommand(id), cancellationToken); var result = await sender.Send(
new DeleteShowCommand(id, withMedia, withSchedule),
cancellationToken
);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -172,10 +187,68 @@ 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);
@@ -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);
}
}
@@ -117,7 +117,9 @@ public static class StreamingEndpoints
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить, // Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу, // что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто). // не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId) // Для IPTV это единственная точка, где блокировка вообще может сработать: у внешнего плеера
// пропуск долгий и перевыпуску не подлежит.
if (Viewer(request, tokens) is not { } userId)
return Results.Unauthorized(); return Results.Unauthorized();
var profile = await identity.GetProfileAsync(userId, cancellationToken); var profile = await identity.GetProfileAsync(userId, cancellationToken);
if (profile is null || profile.IsBlocked) if (profile is null || profile.IsBlocked)
@@ -132,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(
@@ -145,7 +154,7 @@ public static class StreamingEndpoints
MediaPathResolver paths MediaPathResolver paths
) )
{ {
if (tokens.Validate(request.Cookies[StreamCookieName]) is null) if (Viewer(request, tokens) is null)
return Results.Unauthorized(); return Results.Unauthorized();
if (!SegmentFiles.IsSegmentName(file)) if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound(); return Results.NotFound();
@@ -157,7 +166,15 @@ public static class StreamingEndpoints
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();
@@ -171,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)
{ {
@@ -179,7 +202,7 @@ 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"
); );
} }
@@ -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
);
@@ -8,8 +8,12 @@ using TeleWave.Application.Programming.Templates.CopyTemplate;
using TeleWave.Application.Programming.Templates.CreateSlot; using TeleWave.Application.Programming.Templates.CreateSlot;
using TeleWave.Application.Programming.Templates.CreateTemplate; using TeleWave.Application.Programming.Templates.CreateTemplate;
using TeleWave.Application.Programming.Templates.DeleteSlot; using TeleWave.Application.Programming.Templates.DeleteSlot;
using TeleWave.Application.Programming.Templates.Generate;
using TeleWave.Application.Programming.Templates.GetTemplate; using TeleWave.Application.Programming.Templates.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers; 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.UpdateSlot;
using TeleWave.Application.Programming.Templates.Validate; using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
@@ -43,6 +47,14 @@ public static class TemplateEndpoints
admin admin
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate) .MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
.Produces<ApplyResultDto>(); .Produces<ApplyResultDto>();
// Откат правок к состоянию, по которому идёт эфир. Ленту не трогает — она и так по нему.
admin
.MapPost("/channels/{channelId:guid}/template/restore", RestoreTemplate)
.Produces<RestoreTemplateResultDto>();
// Сброс сетки вместе с собранным по ней будущим эфиром. Прошлое и идущая запись остаются.
admin
.MapPost("/channels/{channelId:guid}/template/reset", ResetTemplate)
.Produces<ResetTemplateResultDto>();
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров. // Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
admin admin
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate) .MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
@@ -62,6 +74,30 @@ public static class TemplateEndpoints
) )
.Produces<CopyTemplateResultDto>(); .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 admin
.MapPost("/templates/{templateId:guid}/layers", CreateLayer) .MapPost("/templates/{templateId:guid}/layers", CreateLayer)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created); .Produces<CreatedIdResponse>(StatusCodes.Status201Created);
@@ -124,6 +160,26 @@ public static class TemplateEndpoints
return result.ToHttpResult(); 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( private static async Task<IResult> PreviewTemplate(
Guid channelId, Guid channelId,
ISender sender, ISender sender,
@@ -158,6 +214,82 @@ public static class TemplateEndpoints
return result.ToHttpResult(); 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( private static async Task<IResult> CopyTemplate(
Guid channelId, Guid channelId,
Guid targetChannelId, Guid targetChannelId,
@@ -286,6 +418,12 @@ public sealed record UpdateTemplateBody(
PlanningRules? Rules 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 CreateLayerBody(string Name, int Priority);
public sealed record UpdateLayerBody( public sealed record UpdateLayerBody(
+26 -12
View File
@@ -38,26 +38,35 @@ 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();
@@ -130,13 +139,18 @@ app.MapInterstitialEndpoints();
app.MapCollectionEndpoints(); app.MapCollectionEndpoints();
app.MapGroupEndpoints(); app.MapGroupEndpoints();
app.MapTemplateEndpoints(); app.MapTemplateEndpoints();
app.MapBumperEndpoints();
app.MapJunctionEndpoints(); 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();
@@ -20,5 +20,10 @@
}, },
"Storage": { "Storage": {
"RootPath": ".dev-media" "RootPath": ".dev-media"
},
// Отладочные дампы каналов ложатся в папку репозитория: разбирать «почему сетка построилась
// так» приходится рядом с кодом. Путь относительный — от рабочего каталога Api (backend/src/TeleWave.Api).
"Debug": {
"ExportDirectory": "../../../debug-exports"
} }
} }
@@ -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,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 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)
.AsSplitQuery()
.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;
}
}
@@ -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 [];
}
}
}
@@ -4,22 +4,18 @@ using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers; namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary> /// <summary>
/// Чистая сборка <see cref="BumperRenderSpec"/> из уже разрешённых входов (пути к постеру/фону/звуку, /// Чистая сборка <see cref="BumperRenderSpec"/> из уже разрешённых входов (готовые строки, пути к
/// названия шоу). Общая точка для фонового рендерера заставок расписания и превью в админке. /// постеру/фону/звуку). Общая точка для фонового рендерера заставок расписания и превью в админке.
/// </summary> /// </summary>
public static class BumperSpecFactory public static class BumperSpecFactory
{ {
public static BumperRenderSpec Build( public static BumperRenderSpec Build(
BumperOptions bumper, BumperOptions bumper,
BumperFont font,
BumperTemplate template, BumperTemplate template,
BumperTextVariant variant,
int alignedDurationSeconds, int alignedDurationSeconds,
BumperSpecInputs inputs BumperSpecInputs inputs
) ) =>
{ new(
var free = variant.Kind == BumperTextKind.Free;
return new BumperRenderSpec(
alignedDurationSeconds, alignedDurationSeconds,
bumper.Width, bumper.Width,
bumper.Height, bumper.Height,
@@ -27,17 +23,14 @@ public static class BumperSpecFactory
template.BackgroundColor2, template.BackgroundColor2,
template.AccentColor, template.AccentColor,
template.TextColor, template.TextColor,
font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans, template.Font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans,
free ? "" : variant.NowLabel, inputs.Lines,
free ? "" : inputs.FromName,
free ? "" : variant.NextLabel,
free ? "" : inputs.ToName,
inputs.BackgroundAbsolutePath, inputs.BackgroundAbsolutePath,
inputs.AudioPath, inputs.AudioPath,
inputs.PosterAbsolutePath, inputs.PosterAbsolutePath,
free, inputs.ClipAbsolutePath,
variant.Line1, // Джингл назначен — он и звучит, подрезанный под ролик; не назначен — играет
variant.Line2 // оригинальная дорожка ролика.
UseClipAudio: inputs.ClipAbsolutePath is not null && inputs.AudioPath is null
); );
} }
}
@@ -1,15 +1,18 @@
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Broadcast.Bumpers; namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary> /// <summary>
/// Уже разрешённые входы рендера заставки: названия шоу «из/в» и пути к файлам. Разрешает их /// Уже разрешённые входы рендера заставки: готовые строки (плейсхолдеры подставлены) и пути к
/// вызывающий (генератор эфира по реальной паре соседей, превью — по образцам канала), а /// файлам. Разрешает их вызывающий генератор эфира по реальной паре соседей, редактор по
/// <see cref="BumperSpecFactory"/> только раскладывает их по спецификации. /// образцам, — а <see cref="BumperSpecFactory"/> только раскладывает их по спецификации.
/// </summary> /// </summary>
public sealed record BumperSpecInputs( public sealed record BumperSpecInputs(
string FromName, IReadOnlyList<BumperRenderLine> Lines,
string ToName,
string? AudioPath = null, string? AudioPath = null,
/// <summary>Постер «следующего» шоу как фон; в превью не подставляется — шоу ещё неизвестно.</summary> /// <summary>Постер шоу как фон; подставляется, только если подблок его запросил.</summary>
string? PosterAbsolutePath = null, string? PosterAbsolutePath = null,
string? BackgroundAbsolutePath = null string? BackgroundAbsolutePath = null,
/// <summary>Плейлист ролика-подложки, если текст пишется поверх готового видео.</summary>
string? ClipAbsolutePath = null
); );
@@ -4,19 +4,20 @@ using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming; using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Media;
namespace TeleWave.Application.Broadcast.Bumpers; namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary> /// <summary>
/// Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки: планировщик сохранил только /// Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки: планировщик сохранил
/// ссылки (канал/блок/подблок/пара шоу), а рендеру нужны названия шоу и абсолютные пути к звуку, /// готовые строки и ссылку на блок, а рендеру нужны ещё абсолютные пути к звуку, постеру и фону.
/// постеру и фону. Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения, /// Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения, воркер лишь крутит ffmpeg.
/// воркер лишь крутит ffmpeg.
/// </summary> /// </summary>
public sealed class BumperSpecLoader( public sealed class BumperSpecLoader(
IAppDbContext dbContext, IAppDbContext dbContext,
IBumperTemplateStorage bumperStorage, IBumperTemplateStorage bumperStorage,
IImageStore imageStore, IImageStore imageStore,
IMediaStorage mediaStorage,
IOptions<BumperOptions> bumperOptions, IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions IOptions<StreamingOptions> streamingOptions
) )
@@ -38,50 +39,63 @@ public sealed class BumperSpecLoader(
if (cache is null) if (cache is null)
return null; return null;
var channel = await dbContext var template = await dbContext
.Channels.AsNoTracking() .BumperTemplates.AsNoTracking()
.Include(c => c.BumperTemplates) .FirstOrDefaultAsync(t => t.Id == cache.TemplateId, cancellationToken);
.ThenInclude(t => t.Variants) if (template is null)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == cache.ChannelId, cancellationToken);
var template = channel?.BumperTemplates.FirstOrDefault(t => t.Id == cache.TemplateId);
var variant = template?.Variants.FirstOrDefault(v => v.Id == cache.VariantId);
if (channel is null || template is null || variant is null)
return null; return null;
var names = await dbContext var posterPath = cache.PosterShowId is { } showId
.Shows.AsNoTracking() ? await ResolveShowPosterAsync(showId, cancellationToken)
.Where(s => s.Id == cache.FromShowId || s.Id == cache.ToShowId) : null;
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
// Постер шоу-получателя как фон — только для «Сейчас/Далее».
string? posterPath = null;
if (variant.Kind == BumperTextKind.NowNext)
posterPath = await ResolveShowPosterAsync(cache.ToShowId, cancellationToken);
var bgPath = await ResolveImagePathAsync(template.BackgroundImageId, cancellationToken); var bgPath = await ResolveImagePathAsync(template.BackgroundImageId, cancellationToken);
var (clipPath, clipSeconds) = await ResolveClipAsync(
cache.BackgroundAssetId,
cancellationToken
);
// Длину задаёт ролик, если он есть: подрезать чужую заставку под джингл значило бы обрывать
// её на полуслове. Выравнивание по сегменту остаётся — лента этого требует от любой записи.
var aligned = BumperDuration.Aligned( var aligned = BumperDuration.Aligned(
BumperDuration.TemplateSeconds(template), clipSeconds ?? BumperDuration.TemplateSeconds(template),
_segmentSeconds _segmentSeconds
); );
return BumperSpecFactory.Build( return BumperSpecFactory.Build(
_bumper, _bumper,
channel.BumperFont,
template, template,
variant,
aligned, aligned,
new BumperSpecInputs( new BumperSpecInputs(
names.GetValueOrDefault(cache.FromShowId, "…"), BumperRenderedText.FromJson(cache.RenderedLinesJson),
names.GetValueOrDefault(cache.ToShowId, "…"),
bumperStorage.AudioPath(template.Id, template.AudioExtension), bumperStorage.AudioPath(template.Id, template.AudioExtension),
posterPath, posterPath,
bgPath 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( private async Task<string?> ResolveShowPosterAsync(
Guid showId, Guid showId,
CancellationToken cancellationToken CancellationToken cancellationToken
@@ -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,19 +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();
await dbContext.SaveChangesAsync(cancellationToken); await storage.DeleteAudioAsync(template.Id, cancellationToken);
await storage.DeleteAudioAsync(command.TemplateId, 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,35 +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);
await dbContext.SaveChangesAsync(cancellationToken);
await storage.DeleteTemplateAsync(command.TemplateId, cancellationToken);
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,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 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)
.AsSplitQuery()
.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);
}
}
@@ -1,11 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Синхронно рендерит примеры всех подблоков блока (с примерными названиями шоу). Каждый подблок —
/// в свой ассет-превью (id детерминирован по подблоку). БД не меняет, но пишет артефакты на диск —
/// поэтому это команда (действие с побочным эффектом), а не запрос.
/// </summary>
public sealed record RenderBumperPreviewCommand(Guid ChannelId, Guid TemplateId) : ICommand<Result>;
@@ -1,19 +1,27 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Application.Streaming; using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
namespace TeleWave.Application.Broadcast.Bumpers; namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Рендерит пример каждого подблока. Блок общий, поэтому образцы подстановки берутся глазами
/// выбранного канала: без него <c>{channel}</c> не на что заменить, а названия шоу были бы
/// случайными из библиотеки.
/// </summary>
public sealed class RenderBumperPreviewCommandHandler( public sealed class RenderBumperPreviewCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
IBumperRenderer renderer, IBumperRenderer renderer,
IBumperTemplateStorage storage, IBumperTemplateStorage storage,
IImageStore imageStore, IImageStore imageStore,
IMediaStorage mediaStorage,
IOptions<BumperOptions> bumperOptions, IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions IOptions<StreamingOptions> streamingOptions
) : ICommandHandler<RenderBumperPreviewCommand, Result> ) : ICommandHandler<RenderBumperPreviewCommand, Result>
@@ -21,56 +29,68 @@ public sealed class RenderBumperPreviewCommandHandler(
private readonly BumperOptions _bumper = bumperOptions.Value; private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
private const int DefaultBumperDurationSeconds = 8;
public async Task<Result> Handle( public async Task<Result> Handle(
RenderBumperPreviewCommand query, RenderBumperPreviewCommand command,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var channel = await dbContext var template = await dbContext
.Channels.AsNoTracking() .BumperTemplates.AsNoTracking()
.Include(c => c.BumperTemplates) .Include(t => t.Variants)
.ThenInclude(t => t.Variants) .FirstOrDefaultAsync(t => t.Id == command.TemplateId, cancellationToken);
.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) if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound); return Result.Failure(BumperErrors.TemplateNotFound);
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken); var channel = command.ChannelId is { } channelId
var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken); ? await dbContext
var seconds = template.AudioDurationSeconds is { } d and > 0 .Channels.AsNoTracking()
? d .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken)
: DefaultBumperDurationSeconds; : null;
var aligned = (int)(
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds 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 audioPath = storage.AudioPath(template.Id, template.AudioExtension);
// Постер зависит от конкретного «следующего» шоу — в превью его не подставляем. var clips = await ResolveClipsAsync(template, cancellationToken);
var inputs = new BumperSpecInputs(
fromName,
toName,
audioPath,
PosterAbsolutePath: null,
backgroundPath
);
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
foreach (var variant in template.Variants.OrderBy(v => v.Position)) 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( var spec = BumperSpecFactory.Build(
_bumper, _bumper,
channel.BumperFont,
template, template,
variant, clip is null ? aligned : BumperDuration.Aligned(clip.Seconds, _segmentSeconds),
aligned, new BumperSpecInputs(
inputs lines,
audioPath,
variant.Background == BumperBackground.Template ? null : posterPath,
backgroundPath,
clip?.Path
)
); );
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken); await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
} }
@@ -78,49 +98,155 @@ public sealed class RenderBumperPreviewCommandHandler(
return Result.Success(); return Result.Success();
} }
/// <summary>Путь к фон-картинке блока в общем реестре или null, если она не привязана.</summary> /// <summary>Ролик-подложка: плейлист и длительность.</summary>
private async Task<string?> ResolveBackgroundPathAsync( private sealed record ClipSource(string Path, int Seconds);
/// <summary>Ролики-подложки подблоков блока, по шоу. Неготовые молча выпадают — превью не эфир.</summary>
private async Task<Dictionary<Guid, ClipSource>> ResolveClipsAsync(
BumperTemplate template, BumperTemplate template,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
if (template.BackgroundImageId is not { } imageId) var showIds = template
return null; .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 extension = await dbContext var episodes = await dbContext
.Images.AsNoTracking() .Shows.AsNoTracking()
.Where(i => i.Id == imageId) .SelectMany(s => s.Episodes)
.Select(i => i.FileExtension) .Where(e => showIds.Contains(e.ShowId))
.FirstOrDefaultAsync(cancellationToken); .OrderBy(e => e.Position)
.Select(e => new { e.ShowId, e.MediaAssetId })
.ToListAsync(cancellationToken);
return extension is null ? null : imageStore.ResolvePath(imageId, extension); 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>
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала: /// Пара шоу для образца. Берём те, что реально ходят в этом канале (через группы его слотов), —
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки. /// иначе предпросмотр показывает библиотеку, а не канал.
/// </summary> /// </summary>
private async Task<(string From, string To)> SampleNamesAsync( private async Task<SampleShows> SampleShowsAsync(
Channel channel, Channel? channel,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var names = await ( var query = dbContext.Shows.AsNoTracking().AsQueryable();
from slot in dbContext.Slots.AsNoTracking() 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 join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
join item in dbContext.GroupItems.AsNoTracking() on slot.GroupId equals item.GroupId where layer.TemplateId == templateId && item.ElementKind == GroupElementKind.Show
join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id select show;
where
layer.TemplateId == channel.TemplateId && item.ElementKind == GroupElementKind.Show var shows = await query
select show.Name .Select(s => new
) {
s.Name,
s.Year,
s.PosterImageId,
})
.Distinct() .Distinct()
.Take(2) .Take(2)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return ( var slotTitle = channel?.TemplateId is { } id
names.ElementAtOrDefault(0) ?? "Первое шоу", ? await (
names.ElementAtOrDefault(1) ?? "Второе шоу" 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,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,47 +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.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)
.AsSplitQuery()
.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(),
new BumperTextContent(
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,39 +4,6 @@ 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);
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection);
/// <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,
@@ -46,10 +13,9 @@ public sealed record ChannelDto(
int UtcOffsetMinutes, int UtcOffsetMinutes,
TimeOnly DayStartTime, TimeOnly DayStartTime,
Guid? TemplateId, Guid? TemplateId,
bool BumpersEnabled,
BumperSettingsDto Bumper,
IReadOnlyList<BumperTemplateDto> BumperTemplates,
Guid? FillerAssetId, Guid? FillerAssetId,
/// <summary>Иконка канала для внешних IPTV-плееров (M3U/XMLTV); null — отдаётся заглушка.</summary>
Guid? IconImageId,
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary> /// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
ViewerSettingsDto Viewer ViewerSettingsDto Viewer
); );
@@ -17,44 +17,10 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
// (GetChannelTemplateQuery) — здесь только собственные свойства канала. // (GetChannelTemplateQuery) — здесь только собственные свойства канала.
var channel = await dbContext var channel = await dbContext
.Channels.AsNoTracking() .Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken); .FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
if (channel is null) if (channel is null)
return Result.Failure<ChannelDto>(ChannelErrors.NotFound); return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
var bumperTemplates = channel
.BumperTemplates.OrderBy(t => t.Position)
.Select(t => new BumperTemplateDto(
t.Id,
t.Position,
t.IsDefault,
t.Name,
t.BackgroundColor,
t.BackgroundColor2,
t.AccentColor,
t.TextColor,
t.BackgroundImageId,
t.AudioExtension is not null,
t.AudioDurationSeconds,
t.Variants.OrderBy(v => v.Position)
.Select(v => new BumperTextVariantDto(
v.Id,
v.Position,
v.Name,
v.Kind,
v.NowLabel,
v.NextLabel,
v.Line1,
v.Line2,
v.Trigger,
v.Weight
))
.ToList()
))
.ToList();
return Result.Success( return Result.Success(
new ChannelDto( new ChannelDto(
channel.Id, channel.Id,
@@ -65,10 +31,8 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
channel.UtcOffsetMinutes, channel.UtcOffsetMinutes,
channel.DayStartTime, channel.DayStartTime,
channel.TemplateId, channel.TemplateId,
channel.BumpersEnabled,
new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
bumperTemplates,
channel.FillerAssetId, channel.FillerAssetId,
channel.IconImageId,
new ViewerSettingsDto( new ViewerSettingsDto(
channel.LogoImageId, channel.LogoImageId,
channel.LogoCorner, channel.LogoCorner,
@@ -1,5 +1,6 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Application.Library; using TeleWave.Application.Library;
@@ -43,22 +44,31 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
.Select(s => new { s.Id, s.Name }) .Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
// Подблоки заставок в окне — чтобы показать в расписании, какая именно заставка и с каким текстом. // Заставки в окне: берём их из кэша по ассету — там лежит ровно тот текст, который играл,
var variantIds = entries // с уже подставленными плейсхолдерами. Собирать его заново из подблока значило бы гадать.
.Where(e => var bumperAssetIds = entries
e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper && e.BumperVariantId != null .Where(e => e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper)
) .Select(e => e.MediaAssetId)
.Select(e => e.BumperVariantId!.Value)
.Distinct() .Distinct()
.ToList(); .ToList();
var variants = var bumpers =
variantIds.Count == 0 bumperAssetIds.Count == 0
? [] ? []
: await dbContext : await (
.BumperTextVariants.AsNoTracking() from cache in dbContext.BumperAssets.AsNoTracking()
.Where(v => variantIds.Contains(v.Id)) join variant in dbContext.BumperTextVariants.AsNoTracking()
.ToListAsync(cancellationToken); on cache.VariantId equals variant.Id
var variantsById = variants.ToDictionary(v => v.Id); where bumperAssetIds.Contains(cache.MediaAssetId)
select new
{
cache.MediaAssetId,
variant.Name,
cache.RenderedLinesJson,
}
).ToListAsync(cancellationToken);
var bumpersByAsset = bumpers
.GroupBy(b => b.MediaAssetId)
.ToDictionary(g => g.Key, g => g.First());
// Имена ассетов программ — чтобы показать реальную метку S16E03 в расписании админки. // Имена ассетов программ — чтобы показать реальную метку S16E03 в расписании админки.
var assetIds = entries var assetIds = entries
@@ -72,23 +82,45 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
.Select(a => new { a.Id, a.OriginalFileName }) .Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken); .ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
// «Из какого шоу» для заставки берём из ближайшей предыдущей программы в упорядоченном окне. // Франшизы записей: у фильма серия всегда одна, и «Серия 1» в расписании — пустая метка.
Guid? prevProgramShowId = null; // Осмысленно другое — какой частью коллекции он шёл.
var collectionIds = entries
.Where(e => e.CollectionId != null)
.Select(e => e.CollectionId!.Value)
.Distinct()
.ToList();
var collectionParts =
collectionIds.Count == 0
? []
: await (
from item in dbContext.CollectionItems.AsNoTracking()
join collection in dbContext.Collections.AsNoTracking()
on item.CollectionId equals collection.Id
where collectionIds.Contains(item.CollectionId)
select new CollectionPart(
item.CollectionId,
item.ShowId,
item.Position,
collection.Name
)
).ToListAsync(cancellationToken);
var partsByCollection = collectionParts
.GroupBy(p => p.CollectionId)
.ToDictionary(g => g.Key, g => g.OrderBy(p => p.Position).ToList());
var dtos = new List<ScheduleEntryDto>(entries.Count); var dtos = new List<ScheduleEntryDto>(entries.Count);
foreach (var e in entries) foreach (var e in entries)
{ {
string? bumperName = null; string? bumperName = null;
string? bumperText = null; string? bumperText = null;
if ( if (bumpersByAsset.TryGetValue(e.MediaAssetId, out var bumper))
e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper
&& e.BumperVariantId is { } vid
&& variantsById.TryGetValue(vid, out var variant)
)
{ {
bumperName = variant.Name; bumperName = bumper.Name;
bumperText = BumperText(variant, prevProgramShowId, e.ShowId, showNames); bumperText = BumperText(bumper.RenderedLinesJson);
} }
var franchise = FranchisePart(partsByCollection, e.CollectionId, e.ShowId);
dtos.Add( dtos.Add(
new ScheduleEntryDto( new ScheduleEntryDto(
e.Id, e.Id,
@@ -103,37 +135,54 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
? EpisodeName.ParseLabel(name) ? EpisodeName.ParseLabel(name)
: null, : null,
bumperName, bumperName,
bumperText bumperText,
franchise?.Part,
franchise?.Total,
franchise?.Name
) )
); );
if (e.Kind == Domain.Broadcast.ScheduleEntryKind.Program)
prevProgramShowId = e.ShowId;
} }
return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos); return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos);
} }
/// <summary>Место записи во франшизе: «часть N из M» и её название.</summary>
private sealed record FranchisePosition(int Part, int Total, string Name);
/// <summary> /// <summary>
/// Текст заставки для метки в расписании: для «Сейчас/Далее» — подписи + названия шоу (из→в), /// Какой частью коллекции шла запись. Номер ищется по позиции шоу в текущем составе, а не по
/// для свободного текста — заданные строки. Возвращает null, если показывать нечего. /// индексу записи: франшизу могли переупорядочить, и «часть 2» обязана означать вторую сейчас.
/// </summary> /// </summary>
private static string? BumperText( private static FranchisePosition? FranchisePart(
Domain.Broadcast.BumperTextVariant variant, IReadOnlyDictionary<Guid, List<CollectionPart>> partsByCollection,
Guid? fromShowId, Guid? collectionId,
Guid? toShowId, Guid? showId
IReadOnlyDictionary<Guid, string> showNames
) )
{ {
if (variant.Kind == Domain.Broadcast.BumperTextKind.Free) if (
{ collectionId is not { } id
var parts = new[] { variant.Line1, variant.Line2 } || showId is not { } show
.Where(s => !string.IsNullOrWhiteSpace(s)) || !partsByCollection.TryGetValue(id, out var ordered)
.ToArray(); )
return parts.Length == 0 ? null : string.Join(" · ", parts); return null;
var at = ordered.FindIndex(p => p.ShowId == show);
return at < 0 ? null : new FranchisePosition(at + 1, ordered.Count, ordered[0].Name);
} }
string Name(Guid? id) => id is { } g ? showNames.GetValueOrDefault(g, "…") : "…"; /// <summary>Позиция шоу в коллекции вместе с её названием — то, чем считается номер части.</summary>
return $"{variant.NowLabel} {Name(fromShowId)} · {variant.NextLabel} {Name(toShowId)}"; private sealed record CollectionPart(Guid CollectionId, Guid ShowId, int Position, string Name);
/// <summary>Строки сыгравшей заставки одной меткой для расписания; null — показывать нечего.</summary>
private static string? BumperText(string? renderedLinesJson)
{
var text = string.Join(
" · ",
BumperRenderedText
.FromJson(renderedLinesJson)
.Select(l => l.Text)
.Where(t => !string.IsNullOrWhiteSpace(t))
);
return text.Length == 0 ? null : text;
} }
} }
@@ -14,5 +14,12 @@ public sealed record ScheduleEntryDto(
string? SeasonEpisode, string? SeasonEpisode,
// Для заставок (Kind == Bumper): имя подблока и его текст — для метки в админ-расписании. // Для заставок (Kind == Bumper): имя подблока и его текст — для метки в админ-расписании.
string? BumperName = null, string? BumperName = null,
string? BumperText = null string? BumperText = null,
/// <summary>
/// Какой частью франшизы шла запись и сколько их всего. У фильма серия всегда одна, и «Серия 1»
/// в расписании ничего не значит; «часть 3 из 3» — значит.
/// </summary>
int? CollectionPart = null,
int? CollectionParts = null,
string? CollectionName = null
); );
@@ -1,6 +1,5 @@
using LiteCqrs; using LiteCqrs;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings; namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
@@ -8,11 +7,6 @@ public sealed record UpdateChannelSettingsCommand(
Guid ChannelId, Guid ChannelId,
string Name, string Name,
bool IsEnabled, bool IsEnabled,
bool BumpersEnabled, Guid? FillerAssetId,
BumperSettingsInput Bumper, Guid? IconImageId
Guid? FillerAssetId
) : ICommand<Result>; ) : ICommand<Result>;
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>). Условия
/// показа сюда не входят — они задаются на элементе стыка.</summary>
public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection);
@@ -33,10 +33,9 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
channel.UpdateSettings( channel.UpdateSettings(
command.Name, command.Name,
command.IsEnabled, command.IsEnabled,
command.BumpersEnabled, command.FillerAssetId,
command.FillerAssetId command.IconImageId
); );
channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection);
return Result.Success(); return Result.Success();
} }
} }
@@ -0,0 +1,20 @@
namespace TeleWave.Application.Common;
/// <summary>Настройки отладочного дампа. Секция «Debug».</summary>
public sealed class DebugExportOptions
{
public const string SectionName = "Debug";
/// <summary>
/// Куда класть копии архивов. Пусто — не класть никуда, отдавать только в браузер: на сервере
/// дампы на диске не нужны, а в разработке путь указывает на папку рядом с репозиторием.
/// Относительный путь считается от рабочего каталога приложения.
/// </summary>
public string? ExportDirectory { get; init; }
/// <summary>
/// Сколько последних архивов держать в каталоге. Дамп канала — это мегабайты, а собирают их
/// пачками, отлаживая одну и ту же сетку; без потолка папка растёт молча.
/// </summary>
public int KeepLast { get; init; } = 20;
}
@@ -5,6 +5,7 @@ using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images; using TeleWave.Domain.Images;
using TeleWave.Domain.Library; using TeleWave.Domain.Library;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
using TeleWave.Domain.Notifications;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
using TeleWave.Domain.Settings; using TeleWave.Domain.Settings;
@@ -30,7 +31,12 @@ public interface IAppDbContext
DbSet<JunctionElement> JunctionElements { get; } DbSet<JunctionElement> JunctionElements { get; }
DbSet<Channel> Channels { get; } DbSet<Channel> Channels { get; }
DbSet<ScheduleEntry> ScheduleEntries { get; } DbSet<ScheduleEntry> ScheduleEntries { get; }
DbSet<BumperTemplate> BumperTemplates { get; }
DbSet<BumperTextVariant> BumperTextVariants { get; } DbSet<BumperTextVariant> BumperTextVariants { get; }
DbSet<TelegramSettings> TelegramSettings { get; }
DbSet<TelegramSubscriber> TelegramSubscribers { get; }
DbSet<TelegramSubscription> TelegramSubscriptions { get; }
DbSet<TelegramLinkCode> TelegramLinkCodes { get; }
DbSet<BumperAsset> BumperAssets { get; } DbSet<BumperAsset> BumperAssets { get; }
DbSet<AppSetting> AppSettings { get; } DbSet<AppSetting> AppSettings { get; }
DbSet<Image> Images { get; } DbSet<Image> Images { get; }
@@ -1,7 +1,13 @@
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Common.Interfaces; namespace TeleWave.Application.Common.Interfaces;
/// <summary>Готовая строка заставки: роль, цвет из палитры блока и уже подставленный текст.</summary>
public sealed record BumperRenderLine(BumperLineStyle Style, BumperLineColor Color, string Text);
/// <summary> /// <summary>
/// Полная спецификация одной заставки для рендера: оформление канала + подписи + названия шоу. /// Полная спецификация одной заставки для рендера: оформление блока + готовые строки. Плейсхолдеры
/// в <see cref="Lines"/> уже подставлены — рендер работает с текстом, а не с шаблоном.
/// <see cref="DurationSeconds"/> уже выровнена на длину сегмента (готовит оркестратор), а /// <see cref="DurationSeconds"/> уже выровнена на длину сегмента (готовит оркестратор), а
/// <see cref="FontFile"/> — абсолютный путь к TTF внутри контейнера. /// <see cref="FontFile"/> — абсолютный путь к TTF внутри контейнера.
/// </summary> /// </summary>
@@ -14,18 +20,21 @@ public sealed record BumperRenderSpec(
string AccentColor, string AccentColor,
string TextColor, string TextColor,
string FontFile, string FontFile,
string NowLabel, IReadOnlyList<BumperRenderLine> Lines,
string NowTitle,
string NextLabel,
string NextTitle,
string? BackgroundFile = null, string? BackgroundFile = null,
string? MusicFile = null, string? MusicFile = null,
/// <summary>Постер шоу как фон (используется, если нет загруженного фона канала; затемняется).</summary> /// <summary>Постер шоу как фон (используется, если подблок его запросил; затемняется).</summary>
string? PosterFile = null, string? PosterFile = null,
/// <summary>Режим свободного текста: вместо «Сейчас/Далее» рисуются <see cref="FreeLine1"/>/<see cref="FreeLine2"/>.</summary> /// <summary>
bool FreeText = false, /// Готовый ролик как подложка: плейлист ассета из библиотеки. Побеждает остальные источники —
string FreeLine1 = "", /// это самый явный выбор из всех, его не подставляют молча.
string FreeLine2 = "" /// </summary>
string? ClipFile = null,
/// <summary>
/// Брать звук из самого ролика. Ставится, когда у блока нет джингла: подкладывать под чужую
/// заставку синтезированный писк хуже, чем оставить её собственную дорожку.
/// </summary>
bool UseClipAudio = false
); );
/// <summary>Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки.</summary> /// <summary>Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки.</summary>
@@ -39,9 +48,9 @@ public sealed record BumperRenderResult(
); );
/// <summary> /// <summary>
/// Порт рендера ТВ-заставок. Реализация синтезирует короткий клип (анимированный фон + текст /// Порт рендера ТВ-заставок. Реализация синтезирует короткий клип (анимированный фон + строки текста
/// «Сейчас/Далее» + джингл) по <see cref="BumperRenderSpec"/> и режет его на HLS-сегменты в /// + джингл) по <see cref="BumperRenderSpec"/> и режет его на HLS-сегменты в assets/{assetId} — так
/// assets/{assetId} — так же, как обычный ассет, чтобы раздача эфира не отличала заставку от программы. /// же, как обычный ассет, чтобы раздача эфира не отличала заставку от программы.
/// </summary> /// </summary>
public interface IBumperRenderer public interface IBumperRenderer
{ {
@@ -0,0 +1,19 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Куда ложится копия отладочного дампа на сервере. Сам архив всегда уезжает в браузер — это порт
/// для второй копии, которая остаётся рядом с репозиторием: разбирать «почему сетка построилась
/// вот так» приходится вместе с кодом, и перекладывать архивы руками из загрузок каждый раз —
/// лишний шаг.
///
/// Каталог задаётся настройкой и по умолчанию пуст: на сервере писать дампы на диск незачем,
/// это инструмент разработки.
/// </summary>
public interface IDebugExportStore
{
/// <summary>
/// Сохраняет архив. Возвращает путь, куда он лёг, либо null — сохранение выключено настройкой.
/// Ошибка записи наружу не идёт: дамп уже собран, и терять его из-за недоступной папки нельзя.
/// </summary>
Task<string?> SaveAsync(string fileName, byte[] content, CancellationToken cancellationToken);
}
@@ -0,0 +1,26 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Живые проверки инфраструктуры для админской диагностики: свободное место на томе хранилища и
/// доступность внешних инструментов (ffmpeg/ffprobe). Отдельный порт — чтобы Application не ходил
/// в файловую систему и процессы напрямую: это забота Infrastructure.
/// </summary>
public interface IDiagnosticsProbe
{
Task<StorageProbe> ProbeStorageAsync(CancellationToken cancellationToken);
Task<ToolProbe> ProbeFfmpegAsync(CancellationToken cancellationToken);
Task<ToolProbe> ProbeFfprobeAsync(CancellationToken cancellationToken);
}
/// <summary>Состояние тома хранилища. Нули в размерах — ФС не отдала метрику (экзотический маунт).</summary>
public sealed record StorageProbe(
string RootPath,
bool Exists,
bool Writable,
long TotalBytes,
long FreeBytes,
long MinFreeBytes
);
/// <summary>Доступность внешнего инструмента и его версия (первая строка вывода <c>-version</c>).</summary>
public sealed record ToolProbe(bool Available, string? Version);
@@ -0,0 +1,14 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Пропускная способность обработчика медиа. Нужна, чтобы по очереди и среднему времени посчитать,
/// когда она разгребётся: при двух параллельных транскодах ждать вдвое меньше, чем при одном.
///
/// Порт, а не чтение настроек напрямую: настройки ffmpeg живут в Infrastructure, и прикладной слой
/// про их устройство знать не должен.
/// </summary>
public interface IMediaProcessingLimits
{
/// <summary>Сколько файлов обрабатывается одновременно.</summary>
int MaxParallelTranscodes { get; }
}
@@ -11,6 +11,12 @@ public interface IMediaStorage
/// <summary>Свободное место на томе хранилища, байт.</summary> /// <summary>Свободное место на томе хранилища, байт.</summary>
long GetAvailableFreeSpaceBytes(); long GetAvailableFreeSpaceBytes();
/// <summary>
/// Абсолютный путь к HLS-плейлисту готового ассета (<c>assets/{id}/index.m3u8</c>) — им ffmpeg
/// читает уже нарезанное медиа как обычный вход. Null, если каталога ассета нет на диске.
/// </summary>
string? ResolvePlaylistPath(Guid assetId);
/// <summary> /// <summary>
/// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память. /// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память.
/// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>. /// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>.
@@ -35,9 +41,10 @@ public interface IMediaStorage
IReadOnlyList<ManualInboxFile> ListManualInbox(int max); IReadOnlyList<ManualInboxFile> ListManualInbox(int max);
/// <summary> /// <summary>
/// Убирает то, что осталось в <c>manual/</c> рядом с забранным файлом: спутники с тем же именем /// Убирает то, что осталось в <c>manual/</c> от забранного файла: спутники с тем же именем
/// и другим расширением (субтитры, nfo, обложка) и опустевший каталог. Другие видеофайлы /// и другим расширением (субтитры, nfo, обложка), файлы того же эпизода в соседних подпапках
/// не трогает — рядом может лежать следующая серия. /// (<c>subs_ru</c>, <c>Subtitles</c>) и опустевшие каталоги вверх до корня <c>manual/</c>.
/// Другие видеофайлы не трогает — рядом может лежать следующая серия или иная версия.
/// </summary> /// </summary>
Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken); Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken);
@@ -48,6 +48,15 @@ public interface IMetadataProvider
int season, int season,
CancellationToken cancellationToken CancellationToken cancellationToken
); );
/// <summary>
/// Номера сезонов сериала по данным источника (пусто — источник не отдал данные). Спецвыпуски
/// (нулевой сезон) не отдаются: в библиотеку они не грузятся и считались бы вечно отсутствующими.
/// </summary>
Task<IReadOnlyList<int>> GetSeasonNumbersAsync(
string externalId,
CancellationToken cancellationToken
);
} }
/// <summary>Резолвит провайдер по ключу и перечисляет реально настроенные (с API-ключом) источники.</summary> /// <summary>Резолвит провайдер по ключу и перечисляет реально настроенные (с API-ключом) источники.</summary>
@@ -0,0 +1,72 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Части хранилища, которые считаются отдельно. Это не «папки», а ответ на вопрос «чем занят диск»:
/// сегменты программ и сегменты заставок лежат в одном каталоге, но растут по разным причинам,
/// и администратору важно видеть их порознь.
/// </summary>
public enum StorageArea
{
/// <summary>HLS-сегменты программ — обычно это почти весь объём.</summary>
Assets = 0,
/// <summary>HLS-сегменты отрендеренных заставок.</summary>
BumperAssets = 1,
/// <summary>Исходники, оставленные после нарезки (<c>Storage:KeepOriginals</c>).</summary>
Originals = 2,
/// <summary>Автоматический inbox — то, что ещё не разобрано сканером.</summary>
Inbox = 3,
/// <summary>Ручной inbox — то, что админ забирает в шоу сам.</summary>
ManualInbox = 4,
/// <summary>Перевалочный каталог заливок: всё, что здесь залежалось, — след оборванной загрузки.</summary>
Uploads = 5,
/// <summary>Звук и фоны блоков заставок (сырые файлы, не нарезка).</summary>
BumperSources = 6,
/// <summary>Общий реестр изображений: постеры, кадры, логотипы.</summary>
Images = 7,
/// <summary>Всё прочее под корнем хранилища — обычно ноль, а если нет, то это и надо увидеть.</summary>
Other = 8,
}
/// <param name="Bytes">Объём области, байт.</param>
/// <param name="Files">Число файлов — по нему видно, что «пусто» и «много мелочи» это разные беды.</param>
public sealed record StorageAreaUsage(StorageArea Area, long Bytes, int Files);
/// <param name="RootPath">Корень хранилища — на сервере их может быть несколько, и надо видеть, о каком речь.</param>
/// <param name="VolumeTotalBytes">Размер тома целиком; 0 — файловая система не отдала метрику.</param>
/// <param name="VolumeFreeBytes">Свободно на томе. Свободное место делится со всем, что есть на диске.</param>
/// <param name="MinFreeSpaceBytes">Порог, ниже которого загрузка отклоняется; едет вместе с цифрами тома, чтобы их было с чем сравнить.</param>
/// <param name="ComputedAt">Когда посчитано: обход дерева кэшируется, и возраст цифры надо показывать.</param>
public sealed record StorageUsage(
string RootPath,
long VolumeTotalBytes,
long VolumeFreeBytes,
long StorageBytes,
int StorageFiles,
long MinFreeSpaceBytes,
IReadOnlyList<StorageAreaUsage> Areas,
DateTimeOffset ComputedAt
);
/// <summary>
/// Порт подсчёта занятого места. Размеров в БД нет и не будет: правда о диске лежит на диске,
/// а любое дублирование в таблицах разошлось бы с ней при первой же ручной правке файлов.
///
/// Обход дерева стоит дорого (сегментов — сотни тысяч), поэтому результат кэшируется реализацией,
/// а <paramref name="refresh"/> — это явная кнопка «пересчитать» в админке.
/// </summary>
public interface IStorageInspector
{
Task<StorageUsage> InspectAsync(
IReadOnlySet<Guid> generatedAssetIds,
bool refresh,
CancellationToken cancellationToken
);
}
@@ -0,0 +1,90 @@
using TeleWave.Domain.Notifications;
namespace TeleWave.Application.Common.Interfaces;
/// <summary>Кнопка инлайн-клавиатуры: подпись и что вернётся в callback.</summary>
public sealed record TelegramButton(string Text, string CallbackData);
/// <summary>Обновление от Telegram — только то, что нам нужно от его модели.</summary>
public sealed record TelegramUpdate(
long UpdateId,
long? ChatId,
string? Text,
string? From,
/// <summary>Идентификатор нажатия: на него надо ответить, иначе кнопка «крутится» у зрителя.</summary>
string? CallbackId,
string? CallbackData,
long? CallbackMessageId
);
/// <summary>Кто мы для Telegram — ответ getMe, по нему проверяется токен.</summary>
public sealed record TelegramBotInfo(long Id, string Username);
/// <summary>
/// Порт к Bot API. Держим ровно те вызовы, что нужны боту расписания: узнать себя, получить
/// обновления, ответить сообщением с кнопками и переключить вебхук.
///
/// Настройки (токен, прокси) реализация читает сама на каждый вызов: админ меняет их в рантайме,
/// и держать клиента, собранного на старте, значило бы требовать перезапуск контейнера.
/// </summary>
public interface ITelegramApi
{
Task<TelegramBotInfo?> GetMeAsync(
TelegramSettings settings,
CancellationToken cancellationToken
);
Task<IReadOnlyList<TelegramUpdate>> GetUpdatesAsync(
TelegramSettings settings,
long offset,
CancellationToken cancellationToken
);
/// <summary>Отправляет сообщение и возвращает его идентификатор — по нему потом правят и удаляют.</summary>
Task<long?> SendMessageAsync(
TelegramSettings settings,
long chatId,
string text,
IReadOnlyList<IReadOnlyList<TelegramButton>>? keyboard,
CancellationToken cancellationToken
);
/// <summary>Переписывает уже отправленное сообщение — так меню подписок обновляется на месте.</summary>
Task EditMessageAsync(
TelegramSettings settings,
long chatId,
long messageId,
string text,
IReadOnlyList<IReadOnlyList<TelegramButton>>? keyboard,
CancellationToken cancellationToken
);
/// <summary>
/// Снимает своё сообщение. В личном чате бот вправе удалять только собственные — команды
/// зрителя остаются, и это не в нашей власти.
/// </summary>
Task DeleteMessageAsync(
TelegramSettings settings,
long chatId,
long messageId,
CancellationToken cancellationToken
);
/// <summary>Гасит «часики» на нажатой кнопке; текст — всплывающая подсказка.</summary>
Task AnswerCallbackAsync(
TelegramSettings settings,
string callbackId,
string? text,
CancellationToken cancellationToken
);
/// <summary>
/// Разбирает тело вебхука в то же обновление, что приходит опросом: разговор с ботом один,
/// и знать про две модели ему незачем. null — тело не разобралось.
/// </summary>
Task<TelegramUpdate?> ReadUpdateAsync(Stream body, CancellationToken cancellationToken);
Task SetWebhookAsync(TelegramSettings settings, CancellationToken cancellationToken);
Task DeleteWebhookAsync(TelegramSettings settings, CancellationToken cancellationToken);
}
@@ -0,0 +1,28 @@
namespace TeleWave.Application.Common.Models;
/// <summary>
/// Склейка ответа на вопрос «где это используется» для текста ошибки. Отказ вида «объект занят»
/// без адреса заставляет обходить все экраны подряд, а перечисление держателей превращает ошибку
/// в инструкцию: видно, что именно отвязать.
/// </summary>
public static class UsageText
{
/// <summary>Сколько имён показываем: длинный список в тосте всё равно не читается.</summary>
private const int MaxNames = 5;
/// <summary>Раздел перечисления: «слоты каналов: Первый, Второй». Пустой список — пропускается.</summary>
public static string? Part(string what, IReadOnlyCollection<string> names)
{
if (names.Count == 0)
return null;
var shown = string.Join(", ", names.Take(MaxNames));
return names.Count > MaxNames
? $"{what}: {shown} и ещё {names.Count - MaxNames}"
: $"{what}: {shown}";
}
/// <summary>Собирает разделы в одну строку; пустые отбрасываются.</summary>
public static string Join(params string?[] parts) =>
string.Join("; ", parts.Where(p => !string.IsNullOrWhiteSpace(p)));
}
@@ -5,10 +5,21 @@ using LiteCqrs.DependencyInjection;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Behaviors; using TeleWave.Application.Common.Behaviors;
using TeleWave.Application.Library.Collections;
using TeleWave.Application.Library.Collections.Suggest;
using TeleWave.Application.Library.Genres; using TeleWave.Application.Library.Genres;
using TeleWave.Application.Media;
using TeleWave.Application.Media.ManualInbox;
using TeleWave.Application.Media.MovieImport;
using TeleWave.Application.Metadata;
using TeleWave.Application.Notifications;
using TeleWave.Application.Notifications.Settings;
using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Groups.Suggest;
using TeleWave.Application.Programming.Planning; using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Planning.DebugExport;
using TeleWave.Application.Programming.Templates; using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.Generate;
namespace TeleWave.Application; namespace TeleWave.Application;
@@ -35,14 +46,31 @@ public static class DependencyInjection
// здесь, а не в Infrastructure: тот слой не должен знать внутреннего устройства Application. // здесь, а не в Infrastructure: тот слой не должен знать внутреннего устройства Application.
services.AddScoped<BumperSpecLoader>(); services.AddScoped<BumperSpecLoader>();
services.AddScoped<GenreMatcher>(); services.AddScoped<GenreMatcher>();
services.AddScoped<ShowMetadataApplier>();
services.AddScoped<MovieMatcher>();
services.AddScoped<ManualInboxTaker>();
services.AddScoped<ShowMediaEraser>();
services.AddScoped<CollectionSuggestionBuilder>();
services.AddScoped<CollectionPosterPicker>();
services.AddScoped<GroupElementResolver>(); services.AddScoped<GroupElementResolver>();
services.AddScoped<GroupFilterMatcher>();
services.AddScoped<DynamicGroupResolver>();
services.AddScoped<GroupStatsService>(); services.AddScoped<GroupStatsService>();
services.AddScoped<GroupSuggestionBuilder>();
services.AddScoped<GroupMembershipCleaner>(); services.AddScoped<GroupMembershipCleaner>();
services.AddScoped<InterstitialGroups>();
services.AddScoped<SlotWriter>(); services.AddScoped<SlotWriter>();
services.AddScoped<GroupCatalog>();
services.AddScoped<GridPlanner>();
services.AddScoped<GroupExpander>(); services.AddScoped<GroupExpander>();
services.AddScoped<BumperResolver>(); services.AddScoped<BumperResolver>();
services.AddScoped<PostCheckRunner>(); services.AddScoped<PostCheckRunner>();
services.AddScoped<GridScheduleGenerator>(); services.AddScoped<GridScheduleGenerator>();
services.AddScoped<ChannelDebugCollector>();
services.AddScoped<LibraryDebugCollector>();
services.AddScoped<TelegramSettingsService>();
services.AddScoped<TelegramBotService>();
services.AddScoped<TelegramNotifier>();
return services; return services;
} }
@@ -0,0 +1,29 @@
using LiteCqrs;
namespace TeleWave.Application.Diagnostics;
/// <summary>Живое состояние инфраструктуры для админской панели диагностики: БД, хранилище, ffmpeg.</summary>
public sealed record GetSystemDiagnosticsQuery : IQuery<SystemDiagnosticsDto>;
public sealed record SystemDiagnosticsDto(
DatabaseDiagnosticsDto Database,
StorageDiagnosticsDto Storage,
ToolDiagnosticsDto Ffmpeg,
ToolDiagnosticsDto Ffprobe
);
/// <summary>Доступность БД и время лёгкого запроса; при ошибке — её текст без latency.</summary>
public sealed record DatabaseDiagnosticsDto(bool Reachable, long? LatencyMs, string? Error);
/// <summary><c>LowSpace</c> — свободного меньше порога <c>MinFreeBytes</c> (место известно и оно мало).</summary>
public sealed record StorageDiagnosticsDto(
string RootPath,
bool Exists,
bool Writable,
long TotalBytes,
long FreeBytes,
long MinFreeBytes,
bool LowSpace
);
public sealed record ToolDiagnosticsDto(bool Available, string? Version);
@@ -0,0 +1,64 @@
using System.Data.Common;
using System.Diagnostics;
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Diagnostics;
public sealed class GetSystemDiagnosticsQueryHandler(
IAppDbContext dbContext,
IDiagnosticsProbe probe
) : IQueryHandler<GetSystemDiagnosticsQuery, SystemDiagnosticsDto>
{
public async Task<SystemDiagnosticsDto> Handle(
GetSystemDiagnosticsQuery query,
CancellationToken cancellationToken
)
{
var database = await CheckDatabaseAsync(cancellationToken);
var storageProbe = await probe.ProbeStorageAsync(cancellationToken);
var ffmpeg = await probe.ProbeFfmpegAsync(cancellationToken);
var ffprobe = await probe.ProbeFfprobeAsync(cancellationToken);
var storage = new StorageDiagnosticsDto(
storageProbe.RootPath,
storageProbe.Exists,
storageProbe.Writable,
storageProbe.TotalBytes,
storageProbe.FreeBytes,
storageProbe.MinFreeBytes,
LowSpace: storageProbe.FreeBytes > 0
&& storageProbe.FreeBytes < storageProbe.MinFreeBytes
);
return new SystemDiagnosticsDto(
database,
storage,
new ToolDiagnosticsDto(ffmpeg.Available, ffmpeg.Version),
new ToolDiagnosticsDto(ffprobe.Available, ffprobe.Version)
);
}
/// <summary>
/// Лёгкая живая проверка БД: тривиальный запрос под секундомер. Не полагаемся на стартовый
/// health-check — админ хочет видеть состояние прямо сейчас. Ловим <see cref="DbException"/>
/// (база провайдера Npgsql-исключений) — так Application не тянет ссылку на Npgsql.
/// </summary>
private async Task<DatabaseDiagnosticsDto> CheckDatabaseAsync(
CancellationToken cancellationToken
)
{
var stopwatch = Stopwatch.StartNew();
try
{
await dbContext.AppSettings.AnyAsync(cancellationToken);
stopwatch.Stop();
return new DatabaseDiagnosticsDto(true, stopwatch.ElapsedMilliseconds, null);
}
catch (DbException ex)
{
return new DatabaseDiagnosticsDto(false, null, ex.Message);
}
}
}
@@ -0,0 +1,28 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.BulkTagShows;
/// <summary>
/// Проставляет жанры и/или возрастной рейтинг сразу нескольким шоу. Оба поля необязательны и
/// применяются независимо: обычно правят что-то одно, а связывать их в одну форму значило бы
/// заставлять переставлять второе «как было».
/// </summary>
/// <param name="GenreIds">
/// Что делать с жанрами: null — не трогать. Иначе <paramref name="ReplaceGenres"/> решает, заменить
/// набор целиком или дописать к имеющимся.
/// </param>
/// <param name="ReplaceGenres">
/// true — заменить набор жанров переданным; false — дописать. Дописывание нужно чаще: у шоу уже
/// есть жанры из метаданных, и разметка «это ещё и детское» не должна их стирать.
/// </param>
/// <param name="Audience">Рейтинг: null со <paramref name="SetAudience"/> = true снимает проставленный.</param>
/// <param name="SetAudience">Трогать ли рейтинг вообще.</param>
public sealed record BulkTagShowsCommand(
IReadOnlyList<Guid> ShowIds,
IReadOnlyList<Guid>? GenreIds = null,
bool ReplaceGenres = false,
ShowAudience? Audience = null,
bool SetAudience = false
) : ICommand<Result<int>>;
@@ -0,0 +1,66 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Library.Genres;
namespace TeleWave.Application.Library.BulkTagShows;
public sealed class BulkTagShowsCommandHandler(IAppDbContext dbContext)
: ICommandHandler<BulkTagShowsCommand, Result<int>>
{
public async Task<Result<int>> Handle(
BulkTagShowsCommand command,
CancellationToken cancellationToken
)
{
var showIds = command.ShowIds.Distinct().ToList();
if (showIds.Count == 0)
return Result.Success(0);
var genreIds = command.GenreIds?.Distinct().ToList();
if (genreIds is { Count: > 0 })
{
// Ссылка на несуществующий жанр упала бы нарушением внешнего ключа — проверяем заранее.
var known = await dbContext
.Genres.Where(g => genreIds.Contains(g.Id))
.CountAsync(cancellationToken);
if (known != genreIds.Count)
return Result.Failure<int>(GenreErrors.NotFound);
}
var shows = await dbContext
.Shows.Include(s => s.Genres)
.Where(s => showIds.Contains(s.Id))
.ToListAsync(cancellationToken);
// Пропавшее шоу не роняет всю операцию: список выбирали галочками, и пока его собирали,
// что-то могли удалить. Возвращаем, скольких реально коснулись.
foreach (var show in shows)
{
if (genreIds is not null)
ApplyGenres(show, genreIds, command.ReplaceGenres);
if (command.SetAudience)
show.SetAudience(command.Audience);
}
return Result.Success(shows.Count);
}
/// <summary>
/// Основным остаётся прежний основной жанр — массовая разметка не должна незаметно менять то,
/// что показано в списке шоу. Если его не было (жанров не было вовсе), основным станет первый
/// из добавленных, как и при обычной правке.
/// </summary>
private static void ApplyGenres(
Domain.Library.Show show,
IReadOnlyList<Guid> genreIds,
bool replace
)
{
var primary = show.PrimaryGenreId;
var ids = replace ? genreIds : show.Genres.Select(g => g.GenreId).Union(genreIds).ToList();
show.SetGenres(ids, ids.Contains(primary ?? Guid.Empty) ? primary : null);
}
}
@@ -0,0 +1,15 @@
using FluentValidation;
namespace TeleWave.Application.Library.BulkTagShows;
public sealed class BulkTagShowsCommandValidator : AbstractValidator<BulkTagShowsCommand>
{
public BulkTagShowsCommandValidator()
{
RuleFor(x => x.ShowIds).NotEmpty();
// Команда, которая ничего не меняет, — почти наверняка недосмотр вызывающей стороны.
RuleFor(x => x)
.Must(x => x.GenreIds is not null || x.SetAudience)
.WithMessage("Нечего применять: не заданы ни жанры, ни рейтинг.");
}
}
@@ -0,0 +1,22 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.AddShowsToCollection;
/// <summary>
/// Отправляет несколько шоу во франшизу разом. Коллекция либо уже есть
/// (<paramref name="CollectionId"/>), либо заводится по имени
/// (<paramref name="NewCollectionName"/>) — иначе на сборку франшизы уходило бы два экрана.
///
/// Порядок добавления — по году выпуска: сиквелы почти всегда идут так, а исключения правятся
/// перетаскиванием в карточке коллекции.
/// </summary>
public sealed record AddShowsToCollectionCommand(
IReadOnlyList<Guid> ShowIds,
Guid? CollectionId = null,
string? NewCollectionName = null
) : ICommand<Result<AddShowsToCollectionResultDto>>;
/// <param name="CollectionId">Куда в итоге сложили — новая коллекция или переданная.</param>
/// <param name="Added">Сколько шоу реально добавлено (уже входящие пропускаются).</param>
public sealed record AddShowsToCollectionResultDto(Guid CollectionId, string Name, int Added);
@@ -0,0 +1,62 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Collections.AddShowsToCollection;
public sealed class AddShowsToCollectionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddShowsToCollectionCommand, Result<AddShowsToCollectionResultDto>>
{
public async Task<Result<AddShowsToCollectionResultDto>> Handle(
AddShowsToCollectionCommand command,
CancellationToken cancellationToken
)
{
var showIds = command.ShowIds.Distinct().ToList();
var shows = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Name,
s.Year,
})
.ToListAsync(cancellationToken);
if (shows.Count == 0)
return Result.Failure<AddShowsToCollectionResultDto>(ShowErrors.NotFound);
var collection = await ResolveCollectionAsync(command, cancellationToken);
if (collection is null)
return Result.Failure<AddShowsToCollectionResultDto>(CollectionErrors.NotFound);
// Год — главный порядок франшизы; без года часть уходит в конец, там её и переставят руками.
var ordered = shows
.OrderBy(s => s.Year is null)
.ThenBy(s => s.Year)
.ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase);
var added = ordered.Count(show => collection.AddShow(show.Id) is not null);
return Result.Success(
new AddShowsToCollectionResultDto(collection.Id, collection.Name, added)
);
}
private async Task<Collection?> ResolveCollectionAsync(
AddShowsToCollectionCommand command,
CancellationToken cancellationToken
)
{
if (command.CollectionId is { } id)
return await dbContext
.Collections.Include(c => c.Items)
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
var created = Collection.Create(command.NewCollectionName!);
dbContext.Collections.Add(created);
return created;
}
}
@@ -0,0 +1,19 @@
using FluentValidation;
namespace TeleWave.Application.Library.Collections.AddShowsToCollection;
public sealed class AddShowsToCollectionCommandValidator
: AbstractValidator<AddShowsToCollectionCommand>
{
public AddShowsToCollectionCommandValidator()
{
RuleFor(x => x.ShowIds).NotEmpty();
// Либо кладём в существующую, либо заводим новую — «ни то ни другое» указывает на ошибку UI.
RuleFor(x => x)
.Must(x =>
x.CollectionId is not null || !string.IsNullOrWhiteSpace(x.NewCollectionName)
)
.WithMessage("Не указано, в какую коллекцию складывать.");
RuleFor(x => x.NewCollectionName).MaximumLength(256);
}
}
@@ -18,4 +18,14 @@ public static class CollectionErrors
"Collections.ShowNotInCollection", "Collections.ShowNotInCollection",
"Шоу не входит в коллекцию." "Шоу не входит в коллекцию."
); );
public static readonly Error SuggestionNotFound = Error.NotFound(
"Collections.SuggestionNotFound",
"Предложение устарело — библиотека изменилась. Обновите список предложений."
);
public static readonly Error SuggestionAlreadyCreated = Error.Conflict(
"Collections.SuggestionAlreadyCreated",
"Коллекция с таким названием уже есть."
);
} }
@@ -0,0 +1,72 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Collections;
/// <summary>
/// Подбирает коллекции постер по её частям: берётся первая часть с постером — обычно это первый
/// фильм цикла, и он же узнаётся с обложки. Отдельный тип, потому что подбор нужен и при создании
/// коллекции, и по кнопке для уже заведённых.
///
/// Своего изображения коллекция не заводит: постер — это тот же <see cref="Image"/> из реестра,
/// на который ссылается шоу. Копировать файл ради второй ссылки незачем.
/// </summary>
public sealed class CollectionPosterPicker(IAppDbContext dbContext)
{
/// <summary>
/// Проставляет постер коллекциям, у которых его нет. Уже выбранный не трогаем: его мог
/// поставить человек, и перебивать такой выбор автоматикой нельзя. Возвращает, скольким
/// коллекциям постер нашёлся.
/// </summary>
public async Task<int> FillAsync(
IReadOnlyList<Collection> collections,
CancellationToken cancellationToken
)
{
var pending = collections.Where(c => c.PosterImageId is null).ToList();
if (pending.Count == 0)
return 0;
var posters = await PostersAsync(pending, cancellationToken);
var filled = 0;
foreach (var collection in pending)
{
if (Pick(collection, posters) is not { } imageId)
continue;
collection.SetPosterImage(imageId);
filled++;
}
return filled;
}
/// <summary>Постер первой по порядку части, у которой он есть.</summary>
private static Guid? Pick(Collection collection, IReadOnlyDictionary<Guid, Guid> posters) =>
collection
.Items.OrderBy(i => i.Position)
.Select(i => posters.TryGetValue(i.ShowId, out var image) ? image : (Guid?)null)
.FirstOrDefault(image => image is not null);
/// <summary>Постеры шоу, входящих в эти коллекции, одним запросом на всю пачку.</summary>
private async Task<Dictionary<Guid, Guid>> PostersAsync(
IReadOnlyList<Collection> collections,
CancellationToken cancellationToken
)
{
var showIds = collections
.SelectMany(c => c.Items.Select(i => i.ShowId))
.Distinct()
.ToList();
var shows = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id) && s.PosterImageId != null)
.Select(s => new { s.Id, PosterImageId = s.PosterImageId!.Value })
.ToListAsync(cancellationToken);
return shows.ToDictionary(s => s.Id, s => s.PosterImageId);
}
}
@@ -0,0 +1,11 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.FillCollectionPosters;
/// <summary>
/// Проставляет постеры коллекциям по их частям. Пустой список — всем коллекциям без постера:
/// после массового импорта фильмов их сразу десятки, и обходить каждую руками бессмысленно.
/// </summary>
public sealed record FillCollectionPostersCommand(IReadOnlyList<Guid>? CollectionIds = null)
: ICommand<Result<int>>;
@@ -0,0 +1,28 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.FillCollectionPosters;
public sealed class FillCollectionPostersCommandHandler(
IAppDbContext dbContext,
CollectionPosterPicker picker
) : ICommandHandler<FillCollectionPostersCommand, Result<int>>
{
public async Task<Result<int>> Handle(
FillCollectionPostersCommand command,
CancellationToken cancellationToken
)
{
var query = dbContext.Collections.Include(c => c.Items).AsQueryable();
if (command.CollectionIds is { Count: > 0 } ids)
query = query.Where(c => ids.Contains(c.Id));
var collections = await query.ToListAsync(cancellationToken);
var filled = await picker.FillAsync(collections, cancellationToken);
return Result.Success(filled);
}
}
@@ -0,0 +1,10 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.Suggest;
/// <summary>
/// Дополняет уже собранную франшизу по предложению: состав сервер пересобирает по ключу, а не берёт
/// с клиента. Между показом и нажатием библиотека могла измениться — добавиться должно актуальное.
/// </summary>
public sealed record AddSuggestedShowsCommand(string Key) : ICommand<Result<int>>;
@@ -0,0 +1,43 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.Suggest;
public sealed class AddSuggestedShowsCommandHandler(
IAppDbContext dbContext,
CollectionSuggestionBuilder builder,
CollectionPosterPicker posters
) : ICommandHandler<AddSuggestedShowsCommand, Result<int>>
{
public async Task<Result<int>> Handle(
AddSuggestedShowsCommand command,
CancellationToken cancellationToken
)
{
var suggestions = await builder.BuildAsync(cancellationToken);
var suggestion = suggestions.FirstOrDefault(s => s.Key == command.Key);
// Ключ создания сюда приходить не должен: у такого предложения нет коллекции, которую
// дополнять. Для админа это одно и то же — список предложений устарел.
if (suggestion?.CollectionId is not { } collectionId)
return Result.Failure<int>(CollectionErrors.SuggestionNotFound);
var collection = await dbContext
.Collections.Include(c => c.Items)
.FirstOrDefaultAsync(c => c.Id == collectionId, cancellationToken);
if (collection is null)
return Result.Failure<int>(CollectionErrors.NotFound);
// Части предложения уже упорядочены по году, а AddShow ставит в конец: франшиза пополняется
// в хронологии, и переставлять руками обычно не приходится.
var added = suggestion.Parts.Count(part => collection.AddShow(part.ShowId) is not null);
// Коллекция без постера могла остаться такой именно потому, что первой части не хватало.
// Уже выбранный постер подборщик не трогает — проверять это здесь незачем.
await posters.FillAsync([collection], cancellationToken);
return Result.Success(added);
}
}
@@ -0,0 +1,353 @@
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Collections.Suggest;
/// <summary>
/// Предложение франшизы: имя и части в предлагаемом порядке. <paramref name="CollectionId"/>
/// задан — франшиза уже собрана, и предлагается дополнить её найденными частями, а не заводить
/// вторую такую же.
/// </summary>
public sealed record CollectionSuggestion(
string Key,
CollectionSuggestionSource Source,
string Name,
IReadOnlyList<CollectionSuggestionPartDto> Parts,
Guid? CollectionId = null
);
/// <summary>
/// Ищет, что стоит собрать в коллекцию, среди полнометражек, ещё не входящих ни в одну.
///
/// Два источника, и они не равны по надёжности. Франшиза из метаданных — это факт: TMDb сам считает
/// эти фильмы одним целым. Догадка по названиям — эвристика для библиотеки без метаданных, и она
/// намеренно осторожна: совпадать должно осмысленное начало названия, а не первое слово, иначе
/// «Человек-паук» и «Человек дождя» уедут в одну франшизу.
///
/// Предложений тоже два вида: собрать новую франшизу и **дополнить уже собранную**. Второй важнее,
/// чем кажется: библиотека пополняется по одному фильму, и четвёртая часть, приехавшая после сборки
/// коллекции, сама по себе франшизы не образует — раньше она просто не показывалась нигде.
///
/// Решение всё равно за человеком: предложение показывает состав целиком, а применяется кнопкой.
/// Сериалы сюда не попадают — у них своя форма продолжения, сезоны.
/// </summary>
public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext)
{
/// <summary>Меньше двух частей — не франшиза.</summary>
private const int MinParts = 2;
/// <summary>
/// Со скольких общих слов начало названия само по себе считается признаком франшизы:
/// «Harry Potter and the …» случайным совпадением не бывает, а «Человек …» — бывает.
/// </summary>
private const int StrongCommonWords = 3;
/// <summary>
/// Чем продолжение отличается от однофамильца: номер («Рокки 2», «Рокки III»), слово «часть»
/// либо подзаголовок через двоеточие или тире («Матрица: Перезагрузка»). У «Человек дождя»
/// и «Человек-слон» хвосты — обычные слова, и в одну франшизу они не попадут.
/// </summary>
private static readonly Regex SequelTail = new(
@"^(?:\d{1,2}\b|[IVXivx]{1,4}\b|part\b|часть\b|[:–—-]\s*\S)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
TimeSpan.FromSeconds(1)
);
public async Task<IReadOnlyList<CollectionSuggestion>> BuildAsync(
CancellationToken cancellationToken
)
{
var members = await dbContext
.CollectionItems.AsNoTracking()
.Select(i => new MemberRow(i.CollectionId, i.ShowId))
.ToListAsync(cancellationToken);
var allMovies = await dbContext
.Shows.AsNoTracking()
.Where(s => s.Kind == ShowKind.Single)
.Select(s => new MovieRow(
s.Id,
s.Name,
s.OriginalName,
s.Year,
s.FranchiseExternalId,
s.FranchiseName
))
.ToListAsync(cancellationToken);
var inCollections = members.Select(m => m.ShowId).ToHashSet();
var movies = allMovies.Where(m => !inCollections.Contains(m.Id)).ToList();
if (movies.Count == 0)
return [];
var suggestions = new List<CollectionSuggestion>();
var taken = new HashSet<Guid>();
// Дополнение существующих идёт первым: если франшиза уже собрана, вторую такую же заводить
// нельзя (имя занято), и предложить админу «создать» вместо «добавить» значило бы завести
// его в конфликт.
var additions = await ToExistingAsync(movies, members, allMovies, cancellationToken);
foreach (var addition in additions)
{
suggestions.Add(addition);
foreach (var part in addition.Parts)
taken.Add(part.ShowId);
}
var free = movies.Where(m => !taken.Contains(m.Id)).ToList();
foreach (var franchise in ByMetadata(free))
{
suggestions.Add(franchise);
foreach (var part in franchise.Parts)
taken.Add(part.ShowId);
}
// По названиям добираем только то, что не разобрала метадата: иначе одни и те же фильмы
// попали бы в два предложения сразу и админ выбирал бы между ними вслепую.
suggestions.AddRange(ByName(free.Where(m => !taken.Contains(m.Id)).ToList()));
return
[
.. suggestions
// Дополнения выше новых франшиз: это точечное действие на один-два фильма, и оно
// не должно теряться под списком крупных предложений.
.OrderByDescending(s => s.CollectionId is not null)
.ThenBy(s => s.Source)
.ThenByDescending(s => s.Parts.Count)
.ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase),
];
}
/// <summary>
/// Чего не хватает уже собранным франшизам. Фильм признаётся частью коллекции по тем же двум
/// признакам, что и при сборке новой: франшиза из метаданных совпала с франшизой любого её
/// участника либо название подошло по правилам догадки.
///
/// Метадата проверяется по всем коллекциям раньше догадки: точное совпадение не должно
/// проигрывать похожему названию из соседней франшизы.
/// </summary>
private async Task<IReadOnlyList<CollectionSuggestion>> ToExistingAsync(
List<MovieRow> orphans,
List<MemberRow> members,
List<MovieRow> allMovies,
CancellationToken cancellationToken
)
{
var collections = await dbContext
.Collections.AsNoTracking()
.Select(c => new { c.Id, c.Name })
.ToListAsync(cancellationToken);
if (collections.Count == 0)
return [];
var byId = allMovies.ToDictionary(m => m.Id);
var membersByCollection = members
.GroupBy(m => m.CollectionId)
.ToDictionary(
g => g.Key,
// Сериал в коллекции — не ориентир для франшизы полнометражек: в справочнике
// фильмов его просто нет, поэтому он и отсеивается по словарю.
g => g.Where(m => byId.ContainsKey(m.ShowId)).Select(m => byId[m.ShowId]).ToList()
);
var hits = new Dictionary<Guid, List<MovieRow>>();
foreach (var orphan in orphans)
{
var target = collections.FirstOrDefault(c =>
Members(membersByCollection, c.Id).Any(m => SameMetadataFranchise(orphan, m))
);
var byMetadata = target is not null;
target ??= collections.FirstOrDefault(c =>
Members(membersByCollection, c.Id)
.Any(m => SameFranchise(Words(Title(orphan)), Words(Title(m))))
);
if (target is null)
continue;
if (!hits.TryGetValue(target.Id, out var found))
hits[target.Id] = found = [];
found.Add(orphan with { MatchedByMetadata = byMetadata });
}
return
[
.. hits.Select(pair => new CollectionSuggestion(
$"existing:{pair.Key:N}",
// Источник — по самому слабому попаданию: одна догадка в наборе означает, что
// весь набор надо проверить глазами.
pair.Value.All(m => m.MatchedByMetadata)
? CollectionSuggestionSource.Metadata
: CollectionSuggestionSource.NameGuess,
collections.First(c => c.Id == pair.Key).Name,
Parts(pair.Value),
pair.Key
)),
];
}
private static List<MovieRow> Members(
Dictionary<Guid, List<MovieRow>> membersByCollection,
Guid collectionId
) => membersByCollection.TryGetValue(collectionId, out var list) ? list : [];
/// <summary>Обе части знают свою франшизу из метаданных, и это одна и та же франшиза.</summary>
private static bool SameMetadataFranchise(MovieRow first, MovieRow second) =>
first.FranchiseExternalId is not null
&& first.FranchiseExternalId == second.FranchiseExternalId;
private static IEnumerable<CollectionSuggestion> ByMetadata(List<MovieRow> movies)
{
var groups = movies
.Where(m => m.FranchiseExternalId is not null && m.FranchiseName is not null)
.GroupBy(m => m.FranchiseExternalId!)
.Where(g => g.Count() >= MinParts);
foreach (var group in groups)
yield return new CollectionSuggestion(
$"franchise:{group.Key}",
CollectionSuggestionSource.Metadata,
group.First().FranchiseName!,
Parts(group)
);
}
/// <summary>
/// Догадка по названиям для библиотеки без метаданных. Сравниваются целые слова, а не символы:
/// посимвольное совпадение слишком легко ловит однофамильцев («Человек дождя» и «Человек-слон»).
/// Пара считается франшизой, если общее начало длинное либо хвосты обеих частей выглядят
/// продолжением. Имя предложения — общее начало, очищенное от хвостовых предлогов и знаков.
/// </summary>
private static IEnumerable<CollectionSuggestion> ByName(List<MovieRow> movies)
{
var used = new HashSet<Guid>();
// От длинных названий к коротким: так более специфичная франшиза собирается первой.
foreach (var movie in movies.OrderByDescending(m => Title(m).Length))
{
if (used.Contains(movie.Id))
continue;
var words = Words(Title(movie));
var family = movies
.Where(other =>
other.Id != movie.Id
&& !used.Contains(other.Id)
&& SameFranchise(words, Words(Title(other)))
)
.ToList();
if (family.Count == 0)
continue;
var common = family.Aggregate(
words,
(acc, other) => acc[..CommonWordCount(acc, Words(Title(other)))]
);
var name = Clean(string.Join(' ', common));
if (name.Length < 3)
continue;
family.Add(movie);
foreach (var part in family)
used.Add(part.Id);
yield return new CollectionSuggestion(
$"name:{name.ToLowerInvariant()}",
CollectionSuggestionSource.NameGuess,
name,
Parts(family)
);
}
}
/// <summary>Части идут по году: у франшизы это и есть порядок просмотра.</summary>
private static IReadOnlyList<CollectionSuggestionPartDto> Parts(IEnumerable<MovieRow> movies) =>
[
.. movies
.OrderBy(m => m.Year is null)
.ThenBy(m => m.Year)
.ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
.Select(m => new CollectionSuggestionPartDto(m.Id, m.Name, m.Year)),
];
/// <summary>Сравниваем по оригинальному названию, если оно есть: у релизов оно стабильнее.</summary>
private static string Title(MovieRow movie) =>
string.IsNullOrWhiteSpace(movie.OriginalName) ? movie.Name : movie.OriginalName;
private static bool SameFranchise(string[] first, string[] second)
{
var common = CommonWordCount(first, second);
if (common == 0)
return false;
if (common >= StrongCommonWords)
return true;
return IsSequelTail(first, common) && IsSequelTail(second, common);
}
/// <summary>Хвост после общего начала: пусто — это первая часть, иначе должен выглядеть продолжением.</summary>
private static bool IsSequelTail(string[] words, int common)
{
var tail = string.Join(' ', words.Skip(common)).Trim();
return tail.Length == 0 || SequelTail.IsMatch(tail);
}
private static int CommonWordCount(string[] first, string[] second)
{
var count = 0;
while (
count < first.Length
&& count < second.Length
&& string.Equals(first[count], second[count], StringComparison.CurrentCultureIgnoreCase)
)
count++;
return count;
}
private static string[] Words(string title) =>
title.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
/// <summary>
/// Общее начало обрывается на союзе или знаке: «Harry Potter and the» — это ещё не имя франшизы.
/// Чистим, пока есть что убирать: за один проход «and the» не снять.
/// </summary>
private static string Clean(string prefix)
{
var trimmed = prefix.Trim();
bool changed;
do
{
changed = false;
trimmed = trimmed.TrimEnd(':', '-', '', '—', '.', ',', ' ');
foreach (var tail in (string[])[" и", " and", " the", " part", " часть"])
{
if (!trimmed.EndsWith(tail, StringComparison.CurrentCultureIgnoreCase))
continue;
trimmed = trimmed[..^tail.Length];
changed = true;
}
} while (changed);
return trimmed.Trim();
}
/// <param name="MatchedByMetadata">
/// Как фильм попал в дополнение существующей коллекции: по франшизе из метаданных или догадкой
/// по названию. Живёт здесь, а не отдельным списком, чтобы признак не разъезжался с самим
/// фильмом при группировке.
/// </param>
private sealed record MovieRow(
Guid Id,
string Name,
string? OriginalName,
int? Year,
string? FranchiseExternalId,
string? FranchiseName,
bool MatchedByMetadata = false
);
private sealed record MemberRow(Guid CollectionId, Guid ShowId);
}
@@ -0,0 +1,11 @@
namespace TeleWave.Application.Library.Collections.Suggest;
/// <summary>Насколько предложению можно верить — по этому UI подписывает происхождение.</summary>
public enum CollectionSuggestionSource
{
/// <summary>Франшиза пришла из метаданных (TMDb знает её как единое целое).</summary>
Metadata = 0,
/// <summary>Догадка по названиям: общее начало и нумерация. Требует проверки глазами.</summary>
NameGuess = 1,
}
@@ -0,0 +1,11 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.Suggest;
/// <summary>
/// Создаёт коллекцию по предложению: имя и состав сервер пересобирает по ключу, а не берёт
/// с клиента. Между показом и нажатием библиотека могла измениться — во франшизу должно попасть
/// актуальное.
/// </summary>
public sealed record CreateCollectionFromSuggestionCommand(string Key) : ICommand<Result<Guid>>;

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